Better identification of checkout of untrusted code depending on the triggering events

This commit is contained in:
Alvaro Muñoz
2024-10-22 22:42:11 +02:00
parent 8f350d9068
commit 42d4bb577c
11 changed files with 250 additions and 144 deletions

View File

@@ -90,7 +90,8 @@ class GitCommandSource extends RemoteFlowSource, CommandSource {
checkout = uses and
uses.getCallee() = "actions/checkout" and
exists(uses.getArgument("ref")) and
not uses.getArgument("ref").matches("%base%")
not uses.getArgument("ref").matches("%base%") and
uses.getEnclosingJob().getATriggerEvent().getName() = checkoutTriggers()
)
or
checkout instanceof GitMutableRefCheckout
@@ -237,7 +238,8 @@ private class CheckoutSource extends RemoteFlowSource, FileSource {
this.asExpr() = uses and
uses.getCallee() = "actions/checkout" and
exists(uses.getArgument("ref")) and
not uses.getArgument("ref").matches("%base%")
not uses.getArgument("ref").matches("%base%") and
uses.getEnclosingJob().getATriggerEvent().getName() = checkoutTriggers()
)
or
this.asExpr() instanceof GitMutableRefCheckout

View File

@@ -29,7 +29,8 @@ class OutputClobberingFromFileReadSink extends OutputClobberingSink {
step = uses and
uses.getCallee() = "actions/checkout" and
exists(uses.getArgument("ref")) and
not uses.getArgument("ref").matches("%base%")
not uses.getArgument("ref").matches("%base%") and
uses.getEnclosingJob().getATriggerEvent().getName() = checkoutTriggers()
)
or
step instanceof GitMutableRefCheckout

View File

@@ -17,12 +17,13 @@ class PoisonableCommandStep extends PoisonableStep, Run {
class JavascriptImportUsesStep extends PoisonableStep, UsesStep {
JavascriptImportUsesStep() {
exists(string script, string line, string import_stmt |
exists(string script, string line |
this.getCallee() = "actions/github-script" and
script = this.getArgument("script") and
line = script.splitAt("\n").trim() and
import_stmt = line.regexpCapture(".*await\\s+import\\((.*)\\).*", 1) and
import_stmt.regexpMatch(".*\\bgithub.workspace\\b.*")
// const script = require('${{ github.workspace }}/scripts/test.js');
// await script({ github, context, core });
line.regexpMatch(".*(import|require)\\b.*github.workspace\\b.*")
)
}
}

View File

@@ -3,49 +3,57 @@ private import codeql.actions.DataFlow
private import codeql.actions.dataflow.FlowSources
private import codeql.actions.TaintTracking
string checkoutTriggers() {
result = ["pull_request_target", "workflow_run", "workflow_call", "issue_comment"]
}
/**
* A taint-tracking configuration for PR HEAD references flowing
* into actions/checkout's ref argument.
*/
private module ActionsMutableRefCheckoutConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) {
// remote flow sources
source instanceof ArtifactSource
or
source instanceof GitHubCtxSource
or
source instanceof GitHubEventCtxSource
or
source instanceof GitHubEventJsonSource
or
source instanceof MaDSource
or
// `ref` argument contains the PR id/number or head ref
exists(Expression e |
source.asExpr() = e and
(
containsHeadRef(e.getExpression()) or
containsPullRequestNumber(e.getExpression())
source.asExpr().getEnclosingJob().getATriggerEvent().getName() = checkoutTriggers() and
(
// remote flow sources
source instanceof ArtifactSource
or
source instanceof GitHubCtxSource
or
source instanceof GitHubEventCtxSource
or
source instanceof GitHubEventJsonSource
or
source instanceof MaDSource
or
// `ref` argument contains the PR id/number or head ref
exists(Expression e |
source.asExpr() = e and
(
containsHeadRef(e.getExpression()) or
containsPullRequestNumber(e.getExpression())
)
)
)
or
// 3rd party actions returning the PR head ref
exists(StepsExpression e, UsesStep step |
source.asExpr() = e and
e.getStepId() = step.getId() and
(
step.getCallee() = "eficode/resolve-pr-refs" and e.getFieldName() = "head_ref"
or
step.getCallee() = "xt0rted/pull-request-comment-branch" and e.getFieldName() = "head_ref"
or
step.getCallee() = "alessbell/pull-request-comment-branch" and e.getFieldName() = "head_ref"
or
step.getCallee() = "gotson/pull-request-comment-branch" and e.getFieldName() = "head_ref"
or
step.getCallee() = "potiuk/get-workflow-origin" and
e.getFieldName() = ["sourceHeadBranch", "pullRequestNumber"]
or
step.getCallee() = "github/branch-deploy" and e.getFieldName() = ["ref", "fork_ref"]
or
// 3rd party actions returning the PR head ref
exists(StepsExpression e, UsesStep step |
source.asExpr() = e and
e.getStepId() = step.getId() and
(
step.getCallee() = "eficode/resolve-pr-refs" and e.getFieldName() = "head_ref"
or
step.getCallee() = "xt0rted/pull-request-comment-branch" and e.getFieldName() = "head_ref"
or
step.getCallee() = "alessbell/pull-request-comment-branch" and
e.getFieldName() = "head_ref"
or
step.getCallee() = "gotson/pull-request-comment-branch" and e.getFieldName() = "head_ref"
or
step.getCallee() = "potiuk/get-workflow-origin" and
e.getFieldName() = ["sourceHeadBranch", "pullRequestNumber"]
or
step.getCallee() = "github/branch-deploy" and e.getFieldName() = ["ref", "fork_ref"]
)
)
)
}
@@ -71,27 +79,32 @@ module ActionsMutableRefCheckoutFlow = TaintTracking::Global<ActionsMutableRefCh
private module ActionsSHACheckoutConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) {
// `ref` argument contains the PR head/merge commit sha
exists(Expression e |
source.asExpr() = e and
containsHeadSHA(e.getExpression())
)
or
// 3rd party actions returning the PR head sha
exists(StepsExpression e, UsesStep step |
source.asExpr() = e and
e.getStepId() = step.getId() and
(
step.getCallee() = "eficode/resolve-pr-refs" and e.getFieldName() = "head_sha"
or
step.getCallee() = "xt0rted/pull-request-comment-branch" and e.getFieldName() = "head_sha"
or
step.getCallee() = "alessbell/pull-request-comment-branch" and e.getFieldName() = "head_sha"
or
step.getCallee() = "gotson/pull-request-comment-branch" and e.getFieldName() = "head_sha"
or
step.getCallee() = "potiuk/get-workflow-origin" and
e.getFieldName() = ["sourceHeadSha", "mergeCommitSha"]
source.asExpr().getEnclosingJob().getATriggerEvent().getName() =
["pull_request_target", "workflow_run", "workflow_call", "issue_comment"] and
(
// `ref` argument contains the PR head/merge commit sha
exists(Expression e |
source.asExpr() = e and
containsHeadSHA(e.getExpression())
)
or
// 3rd party actions returning the PR head sha
exists(StepsExpression e, UsesStep step |
source.asExpr() = e and
e.getStepId() = step.getId() and
(
step.getCallee() = "eficode/resolve-pr-refs" and e.getFieldName() = "head_sha"
or
step.getCallee() = "xt0rted/pull-request-comment-branch" and e.getFieldName() = "head_sha"
or
step.getCallee() = "alessbell/pull-request-comment-branch" and
e.getFieldName() = "head_sha"
or
step.getCallee() = "gotson/pull-request-comment-branch" and e.getFieldName() = "head_sha"
or
step.getCallee() = "potiuk/get-workflow-origin" and
e.getFieldName() = ["sourceHeadSha", "mergeCommitSha"]
)
)
)
}
@@ -201,44 +214,24 @@ class ActionsMutableRefCheckout extends MutableRefCheckoutStep instanceof UsesSt
ActionsMutableRefCheckoutFlow::PathNode source, ActionsMutableRefCheckoutFlow::PathNode sink
|
ActionsMutableRefCheckoutFlow::flowPath(source, sink) and
sink.getNode().asExpr() = this.getArgumentExpr(["ref", "repository"]) and
(
not source.getNode() instanceof GitHubEventCtxSource
or
source.getNode() instanceof GitHubEventCtxSource and
// the context is available for the job trigger events
exists(string context, string context_prefix |
contextTriggerDataModel(this.getEnclosingWorkflow().getATriggerEvent().getName(),
context_prefix) and
context = source.getNode().(GitHubEventCtxSource).getContext() and
normalizeExpr(context).matches("%" + context_prefix + "%")
)
)
sink.getNode().asExpr() = this.getArgumentExpr(["ref", "repository"])
)
or
// heuristic base on the step id and field name
exists(StepsExpression e |
this.getArgumentExpr("ref") = e and
(
e.getStepId().matches("%" + ["head", "branch", "ref"] + "%") or
e.getFieldName().matches("%" + ["head", "branch", "ref"] + "%")
)
)
or
exists(NeedsExpression e |
this.getArgumentExpr("ref") = e and
(
e.getNeededJobId().matches("%" + ["head", "branch", "ref"] + "%") or
e.getFieldName().matches("%" + ["head", "branch", "ref"] + "%")
)
)
or
exists(JsonReferenceExpression e |
this.getArgumentExpr("ref") = e and
(
e.getAccessPath().matches("%." + ["head", "branch", "ref"] + "%") or
e.getInnerExpression().matches("%." + ["head", "branch", "ref"] + "%")
)
exists(string value |
this.getArgumentExpr("ref")
.(SimpleReferenceExpression)
.getEnclosingJob()
.getATriggerEvent()
.getName() = checkoutTriggers() and
value.regexpMatch(".*(head|branch|ref).*")
|
this.getArgumentExpr("ref").(StepsExpression).getStepId() = value or
this.getArgumentExpr("ref").(StepsExpression).getFieldName() = value or
this.getArgumentExpr("ref").(NeedsExpression).getNeededJobId() = value or
this.getArgumentExpr("ref").(NeedsExpression).getFieldName() = value or
this.getArgumentExpr("ref").(JsonReferenceExpression).getAccessPath() = value or
this.getArgumentExpr("ref").(JsonReferenceExpression).getInnerExpression() = value
)
)
}
@@ -257,44 +250,24 @@ class ActionsSHACheckout extends SHACheckoutStep instanceof UsesStep {
(
exists(ActionsSHACheckoutFlow::PathNode source, ActionsSHACheckoutFlow::PathNode sink |
ActionsSHACheckoutFlow::flowPath(source, sink) and
sink.getNode().asExpr() = this.getArgumentExpr(["ref", "repository"]) and
(
not source.getNode() instanceof GitHubEventCtxSource
or
source.getNode() instanceof GitHubEventCtxSource and
// the context is available for the job trigger events
exists(string context, string context_prefix |
contextTriggerDataModel(this.getEnclosingWorkflow().getATriggerEvent().getName(),
context_prefix) and
context = source.getNode().(GitHubEventCtxSource).getContext() and
normalizeExpr(context).matches("%" + context_prefix + "%")
)
)
sink.getNode().asExpr() = this.getArgumentExpr(["ref", "repository"])
)
or
// heuristic base on the step id and field name
exists(StepsExpression e |
this.getArgumentExpr("ref") = e and
(
e.getStepId().matches("%" + ["head", "sha", "commit"] + "%") or
e.getFieldName().matches("%" + ["head", "sha", "commit"] + "%")
)
)
or
exists(NeedsExpression e |
this.getArgumentExpr("ref") = e and
(
e.getNeededJobId().matches("%" + ["head", "sha", "commit"] + "%") or
e.getFieldName().matches("%" + ["head", "sha", "commit"] + "%")
)
)
or
exists(JsonReferenceExpression e |
this.getArgumentExpr("ref") = e and
(
e.getAccessPath().matches("%." + ["head", "sha", "commit"] + "%") or
e.getInnerExpression().matches("%." + ["head", "sha", "commit"] + "%")
)
exists(string value |
this.getArgumentExpr("ref")
.(SimpleReferenceExpression)
.getEnclosingJob()
.getATriggerEvent()
.getName() = checkoutTriggers() and
value.regexpMatch(".*(head|sha|commit).*")
|
this.getArgumentExpr("ref").(StepsExpression).getStepId() = value or
this.getArgumentExpr("ref").(StepsExpression).getFieldName() = value or
this.getArgumentExpr("ref").(NeedsExpression).getNeededJobId() = value or
this.getArgumentExpr("ref").(NeedsExpression).getFieldName() = value or
this.getArgumentExpr("ref").(JsonReferenceExpression).getAccessPath() = value or
this.getArgumentExpr("ref").(JsonReferenceExpression).getInnerExpression() = value
)
)
}

View File

@@ -47,6 +47,7 @@ where
) and
// the checkout occurs in a privileged context
inPrivilegedContext(poisonable, event) and
inPrivilegedContext(checkout, event) and
not exists(ControlCheck check | check.protects(checkout, event, "untrusted-checkout")) and
not exists(ControlCheck check | check.protects(poisonable, event, "untrusted-checkout"))
select poisonable, checkout, poisonable,

View File

@@ -0,0 +1,27 @@
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
publish_docs:
description: "pub"
default: true
type: boolean
jobs:
Docs:
if: github.repository == 'test/test'
runs-on: macos-14
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
token: ${{ secrets.GITHUB_TOKEN }}
ref: ${{ github.head_ref || github.ref }}
fetch-depth: 0
- run: |
# NOT VULNERABLE
python docs/build_docs.py

View File

@@ -0,0 +1,62 @@
on:
pull_request:
push:
branches:
- trunk
- 'release/**'
- 'wp/**'
workflow_dispatch:
inputs:
version:
description: ''
required: true
jobs:
bump-version:
name: Bump version
runs-on: ubuntu-latest
outputs:
release_branch: ${{ steps.get_version.outputs.release_branch }}
steps:
- name: Compute old and new version
id: get_version
run: |
OLD_VERSION=$(jq --raw-output '.version' package.json)
echo "old_version=${OLD_VERSION}" >> $GITHUB_OUTPUT
if [[ ${{ github.event.inputs.version }} == 'stable' ]]; then
NEW_VERSION=$(npx semver $OLD_VERSION -i patch)
else
if [[ $OLD_VERSION == *"rc"* ]]; then
NEW_VERSION=$(npx semver $OLD_VERSION -i prerelease)
else
# WordPress version guidelines: If minor is 9, bump major instead.
IFS='.' read -r -a OLD_VERSION_ARRAY <<< "$OLD_VERSION"
if [[ ${OLD_VERSION_ARRAY[1]} == "9" ]]; then
NEW_VERSION="$(npx semver $OLD_VERSION -i major)-rc.1"
else
NEW_VERSION="$(npx semver $OLD_VERSION -i minor)-rc.1"
fi
fi
fi
echo "new_version=${NEW_VERSION}" >> $GITHUB_OUTPUT
IFS='.' read -r -a NEW_VERSION_ARRAY <<< "$NEW_VERSION"
RELEASE_BRANCH="release/${NEW_VERSION_ARRAY[0]}.${NEW_VERSION_ARRAY[1]}"
echo "release_branch=${RELEASE_BRANCH}" >> $GITHUB_OUTPUT
build:
runs-on: ubuntu-latest
needs: bump-version
if: |
always() && (
github.event_name == 'pull_request' ||
github.event_name == 'workflow_dispatch' ||
github.repository == 'test/test'
)
steps:
- name: Checkout code
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
with:
ref: ${{ needs.bump-version.outputs.release_branch || github.ref }}
- run: ./bin/build-plugin-zip.sh

View File

@@ -0,0 +1,47 @@
on:
schedule:
- cron: "0 3 * * 2-6" # Tuesdays - Saturdays, at 3am UTC
workflow_dispatch:
inputs:
pr:
description: "PR Number"
required: false
type: number
release:
types: [ published ]
jobs:
resolve-required-data:
name: Resolve Required Data
if: ${{ github.repository_owner == 'test' }}
runs-on: ubuntu-latest
outputs:
ref: ${{ steps.script.outputs.ref }}
steps:
- name: Resolve and set checkout and version data to use for release
id: script
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ github.event.inputs.pr }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const script = require('${{ github.workspace }}/scripts/publish-resolve-data.js');
await script({ github, context, core });
build:
needs: [ resolve-required-data ]
if: ${{ github.repository_owner == 'test' }}
name: stable
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: ${{ needs.resolve-required-data.outputs.repo }}
ref: ${{ needs.resolve-required-data.outputs.ref }}
- name: Build
shell: bash
run: |
./cmd

View File

@@ -267,6 +267,9 @@ edges
| .github/workflows/test18.yml:33:15:36:12 | Run Step | .github/workflows/test18.yml:36:15:40:58 | Uses Step |
| .github/workflows/test19.yml:16:7:21:4 | Uses Step | .github/workflows/test19.yml:21:7:22:14 | Run Step |
| .github/workflows/test20.yml:16:7:21:4 | Uses Step | .github/workflows/test20.yml:21:7:22:14 | Run Step |
| .github/workflows/test21.yml:18:9:25:6 | Uses Step | .github/workflows/test21.yml:25:9:27:36 | Run Step |
| .github/workflows/test22.yml:57:15:62:12 | Uses Step | .github/workflows/test22.yml:62:15:62:45 | Run Step |
| .github/workflows/test23.yml:38:9:43:6 | Uses Step | .github/workflows/test23.yml:43:9:46:16 | Run Step |
| .github/workflows/test.yml:13:9:14:6 | Uses Step | .github/workflows/test.yml:14:9:25:6 | Run Step |
| .github/workflows/test.yml:14:9:25:6 | Run Step | .github/workflows/test.yml:25:9:33:6 | Run Step |
| .github/workflows/test.yml:25:9:33:6 | Run Step | .github/workflows/test.yml:33:9:37:34 | Run Step |

View File

@@ -1,7 +1,3 @@
| .github/workflows/issue_comment_3rd_party_action.yml:16:9:22:2 | Uses Step | Potential execution of untrusted code on a privileged workflow. |
| .github/workflows/issue_comment_3rd_party_action.yml:30:9:36:2 | Uses Step | Potential execution of untrusted code on a privileged workflow. |
| .github/workflows/issue_comment_3rd_party_action.yml:45:9:49:6 | Uses Step | Potential execution of untrusted code on a privileged workflow. |
| .github/workflows/issue_comment_3rd_party_action.yml:49:9:52:25 | Uses Step | Potential execution of untrusted code on a privileged workflow. |
| .github/workflows/issue_comment_direct.yml:12:9:16:2 | Uses Step | Potential execution of untrusted code on a privileged workflow. |
| .github/workflows/issue_comment_direct.yml:20:9:24:2 | Uses Step | Potential execution of untrusted code on a privileged workflow. |
| .github/workflows/issue_comment_direct.yml:28:9:32:2 | Uses Step | Potential execution of untrusted code on a privileged workflow. |

View File

@@ -1,13 +1,6 @@
| .github/reusable_workflows/TestOrg/TestRepo/.github/workflows/formal.yml:14:9:19:6 | Uses Step | Potential unsafe checkout of untrusted pull request on privileged workflow. |
| .github/workflows/artifactpoisoning81.yml:11:9:14:6 | Uses Step | Potential unsafe checkout of untrusted pull request on privileged workflow. |
| .github/workflows/artifactpoisoning82.yml:11:9:14:6 | Uses Step | Potential unsafe checkout of untrusted pull request on privileged workflow. |
| .github/workflows/dependabot1.yml:15:9:19:6 | Uses Step | Potential unsafe checkout of untrusted pull request on privileged workflow. |
| .github/workflows/dependabot1.yml:39:9:43:6 | Uses Step | Potential unsafe checkout of untrusted pull request on privileged workflow. |
| .github/workflows/dependabot2.yml:33:9:38:6 | Uses Step | Potential unsafe checkout of untrusted pull request on privileged workflow. |
| .github/workflows/mend.yml:22:9:29:6 | Uses Step | Potential unsafe checkout of untrusted pull request on privileged workflow. |
| .github/workflows/poc3.yml:18:7:25:4 | Uses Step | Potential unsafe checkout of untrusted pull request on privileged workflow. |
| .github/workflows/poc.yml:30:9:36:6 | Uses Step | Potential unsafe checkout of untrusted pull request on privileged workflow. |
| .github/workflows/priv_pull_request_checkout.yml:14:9:20:6 | Uses Step | Potential unsafe checkout of untrusted pull request on privileged workflow. |
| .github/workflows/test3.yml:28:9:33:6 | Uses Step | Potential unsafe checkout of untrusted pull request on privileged workflow. |
| .github/workflows/test4.yml:18:7:25:4 | Uses Step | Potential unsafe checkout of untrusted pull request on privileged workflow. |
| .github/workflows/test8.yml:20:9:26:6 | Uses Step | Potential unsafe checkout of untrusted pull request on privileged workflow. |