@@ -64,7 +64,7 @@ private newtype TAstNode =
6464 TInputsNode ( YamlMapping n ) { exists ( YamlMapping m | m .lookup ( "inputs" ) = n ) } or
6565 TInputNode ( YamlValue n ) { exists ( YamlMapping m | m .lookup ( "inputs" ) .( YamlMapping ) .maps ( n , _) ) } or
6666 TOutputsNode ( YamlMapping n ) { exists ( YamlMapping m | m .lookup ( "outputs" ) = n ) } or
67- TPermissionsNode ( YamlMapping n ) { exists ( YamlMapping m | m .lookup ( "permissions" ) = n ) } or
67+ TPermissionsNode ( YamlMappingLikeNode n ) { exists ( YamlMapping m | m .lookup ( "permissions" ) = n ) } or
6868 TStrategyNode ( YamlMapping n ) { exists ( YamlMapping m | m .lookup ( "strategy" ) = n ) } or
6969 TNeedsNode ( YamlMappingLikeNode n ) { exists ( YamlMapping m | m .lookup ( "needs" ) = n ) } or
7070 TJobNode ( YamlMapping n ) { exists ( YamlMapping w | w .lookup ( "jobs" ) .( YamlMapping ) .lookup ( _) = n ) } or
@@ -320,6 +320,9 @@ class WorkflowImpl extends AstNodeImpl, TWorkflowNode {
320320 /** Gets a job within this workflow */
321321 JobImpl getAJob ( ) { result = this .getJob ( _) }
322322
323+ /** Gets the permissions granted to this workflow. */
324+ PermissionsImpl getPermissions ( ) { result .getNode ( ) = n .lookup ( "permissions" ) }
325+
323326 /** Workflow is triggered by given trigger event */
324327 predicate hasTriggerEvent ( string trigger ) {
325328 exists ( YamlNode y | y = n .lookup ( "on" ) .( YamlMappingLikeNode ) .getNode ( trigger ) )
@@ -330,43 +333,8 @@ class WorkflowImpl extends AstNodeImpl, TWorkflowNode {
330333 exists ( YamlNode y | y = n .lookup ( "on" ) .( YamlMappingLikeNode ) .getNode ( result ) )
331334 }
332335
333- /** Gets the permissions granted to this workflow. */
334- PermissionsImpl getPermissions ( ) { result .getNode ( ) = n .lookup ( "permissions" ) }
335-
336- private predicate hasSingleTrigger ( string trigger ) {
337- this .getATriggerEvent ( ) = trigger and
338- count ( this .getATriggerEvent ( ) ) = 1
339- }
340-
341336 /** Gets the strategy for this workflow. */
342337 StrategyImpl getStrategy ( ) { result .getNode ( ) = n .lookup ( "strategy" ) }
343-
344- /** Holds if the workflow is privileged. */
345- predicate isPrivileged ( ) {
346- // The Workflow has a permission to write to some scope
347- this .getPermissions ( ) .getAPermission ( ) = "write"
348- or
349- // The Workflow accesses a secret
350- exists ( SecretsExpressionImpl expr |
351- expr .getEnclosingWorkflow ( ) = this and not expr .getFieldName ( ) = "GITHUB_TOKEN"
352- )
353- or
354- // The Workflow is triggered by an event other than `pull_request`
355- count ( this .getATriggerEvent ( ) ) = 1 and
356- not this .getATriggerEvent ( ) = [ "pull_request" , "workflow_call" ]
357- or
358- // The Workflow is only triggered by `workflow_call` and there is
359- // a caller workflow triggered by an event other than `pull_request`
360- this .hasSingleTrigger ( "workflow_call" ) and
361- exists ( ExternalJobImpl call , WorkflowImpl caller |
362- call .getCallee ( ) = this .getLocation ( ) .getFile ( ) .getRelativePath ( ) and
363- caller = call .getWorkflow ( ) and
364- caller .isPrivileged ( )
365- )
366- or
367- // The Workflow has multiple triggers so at least one is not "pull_request"
368- count ( this .getATriggerEvent ( ) ) > 1
369- }
370338}
371339
372340class ReusableWorkflowImpl extends AstNodeImpl , WorkflowImpl {
@@ -502,7 +470,7 @@ class OutputsImpl extends AstNodeImpl, TOutputsNode {
502470}
503471
504472class PermissionsImpl extends AstNodeImpl , TPermissionsNode {
505- YamlMapping n ;
473+ YamlMappingLikeNode n ;
506474
507475 PermissionsImpl ( ) { this = TPermissionsNode ( n ) }
508476
@@ -516,11 +484,41 @@ class PermissionsImpl extends AstNodeImpl, TPermissionsNode {
516484
517485 override Location getLocation ( ) { result = n .getLocation ( ) }
518486
519- override YamlMapping getNode ( ) { result = n }
487+ override YamlMappingLikeNode getNode ( ) { result = n }
488+
489+ string getAScope ( ) {
490+ result =
491+ [
492+ "actions" , "attestations" , "checks" , "contents" , "deployments" , "discussions" , "id-token" ,
493+ "issues" , "packages" , "pages" , "pull-requests" , "repository-projects" , "security-events" ,
494+ "statuses"
495+ ]
496+ }
520497
521- string getPermission ( string perm ) { result = n .lookup ( perm ) .( YamlScalar ) .getValue ( ) }
498+ string getAPermission ( ) {
499+ exists ( YamlMapping mapping , string scope |
500+ mapping = n and
501+ result = scope + ": " + mapping .lookup ( scope ) .( YamlScalar ) .getValue ( )
502+ )
503+ or
504+ exists ( YamlScalar scalar |
505+ scalar = n and
506+ (
507+ scalar .getValue ( ) = "write-all" and
508+ result = this .getAScope ( ) + ":write"
509+ or
510+ scalar .getValue ( ) = "read-all" and
511+ result = this .getAScope ( ) + ":read"
512+ )
513+ )
514+ }
522515
523- string getAPermission ( ) { result = this .getPermission ( _) }
516+ bindingset [ perm]
517+ string getPermission ( string perm ) {
518+ exists ( string p |
519+ p = this .getAPermission ( ) and p .matches ( perm + ":%" ) and result = p .splitAt ( ":" , 1 ) .trim ( )
520+ )
521+ }
524522}
525523
526524class StrategyImpl extends AstNodeImpl , TStrategyNode {
@@ -633,37 +631,87 @@ class JobImpl extends AstNodeImpl, TJobNode {
633631 /** Gets the strategy for this job. */
634632 StrategyImpl getStrategy ( ) { result .getNode ( ) = n .lookup ( "strategy" ) }
635633
636- /** Holds if the workflow is privileged. */
634+ /** Holds if the job is privileged. */
637635 predicate isPrivileged ( ) {
636+ // the job has privileged runtime permissions
637+ this .hasRuntimeWritePermissions ( )
638+ or
639+ // the job has an explicit secret accesses
640+ this .hasExplicitSecretAccess ( )
641+ or
638642 // the job has an explicit write permission
639- this .getPermissions ( ) .getAPermission ( ) = "write"
643+ this .hasExplicitWritePermission ( )
644+ or
645+ // the job has no explicit permissions but the workflow has write permissions
646+ not exists ( this .getPermissions ( ) ) and
647+ this .hasImplicitWritePermission ( )
640648 or
649+ // neither the job nor the workflow have permissions but the job has a privileged trigger
650+ not exists ( this .getPermissions ( ) ) and
651+ not exists ( this .getEnclosingWorkflow ( ) .getPermissions ( ) ) and
652+ this .hasPrivilegedTrigger ( )
653+ }
654+
655+ private predicate hasExplicitSecretAccess ( ) {
641656 // the job accesses a secret other than GITHUB_TOKEN
642657 exists ( SecretsExpressionImpl expr |
643658 expr .getEnclosingJob ( ) = this and not expr .getFieldName ( ) = "GITHUB_TOKEN"
644659 )
645- or
646- // the effective permissions have write access
660+ }
661+
662+ private predicate hasExplicitWritePermission ( ) {
663+ // the job has an explicit write permission
664+ this .getPermissions ( ) .getAPermission ( ) .matches ( "%write" )
665+ }
666+
667+ private predicate hasImplicitWritePermission ( ) {
668+ // the job has an explicit write permission
669+ this .getEnclosingWorkflow ( ) .getPermissions ( ) .getAPermission ( ) .matches ( "%write" )
670+ }
671+
672+ private predicate hasRuntimeWritePermissions ( ) {
673+ // the effective runtime permissions have write access
647674 exists ( string path , string trigger , string name , string secrets_source , string perms |
648675 workflowDataModel ( path , trigger , name , secrets_source , perms , _) and
649676 path .trim ( ) = this .getLocation ( ) .getFile ( ) .getRelativePath ( ) and
650677 name .trim ( ) .matches ( this .getId ( ) + "%" ) and
651678 // We cannot trust the permissions for pull_request events since they depend on the
652- // location of the head branch
679+ // provenance of the head branch (local vs fork)
653680 not trigger .trim ( ) = "pull_request" and
654- (
655- secrets_source .trim ( ) .toLowerCase ( ) = "actions" or
656- perms .toLowerCase ( ) .matches ( "%write%" )
657- )
681+ perms .toLowerCase ( ) .matches ( "%write%" )
658682 )
683+ }
684+
685+ private predicate hasPrivilegedTrigger ( ) {
686+ // For workflows that are triggered by the pull_request_target event, the GITHUB_TOKEN is granted read/write repository permission unless the permissions key is specified and the workflow can access secrets, even when it is triggered from a fork.
687+ // The Job is triggered by an event other than `pull_request`
688+ count ( this .getATriggerEvent ( ) ) = 1 and
689+ not this .getATriggerEvent ( ) = [ "pull_request" , "workflow_call" ]
659690 or
660- // The job has no expliclit permission, but the enclosing workflow is privileged
661- not exists ( this .getPermissions ( ) ) and
662- not exists ( SecretsExpressionImpl expr |
663- expr .getEnclosingJob ( ) = this and not expr .getFieldName ( ) = "GITHUB_TOKEN"
664- ) and
665- // The enclosing workflow is privileged
666- this .getEnclosingWorkflow ( ) .isPrivileged ( )
691+ // The Workflow is only triggered by `workflow_call` and there is
692+ // a caller workflow triggered by an event other than `pull_request`
693+ this .hasSingleTrigger ( "workflow_call" ) and
694+ exists ( ExternalJobImpl call , JobImpl caller |
695+ call .getCallee ( ) = this .getLocation ( ) .getFile ( ) .getRelativePath ( ) and
696+ caller = call .getEnclosingJob ( ) and
697+ caller .isPrivileged ( )
698+ )
699+ or
700+ // The Workflow has multiple triggers so at least one is not "pull_request"
701+ count ( this .getATriggerEvent ( ) ) > 1
702+ }
703+
704+ /** Workflow is triggered by given trigger event */
705+ predicate hasTriggerEvent ( string trigger ) {
706+ exists ( YamlNode y | y = n .lookup ( "on" ) .( YamlMappingLikeNode ) .getNode ( trigger ) )
707+ }
708+
709+ /** Gets the trigger event that starts this workflow. */
710+ string getATriggerEvent ( ) { result = this .getEnclosingWorkflow ( ) .getATriggerEvent ( ) }
711+
712+ private predicate hasSingleTrigger ( string trigger ) {
713+ this .getATriggerEvent ( ) = trigger and
714+ count ( this .getATriggerEvent ( ) ) = 1
667715 }
668716
669717 /** Gets the runs-on field of the job. */
@@ -827,11 +875,14 @@ class UsesStepImpl extends StepImpl, UsesImpl {
827875
828876 /** Gets the owner and name of the repository where the Action comes from, e.g. `actions/checkout` in `actions/checkout@v2`. */
829877 override string getCallee ( ) {
830- result =
831- (
832- u .getValue ( ) .regexpCapture ( usesParser ( ) , 1 ) + "/" +
833- u .getValue ( ) .regexpCapture ( usesParser ( ) , 2 )
834- ) .toLowerCase ( )
878+ if u .getValue ( ) .matches ( "./%" )
879+ then result = u .getValue ( )
880+ else
881+ result =
882+ (
883+ u .getValue ( ) .regexpCapture ( usesParser ( ) , 1 ) + "/" +
884+ u .getValue ( ) .regexpCapture ( usesParser ( ) , 2 )
885+ ) .toLowerCase ( )
835886 }
836887
837888 /** Gets the version reference used when checking out the Action, e.g. `2` in `actions/checkout@v2`. */
0 commit comments