diff --git a/actions/ql/lib/codeql/actions/security/ControlChecks.qll b/actions/ql/lib/codeql/actions/security/ControlChecks.qll index 63b04d46f8c..cf507b08940 100644 --- a/actions/ql/lib/codeql/actions/security/ControlChecks.qll +++ b/actions/ql/lib/codeql/actions/security/ControlChecks.qll @@ -67,19 +67,15 @@ abstract class ControlCheck extends AstNode { this.protectsCategoryAndEvent(category, event.getName()) and // The check can be triggered by the event this.getATriggerEvent() = event and - // For reusable workflows, ALL callers for this event must be protected by SOME check + // For reusable workflows, there must be no unprotected caller chain for this event. ( not node.getEnclosingWorkflow() instanceof ReusableWorkflow or - forall(ExternalJob directCaller | + this.dominatesSameWorkflow(node, event) + or + not exists(ExternalJob directCaller | directCaller = node.getEnclosingWorkflow().(ReusableWorkflow).getACaller() and - directCaller.getATriggerEvent() = event - | - exists(ControlCheck check | - check.protectsCategoryAndEvent(category, event.getName()) and - check.getATriggerEvent() = event and - check.dominatesViaCaller(node, event, directCaller) - ) + unprotectedCallerChain(directCaller, event, category) ) ) } @@ -88,7 +84,17 @@ abstract class ControlCheck extends AstNode { * Holds if this control check must execute and pass before `node` can run. */ predicate dominates(AstNode node, Event event) { - // Same-workflow dominance: bind event to this check's trigger event. + this.dominatesSameWorkflow(node, event) + or + // When the node is inside a reusable workflow, + // this check dominates via at least one caller chain. + this.dominatesViaCaller(node, event, _) + } + + /** + * Holds if this control check dominates `node` within the same workflow. + */ + predicate dominatesSameWorkflow(AstNode node, Event event) { this.getATriggerEvent() = event and ( // Step-level: the check is an `if:` on the step containing `node`, @@ -121,10 +127,6 @@ abstract class ControlCheck extends AstNode { node.getEnclosingJob().getANeededJob().(LocalJob).getAStep() = this ) ) - or - // When the node is inside a reusable workflow, - // this check dominates via at least one caller chain. - this.dominatesViaCaller(node, event, _) } /** @@ -136,29 +138,93 @@ abstract class ControlCheck extends AstNode { directCaller.getATriggerEvent() = event and exists(ExternalJob caller | caller = getAnOuterCaller*(directCaller) and - ( - this instanceof If and - ( - caller.getIf() = this or - caller.getANeededJob().(LocalJob).getIf() = this or - caller.getANeededJob().(LocalJob).getAStep().getIf() = this - ) - or - this instanceof Environment and - ( - caller.getEnvironment() = this or - caller.getANeededJob().getEnvironment() = this - ) - or - (this instanceof Run or this instanceof UsesStep) and - caller.getANeededJob().(LocalJob).getAStep() = this - ) + this.dominatesCaller(caller) ) } + /** + * Holds if this control check directly dominates `caller`. + */ + predicate dominatesCaller(ExternalJob caller) { + this instanceof If and + ( + caller.getIf() = this or + caller.getANeededJob().(LocalJob).getIf() = this or + caller.getANeededJob().(LocalJob).getAStep().getIf() = this + ) + or + this instanceof Environment and + ( + caller.getEnvironment() = this or + caller.getANeededJob().getEnvironment() = this + ) + or + (this instanceof Run or this instanceof UsesStep) and + caller.getANeededJob().(LocalJob).getAStep() = this + } + abstract predicate protectsCategoryAndEvent(string category, string event); } +/** + * Holds if this control check directly protects `caller`. + */ +bindingset[caller, event, category] +private predicate protectedCaller(ExternalJob caller, Event event, string category) { + exists(ControlCheck check | + check.protectsCategoryAndEvent(category, event.getName()) and + check.getATriggerEvent() = event and + check.dominatesCaller(caller) + ) +} + +cached +private newtype TCallerState = MkCallerState(ExternalJob caller, Event event, string category) { + caller.getATriggerEvent() = event and + category = any_category() +} + +private class CallerState extends TCallerState, MkCallerState { + ExternalJob caller; + Event event; + string category; + + CallerState() { this = MkCallerState(caller, event, category) } + + ExternalJob getCaller() { result = caller } + + Event getEvent() { result = event } + + string getCategory() { result = category } + + /** + * Gets an outer caller state if this caller is not protected. + */ + CallerState getUnprotectedOuterState() { + not protectedCaller(this.getCaller(), this.getEvent(), this.getCategory()) and + result = MkCallerState(getAnOuterCaller(this.getCaller()), this.getEvent(), this.getCategory()) + } + + predicate isUnprotectedOutermost() { + not protectedCaller(this.getCaller(), this.getEvent(), this.getCategory()) and + not exists(getAnOuterCaller(this.getCaller())) + } + + string toString() { result = caller + " / " + event + " / " + category } +} + +/** + * Holds if there is a caller path from `caller` to an outer workflow that has no protection. + */ +bindingset[caller, event, category] +private predicate unprotectedCallerChain(ExternalJob caller, Event event, string category) { + exists(CallerState start, CallerState outermost | + start = MkCallerState(caller, event, category) and + outermost = start.getUnprotectedOuterState*() and + outermost.isUnprotectedOutermost() + ) +} + abstract class AssociationCheck extends ControlCheck { // Checks if the actor is a MEMBER/OWNER the repo // - 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 diff --git a/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/external/TestOrg/TestRepo/.github/workflows/build_nested_branching.yml b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/external/TestOrg/TestRepo/.github/workflows/build_nested_branching.yml new file mode 100644 index 00000000000..79e65617673 --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/external/TestOrg/TestRepo/.github/workflows/build_nested_branching.yml @@ -0,0 +1,33 @@ +on: + workflow_call: + inputs: + COMMIT_SHA: + type: string + +jobs: + is-collaborator: + runs-on: ubuntu-latest + steps: + - name: Get User Permission + id: checkAccess + uses: actions-cool/check-user-permission@cd622002ff25c2311d2e7fb82107c0d24be83f9b + with: + require: write + username: ${{ github.actor }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Check User Permission + if: steps.checkAccess.outputs.require-result == 'false' + run: | + echo "${{ github.actor }} does not have permissions on this repo." + echo "Current permission level is ${{ steps.checkAccess.outputs.user-permission }}" + exit 1 + build_safe: + needs: is-collaborator + uses: TestOrg/TestRepo/.github/workflows/build_nested.yml@main + with: + COMMIT_SHA: ${{ inputs.COMMIT_SHA }} + build_unsafe: + uses: TestOrg/TestRepo/.github/workflows/build_nested.yml@main + with: + COMMIT_SHA: ${{ inputs.COMMIT_SHA }} diff --git a/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/untrusted_checkout_permission_check_reusable_branching_nested.yml b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/untrusted_checkout_permission_check_reusable_branching_nested.yml new file mode 100644 index 00000000000..9b96cb95e00 --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/untrusted_checkout_permission_check_reusable_branching_nested.yml @@ -0,0 +1,8 @@ +on: + pull_request_target: + +jobs: + build: + uses: TestOrg/TestRepo/.github/workflows/build_nested_branching.yml@main + with: + COMMIT_SHA: ${{ github.event.pull_request.head.sha }} diff --git a/actions/ql/test/query-tests/Security/CWE-829/UntrustedCheckoutCritical.expected b/actions/ql/test/query-tests/Security/CWE-829/UntrustedCheckoutCritical.expected index 2da0e4f61ec..b6c349bd64f 100644 --- a/actions/ql/test/query-tests/Security/CWE-829/UntrustedCheckoutCritical.expected +++ b/actions/ql/test/query-tests/Security/CWE-829/UntrustedCheckoutCritical.expected @@ -94,6 +94,7 @@ edges | .github/workflows/dependabot3.yml:20:9:25:6 | Uses Step | .github/workflows/dependabot3.yml:25:9:48:6 | Run Step: set-milestone | | .github/workflows/dependabot3.yml:25:9:48:6 | Run Step: set-milestone | .github/workflows/dependabot3.yml:48:9:52:57 | Run Step | | .github/workflows/external/TestOrg/TestRepo/.github/workflows/build.yml:11:9:14:6 | Uses Step | .github/workflows/external/TestOrg/TestRepo/.github/workflows/build.yml:14:9:17:7 | Run Step | +| .github/workflows/external/TestOrg/TestRepo/.github/workflows/build_nested_branching.yml:11:9:19:6 | Uses Step: checkAccess | .github/workflows/external/TestOrg/TestRepo/.github/workflows/build_nested_branching.yml:19:9:25:2 | Run Step | | .github/workflows/external/TestOrg/TestRepo/.github/workflows/formal.yml:14:9:19:6 | Uses Step | .github/workflows/external/TestOrg/TestRepo/.github/workflows/formal.yml:19:9:25:6 | Run Step | | .github/workflows/external/TestOrg/TestRepo/.github/workflows/formal.yml:19:9:25:6 | Run Step | .github/workflows/external/TestOrg/TestRepo/.github/workflows/formal.yml:25:9:70:20 | Run Step | | .github/workflows/external/TestOrg/TestRepo/.github/workflows/reusable.yml:23:9:26:6 | Uses Step | .github/workflows/external/TestOrg/TestRepo/.github/workflows/reusable.yml:26:9:29:7 | Run Step | @@ -357,6 +358,7 @@ edges | .github/workflows/auto_ci.yml:67:9:74:6 | Uses Step | .github/workflows/auto_ci.yml:67:9:74:6 | Uses Step | .github/workflows/auto_ci.yml:84:9:93:6 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/auto_ci.yml:6:3:6:21 | pull_request_target | pull_request_target | | .github/workflows/dependabot3.yml:15:9:20:6 | Uses Step | .github/workflows/dependabot3.yml:15:9:20:6 | Uses Step | .github/workflows/dependabot3.yml:25:9:48:6 | Run Step: set-milestone | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/dependabot3.yml:3:5:3:23 | pull_request_target | pull_request_target | | .github/workflows/external/TestOrg/TestRepo/.github/workflows/build.yml:11:9:14:6 | Uses Step | .github/workflows/external/TestOrg/TestRepo/.github/workflows/build.yml:11:9:14:6 | Uses Step | .github/workflows/external/TestOrg/TestRepo/.github/workflows/build.yml:14:9:17:7 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout_permission_check_reusable2.yml:2:3:2:21 | pull_request_target | pull_request_target | +| .github/workflows/external/TestOrg/TestRepo/.github/workflows/build.yml:11:9:14:6 | Uses Step | .github/workflows/external/TestOrg/TestRepo/.github/workflows/build.yml:11:9:14:6 | Uses Step | .github/workflows/external/TestOrg/TestRepo/.github/workflows/build.yml:14:9:17:7 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout_permission_check_reusable_branching_nested.yml:2:3:2:21 | pull_request_target | pull_request_target | | .github/workflows/external/TestOrg/TestRepo/.github/workflows/build.yml:11:9:14:6 | Uses Step | .github/workflows/external/TestOrg/TestRepo/.github/workflows/build.yml:11:9:14:6 | Uses Step | .github/workflows/external/TestOrg/TestRepo/.github/workflows/build.yml:14:9:17:7 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout_permission_check_reusable_no_needs.yml:2:3:2:21 | pull_request_target | pull_request_target | | .github/workflows/external/TestOrg/TestRepo/.github/workflows/reusable.yml:23:9:26:6 | Uses Step | .github/workflows/external/TestOrg/TestRepo/.github/workflows/reusable.yml:23:9:26:6 | Uses Step | .github/workflows/external/TestOrg/TestRepo/.github/workflows/reusable.yml:26:9:29:7 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/reusable_caller1.yaml:4:3:4:21 | pull_request_target | pull_request_target | | .github/workflows/gitcheckout.yml:10:11:18:8 | Run Step | .github/workflows/gitcheckout.yml:10:11:18:8 | Run Step | .github/workflows/gitcheckout.yml:21:11:23:22 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/gitcheckout.yml:2:3:2:21 | pull_request_target | pull_request_target |