@@ -42,6 +42,15 @@ string actor_not_attacker_event() {
4242 ]
4343}
4444
45+ /**
46+ * Gets the outer caller of `ej`, i.e. the `ExternalJob` that calls the
47+ * reusable workflow containing `ej`. Used with transitive closure to
48+ * walk up nested reusable workflow chains.
49+ */
50+ private ExternalJob getAnOuterCaller ( ExternalJob ej ) {
51+ result = ej .getEnclosingWorkflow ( ) .( ReusableWorkflow ) .getACaller ( )
52+ }
53+
4554/** An If node that contains an actor, user or label check */
4655abstract class ControlCheck extends AstNode {
4756 ControlCheck ( ) {
@@ -53,43 +62,170 @@ abstract class ControlCheck extends AstNode {
5362
5463 predicate protects ( AstNode node , Event event , string category ) {
5564 // The check dominates the step it should protect
56- this .dominates ( node ) and
65+ this .dominates ( node , event ) and
5766 // The check is effective against the event and category
5867 this .protectsCategoryAndEvent ( category , event .getName ( ) ) and
5968 // The check can be triggered by the event
60- this .getATriggerEvent ( ) = event
69+ this .getATriggerEvent ( ) = event and
70+ // For reusable workflows, there must be no unprotected caller chain for this event.
71+ (
72+ not node .getEnclosingWorkflow ( ) instanceof ReusableWorkflow
73+ or
74+ this .dominatesSameWorkflow ( node , event )
75+ or
76+ not exists ( ExternalJob directCaller |
77+ directCaller = node .getEnclosingWorkflow ( ) .( ReusableWorkflow ) .getACaller ( ) and
78+ unprotectedCallerChain ( directCaller , event , category )
79+ )
80+ )
6181 }
6282
63- predicate dominates ( AstNode node ) {
83+ /**
84+ * Holds if this control check must execute and pass before `node` can run.
85+ */
86+ predicate dominates ( AstNode node , Event event ) {
87+ this .dominatesSameWorkflow ( node , event )
88+ or
89+ // When the node is inside a reusable workflow,
90+ // this check dominates via at least one caller chain.
91+ this .dominatesViaCaller ( node , event , _)
92+ }
93+
94+ /**
95+ * Holds if this control check dominates `node` within the same workflow.
96+ */
97+ predicate dominatesSameWorkflow ( AstNode node , Event event ) {
98+ this .getATriggerEvent ( ) = event and
99+ (
100+ // Step-level: the check is an `if:` on the step containing `node`,
101+ // or on the enclosing job, or on a needed job/step.
102+ this instanceof If and
103+ (
104+ node .getEnclosingStep ( ) .getIf ( ) = this or
105+ node .getEnclosingJob ( ) .getIf ( ) = this or
106+ node .getEnclosingJob ( ) .getANeededJob ( ) .( LocalJob ) .getAStep ( ) .getIf ( ) = this or
107+ node .getEnclosingJob ( ) .getANeededJob ( ) .( LocalJob ) .getIf ( ) = this
108+ )
109+ or
110+ // Job-level: the check is an environment on the enclosing job or a needed job.
111+ this instanceof Environment and
112+ (
113+ node .getEnclosingJob ( ) .getEnvironment ( ) = this
114+ or
115+ node .getEnclosingJob ( ) .getANeededJob ( ) .getEnvironment ( ) = this
116+ )
117+ or
118+ // Step-level: the check is a Run/UsesStep that precedes `node`'s step
119+ // in the same job, or is a step in a needed job.
120+ (
121+ this instanceof Run or
122+ this instanceof UsesStep
123+ ) and
124+ (
125+ this .( Step ) .getAFollowingStep ( ) = node .getEnclosingStep ( )
126+ or
127+ node .getEnclosingJob ( ) .getANeededJob ( ) .( LocalJob ) .getAStep ( ) = this
128+ )
129+ )
130+ }
131+
132+ /**
133+ * Holds if this control check dominates `node` in a reusable workflow
134+ * via the caller chain starting at `directCaller`.
135+ */
136+ predicate dominatesViaCaller ( AstNode node , Event event , ExternalJob directCaller ) {
137+ directCaller = node .getEnclosingWorkflow ( ) .( ReusableWorkflow ) .getACaller ( ) and
138+ directCaller .getATriggerEvent ( ) = event and
139+ exists ( ExternalJob caller |
140+ caller = getAnOuterCaller * ( directCaller ) and
141+ this .dominatesCaller ( caller )
142+ )
143+ }
144+
145+ /**
146+ * Holds if this control check directly dominates `caller`.
147+ */
148+ predicate dominatesCaller ( ExternalJob caller ) {
64149 this instanceof If and
65150 (
66- node .getEnclosingStep ( ) .getIf ( ) = this or
67- node .getEnclosingJob ( ) .getIf ( ) = this or
68- node .getEnclosingJob ( ) .getANeededJob ( ) .( LocalJob ) .getAStep ( ) .getIf ( ) = this or
69- node .getEnclosingJob ( ) .getANeededJob ( ) .( LocalJob ) .getIf ( ) = this
151+ caller .getIf ( ) = this or
152+ caller .getANeededJob ( ) .( LocalJob ) .getIf ( ) = this or
153+ caller .getANeededJob ( ) .( LocalJob ) .getAStep ( ) .getIf ( ) = this
70154 )
71155 or
72156 this instanceof Environment and
73157 (
74- node .getEnclosingJob ( ) .getEnvironment ( ) = this
75- or
76- node .getEnclosingJob ( ) .getANeededJob ( ) .getEnvironment ( ) = this
158+ caller .getEnvironment ( ) = this or
159+ caller .getANeededJob ( ) .getEnvironment ( ) = this
77160 )
78161 or
79- (
80- this instanceof Run or
81- this instanceof UsesStep
82- ) and
83- (
84- this .( Step ) .getAFollowingStep ( ) = node .getEnclosingStep ( )
85- or
86- node .getEnclosingJob ( ) .getANeededJob ( ) .( LocalJob ) .getAStep ( ) = this .( Step )
87- )
162+ ( this instanceof Run or this instanceof UsesStep ) and
163+ caller .getANeededJob ( ) .( LocalJob ) .getAStep ( ) = this
88164 }
89165
90166 abstract predicate protectsCategoryAndEvent ( string category , string event ) ;
91167}
92168
169+ /**
170+ * Holds if this control check directly protects `caller`.
171+ */
172+ bindingset [ caller, event, category]
173+ private predicate protectedCaller ( ExternalJob caller , Event event , string category ) {
174+ exists ( ControlCheck check |
175+ check .protectsCategoryAndEvent ( category , event .getName ( ) ) and
176+ check .getATriggerEvent ( ) = event and
177+ check .dominatesCaller ( caller )
178+ )
179+ }
180+
181+ cached
182+ private newtype TCallerState =
183+ MkCallerState ( ExternalJob caller , Event event , string category ) {
184+ caller .getATriggerEvent ( ) = event and
185+ category = any_category ( )
186+ }
187+
188+ private class CallerState extends TCallerState , MkCallerState {
189+ ExternalJob caller ;
190+ Event event ;
191+ string category ;
192+
193+ CallerState ( ) { this = MkCallerState ( caller , event , category ) }
194+
195+ ExternalJob getCaller ( ) { result = caller }
196+
197+ Event getEvent ( ) { result = event }
198+
199+ string getCategory ( ) { result = category }
200+
201+ /**
202+ * Gets an outer caller state if this caller is not protected.
203+ */
204+ CallerState getUnprotectedOuterState ( ) {
205+ not protectedCaller ( this .getCaller ( ) , this .getEvent ( ) , this .getCategory ( ) ) and
206+ result = MkCallerState ( getAnOuterCaller ( this .getCaller ( ) ) , this .getEvent ( ) , this .getCategory ( ) )
207+ }
208+
209+ predicate isUnprotectedOutermost ( ) {
210+ not protectedCaller ( this .getCaller ( ) , this .getEvent ( ) , this .getCategory ( ) ) and
211+ not exists ( getAnOuterCaller ( this .getCaller ( ) ) )
212+ }
213+
214+ string toString ( ) { result = caller + " / " + event + " / " + category }
215+ }
216+
217+ /**
218+ * Holds if there is a caller path from `caller` to an outer workflow that has no protection.
219+ */
220+ bindingset [ caller, event, category]
221+ private predicate unprotectedCallerChain ( ExternalJob caller , Event event , string category ) {
222+ exists ( CallerState start , CallerState outermost |
223+ start = MkCallerState ( caller , event , category ) and
224+ outermost = start .getUnprotectedOuterState * ( ) and
225+ outermost .isUnprotectedOutermost ( )
226+ )
227+ }
228+
93229abstract class AssociationCheck extends ControlCheck {
94230 // Checks if the actor is a MEMBER/OWNER the repo
95231 // - they are effective against pull requests and workflow_run (since these are triggered by pull_requests) since they can control who is making the PR
0 commit comments