From ccae719613308e283de4d43d82daf297325ca131 Mon Sep 17 00:00:00 2001 From: Antero Silva Date: Tue, 24 Mar 2026 10:11:21 +0000 Subject: [PATCH 1/3] feat(queries): Add 8 Beta security queries covering policy gaps in github action scenarios --- .../metadata.json | 15 ++ .../query.rego | 43 +++++ .../test/negative1.yaml | 147 ++++++++++++++++++ .../test/negative2.yaml | 42 +++++ .../test/positive1.yaml | 118 ++++++++++++++ .../test/positive2.yaml | 44 ++++++ .../test/positive_expected_result.json | 14 ++ .../github/github_env_injection/metadata.json | 15 ++ .../github/github_env_injection/query.rego | 135 ++++++++++++++++ .../github_env_injection/test/negative1.yaml | 24 +++ .../github_env_injection/test/negative2.yaml | 20 +++ .../github_env_injection/test/positive1.yaml | 20 +++ .../github_env_injection/test/positive2.yaml | 15 ++ .../test/positive_expected_result.json | 14 ++ .../metadata.json | 15 ++ .../query.rego | 36 +++++ .../test/negative1.yaml | 21 +++ .../test/negative2.yaml | 20 +++ .../test/positive1.yaml | 17 ++ .../test/positive2.yaml | 14 ++ .../test/positive_expected_result.json | 14 ++ .../metadata.json | 15 ++ .../query.rego | 52 +++++++ .../test/negative1.yaml | 106 +++++++++++++ .../test/negative2.yaml | 24 +++ .../test/negative3.yaml | 21 +++ .../test/positive1.yaml | 99 ++++++++++++ .../test/positive2.yaml | 20 +++ .../test/positive_expected_result.json | 20 +++ .../metadata.json | 15 ++ .../query.rego | 41 +++++ .../test/negative1.yaml | 15 ++ .../test/negative2.yaml | 14 ++ .../test/positive1.yaml | 10 ++ .../test/positive2.yaml | 22 +++ .../test/positive_expected_result.json | 14 ++ .../metadata.json | 15 ++ .../query.rego | 43 +++++ .../test/negative1.yaml | 16 ++ .../test/negative2.yaml | 21 +++ .../test/positive1.yaml | 13 ++ .../test/positive2.yaml | 16 ++ .../test/positive_expected_result.json | 14 ++ .../metadata.json | 15 ++ .../query.rego | 35 +++++ .../test/negative1.yaml | 82 ++++++++++ .../test/negative2.yaml | 33 ++++ .../test/negative3.yaml | 27 ++++ .../test/positive1.yaml | 58 +++++++ .../test/positive_expected_result.json | 14 ++ .../metadata.json | 15 ++ .../workflow_write_all_permissions/query.rego | 48 ++++++ .../test/negative1.yaml | 18 +++ .../test/negative2.yaml | 17 ++ .../test/positive1.yaml | 14 ++ .../test/positive2.yaml | 23 +++ .../test/positive_expected_result.json | 14 ++ 57 files changed, 1842 insertions(+) create mode 100644 assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/metadata.json create mode 100644 assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/query.rego create mode 100644 assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/negative1.yaml create mode 100644 assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/negative2.yaml create mode 100644 assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive1.yaml create mode 100644 assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive2.yaml create mode 100644 assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive_expected_result.json create mode 100644 assets/queries/cicd/github/github_env_injection/metadata.json create mode 100644 assets/queries/cicd/github/github_env_injection/query.rego create mode 100644 assets/queries/cicd/github/github_env_injection/test/negative1.yaml create mode 100644 assets/queries/cicd/github/github_env_injection/test/negative2.yaml create mode 100644 assets/queries/cicd/github/github_env_injection/test/positive1.yaml create mode 100644 assets/queries/cicd/github/github_env_injection/test/positive2.yaml create mode 100644 assets/queries/cicd/github/github_env_injection/test/positive_expected_result.json create mode 100644 assets/queries/cicd/github/missing_persist_credentials_false/metadata.json create mode 100644 assets/queries/cicd/github/missing_persist_credentials_false/query.rego create mode 100644 assets/queries/cicd/github/missing_persist_credentials_false/test/negative1.yaml create mode 100644 assets/queries/cicd/github/missing_persist_credentials_false/test/negative2.yaml create mode 100644 assets/queries/cicd/github/missing_persist_credentials_false/test/positive1.yaml create mode 100644 assets/queries/cicd/github/missing_persist_credentials_false/test/positive2.yaml create mode 100644 assets/queries/cicd/github/missing_persist_credentials_false/test/positive_expected_result.json create mode 100644 assets/queries/cicd/github/missing_workflow_trigger_authorization/metadata.json create mode 100644 assets/queries/cicd/github/missing_workflow_trigger_authorization/query.rego create mode 100644 assets/queries/cicd/github/missing_workflow_trigger_authorization/test/negative1.yaml create mode 100644 assets/queries/cicd/github/missing_workflow_trigger_authorization/test/negative2.yaml create mode 100644 assets/queries/cicd/github/missing_workflow_trigger_authorization/test/negative3.yaml create mode 100644 assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive1.yaml create mode 100644 assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive2.yaml create mode 100644 assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive_expected_result.json create mode 100644 assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/metadata.json create mode 100644 assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/query.rego create mode 100644 assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/negative1.yaml create mode 100644 assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/negative2.yaml create mode 100644 assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive1.yaml create mode 100644 assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive2.yaml create mode 100644 assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive_expected_result.json create mode 100644 assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/metadata.json create mode 100644 assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/query.rego create mode 100644 assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/negative1.yaml create mode 100644 assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/negative2.yaml create mode 100644 assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive1.yaml create mode 100644 assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive2.yaml create mode 100644 assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive_expected_result.json create mode 100644 assets/queries/cicd/github/workflow_with_overly_permissive_permissions/metadata.json create mode 100644 assets/queries/cicd/github/workflow_with_overly_permissive_permissions/query.rego create mode 100644 assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/negative1.yaml create mode 100644 assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/negative2.yaml create mode 100644 assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/negative3.yaml create mode 100644 assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/positive1.yaml create mode 100644 assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/positive_expected_result.json create mode 100644 assets/queries/cicd/github/workflow_write_all_permissions/metadata.json create mode 100644 assets/queries/cicd/github/workflow_write_all_permissions/query.rego create mode 100644 assets/queries/cicd/github/workflow_write_all_permissions/test/negative1.yaml create mode 100644 assets/queries/cicd/github/workflow_write_all_permissions/test/negative2.yaml create mode 100644 assets/queries/cicd/github/workflow_write_all_permissions/test/positive1.yaml create mode 100644 assets/queries/cicd/github/workflow_write_all_permissions/test/positive2.yaml create mode 100644 assets/queries/cicd/github/workflow_write_all_permissions/test/positive_expected_result.json diff --git a/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/metadata.json b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/metadata.json new file mode 100644 index 00000000000..3fc50bf87ee --- /dev/null +++ b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/metadata.json @@ -0,0 +1,15 @@ +{ + "id": "d3a8b4c1-f2e7-4a9b-8c5d-1e6f0a2b3c4d", + "queryName": "Beta - Checkout Of Untrusted Code In Privileged Context", + "severity": "HIGH", + "category": "Insecure Configurations", + "descriptionText": "A workflow triggered by pull_request_target runs with the target repository's secrets and write permissions, because it always executes in the context of the base branch. If such a workflow checks out the PR contributor's code (e.g. using github.event.pull_request.head.sha or github.head_ref) and then executes it, an attacker can submit a malicious PR to run arbitrary code with elevated privileges. This is known as a 'pwn request'. The fix is to check out only the trusted base branch ref (e.g. github.event.pull_request.base.sha) in the privileged job, and perform any operations on untrusted code in a separate unprivileged workflow triggered by pull_request.", + "descriptionUrl": "https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/", + "platform": "CICD", + "descriptionID": "b1c2d3e4", + "cloudProvider": "common", + "cwe": "829", + "oldSeverity": "HIGH", + "riskScore": "9.0", + "experimental": "true" +} diff --git a/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/query.rego b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/query.rego new file mode 100644 index 00000000000..8604c004620 --- /dev/null +++ b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/query.rego @@ -0,0 +1,43 @@ +package Cx + +import data.generic.common as common_lib + +# Detect checkout of attacker-controlled code in a pull_request_target workflow. +# +# pull_request_target runs with the target repo's secrets and write permissions. +# Checking out the PR contributor's branch/commit (head ref) and then executing +# any code from that checkout gives the attacker elevated access ("pwn request"). +# +# Safe pattern: check out only the base branch ref (pull_request.base.sha / base.ref). +# Unsafe patterns include: pull_request.head.sha, pull_request.head.ref, head_ref, etc. + +CxPolicy[result] { + input.document[i].on["pull_request_target"] + + step := input.document[i].jobs[j].steps[k] + startswith(step.uses, "actions/checkout") + + ref := step["with"].ref + isUntrustedHeadRef(ref) + + result := { + "documentId": input.document[i].id, + "searchKey": sprintf("jobs.%s.steps.with.ref={{%s}}", [j, ref]), + "issueType": "IncorrectValue", + "keyExpectedValue": "Checkout uses a trusted base branch ref (e.g. github.event.pull_request.base.sha) in a pull_request_target workflow.", + "keyActualValue": "Checkout uses an untrusted PR head ref in a pull_request_target workflow, allowing execution of attacker-controlled code with elevated permissions.", + "searchLine": common_lib.build_search_line(["jobs", j, "steps", k, "with", "ref"], []), + } +} + +# Matches any ref expression that references the PR head (attacker-controlled): +# github.event.pull_request.head.* (e.g. .sha, .ref, .label, .repo.*) +# github.head_ref + +isUntrustedHeadRef(ref) { + regex.match("github\\.event\\.pull_request\\.head", ref) +} + +isUntrustedHeadRef(ref) { + regex.match("github\\.head_ref", ref) +} diff --git a/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/negative1.yaml b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/negative1.yaml new file mode 100644 index 00000000000..1a7392ed3db --- /dev/null +++ b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/negative1.yaml @@ -0,0 +1,147 @@ +name: PR Quality Checks + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + detect: + name: Detect PR type + runs-on: ubuntu-latest + outputs: + is_package_pr: ${{ steps.check.outputs.is_package_pr }} + steps: + - name: Check if README.md is modified + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + files=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files --jq '.[].filename' 2>/dev/null || echo "") + if echo "$files" | grep -q '^README.md$'; then + echo "is_package_pr=true" >> "$GITHUB_OUTPUT" + echo "README.md is modified — this is a package PR" + else + echo "is_package_pr=false" >> "$GITHUB_OUTPUT" + echo "README.md not modified — skipping quality checks" + fi + + quality: + name: Repository quality checks + needs: detect + if: needs.detect.outputs.is_package_pr == 'true' + runs-on: ubuntu-latest + environment: action + container: golang:latest + permissions: + contents: read + pull-requests: read + outputs: + comment: ${{ steps.quality.outputs.comment }} + labels: ${{ steps.quality.outputs.labels }} + fail: ${{ steps.quality.outputs.fail }} + diff_comment: ${{ steps.diff.outputs.diff_comment }} + diff_fail: ${{ steps.diff.outputs.diff_fail }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + fetch-depth: 0 + + - name: Fetch base branch and PR head + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + AUTH="$(printf '%s' "x-access-token:${GITHUB_TOKEN}" | base64 -w0)" + git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${AUTH}" fetch origin "${{ github.base_ref }}" + git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${AUTH}" fetch origin "+refs/pull/${{ github.event.pull_request.number }}/head" + + - name: Run quality checks + id: quality + continue-on-error: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: go run ./.github/scripts/check-quality/ + + - name: Run diff checks + id: diff + continue-on-error: true + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: go run ./.github/scripts/check-pr-diff/ + + report: + name: Post quality report + needs: [detect, quality] + if: always() && needs.detect.outputs.is_package_pr == 'true' && needs.quality.result != 'cancelled' + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: write + steps: + - name: Post quality report comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: pr-quality-check + message: | + ${{ needs.quality.outputs.comment }} + + --- + + ${{ needs.quality.outputs.diff_comment }} + + - name: Sync labels + if: needs.quality.outputs.labels != '' + uses: actions-ecosystem/action-add-labels@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + labels: ${{ join(fromJson(needs.quality.outputs.labels), '\n') }} + + - name: Fail if critical checks failed + if: needs.quality.outputs.fail == 'true' || needs.quality.outputs.diff_fail == 'true' + run: | + echo "Quality or diff checks failed." + exit 1 + + skip-notice: + name: Skip quality checks (non-package PR) + needs: detect + if: needs.detect.outputs.is_package_pr == 'false' + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Post skip notice + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: pr-quality-check + message: | + ## Automated Quality Checks + + **Skipped** — this PR does not modify `README.md`, so package quality checks do not apply. + + _This is expected for maintenance, documentation, or workflow PRs._ + + auto-merge: + name: Enable auto-merge + needs: [quality, report] + if: always() && needs.quality.result == 'success' && needs.report.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Enable auto-merge via squash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr merge ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --auto \ + --squash diff --git a/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/negative2.yaml b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/negative2.yaml new file mode 100644 index 00000000000..949ee37e1f1 --- /dev/null +++ b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/negative2.yaml @@ -0,0 +1,42 @@ +# Safe pattern: uses pull_request (not pull_request_target). +# The regular pull_request trigger runs in an isolated environment without +# access to target-repo secrets, so checking out the PR head is safe. +# Additionally, the job condition restricts to same-repo PRs only. + +name: Amber Automatic Code Review + +on: + pull_request: + types: [opened, synchronize] + +jobs: + amber-review: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read + + steps: + - name: Checkout base ref (for command file security) + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 1 + path: base-ref + + - name: Checkout PR code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Run code review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + prompt: | + Review the PR code and post findings as a comment. diff --git a/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive1.yaml b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive1.yaml new file mode 100644 index 00000000000..6c69c15dff4 --- /dev/null +++ b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive1.yaml @@ -0,0 +1,118 @@ +name: PR Quality Checks + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: write + contents: write + +jobs: + detect: + name: Detect PR type + runs-on: ubuntu-latest + outputs: + is_package_pr: ${{ steps.check.outputs.is_package_pr }} + steps: + - name: Check if README.md is modified + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + files=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files --jq '.[].filename' 2>/dev/null || echo "") + if echo "$files" | grep -q '^README.md$'; then + echo "is_package_pr=true" >> "$GITHUB_OUTPUT" + echo "README.md is modified — this is a package PR" + else + echo "is_package_pr=false" >> "$GITHUB_OUTPUT" + echo "README.md not modified — skipping quality checks" + fi + + quality: + name: Repository quality checks + needs: detect + if: needs.detect.outputs.is_package_pr == 'true' + runs-on: ubuntu-latest + environment: action + container: golang:latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Fetch base branch + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + git fetch origin ${{ github.base_ref }} + + - name: Run quality checks + id: quality + continue-on-error: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: go run ./.github/scripts/check-quality/ + + - name: Run diff checks + id: diff + continue-on-error: true + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: go run ./.github/scripts/check-pr-diff/ + + - name: Post quality report comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: pr-quality-check + message: | + ${{ steps.quality.outputs.comment }} + + --- + + ${{ steps.diff.outputs.diff_comment }} + + - name: Sync labels + uses: actions-ecosystem/action-add-labels@v1 + if: ${{ steps.quality.outputs.labels != '' }} + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + labels: ${{ join(fromJson(steps.quality.outputs.labels), '\n') }} + + - name: Fail if critical checks failed + if: ${{ steps.quality.outputs.fail == 'true' || steps.diff.outputs.diff_fail == 'true' }} + run: | + echo "Quality or diff checks failed." + exit 1 + + skip-notice: + name: Skip quality checks (non-package PR) + needs: detect + if: needs.detect.outputs.is_package_pr == 'false' + runs-on: ubuntu-latest + steps: + - name: Post skip notice + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: pr-quality-check + message: | + ## Automated Quality Checks + + **Skipped** — this PR does not modify `README.md`, so package quality checks do not apply. + + _This is expected for maintenance, documentation, or workflow PRs._ + + auto-merge: + name: Enable auto-merge + needs: quality + if: always() && needs.quality.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Enable auto-merge via squash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr merge ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --auto \ + --squash diff --git a/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive2.yaml b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive2.yaml new file mode 100644 index 00000000000..5f57aff91f8 --- /dev/null +++ b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive2.yaml @@ -0,0 +1,44 @@ +# Amber Automatic Code Review +# +# Vulnerable pattern: pull_request_target trigger with a checkout +# that uses github.event.pull_request.head.ref (attacker-controlled). +# The same job also executes the checked-out code via the review action. + +name: Amber Automatic Code Review + +on: + pull_request_target: + types: [opened, synchronize] + +jobs: + amber-review: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read + + steps: + - name: Checkout base ref (for command file security) + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 1 + path: base-ref + + - name: Checkout PR head + uses: actions/checkout@v6 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + + - name: Run code review on checked-out PR head + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + prompt: | + Review the PR code and post findings as a comment. diff --git a/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive_expected_result.json b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive_expected_result.json new file mode 100644 index 00000000000..0745692b307 --- /dev/null +++ b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive_expected_result.json @@ -0,0 +1,14 @@ +[ + { + "queryName": "Checkout Of Untrusted Code In Privileged Context", + "severity": "HIGH", + "line": 42, + "fileName": "positive1.yaml" + }, + { + "queryName": "Checkout Of Untrusted Code In Privileged Context", + "severity": "HIGH", + "line": 35, + "fileName": "positive2.yaml" + } +] diff --git a/assets/queries/cicd/github/github_env_injection/metadata.json b/assets/queries/cicd/github/github_env_injection/metadata.json new file mode 100644 index 00000000000..fb251fe088a --- /dev/null +++ b/assets/queries/cicd/github/github_env_injection/metadata.json @@ -0,0 +1,15 @@ +{ + "id": "b5c6d7e8-f9a0-4b1c-2d3e-4f5a6b7c8d9e", + "queryName": "Beta - GitHub Environment File Injection", + "severity": "HIGH", + "category": "Insecure Configurations", + "descriptionText": "A run block writes user-controlled GitHub context variables directly to the $GITHUB_ENV or $GITHUB_PATH special files. Writing to $GITHUB_ENV sets environment variables for all subsequent steps in the job; writing to $GITHUB_PATH prepends entries to the system PATH. Because these files affect the execution environment of subsequent steps, an attacker who controls the written value (e.g. via a crafted PR branch name, issue title, or comment body) can inject arbitrary environment variables or hijack command resolution — potentially achieving remote code execution in downstream steps. This is distinct from direct run-block injection: the current step may look harmless (just an echo), but the effect on later steps is the attack surface.", + "descriptionUrl": "https://securitylab.github.com/research/github-actions-untrusted-input/", + "platform": "CICD", + "descriptionID": "c9d0e1f2", + "cloudProvider": "common", + "cwe": "74", + "oldSeverity": "HIGH", + "riskScore": "8.5", + "experimental": "true" +} diff --git a/assets/queries/cicd/github/github_env_injection/query.rego b/assets/queries/cicd/github/github_env_injection/query.rego new file mode 100644 index 00000000000..aa40fcd6d5c --- /dev/null +++ b/assets/queries/cicd/github/github_env_injection/query.rego @@ -0,0 +1,135 @@ +package Cx + +import data.generic.common as common_lib + +# Detect untrusted user-controlled context variables written to $GITHUB_ENV +# or $GITHUB_PATH in run blocks. +# +# Writing attacker-controlled data to $GITHUB_ENV sets environment variables +# for all subsequent steps. Writing to $GITHUB_PATH adds entries to the PATH +# used by subsequent steps. Either can be exploited to hijack step execution. +# +# This is a different risk from direct run-block injection: the offending step +# may look benign (a simple `echo`), but the environment it contaminates is +# consumed by downstream steps that may be trusted actions or scripts. + +# pull_request_target: PR head ref, branch, title and body are attacker-controlled +CxPolicy[result] { + input.document[i].on["pull_request_target"] + run := input.document[i].jobs[j].steps[k].run + + writesToGithubEnvOrPath(run) + + patterns := [ + "github\\.head_ref", + "github\\.event\\.pull_request\\.head", + "github\\.event\\.pull_request\\.title", + "github\\.event\\.pull_request\\.body", + ] + + matched = containsPatterns(run, patterns) + + result := { + "documentId": input.document[i].id, + "searchKey": sprintf("run={{%s}}", [run]), + "issueType": "IncorrectValue", + "keyExpectedValue": "Run block does not write attacker-controlled context variables to GITHUB_ENV or GITHUB_PATH.", + "keyActualValue": "Run block writes attacker-controlled data to GITHUB_ENV or GITHUB_PATH, allowing environment manipulation for subsequent steps.", + "searchLine": common_lib.build_search_line(["jobs", j, "steps", k, "run"], []), + "searchValue": matched[_], + } +} + +# issue_comment: comment body is fully attacker-controlled +CxPolicy[result] { + input.document[i].on["issue_comment"] + run := input.document[i].jobs[j].steps[k].run + + writesToGithubEnvOrPath(run) + + patterns := [ + "github\\.event\\.comment\\.body", + "github\\.event\\.issue\\.title", + "github\\.event\\.issue\\.body", + ] + + matched = containsPatterns(run, patterns) + + result := { + "documentId": input.document[i].id, + "searchKey": sprintf("run={{%s}}", [run]), + "issueType": "IncorrectValue", + "keyExpectedValue": "Run block does not write attacker-controlled context variables to GITHUB_ENV or GITHUB_PATH.", + "keyActualValue": "Run block writes attacker-controlled data to GITHUB_ENV or GITHUB_PATH, allowing environment manipulation for subsequent steps.", + "searchLine": common_lib.build_search_line(["jobs", j, "steps", k, "run"], []), + "searchValue": matched[_], + } +} + +# issues: issue title and body are attacker-controlled +CxPolicy[result] { + input.document[i].on["issues"] + run := input.document[i].jobs[j].steps[k].run + + writesToGithubEnvOrPath(run) + + patterns := [ + "github\\.event\\.issue\\.title", + "github\\.event\\.issue\\.body", + ] + + matched = containsPatterns(run, patterns) + + result := { + "documentId": input.document[i].id, + "searchKey": sprintf("run={{%s}}", [run]), + "issueType": "IncorrectValue", + "keyExpectedValue": "Run block does not write attacker-controlled context variables to GITHUB_ENV or GITHUB_PATH.", + "keyActualValue": "Run block writes attacker-controlled data to GITHUB_ENV or GITHUB_PATH, allowing environment manipulation for subsequent steps.", + "searchLine": common_lib.build_search_line(["jobs", j, "steps", k, "run"], []), + "searchValue": matched[_], + } +} + +# workflow_run: head branch and commit metadata are attacker-controlled +CxPolicy[result] { + input.document[i].on["workflow_run"] + run := input.document[i].jobs[j].steps[k].run + + writesToGithubEnvOrPath(run) + + patterns := [ + "github\\.event\\.workflow_run\\.head_branch", + "github\\.event\\.workflow_run\\.head_commit\\.message", + "github\\.event\\.workflow_run\\.head_commit\\.author", + "github\\.event\\.workflow_run\\.head_repository\\.description", + ] + + matched = containsPatterns(run, patterns) + + result := { + "documentId": input.document[i].id, + "searchKey": sprintf("run={{%s}}", [run]), + "issueType": "IncorrectValue", + "keyExpectedValue": "Run block does not write attacker-controlled context variables to GITHUB_ENV or GITHUB_PATH.", + "keyActualValue": "Run block writes attacker-controlled data to GITHUB_ENV or GITHUB_PATH, allowing environment manipulation for subsequent steps.", + "searchLine": common_lib.build_search_line(["jobs", j, "steps", k, "run"], []), + "searchValue": matched[_], + } +} + +# The run block appends to $GITHUB_ENV or $GITHUB_PATH +writesToGithubEnvOrPath(run) { + regex.match(`>>\s*"?\$GITHUB_ENV"?`, run) +} + +writesToGithubEnvOrPath(run) { + regex.match(`>>\s*"?\$GITHUB_PATH"?`, run) +} + +containsPatterns(str, patterns) = matched { + matched := {pattern | + pattern := patterns[_] + regex.match(pattern, str) + } +} diff --git a/assets/queries/cicd/github/github_env_injection/test/negative1.yaml b/assets/queries/cicd/github/github_env_injection/test/negative1.yaml new file mode 100644 index 00000000000..c86f28fa5b4 --- /dev/null +++ b/assets/queries/cicd/github/github_env_injection/test/negative1.yaml @@ -0,0 +1,24 @@ +# Safe: writes to GITHUB_ENV but uses only trusted, non-user-controlled values. +# github.event.pull_request.number and base.sha are not attacker-controllable +# in ways that would allow environment injection. + +name: CI + +on: + pull_request_target: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + + - name: Set PR number env var + run: echo "PR_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV + + - name: Build + run: make build diff --git a/assets/queries/cicd/github/github_env_injection/test/negative2.yaml b/assets/queries/cicd/github/github_env_injection/test/negative2.yaml new file mode 100644 index 00000000000..90804f2e909 --- /dev/null +++ b/assets/queries/cicd/github/github_env_injection/test/negative2.yaml @@ -0,0 +1,20 @@ +# Safe: uses a hardcoded value when writing to GITHUB_ENV. +# No user-controlled context variable is involved. + +name: Set Build Config + +on: + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set build mode + run: echo "BUILD_MODE=production" >> $GITHUB_ENV + + - name: Build + run: make build diff --git a/assets/queries/cicd/github/github_env_injection/test/positive1.yaml b/assets/queries/cicd/github/github_env_injection/test/positive1.yaml new file mode 100644 index 00000000000..bc54aa51412 --- /dev/null +++ b/assets/queries/cicd/github/github_env_injection/test/positive1.yaml @@ -0,0 +1,20 @@ +name: CI + +on: + pull_request_target: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + + - name: Set branch env var + run: echo "BRANCH=${{ github.event.pull_request.head.ref }}" >> $GITHUB_ENV + + - name: Build using branch env var + run: make build --branch "$BRANCH" diff --git a/assets/queries/cicd/github/github_env_injection/test/positive2.yaml b/assets/queries/cicd/github/github_env_injection/test/positive2.yaml new file mode 100644 index 00000000000..da835fbc68f --- /dev/null +++ b/assets/queries/cicd/github/github_env_injection/test/positive2.yaml @@ -0,0 +1,15 @@ +name: Process Issue + +on: + issues: + types: [opened] + +jobs: + process: + runs-on: ubuntu-latest + steps: + - name: Export issue title to env + run: echo "ISSUE_TITLE=${{ github.event.issue.title }}" >> $GITHUB_ENV + + - name: Run script with issue title + run: ./scripts/process-issue.sh diff --git a/assets/queries/cicd/github/github_env_injection/test/positive_expected_result.json b/assets/queries/cicd/github/github_env_injection/test/positive_expected_result.json new file mode 100644 index 00000000000..584c3240b74 --- /dev/null +++ b/assets/queries/cicd/github/github_env_injection/test/positive_expected_result.json @@ -0,0 +1,14 @@ +[ + { + "queryName": "GitHub Environment File Injection", + "severity": "HIGH", + "line": 15, + "fileName": "positive1.yaml" + }, + { + "queryName": "GitHub Environment File Injection", + "severity": "HIGH", + "line": 11, + "fileName": "positive2.yaml" + } +] diff --git a/assets/queries/cicd/github/missing_persist_credentials_false/metadata.json b/assets/queries/cicd/github/missing_persist_credentials_false/metadata.json new file mode 100644 index 00000000000..033d2d1a424 --- /dev/null +++ b/assets/queries/cicd/github/missing_persist_credentials_false/metadata.json @@ -0,0 +1,15 @@ +{ + "id": "c9d0e1f2-a3b4-4c5d-6e7f-8a9b0c1d2e3f", + "queryName": "Beta - Missing persist-credentials False in Privileged Checkout", + "severity": "MEDIUM", + "category": "Insecure Configurations", + "descriptionText": "A pull_request_target workflow uses actions/checkout without setting 'persist-credentials: false'. By default, actions/checkout writes the GITHUB_TOKEN (or deploy key) to the repository's .git/config so that subsequent git operations work automatically. In a pull_request_target context — which runs with the target repository's secrets and permissions — any script executed after the checkout can read these credentials from .git/config and use them to exfiltrate secrets or make authenticated API calls. Setting persist-credentials: false removes the token from .git/config immediately after checkout, following the principle of least exposure.", + "descriptionUrl": "https://github.com/actions/checkout#usage", + "platform": "CICD", + "descriptionID": "e7f8a9b0", + "cloudProvider": "common", + "cwe": "522", + "oldSeverity": "MEDIUM", + "riskScore": "5.8", + "experimental": "true" +} diff --git a/assets/queries/cicd/github/missing_persist_credentials_false/query.rego b/assets/queries/cicd/github/missing_persist_credentials_false/query.rego new file mode 100644 index 00000000000..333678bb464 --- /dev/null +++ b/assets/queries/cicd/github/missing_persist_credentials_false/query.rego @@ -0,0 +1,36 @@ +package Cx + +import data.generic.common as common_lib + +# Detect actions/checkout steps in pull_request_target workflows that do not +# set persist-credentials: false. +# +# pull_request_target runs with the target repository's elevated permissions. +# actions/checkout defaults to persist-credentials: true, which writes the +# GITHUB_TOKEN into .git/config so subsequent git operations are authenticated. +# +# If the workflow (or a later step) runs any code that reads .git/config — +# including code from a fork PR — that code can extract the token and use it +# to make authenticated GitHub API calls, exfiltrate secrets, or push changes. +# +# Setting persist-credentials: false ensures credentials are never written to +# disk, limiting the blast radius of any compromised step. + +CxPolicy[result] { + input.document[i].on["pull_request_target"] + + step := input.document[i].jobs[j].steps[k] + startswith(step.uses, "actions/checkout") + + # persist-credentials is not explicitly set to false + not step["with"]["persist-credentials"] == false + + result := { + "documentId": input.document[i].id, + "searchKey": sprintf("jobs.%s.steps.uses={{%s}}", [j, step.uses]), + "issueType": "MissingAttribute", + "keyExpectedValue": sprintf("jobs.%s.steps.with.persist-credentials is set to false.", [j]), + "keyActualValue": sprintf("jobs.%s actions/checkout step does not set persist-credentials: false, leaving GitHub credentials written to .git/config in a privileged pull_request_target workflow.", [j]), + "searchLine": common_lib.build_search_line(["jobs", j, "steps", k, "uses"], []), + } +} diff --git a/assets/queries/cicd/github/missing_persist_credentials_false/test/negative1.yaml b/assets/queries/cicd/github/missing_persist_credentials_false/test/negative1.yaml new file mode 100644 index 00000000000..19618b230ea --- /dev/null +++ b/assets/queries/cicd/github/missing_persist_credentials_false/test/negative1.yaml @@ -0,0 +1,21 @@ +# Safe: persist-credentials: false prevents the GitHub token from being +# written to .git/config after checkout. + +name: PR Quality Check + +on: + pull_request_target: + branches: [main] + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + fetch-depth: 0 + + - name: Run quality checks + run: go run ./.github/scripts/check-quality/ diff --git a/assets/queries/cicd/github/missing_persist_credentials_false/test/negative2.yaml b/assets/queries/cicd/github/missing_persist_credentials_false/test/negative2.yaml new file mode 100644 index 00000000000..7809a5bf600 --- /dev/null +++ b/assets/queries/cicd/github/missing_persist_credentials_false/test/negative2.yaml @@ -0,0 +1,20 @@ +# Safe: pull_request trigger (not pull_request_target) runs in an unprivileged, +# sandboxed context without access to repository secrets. The credential +# exposure risk is much lower than with pull_request_target. + +name: CI + +on: + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run tests + run: make test diff --git a/assets/queries/cicd/github/missing_persist_credentials_false/test/positive1.yaml b/assets/queries/cicd/github/missing_persist_credentials_false/test/positive1.yaml new file mode 100644 index 00000000000..e728d053803 --- /dev/null +++ b/assets/queries/cicd/github/missing_persist_credentials_false/test/positive1.yaml @@ -0,0 +1,17 @@ +name: PR Quality Check + +on: + pull_request_target: + branches: [main] + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + fetch-depth: 0 + + - name: Run quality checks + run: go run ./.github/scripts/check-quality/ diff --git a/assets/queries/cicd/github/missing_persist_credentials_false/test/positive2.yaml b/assets/queries/cicd/github/missing_persist_credentials_false/test/positive2.yaml new file mode 100644 index 00000000000..50c0775cde5 --- /dev/null +++ b/assets/queries/cicd/github/missing_persist_credentials_false/test/positive2.yaml @@ -0,0 +1,14 @@ +name: Security Scan + +on: + pull_request_target: + types: [opened, synchronize] + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Run SAST + run: ./scripts/sast.sh diff --git a/assets/queries/cicd/github/missing_persist_credentials_false/test/positive_expected_result.json b/assets/queries/cicd/github/missing_persist_credentials_false/test/positive_expected_result.json new file mode 100644 index 00000000000..c2e24d0af92 --- /dev/null +++ b/assets/queries/cicd/github/missing_persist_credentials_false/test/positive_expected_result.json @@ -0,0 +1,14 @@ +[ + { + "queryName": "Missing persist-credentials False in Privileged Checkout", + "severity": "MEDIUM", + "line": 11, + "fileName": "positive1.yaml" + }, + { + "queryName": "Missing persist-credentials False in Privileged Checkout", + "severity": "MEDIUM", + "line": 10, + "fileName": "positive2.yaml" + } +] diff --git a/assets/queries/cicd/github/missing_workflow_trigger_authorization/metadata.json b/assets/queries/cicd/github/missing_workflow_trigger_authorization/metadata.json new file mode 100644 index 00000000000..6b01b175120 --- /dev/null +++ b/assets/queries/cicd/github/missing_workflow_trigger_authorization/metadata.json @@ -0,0 +1,15 @@ +{ + "id": "c2d4e6f8-a1b3-4c5d-6e7f-8a9b0c1d2e3f", + "queryName": "Beta - Missing Workflow Trigger Authorization", + "severity": "HIGH", + "category": "Access Control", + "descriptionText": "A GitHub Actions workflow triggered by the issue_comment event contains a job whose if condition checks for a slash command in the comment body (e.g. contains(github.event.comment.body, '/deploy')) without verifying the commenter's identity or role. Because the issue_comment event fires for comments from any GitHub user, this allows an unauthenticated or untrusted user to trigger workflow execution — including jobs that check out and run code from a forked pull request. The fix is to add an authorization check to the job's if condition, for example requiring github.event.comment.author_association == 'MEMBER' or 'OWNER', ensuring only trusted contributors can invoke the command.", + "descriptionUrl": "https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/", + "platform": "CICD", + "descriptionID": "d4e5f6a7", + "cloudProvider": "common", + "cwe": "285", + "oldSeverity": "HIGH", + "riskScore": "8.5", + "experimental": "true" +} diff --git a/assets/queries/cicd/github/missing_workflow_trigger_authorization/query.rego b/assets/queries/cicd/github/missing_workflow_trigger_authorization/query.rego new file mode 100644 index 00000000000..f5ffc3c1724 --- /dev/null +++ b/assets/queries/cicd/github/missing_workflow_trigger_authorization/query.rego @@ -0,0 +1,52 @@ +package Cx + +import data.generic.common as common_lib + +# Detect issue_comment triggered jobs that use slash commands without +# verifying the commenter's authorization. +# +# The issue_comment event fires for ANY comment from ANY GitHub user on +# issues and pull requests. A job whose `if` condition only checks the +# comment body for a slash command (e.g. /deploy, /version) with no +# identity check lets any user trigger that job — including jobs that +# check out and execute code from a forked PR. +# +# Fix: add an author_association guard to the job-level `if` condition: +# +# github.event.comment.author_association == 'MEMBER' || +# github.event.comment.author_association == 'OWNER' || +# github.event.comment.author_association == 'COLLABORATOR' +# +# This restricts command execution to trusted contributors only. + +CxPolicy[result] { + input.document[i].on["issue_comment"] + + job := input.document[i].jobs[j] + jobIf := job["if"] + + # Job is conditioned on a slash command present in the comment body + regex.match("contains\\(github\\.event\\.comment\\.body", jobIf) + + # But there is no check on who sent the comment + not hasAuthorizationCheck(jobIf) + + result := { + "documentId": input.document[i].id, + "searchKey": sprintf("jobs.%s.if={{%s}}", [j, jobIf]), + "issueType": "MissingAttribute", + "keyExpectedValue": sprintf("jobs.%s.if includes an authorization check such as github.event.comment.author_association == 'MEMBER'.", [j]), + "keyActualValue": sprintf("jobs.%s.if triggers on a comment body command without verifying the commenter's authorization, allowing any user to trigger workflow execution.", [j]), + "searchLine": common_lib.build_search_line(["jobs", j, "if"], []), + } +} + +# Authorization is considered present if the job if-condition checks +# the commenter's association or actor identity. +hasAuthorizationCheck(condition) { + regex.match("author_association", condition) +} + +hasAuthorizationCheck(condition) { + regex.match("github\\.actor", condition) +} diff --git a/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/negative1.yaml b/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/negative1.yaml new file mode 100644 index 00000000000..4ec409df022 --- /dev/null +++ b/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/negative1.yaml @@ -0,0 +1,106 @@ +name: Auto Bump Versions + +on: + issue_comment: + types: [created, edited] + +jobs: + add-same-version-label-to-pr: + runs-on: ubuntu-latest + if: github.event.issue.pull_request && contains(github.event.comment.body, '/add-same-version-label') && (github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') + steps: + - uses: actions/checkout@v4 + - name: Add same version label + uses: actions/github-script@v6 + if: success() + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['same version'] + }) + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '👋 Added [same version] label :)!' + }) + + build: + if: github.event.issue.pull_request && contains(github.event.comment.body, '/version') && (github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') + runs-on: ubuntu-latest + + steps: + - name: Get PR details + uses: actions/github-script@v6 + id: get-pr + with: + script: | + const request = { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + } + core.info(`Getting PR #${request.pull_number} from ${request.owner}/${request.repo}`) + try { + const result = await github.rest.pulls.get(request) + return result.data + } catch (err) { + core.setFailed(`Request failed with error ${err}`) + } + + - name: Checkout PR + uses: actions/checkout@v3 + with: + repository: ${{ fromJSON(steps.get-pr.outputs.result).head.repo.full_name }} + ref: ${{ fromJSON(steps.get-pr.outputs.result).head.ref }} + + # Security: use trusted version.sh from main, not the PR contributor's version + - name: Checkout version.sh from main branch + run: | + git fetch origin main + git checkout origin/main -- version.sh + chmod +x version.sh + + - name: Update version minor + if: contains(github.event.comment.body, '/version minor') + run: | + ./version.sh -u -n + echo "BUMP_TYPE=minor" >> $GITHUB_ENV + + - name: Update version major + if: contains(github.event.comment.body, '/version major') + run: | + ./version.sh -u -m + echo "BUMP_TYPE=major" >> $GITHUB_ENV + + - name: Update version patch + if: contains(github.event.comment.body, '/version patch') + run: | + ./version.sh -u -p + echo "BUMP_TYPE=patch" >> $GITHUB_ENV + + - name: Add labels + uses: actions/github-script@v6 + if: ${{ env.BUMP_TYPE }} + with: + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['version/${{ env.BUMP_TYPE }}'] + }) + + - name: Push Changes + if: ${{ env.BUMP_TYPE }} + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git pull + git add . + git commit -m "Update ${{ env.BUMP_TYPE }} version" --signoff + git push diff --git a/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/negative2.yaml b/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/negative2.yaml new file mode 100644 index 00000000000..0bc8de8eb6a --- /dev/null +++ b/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/negative2.yaml @@ -0,0 +1,24 @@ +# Safe: workflow_dispatch trigger requires explicit invocation via the GitHub UI +# or API by an authenticated user with write access. No comment-body command +# is used, so this pattern is outside the scope of this query. + +name: Deploy On Dispatch + +on: + workflow_dispatch: + inputs: + environment: + description: Target environment + type: choice + options: [staging, production] + required: true + +jobs: + deploy: + if: contains(github.event.inputs.environment, 'staging') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Deploy + run: ./scripts/deploy.sh ${{ github.event.inputs.environment }} diff --git a/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/negative3.yaml b/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/negative3.yaml new file mode 100644 index 00000000000..54d8434830b --- /dev/null +++ b/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/negative3.yaml @@ -0,0 +1,21 @@ +# Safe: issue_comment trigger, but uses github.actor as authorization guard. +# Only the specific bot user can trigger this slash command. + +name: Rebase On Comment + +on: + issue_comment: + types: [created] + +jobs: + rebase: + if: github.event.issue.pull_request && contains(github.event.comment.body, '/rebase') && github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Rebase PR + run: | + git fetch origin + git rebase origin/main + git push --force-with-lease diff --git a/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive1.yaml b/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive1.yaml new file mode 100644 index 00000000000..a51e95620a7 --- /dev/null +++ b/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive1.yaml @@ -0,0 +1,99 @@ +name: Auto Bump Versions + +on: + issue_comment: + types: [created, edited] + +jobs: + add-same-version-label-to-pr: + runs-on: ubuntu-latest + if: github.event.issue.pull_request && contains(github.event.comment.body, '/add-same-version-label') + steps: + - uses: actions/checkout@v4 + - name: Add same version label + uses: actions/github-script@v6 + if: success() + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['same version'] + }) + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '👋 Added [same version] label :)!' + }) + + build: + if: ${{ github.event.issue.pull_request }} && contains(github.event.comment.body, '/version') + runs-on: ubuntu-latest + + steps: + - name: Get PR details + uses: actions/github-script@v6 + id: get-pr + with: + script: | + const request = { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + } + core.info(`Getting PR #${request.pull_number} from ${request.owner}/${request.repo}`) + try { + const result = await github.rest.pulls.get(request) + return result.data + } catch (err) { + core.setFailed(`Request failed with error ${err}`) + } + + - name: Checkout PR + uses: actions/checkout@v3 + with: + repository: ${{ fromJSON(steps.get-pr.outputs.result).head.repo.full_name }} + ref: ${{ fromJSON(steps.get-pr.outputs.result).head.ref }} + + - name: Update version minor + if: contains(github.event.comment.body, '/version minor') + run: | + ./version.sh -u -n + echo "BUMP_TYPE=minor" >> $GITHUB_ENV + + - name: Update version major + if: contains(github.event.comment.body, '/version major') + run: | + ./version.sh -u -m + echo "BUMP_TYPE=major" >> $GITHUB_ENV + + - name: Update version patch + if: contains(github.event.comment.body, '/version patch') + run: | + ./version.sh -u -p + echo "BUMP_TYPE=patch" >> $GITHUB_ENV + + - name: Add labels + uses: actions/github-script@v6 + if: ${{ env.BUMP_TYPE }} + with: + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['version/${{ env.BUMP_TYPE }}'] + }) + + - name: Push Changes + if: ${{ env.BUMP_TYPE }} + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git pull + git add . + git commit -m "Update ${{ env.BUMP_TYPE }} version" --signoff + git push diff --git a/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive2.yaml b/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive2.yaml new file mode 100644 index 00000000000..a254a021582 --- /dev/null +++ b/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive2.yaml @@ -0,0 +1,20 @@ +# Minimal example: a single deploy-on-comment job with no authorization check. +# Any GitHub user can comment "/deploy staging" on any PR to trigger deployment. + +name: Deploy On Comment + +on: + issue_comment: + types: [created] + +jobs: + deploy: + if: github.event.issue.pull_request && contains(github.event.comment.body, '/deploy') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.issue.pull_request.head.sha }} + + - name: Deploy to staging + run: ./scripts/deploy.sh staging diff --git a/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive_expected_result.json b/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive_expected_result.json new file mode 100644 index 00000000000..709ec0b99be --- /dev/null +++ b/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive_expected_result.json @@ -0,0 +1,20 @@ +[ + { + "queryName": "Missing Workflow Trigger Authorization", + "severity": "HIGH", + "line": 10, + "fileName": "positive1.yaml" + }, + { + "queryName": "Missing Workflow Trigger Authorization", + "severity": "HIGH", + "line": 33, + "fileName": "positive1.yaml" + }, + { + "queryName": "Missing Workflow Trigger Authorization", + "severity": "HIGH", + "line": 12, + "fileName": "positive2.yaml" + } +] diff --git a/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/metadata.json b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/metadata.json new file mode 100644 index 00000000000..677dd3cc6f3 --- /dev/null +++ b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/metadata.json @@ -0,0 +1,15 @@ +{ + "id": "e3f4a5b6-c7d8-4e9f-0a1b-2c3d4e5f6a7b", + "queryName": "Beta - Secrets Inherit Used in External Reusable Workflow", + "severity": "HIGH", + "category": "Insecure Configurations", + "descriptionText": "A workflow calls an external reusable workflow (outside the current repository) with 'secrets: inherit', which passes ALL repository secrets to the called workflow. If the external workflow is compromised, malicious, or modified by an attacker (e.g. via a supply-chain attack on the referenced tag or branch), it gains access to every secret stored in the repository — including deployment keys, API tokens, and cloud credentials. Secrets should be passed explicitly and minimally: only provide the exact secrets the reusable workflow actually requires.", + "descriptionUrl": "https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-secrets-to-called-workflows", + "platform": "CICD", + "descriptionID": "e5f6a7b8", + "cloudProvider": "common", + "cwe": "522", + "oldSeverity": "HIGH", + "riskScore": "8.8", + "experimental": "true" +} diff --git a/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/query.rego b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/query.rego new file mode 100644 index 00000000000..0f4ef516455 --- /dev/null +++ b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/query.rego @@ -0,0 +1,41 @@ +package Cx + +import data.generic.common as common_lib + +# Detect use of 'secrets: inherit' when calling external reusable workflows. +# +# 'secrets: inherit' passes the ENTIRE set of repository secrets to the +# called workflow. Combined with an external (third-party) workflow, this +# creates a critical supply-chain risk: if the external repository is ever +# compromised, the attacker gains access to all your secrets. +# +# Safe pattern: pass only the exact secrets the reusable workflow needs: +# +# jobs: +# call: +# uses: org/repo/.github/workflows/deploy.yml@abc123... +# secrets: +# DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} # explicit, minimal +# +# Local workflow calls (uses: ./) are lower risk because the code +# is in the same repository and is subject to the same review process. + +CxPolicy[result] { + job := input.document[i].jobs[j] + uses := job.uses + + # Only flag external reusable workflows (not local ./path references) + not startswith(uses, "./") + + # secrets: inherit passes ALL repository secrets to the external workflow + job.secrets == "inherit" + + result := { + "documentId": input.document[i].id, + "searchKey": sprintf("jobs.%s.secrets={{inherit}}", [j]), + "issueType": "IncorrectValue", + "keyExpectedValue": sprintf("jobs.%s.secrets passes only the specific secrets required by the reusable workflow.", [j]), + "keyActualValue": sprintf("jobs.%s.secrets is set to inherit, passing all repository secrets to the external reusable workflow '%s'.", [j, uses]), + "searchLine": common_lib.build_search_line(["jobs", j, "secrets"], []), + } +} diff --git a/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/negative1.yaml b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/negative1.yaml new file mode 100644 index 00000000000..ea42d9b6b42 --- /dev/null +++ b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/negative1.yaml @@ -0,0 +1,15 @@ +# Safe: only the exact secrets needed are passed to the external workflow. +# The external workflow cannot access any other repository secrets. + +name: Deploy + +on: + push: + branches: [main] + +jobs: + deploy: + uses: external-org/deploy-workflows/.github/workflows/deploy.yml@abc1234567890abcdef1234567890abcdef12345678 + secrets: + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} diff --git a/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/negative2.yaml b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/negative2.yaml new file mode 100644 index 00000000000..a5c8bf6197d --- /dev/null +++ b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/negative2.yaml @@ -0,0 +1,14 @@ +# Safe: secrets: inherit used with a LOCAL reusable workflow (./path). +# The local workflow is in the same repository, subject to the same +# code review process, and cannot be compromised by a third party. + +name: Deploy + +on: + push: + branches: [main] + +jobs: + deploy: + uses: ./.github/workflows/internal-deploy.yml + secrets: inherit diff --git a/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive1.yaml b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive1.yaml new file mode 100644 index 00000000000..5ffd001b8b4 --- /dev/null +++ b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive1.yaml @@ -0,0 +1,10 @@ +name: Deploy + +on: + push: + branches: [main] + +jobs: + deploy: + uses: external-org/deploy-workflows/.github/workflows/deploy.yml@main + secrets: inherit diff --git a/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive2.yaml b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive2.yaml new file mode 100644 index 00000000000..9ebba23e734 --- /dev/null +++ b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive2.yaml @@ -0,0 +1,22 @@ +name: CI Pipeline + +on: + pull_request: + branches: [main] + +jobs: + lint: + uses: my-org/shared-workflows/.github/workflows/lint.yml@v2 + with: + node-version: '20' + + test: + uses: another-org/test-framework/.github/workflows/test.yml@abc1234 + secrets: inherit + + build: + needs: [lint, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: make build diff --git a/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive_expected_result.json b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive_expected_result.json new file mode 100644 index 00000000000..5fb0759e9cf --- /dev/null +++ b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive_expected_result.json @@ -0,0 +1,14 @@ +[ + { + "queryName": "Secrets Inherit Used in External Reusable Workflow", + "severity": "HIGH", + "line": 10, + "fileName": "positive1.yaml" + }, + { + "queryName": "Secrets Inherit Used in External Reusable Workflow", + "severity": "HIGH", + "line": 16, + "fileName": "positive2.yaml" + } +] diff --git a/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/metadata.json b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/metadata.json new file mode 100644 index 00000000000..37b01763cbd --- /dev/null +++ b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/metadata.json @@ -0,0 +1,15 @@ +{ + "id": "f1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c", + "queryName": "Beta - Self-hosted Runner Used in Pull Request Workflow", + "severity": "HIGH", + "category": "Insecure Configurations", + "descriptionText": "A workflow triggered by pull_request or pull_request_target events uses a self-hosted runner. Fork pull requests can trigger these events, causing untrusted code from a contributor's fork to run directly on the self-hosted machine. Unlike GitHub-hosted runners, which are ephemeral and isolated, self-hosted runners are persistent and may have access to internal networks, stored credentials, deployment keys, and other sensitive infrastructure. An attacker can exfiltrate secrets, install backdoors, or pivot to internal systems. Self-hosted runners should only be used with workflow triggers that cannot be initiated by untrusted external contributors.", + "descriptionUrl": "https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#hardening-for-self-hosted-runners", + "platform": "CICD", + "descriptionID": "a1b2c3d4", + "cloudProvider": "common", + "cwe": "284", + "oldSeverity": "HIGH", + "riskScore": "9.0", + "experimental": "true" +} diff --git a/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/query.rego b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/query.rego new file mode 100644 index 00000000000..f1803d0399e --- /dev/null +++ b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/query.rego @@ -0,0 +1,43 @@ +package Cx + +import data.generic.common as common_lib + +# Detect self-hosted runners used in workflows that can be triggered by +# fork pull requests (pull_request or pull_request_target events). +# +# Self-hosted runners are persistent machines. If fork code reaches a +# self-hosted runner it can: +# - Exfiltrate stored credentials, SSH keys, or environment variables +# - Pivot to internal networks the runner has access to +# - Install persistent backdoors on the runner +# - Read files from previous workflow runs +# +# Use GitHub-hosted (ephemeral, isolated) runners for any job that may +# process untrusted fork contributions. +# +# runs-on can be a string: "self-hosted" +# or an array: ["self-hosted", "linux", "x64"] + +CxPolicy[result] { + isForkPRTrigger(input.document[i].on) + job := input.document[i].jobs[j] + isSelfHosted(job["runs-on"]) + + result := { + "documentId": input.document[i].id, + "searchKey": sprintf("jobs.%s.runs-on={{%s}}", [j, job["runs-on"]]), + "issueType": "IncorrectValue", + "keyExpectedValue": sprintf("jobs.%s.runs-on uses a GitHub-hosted ephemeral runner (e.g. ubuntu-latest) for fork-triggerable events.", [j]), + "keyActualValue": sprintf("jobs.%s.runs-on uses a self-hosted runner in a workflow that can be triggered by untrusted fork pull requests.", [j]), + "searchLine": common_lib.build_search_line(["jobs", j, "runs-on"], []), + } +} + +isForkPRTrigger(on) { on["pull_request"] } +isForkPRTrigger(on) { on["pull_request_target"] } + +# String form: runs-on: self-hosted +isSelfHosted(runOn) { runOn == "self-hosted" } + +# Array form: runs-on: [self-hosted, linux, x64] +isSelfHosted(runOn) { runOn[_] == "self-hosted" } diff --git a/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/negative1.yaml b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/negative1.yaml new file mode 100644 index 00000000000..e6c585e2759 --- /dev/null +++ b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/negative1.yaml @@ -0,0 +1,16 @@ +# Safe: pull_request trigger but uses a GitHub-hosted runner. +# Fork code runs in an isolated, ephemeral environment. + +name: CI on Pull Request + +on: + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run tests + run: make test diff --git a/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/negative2.yaml b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/negative2.yaml new file mode 100644 index 00000000000..39a8868cef3 --- /dev/null +++ b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/negative2.yaml @@ -0,0 +1,21 @@ +# Safe: self-hosted runner used with workflow_dispatch, which requires +# explicit invocation by an authenticated user with write access. +# Fork code cannot trigger this workflow. + +name: Deploy to Production + +on: + workflow_dispatch: + inputs: + environment: + type: choice + options: [staging, production] + required: true + +jobs: + deploy: + runs-on: [self-hosted, linux, x64] + steps: + - uses: actions/checkout@v4 + - name: Deploy + run: ./scripts/deploy.sh ${{ github.event.inputs.environment }} diff --git a/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive1.yaml b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive1.yaml new file mode 100644 index 00000000000..32db1644552 --- /dev/null +++ b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive1.yaml @@ -0,0 +1,13 @@ +name: CI on Pull Request + +on: + pull_request: + branches: [main] + +jobs: + test: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + - name: Run tests + run: make test diff --git a/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive2.yaml b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive2.yaml new file mode 100644 index 00000000000..609f7beb106 --- /dev/null +++ b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive2.yaml @@ -0,0 +1,16 @@ +name: PR Quality Check + +on: + pull_request_target: + branches: [main] + +jobs: + quality: + runs-on: [self-hosted, linux, x64] + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + - name: Run quality checks + run: make quality diff --git a/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive_expected_result.json b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive_expected_result.json new file mode 100644 index 00000000000..4b3292be492 --- /dev/null +++ b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive_expected_result.json @@ -0,0 +1,14 @@ +[ + { + "queryName": "Self-hosted Runner Used in Pull Request Workflow", + "severity": "HIGH", + "line": 9, + "fileName": "positive1.yaml" + }, + { + "queryName": "Self-hosted Runner Used in Pull Request Workflow", + "severity": "HIGH", + "line": 9, + "fileName": "positive2.yaml" + } +] diff --git a/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/metadata.json b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/metadata.json new file mode 100644 index 00000000000..d191239b603 --- /dev/null +++ b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/metadata.json @@ -0,0 +1,15 @@ +{ + "id": "a9b8c7d6-e5f4-4a3b-2c1d-0e9f8a7b6c5d", + "queryName": "Beta - Workflow With Overly Permissive Permissions", + "severity": "MEDIUM", + "category": "Insecure Configurations", + "descriptionText": "A workflow triggered by pull_request_target runs with the target repository's secrets and permissions. Granting write permissions (pull-requests: write or contents: write) at the workflow level means every job — including those that check out and execute untrusted fork code — inherits those elevated permissions. Following the principle of least privilege, write permissions should be scoped to only the specific jobs that require them (e.g. a dedicated reporting or merge job), while jobs that perform code checkout and analysis should use read-only permissions.", + "descriptionUrl": "https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/", + "platform": "CICD", + "descriptionID": "f5a6b7c8", + "cloudProvider": "common", + "cwe": "272", + "oldSeverity": "MEDIUM", + "riskScore": "6.0", + "experimental": "true" +} diff --git a/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/query.rego b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/query.rego new file mode 100644 index 00000000000..d5c2bc7e511 --- /dev/null +++ b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/query.rego @@ -0,0 +1,35 @@ +package Cx + +import data.generic.common as common_lib + +# Detect write permissions granted at the workflow level in a pull_request_target workflow. +# +# pull_request_target runs with the target repo's elevated permissions. Setting +# pull-requests: write or contents: write at the workflow (top) level means ALL +# jobs inherit those rights, including jobs that check out and execute untrusted +# fork code. Write access should be scoped to only the specific jobs that need it +# (e.g. a separate reporting or merge job), using job-level permissions blocks. +# +# Safe pattern : workflow-level permissions set to read; individual jobs that +# genuinely need write have their own permissions block. +# Unsafe pattern: workflow-level pull-requests: write or contents: write. + +CxPolicy[result] { + input.document[i].on["pull_request_target"] + + perm := input.document[i].permissions + + # Check each permission that should default to read in this context + sensitivePerms := ["pull-requests", "contents"] + permName := sensitivePerms[_] + perm[permName] == "write" + + result := { + "documentId": input.document[i].id, + "searchKey": sprintf("permissions.%s={{write}}", [permName]), + "issueType": "IncorrectValue", + "keyExpectedValue": sprintf("permissions.%s is set to read at the workflow level; grant write only in the specific job(s) that require it.", [permName]), + "keyActualValue": sprintf("permissions.%s is set to write at the workflow level in a pull_request_target workflow, granting all jobs elevated write access.", [permName]), + "searchLine": common_lib.build_search_line(["permissions", permName], []), + } +} diff --git a/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/negative1.yaml b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/negative1.yaml new file mode 100644 index 00000000000..944f33bd836 --- /dev/null +++ b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/negative1.yaml @@ -0,0 +1,82 @@ +name: PR Quality Checks + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +# Workflow-level permissions are read-only (principle of least privilege). +# Write permissions are granted only in the specific jobs that require them. +permissions: + contents: read + pull-requests: read + +jobs: + detect: + name: Detect PR type + runs-on: ubuntu-latest + outputs: + is_package_pr: ${{ steps.check.outputs.is_package_pr }} + steps: + - name: Check if README.md is modified + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + files=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files --jq '.[].filename' 2>/dev/null || echo "") + if echo "$files" | grep -q '^README.md$'; then + echo "is_package_pr=true" >> "$GITHUB_OUTPUT" + else + echo "is_package_pr=false" >> "$GITHUB_OUTPUT" + fi + + quality: + name: Repository quality checks + needs: detect + if: needs.detect.outputs.is_package_pr == 'true' + runs-on: ubuntu-latest + container: golang:latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + fetch-depth: 0 + + - name: Run quality checks + run: go run ./.github/scripts/check-quality/ + + report: + name: Post quality report + needs: [detect, quality] + if: always() && needs.detect.outputs.is_package_pr == 'true' + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: write + steps: + - name: Post report comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: pr-quality-check + message: Quality check completed. + + auto-merge: + name: Enable auto-merge + needs: [quality, report] + if: always() && needs.quality.result == 'success' && needs.report.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Enable auto-merge via squash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr merge ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --auto \ + --squash diff --git a/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/negative2.yaml b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/negative2.yaml new file mode 100644 index 00000000000..e9eb7d2424f --- /dev/null +++ b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/negative2.yaml @@ -0,0 +1,33 @@ +# pull_request_target workflow where write permissions are scoped to job level only. +# No workflow-level write permissions exist, so this should not be flagged. + +name: Amber Automatic Code Review + +on: + pull_request_target: + types: [opened, synchronize] + +jobs: + amber-review: + runs-on: ubuntu-latest + # Write permissions scoped to this job only — not at workflow level. + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read + + steps: + - name: Checkout base ref + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 1 + + - name: Run code review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + prompt: Review the PR and post findings. diff --git a/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/negative3.yaml b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/negative3.yaml new file mode 100644 index 00000000000..5dc1814748a --- /dev/null +++ b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/negative3.yaml @@ -0,0 +1,27 @@ +# pull_request (not pull_request_target) with workflow-level write permissions. +# Since pull_request does not run with the target repo's elevated permissions, +# this pattern is outside the scope of this query. + +name: CI on Pull Request + +on: + pull_request: + branches: [main] + +permissions: + pull-requests: write + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Build + run: make build + + - name: Post status comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + message: Build succeeded. diff --git a/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/positive1.yaml b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/positive1.yaml new file mode 100644 index 00000000000..96f95d08625 --- /dev/null +++ b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/positive1.yaml @@ -0,0 +1,58 @@ +name: PR Quality Checks + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: write + contents: write + +jobs: + detect: + name: Detect PR type + runs-on: ubuntu-latest + outputs: + is_package_pr: ${{ steps.check.outputs.is_package_pr }} + steps: + - name: Check if README.md is modified + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + files=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files --jq '.[].filename' 2>/dev/null || echo "") + if echo "$files" | grep -q '^README.md$'; then + echo "is_package_pr=true" >> "$GITHUB_OUTPUT" + else + echo "is_package_pr=false" >> "$GITHUB_OUTPUT" + fi + + quality: + name: Repository quality checks + needs: detect + if: needs.detect.outputs.is_package_pr == 'true' + runs-on: ubuntu-latest + container: golang:latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Run quality checks + run: go run ./.github/scripts/check-quality/ + + auto-merge: + name: Enable auto-merge + needs: quality + if: always() && needs.quality.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Enable auto-merge via squash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr merge ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --auto \ + --squash diff --git a/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/positive_expected_result.json b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/positive_expected_result.json new file mode 100644 index 00000000000..a810aad15f4 --- /dev/null +++ b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/positive_expected_result.json @@ -0,0 +1,14 @@ +[ + { + "queryName": "Workflow With Overly Permissive Permissions", + "severity": "MEDIUM", + "line": 8, + "fileName": "positive1.yaml" + }, + { + "queryName": "Workflow With Overly Permissive Permissions", + "severity": "MEDIUM", + "line": 9, + "fileName": "positive1.yaml" + } +] diff --git a/assets/queries/cicd/github/workflow_write_all_permissions/metadata.json b/assets/queries/cicd/github/workflow_write_all_permissions/metadata.json new file mode 100644 index 00000000000..dfddbe065d1 --- /dev/null +++ b/assets/queries/cicd/github/workflow_write_all_permissions/metadata.json @@ -0,0 +1,15 @@ +{ + "id": "a7b8c9d0-e1f2-4a3b-4c5d-6e7f8a9b0c1d", + "queryName": "Beta - Workflow Permissions Set to Write-All", + "severity": "MEDIUM", + "category": "Insecure Configurations", + "descriptionText": "The workflow or a job explicitly sets 'permissions: write-all', which grants the GITHUB_TOKEN maximum write access to all available permission scopes (contents, pull-requests, issues, deployments, checks, statuses, packages, and more). This violates the principle of least privilege: if any step in the workflow is compromised or executes untrusted code, the attacker inherits all those write permissions. Permissions should be declared explicitly and minimally — specify only the individual scopes actually needed by the workflow or job.", + "descriptionUrl": "https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token", + "platform": "CICD", + "descriptionID": "a3b4c5d6", + "cloudProvider": "common", + "cwe": "272", + "oldSeverity": "MEDIUM", + "riskScore": "6.5", + "experimental": "true" +} diff --git a/assets/queries/cicd/github/workflow_write_all_permissions/query.rego b/assets/queries/cicd/github/workflow_write_all_permissions/query.rego new file mode 100644 index 00000000000..c33c325eaf6 --- /dev/null +++ b/assets/queries/cicd/github/workflow_write_all_permissions/query.rego @@ -0,0 +1,48 @@ +package Cx + +import data.generic.common as common_lib + +# Detect 'permissions: write-all' at the workflow level or job level. +# +# 'write-all' grants the GITHUB_TOKEN maximum write access across ALL scopes: +# contents, pull-requests, issues, checks, statuses, packages, deployments, +# discussions, id-token, and more. This is the broadest possible permission +# grant and violates the principle of least privilege. +# +# If any step executes untrusted code (e.g. from a fork, an issue comment, +# or a compromised action), the attacker inherits all write permissions. +# +# Fix: replace 'write-all' with an explicit list of only the permissions +# the workflow actually needs: +# +# permissions: +# contents: read +# pull-requests: write + +# Workflow-level write-all: all jobs inherit maximum permissions +CxPolicy[result] { + input.document[i].permissions == "write-all" + + result := { + "documentId": input.document[i].id, + "searchKey": "permissions={{write-all}}", + "issueType": "IncorrectValue", + "keyExpectedValue": "permissions declares only the specific scopes the workflow requires.", + "keyActualValue": "permissions is set to write-all at the workflow level, granting maximum GITHUB_TOKEN permissions to all jobs.", + "searchLine": common_lib.build_search_line(["permissions"], []), + } +} + +# Job-level write-all: the specific job gets maximum permissions +CxPolicy[result] { + input.document[i].jobs[j].permissions == "write-all" + + result := { + "documentId": input.document[i].id, + "searchKey": sprintf("jobs.%s.permissions={{write-all}}", [j]), + "issueType": "IncorrectValue", + "keyExpectedValue": sprintf("jobs.%s.permissions declares only the specific scopes the job requires.", [j]), + "keyActualValue": sprintf("jobs.%s.permissions is set to write-all, granting maximum GITHUB_TOKEN permissions to the job.", [j]), + "searchLine": common_lib.build_search_line(["jobs", j, "permissions"], []), + } +} diff --git a/assets/queries/cicd/github/workflow_write_all_permissions/test/negative1.yaml b/assets/queries/cicd/github/workflow_write_all_permissions/test/negative1.yaml new file mode 100644 index 00000000000..82b91ad1e60 --- /dev/null +++ b/assets/queries/cicd/github/workflow_write_all_permissions/test/negative1.yaml @@ -0,0 +1,18 @@ +# Safe: permissions declared explicitly with only the required scopes. + +name: Deploy + +on: + push: + branches: [main] + +permissions: + contents: read + packages: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: ./scripts/deploy.sh diff --git a/assets/queries/cicd/github/workflow_write_all_permissions/test/negative2.yaml b/assets/queries/cicd/github/workflow_write_all_permissions/test/negative2.yaml new file mode 100644 index 00000000000..d784f01a8a4 --- /dev/null +++ b/assets/queries/cicd/github/workflow_write_all_permissions/test/negative2.yaml @@ -0,0 +1,17 @@ +# Safe: uses read-all (not write-all). Grants read access across all scopes, +# which is the safe "everything read-only" default. + +name: Audit + +on: + schedule: + - cron: '0 0 * * 1' + +permissions: read-all + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: ./scripts/security-audit.sh diff --git a/assets/queries/cicd/github/workflow_write_all_permissions/test/positive1.yaml b/assets/queries/cicd/github/workflow_write_all_permissions/test/positive1.yaml new file mode 100644 index 00000000000..d0cf47b776e --- /dev/null +++ b/assets/queries/cicd/github/workflow_write_all_permissions/test/positive1.yaml @@ -0,0 +1,14 @@ +name: Deploy + +on: + push: + branches: [main] + +permissions: write-all + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: ./scripts/deploy.sh diff --git a/assets/queries/cicd/github/workflow_write_all_permissions/test/positive2.yaml b/assets/queries/cicd/github/workflow_write_all_permissions/test/positive2.yaml new file mode 100644 index 00000000000..7eadebe3378 --- /dev/null +++ b/assets/queries/cicd/github/workflow_write_all_permissions/test/positive2.yaml @@ -0,0 +1,23 @@ +name: Release + +on: + push: + tags: ['v*'] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: make build + + publish: + needs: build + runs-on: ubuntu-latest + permissions: write-all + steps: + - uses: actions/checkout@v4 + - run: ./scripts/publish.sh diff --git a/assets/queries/cicd/github/workflow_write_all_permissions/test/positive_expected_result.json b/assets/queries/cicd/github/workflow_write_all_permissions/test/positive_expected_result.json new file mode 100644 index 00000000000..cdbb53a2ecb --- /dev/null +++ b/assets/queries/cicd/github/workflow_write_all_permissions/test/positive_expected_result.json @@ -0,0 +1,14 @@ +[ + { + "queryName": "Workflow Permissions Set to Write-All", + "severity": "MEDIUM", + "line": 7, + "fileName": "positive1.yaml" + }, + { + "queryName": "Workflow Permissions Set to Write-All", + "severity": "MEDIUM", + "line": 20, + "fileName": "positive2.yaml" + } +] From b5d8d34d59ccd00c8c75dd645a18f7a761d076fa Mon Sep 17 00:00:00 2001 From: Antero Silva Date: Tue, 24 Mar 2026 10:30:57 +0000 Subject: [PATCH 2/3] fix issues in queries that failed the tests --- .../metadata.json | 2 +- assets/queries/cicd/github/github_env_injection/metadata.json | 4 ++-- .../github/missing_persist_credentials_false/metadata.json | 4 ++-- .../missing_workflow_trigger_authorization/metadata.json | 4 ++-- .../github/secrets_inherit_in_reusable_workflow/metadata.json | 4 ++-- .../self_hosted_runner_in_pull_request_workflow/metadata.json | 2 +- .../workflow_with_overly_permissive_permissions/metadata.json | 4 ++-- .../cicd/github/workflow_write_all_permissions/metadata.json | 4 ++-- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/metadata.json b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/metadata.json index 3fc50bf87ee..1994bf4f4a6 100644 --- a/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/metadata.json +++ b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/metadata.json @@ -3,7 +3,7 @@ "queryName": "Beta - Checkout Of Untrusted Code In Privileged Context", "severity": "HIGH", "category": "Insecure Configurations", - "descriptionText": "A workflow triggered by pull_request_target runs with the target repository's secrets and write permissions, because it always executes in the context of the base branch. If such a workflow checks out the PR contributor's code (e.g. using github.event.pull_request.head.sha or github.head_ref) and then executes it, an attacker can submit a malicious PR to run arbitrary code with elevated privileges. This is known as a 'pwn request'. The fix is to check out only the trusted base branch ref (e.g. github.event.pull_request.base.sha) in the privileged job, and perform any operations on untrusted code in a separate unprivileged workflow triggered by pull_request.", + "descriptionText": "A workflow triggered by pull_request_target runs with the target repository's secrets and write permissions. If such a workflow checks out the PR contributor's code (e.g. github.event.pull_request.head.sha or github.head_ref) and then executes it, an attacker can submit a malicious PR to run arbitrary code with elevated privileges. This is known as a 'pwn request'. Fix: check out only the trusted base branch ref (e.g. github.event.pull_request.base.sha) in the privileged job.", "descriptionUrl": "https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/", "platform": "CICD", "descriptionID": "b1c2d3e4", diff --git a/assets/queries/cicd/github/github_env_injection/metadata.json b/assets/queries/cicd/github/github_env_injection/metadata.json index fb251fe088a..5d4660d34d0 100644 --- a/assets/queries/cicd/github/github_env_injection/metadata.json +++ b/assets/queries/cicd/github/github_env_injection/metadata.json @@ -1,9 +1,9 @@ { - "id": "b5c6d7e8-f9a0-4b1c-2d3e-4f5a6b7c8d9e", + "id": "b5c6d7e8-f9a0-4b1c-8d3e-4f5a6b7c8d9e", "queryName": "Beta - GitHub Environment File Injection", "severity": "HIGH", "category": "Insecure Configurations", - "descriptionText": "A run block writes user-controlled GitHub context variables directly to the $GITHUB_ENV or $GITHUB_PATH special files. Writing to $GITHUB_ENV sets environment variables for all subsequent steps in the job; writing to $GITHUB_PATH prepends entries to the system PATH. Because these files affect the execution environment of subsequent steps, an attacker who controls the written value (e.g. via a crafted PR branch name, issue title, or comment body) can inject arbitrary environment variables or hijack command resolution — potentially achieving remote code execution in downstream steps. This is distinct from direct run-block injection: the current step may look harmless (just an echo), but the effect on later steps is the attack surface.", + "descriptionText": "A run block writes user-controlled GitHub context variables to $GITHUB_ENV or $GITHUB_PATH. Writing to $GITHUB_ENV sets environment variables for all subsequent steps; writing to $GITHUB_PATH prepends entries to the PATH. An attacker who controls the written value (e.g. via a crafted PR branch name or comment body) can inject arbitrary environment variables or hijack command resolution in downstream steps, potentially achieving code execution.", "descriptionUrl": "https://securitylab.github.com/research/github-actions-untrusted-input/", "platform": "CICD", "descriptionID": "c9d0e1f2", diff --git a/assets/queries/cicd/github/missing_persist_credentials_false/metadata.json b/assets/queries/cicd/github/missing_persist_credentials_false/metadata.json index 033d2d1a424..2eb2d9f00a9 100644 --- a/assets/queries/cicd/github/missing_persist_credentials_false/metadata.json +++ b/assets/queries/cicd/github/missing_persist_credentials_false/metadata.json @@ -1,9 +1,9 @@ { - "id": "c9d0e1f2-a3b4-4c5d-6e7f-8a9b0c1d2e3f", + "id": "c9d0e1f2-a3b4-4c5d-9e7f-8a9b0c1d2e3f", "queryName": "Beta - Missing persist-credentials False in Privileged Checkout", "severity": "MEDIUM", "category": "Insecure Configurations", - "descriptionText": "A pull_request_target workflow uses actions/checkout without setting 'persist-credentials: false'. By default, actions/checkout writes the GITHUB_TOKEN (or deploy key) to the repository's .git/config so that subsequent git operations work automatically. In a pull_request_target context — which runs with the target repository's secrets and permissions — any script executed after the checkout can read these credentials from .git/config and use them to exfiltrate secrets or make authenticated API calls. Setting persist-credentials: false removes the token from .git/config immediately after checkout, following the principle of least exposure.", + "descriptionText": "A pull_request_target workflow uses actions/checkout without 'persist-credentials: false'. By default, actions/checkout writes the GITHUB_TOKEN to the repository's .git/config. In a pull_request_target context — which runs with the target repository's secrets — any script executed after checkout can read these credentials and use them to exfiltrate secrets or make authenticated API calls. Setting persist-credentials: false removes the token from .git/config immediately after checkout.", "descriptionUrl": "https://github.com/actions/checkout#usage", "platform": "CICD", "descriptionID": "e7f8a9b0", diff --git a/assets/queries/cicd/github/missing_workflow_trigger_authorization/metadata.json b/assets/queries/cicd/github/missing_workflow_trigger_authorization/metadata.json index 6b01b175120..225448d11c4 100644 --- a/assets/queries/cicd/github/missing_workflow_trigger_authorization/metadata.json +++ b/assets/queries/cicd/github/missing_workflow_trigger_authorization/metadata.json @@ -1,9 +1,9 @@ { - "id": "c2d4e6f8-a1b3-4c5d-6e7f-8a9b0c1d2e3f", + "id": "c2d4e6f8-a1b3-4c5d-8e7f-8a9b0c1d2e3f", "queryName": "Beta - Missing Workflow Trigger Authorization", "severity": "HIGH", "category": "Access Control", - "descriptionText": "A GitHub Actions workflow triggered by the issue_comment event contains a job whose if condition checks for a slash command in the comment body (e.g. contains(github.event.comment.body, '/deploy')) without verifying the commenter's identity or role. Because the issue_comment event fires for comments from any GitHub user, this allows an unauthenticated or untrusted user to trigger workflow execution — including jobs that check out and run code from a forked pull request. The fix is to add an authorization check to the job's if condition, for example requiring github.event.comment.author_association == 'MEMBER' or 'OWNER', ensuring only trusted contributors can invoke the command.", + "descriptionText": "A workflow triggered by issue_comment contains a job that checks for a slash command in the comment body (e.g. contains(github.event.comment.body, '/deploy')) without verifying the commenter's identity. Any GitHub user can trigger workflow execution, including jobs that check out and run code from a forked PR. Fix: add an authorization check such as github.event.comment.author_association == 'MEMBER' or 'OWNER' to restrict command execution to trusted contributors.", "descriptionUrl": "https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/", "platform": "CICD", "descriptionID": "d4e5f6a7", diff --git a/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/metadata.json b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/metadata.json index 677dd3cc6f3..f14434f14d7 100644 --- a/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/metadata.json +++ b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/metadata.json @@ -1,9 +1,9 @@ { - "id": "e3f4a5b6-c7d8-4e9f-0a1b-2c3d4e5f6a7b", + "id": "e3f4a5b6-c7d8-4e9f-8a1b-2c3d4e5f6a7b", "queryName": "Beta - Secrets Inherit Used in External Reusable Workflow", "severity": "HIGH", "category": "Insecure Configurations", - "descriptionText": "A workflow calls an external reusable workflow (outside the current repository) with 'secrets: inherit', which passes ALL repository secrets to the called workflow. If the external workflow is compromised, malicious, or modified by an attacker (e.g. via a supply-chain attack on the referenced tag or branch), it gains access to every secret stored in the repository — including deployment keys, API tokens, and cloud credentials. Secrets should be passed explicitly and minimally: only provide the exact secrets the reusable workflow actually requires.", + "descriptionText": "A workflow calls an external reusable workflow with 'secrets: inherit', passing ALL repository secrets to the called workflow. If the external repository is compromised (e.g. via supply-chain attack on the referenced tag), it gains access to every secret — including deployment keys, API tokens, and cloud credentials. Secrets should be passed explicitly: provide only the exact secrets the reusable workflow actually requires.", "descriptionUrl": "https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-secrets-to-called-workflows", "platform": "CICD", "descriptionID": "e5f6a7b8", diff --git a/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/metadata.json b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/metadata.json index 37b01763cbd..edac3ac38b5 100644 --- a/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/metadata.json +++ b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/metadata.json @@ -3,7 +3,7 @@ "queryName": "Beta - Self-hosted Runner Used in Pull Request Workflow", "severity": "HIGH", "category": "Insecure Configurations", - "descriptionText": "A workflow triggered by pull_request or pull_request_target events uses a self-hosted runner. Fork pull requests can trigger these events, causing untrusted code from a contributor's fork to run directly on the self-hosted machine. Unlike GitHub-hosted runners, which are ephemeral and isolated, self-hosted runners are persistent and may have access to internal networks, stored credentials, deployment keys, and other sensitive infrastructure. An attacker can exfiltrate secrets, install backdoors, or pivot to internal systems. Self-hosted runners should only be used with workflow triggers that cannot be initiated by untrusted external contributors.", + "descriptionText": "A workflow triggered by pull_request or pull_request_target uses a self-hosted runner. Fork PRs can trigger these events, causing untrusted code to run on the persistent self-hosted machine. Unlike GitHub-hosted runners, self-hosted runners may have access to internal networks, stored credentials, and deployment keys. An attacker can exfiltrate secrets or pivot to internal systems. Use GitHub-hosted runners for any job that may process untrusted fork contributions.", "descriptionUrl": "https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#hardening-for-self-hosted-runners", "platform": "CICD", "descriptionID": "a1b2c3d4", diff --git a/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/metadata.json b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/metadata.json index d191239b603..73554be6e7d 100644 --- a/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/metadata.json +++ b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/metadata.json @@ -1,9 +1,9 @@ { - "id": "a9b8c7d6-e5f4-4a3b-2c1d-0e9f8a7b6c5d", + "id": "a9b8c7d6-e5f4-4a3b-8c1d-0e9f8a7b6c5d", "queryName": "Beta - Workflow With Overly Permissive Permissions", "severity": "MEDIUM", "category": "Insecure Configurations", - "descriptionText": "A workflow triggered by pull_request_target runs with the target repository's secrets and permissions. Granting write permissions (pull-requests: write or contents: write) at the workflow level means every job — including those that check out and execute untrusted fork code — inherits those elevated permissions. Following the principle of least privilege, write permissions should be scoped to only the specific jobs that require them (e.g. a dedicated reporting or merge job), while jobs that perform code checkout and analysis should use read-only permissions.", + "descriptionText": "A pull_request_target workflow grants write permissions (pull-requests: write or contents: write) at the workflow level. Every job — including those that check out and execute untrusted fork code — inherits those elevated permissions. Following the principle of least privilege, write permissions should be scoped to only the jobs that require them, while jobs that perform code checkout and analysis should use read-only permissions.", "descriptionUrl": "https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/", "platform": "CICD", "descriptionID": "f5a6b7c8", diff --git a/assets/queries/cicd/github/workflow_write_all_permissions/metadata.json b/assets/queries/cicd/github/workflow_write_all_permissions/metadata.json index dfddbe065d1..8db5891fc9b 100644 --- a/assets/queries/cicd/github/workflow_write_all_permissions/metadata.json +++ b/assets/queries/cicd/github/workflow_write_all_permissions/metadata.json @@ -1,9 +1,9 @@ { - "id": "a7b8c9d0-e1f2-4a3b-4c5d-6e7f8a9b0c1d", + "id": "a7b8c9d0-e1f2-4a3b-9c5d-6e7f8a9b0c1d", "queryName": "Beta - Workflow Permissions Set to Write-All", "severity": "MEDIUM", "category": "Insecure Configurations", - "descriptionText": "The workflow or a job explicitly sets 'permissions: write-all', which grants the GITHUB_TOKEN maximum write access to all available permission scopes (contents, pull-requests, issues, deployments, checks, statuses, packages, and more). This violates the principle of least privilege: if any step in the workflow is compromised or executes untrusted code, the attacker inherits all those write permissions. Permissions should be declared explicitly and minimally — specify only the individual scopes actually needed by the workflow or job.", + "descriptionText": "The workflow or a job explicitly sets 'permissions: write-all', granting the GITHUB_TOKEN maximum write access to all scopes (contents, pull-requests, issues, deployments, checks, and more). This violates the principle of least privilege: if any step executes untrusted code, the attacker inherits all write permissions. Declare only the individual scopes the workflow actually needs.", "descriptionUrl": "https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token", "platform": "CICD", "descriptionID": "a3b4c5d6", From 11d1f2d36b2ce69a9a2fe95b1b35c2c11b559d5b Mon Sep 17 00:00:00 2001 From: Antero Silva Date: Tue, 24 Mar 2026 10:48:46 +0000 Subject: [PATCH 3/3] updated query name on expected results that was causing test failure --- .../test/positive_expected_result.json | 4 ++-- .../test/positive_expected_result.json | 8 ++++---- .../test/positive_expected_result.json | 6 +++--- .../test/positive_expected_result.json | 6 +++--- .../test/positive_expected_result.json | 6 +++--- .../test/positive_expected_result.json | 4 ++-- .../test/positive_expected_result.json | 4 ++-- .../test/positive_expected_result.json | 4 ++-- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive_expected_result.json b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive_expected_result.json index 0745692b307..9151d4ec213 100644 --- a/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive_expected_result.json +++ b/assets/queries/cicd/github/checkout_of_untrusted_code_in_privileged_context/test/positive_expected_result.json @@ -1,12 +1,12 @@ [ { - "queryName": "Checkout Of Untrusted Code In Privileged Context", + "queryName": "Beta - Checkout Of Untrusted Code In Privileged Context", "severity": "HIGH", "line": 42, "fileName": "positive1.yaml" }, { - "queryName": "Checkout Of Untrusted Code In Privileged Context", + "queryName": "Beta - Checkout Of Untrusted Code In Privileged Context", "severity": "HIGH", "line": 35, "fileName": "positive2.yaml" diff --git a/assets/queries/cicd/github/github_env_injection/test/positive_expected_result.json b/assets/queries/cicd/github/github_env_injection/test/positive_expected_result.json index 584c3240b74..668a386990a 100644 --- a/assets/queries/cicd/github/github_env_injection/test/positive_expected_result.json +++ b/assets/queries/cicd/github/github_env_injection/test/positive_expected_result.json @@ -1,14 +1,14 @@ [ { - "queryName": "GitHub Environment File Injection", + "queryName": "Beta - GitHub Environment File Injection", "severity": "HIGH", - "line": 15, + "line": 17, "fileName": "positive1.yaml" }, { - "queryName": "GitHub Environment File Injection", + "queryName": "Beta - GitHub Environment File Injection", "severity": "HIGH", - "line": 11, + "line": 12, "fileName": "positive2.yaml" } ] diff --git a/assets/queries/cicd/github/missing_persist_credentials_false/test/positive_expected_result.json b/assets/queries/cicd/github/missing_persist_credentials_false/test/positive_expected_result.json index c2e24d0af92..3eeb9d84baf 100644 --- a/assets/queries/cicd/github/missing_persist_credentials_false/test/positive_expected_result.json +++ b/assets/queries/cicd/github/missing_persist_credentials_false/test/positive_expected_result.json @@ -1,14 +1,14 @@ [ { - "queryName": "Missing persist-credentials False in Privileged Checkout", + "queryName": "Beta - Missing persist-credentials False in Privileged Checkout", "severity": "MEDIUM", "line": 11, "fileName": "positive1.yaml" }, { - "queryName": "Missing persist-credentials False in Privileged Checkout", + "queryName": "Beta - Missing persist-credentials False in Privileged Checkout", "severity": "MEDIUM", - "line": 10, + "line": 11, "fileName": "positive2.yaml" } ] diff --git a/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive_expected_result.json b/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive_expected_result.json index 709ec0b99be..036f6a71233 100644 --- a/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive_expected_result.json +++ b/assets/queries/cicd/github/missing_workflow_trigger_authorization/test/positive_expected_result.json @@ -1,18 +1,18 @@ [ { - "queryName": "Missing Workflow Trigger Authorization", + "queryName": "Beta - Missing Workflow Trigger Authorization", "severity": "HIGH", "line": 10, "fileName": "positive1.yaml" }, { - "queryName": "Missing Workflow Trigger Authorization", + "queryName": "Beta - Missing Workflow Trigger Authorization", "severity": "HIGH", "line": 33, "fileName": "positive1.yaml" }, { - "queryName": "Missing Workflow Trigger Authorization", + "queryName": "Beta - Missing Workflow Trigger Authorization", "severity": "HIGH", "line": 12, "fileName": "positive2.yaml" diff --git a/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive_expected_result.json b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive_expected_result.json index 5fb0759e9cf..67dd74104fc 100644 --- a/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive_expected_result.json +++ b/assets/queries/cicd/github/secrets_inherit_in_reusable_workflow/test/positive_expected_result.json @@ -1,14 +1,14 @@ [ { - "queryName": "Secrets Inherit Used in External Reusable Workflow", + "queryName": "Beta - Secrets Inherit Used in External Reusable Workflow", "severity": "HIGH", "line": 10, "fileName": "positive1.yaml" }, { - "queryName": "Secrets Inherit Used in External Reusable Workflow", + "queryName": "Beta - Secrets Inherit Used in External Reusable Workflow", "severity": "HIGH", - "line": 16, + "line": 15, "fileName": "positive2.yaml" } ] diff --git a/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive_expected_result.json b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive_expected_result.json index 4b3292be492..61d87ee722b 100644 --- a/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive_expected_result.json +++ b/assets/queries/cicd/github/self_hosted_runner_in_pull_request_workflow/test/positive_expected_result.json @@ -1,12 +1,12 @@ [ { - "queryName": "Self-hosted Runner Used in Pull Request Workflow", + "queryName": "Beta - Self-hosted Runner Used in Pull Request Workflow", "severity": "HIGH", "line": 9, "fileName": "positive1.yaml" }, { - "queryName": "Self-hosted Runner Used in Pull Request Workflow", + "queryName": "Beta - Self-hosted Runner Used in Pull Request Workflow", "severity": "HIGH", "line": 9, "fileName": "positive2.yaml" diff --git a/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/positive_expected_result.json b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/positive_expected_result.json index a810aad15f4..4c98767f0dc 100644 --- a/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/positive_expected_result.json +++ b/assets/queries/cicd/github/workflow_with_overly_permissive_permissions/test/positive_expected_result.json @@ -1,12 +1,12 @@ [ { - "queryName": "Workflow With Overly Permissive Permissions", + "queryName": "Beta - Workflow With Overly Permissive Permissions", "severity": "MEDIUM", "line": 8, "fileName": "positive1.yaml" }, { - "queryName": "Workflow With Overly Permissive Permissions", + "queryName": "Beta - Workflow With Overly Permissive Permissions", "severity": "MEDIUM", "line": 9, "fileName": "positive1.yaml" diff --git a/assets/queries/cicd/github/workflow_write_all_permissions/test/positive_expected_result.json b/assets/queries/cicd/github/workflow_write_all_permissions/test/positive_expected_result.json index cdbb53a2ecb..a250b8aeb50 100644 --- a/assets/queries/cicd/github/workflow_write_all_permissions/test/positive_expected_result.json +++ b/assets/queries/cicd/github/workflow_write_all_permissions/test/positive_expected_result.json @@ -1,12 +1,12 @@ [ { - "queryName": "Workflow Permissions Set to Write-All", + "queryName": "Beta - Workflow Permissions Set to Write-All", "severity": "MEDIUM", "line": 7, "fileName": "positive1.yaml" }, { - "queryName": "Workflow Permissions Set to Write-All", + "queryName": "Beta - Workflow Permissions Set to Write-All", "severity": "MEDIUM", "line": 20, "fileName": "positive2.yaml"