CI/CD Security Scanner This skill turns the model into a workflow-YAML scanner. Read the file, walk the detection rules, report findings with severity and a concrete rewrite. No tools to install, no commands to run — the analysis is the model reading the YAML. The rules encode the current consensus from Astral, OpenSSF, GitHub Security Lab, Chainguard, and the zizmor audit set. The goal is to flag the same patterns those tools would flag, without needing to run them. Mental model Every workflow sits on a 2x2: privileged vs unprivileged crossed with trusted vs untrusted code . Compromise happe…

benchmark.txt > /dev/null || exit 1\n - uses: actions/upload-artifact@\u003csha>\n with:\n name: pr-benchmark\n path: benchmark.txt\n - run: echo ${{ github.event.number }} > pr-number.txt\n - run: grep -E '^[0-9]+

CI/CD Security Scanner This skill turns the model into a workflow-YAML scanner. Read the file, walk the detection rules, report findings with severity and a concrete rewrite. No tools to install, no commands to run — the analysis is the model reading the YAML. The rules encode the current consensus from Astral, OpenSSF, GitHub Security Lab, Chainguard, and the zizmor audit set. The goal is to flag the same patterns those tools would flag, without needing to run them. Mental model Every workflow sits on a 2x2: privileged vs unprivileged crossed with trusted vs untrusted code . Compromise happe…

pr-number.txt > /dev/null || exit 1\n - uses: actions/upload-artifact@\u003csha>\n with:\n name: pr-number\n path: pr-number.txt\n```\n\nWorkflow B (`benchmark-comment.yml`) — privileged, but doesn't touch untrusted code:\n```yaml\non:\n workflow_run:\n workflows: [\"benchmark-pr\"]\n types: [completed]\npermissions: {}\njobs:\n comment:\n if: github.event.workflow_run.conclusion == 'success'\n permissions:\n pull-requests: write\n actions: read\n runs-on: ubuntu-latest\n steps:\n - name: Download artifacts\n uses: actions/download-artifact@\u003csha>\n # ... download both artifacts, validate strictly again\n - name: Validate\n run: |\n grep -E '^[0-9.,]+

CI/CD Security Scanner This skill turns the model into a workflow-YAML scanner. Read the file, walk the detection rules, report findings with severity and a concrete rewrite. No tools to install, no commands to run — the analysis is the model reading the YAML. The rules encode the current consensus from Astral, OpenSSF, GitHub Security Lab, Chainguard, and the zizmor audit set. The goal is to flag the same patterns those tools would flag, without needing to run them. Mental model Every workflow sits on a 2x2: privileged vs unprivileged crossed with trusted vs untrusted code . Compromise happe…

benchmark.txt || exit 1\n grep -E '^[0-9]+

CI/CD Security Scanner This skill turns the model into a workflow-YAML scanner. Read the file, walk the detection rules, report findings with severity and a concrete rewrite. No tools to install, no commands to run — the analysis is the model reading the YAML. The rules encode the current consensus from Astral, OpenSSF, GitHub Security Lab, Chainguard, and the zizmor audit set. The goal is to flag the same patterns those tools would flag, without needing to run them. Mental model Every workflow sits on a 2x2: privileged vs unprivileged crossed with trusted vs untrusted code . Compromise happe…

pr-number.txt || exit 1\n - name: Post comment\n env:\n PR_NUM: $(cat pr-number.txt)\n BENCH: $(cat benchmark.txt)\n run: gh pr comment \"$PR_NUM\" --body \"Benchmark: $BENCH\"\n```\n\nEven this is dangerous enough that a GitHub App is better. But this two-workflow pattern with strict artifact validation is the OpenSSF-blessed compromise.\n\n## \"I want to use the PR branch name in a step\"\n\n**Wrong:**\n```yaml\n- run: echo \"Building branch ${{ github.head_ref }}\"\n```\n\n**Right:**\n```yaml\n- env:\n BRANCH: ${{ github.head_ref }}\n run: echo \"Building branch $BRANCH\"\n```\n\nThe `env:` indirection treats the value as data, not as a shell command fragment. Same pattern for any `${{ github.event.* }}`, `inputs.*`, or other potentially attacker-controlled field.\n\n## \"I want to set an environment variable from a file's contents\"\n\n**Wrong:**\n```yaml\n- run: echo \"VERSION=$(cat version.txt)\" >> $GITHUB_ENV\n```\n\nIf `version.txt` came from an untrusted source (artifact from another workflow, file in PR), this is environment file injection — the attacker can include newlines to define arbitrary variables.\n\n**Right:**\n```yaml\n- name: Read and validate version\n run: |\n VERSION=$(cat version.txt)\n # Validate strictly: only the format you expect\n if ! [[ \"$VERSION\" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then\n echo \"Invalid version format\"\n exit 1\n fi\n echo \"VERSION=$VERSION\" >> $GITHUB_ENV\n```\n\nOr better: don't pass through `$GITHUB_ENV` at all. Read the value, use it in the same step.\n\n## \"I want to publish to npm/PyPI on tag push\"\n\n**Wrong:**\n```yaml\non:\n push:\n tags: ['v*']\njobs:\n publish:\n runs-on: ubuntu-latest\n steps:\n - run: npm publish\n env:\n NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n```\n\nThree problems: long-lived `NPM_TOKEN`, no environment isolation, no approval gate, no attestation.\n\n**Right:**\n```yaml\non:\n push:\n tags: ['v*']\npermissions: {}\njobs:\n publish:\n runs-on: ubuntu-latest\n environment: release # required reviewers, scoped to main, restricted secrets\n permissions:\n contents: read\n id-token: write # OIDC for Trusted Publishing\n attestations: write # for Sigstore attestation\n steps:\n - uses: actions/checkout@\u003csha>\n - uses: actions/setup-node@\u003csha>\n # NOTE: no `cache:` — release workflows must not cache\n - run: npm ci\n - run: npm publish --provenance # OIDC + attestation in one\n```\n\nTag protection ruleset prevents the `v*` tag from existing until this deploy succeeds. Immutable releases enabled on the repo. Approver clicks once on the environment gate. No long-lived tokens anywhere.\n\nFor PyPI: use `pypa/gh-action-pypi-publish` with no token argument (it picks up OIDC from `id-token: write`).\n\n## \"I want to download a release binary and use it in CI\"\n\n**Wrong:**\n```yaml\n- run: |\n curl -L https://github.com/owner/tool/releases/latest/download/tool > tool\n chmod +x tool\n ./tool\n```\n\nTwo problems: `latest` is mutable (attacker can replace it), and no integrity check on the binary.\n\n**Right:**\n```yaml\n- run: |\n curl -L https://github.com/owner/tool/releases/download/v1.2.3/tool > tool\n echo \"abc123...expected_sha256... tool\" | sha256sum -c -\n chmod +x tool\n ./tool\n```\n\nPin the version, check the hash. If the action you're using wraps this download, verify the action itself embeds an expected hash (not just a download URL). This is the \"hash-pinning is necessary but not sufficient\" problem: a SHA-pinned action that curl-pipes-bash to a release URL is no safer than an unpinned action.\n\n## \"I need a workflow to comment on third-party issues / PRs\"\n\nThis is the legitimate use case that `pull_request_target` is most often reached for. The right answer is **don't use Actions for this** — use a GitHub App that listens for the relevant webhook events and acts in an isolated context.\n\nA GitHub App:\n- Receives the same event data Actions would have received\n- Runs outside the workflow context, so it can't be poisoned by template injection\n- Has explicit, scoped credentials (the App's installation token)\n- Is reviewable as code in its own repo, not buried in YAML\n\nThe Mariatta Python tutorial and `gidgethub` library are the canonical references for building these. Astral's `astral-sh-bot` is the production pattern.\n\nThis is more work than a workflow, which is the actual reason most projects keep their `pull_request_target` workflows: hosting and maintaining an App is non-trivial. But if the workflow handles privileged operations triggered by external contributors, an App is the only safe option.\n\n## \"I want to skip the checks for a specific bot account\"\n\n**Wrong:**\n```yaml\nif: github.actor == 'dependabot[bot]'\n```\n\nThe actor field on certain triggers can be forged or spoofed in chained workflows. If an attacker can get *any* code path to run on behalf of `dependabot[bot]` (or your CI bot), they bypass the check.\n\n**Right:** use signed commits and verify the GPG signature, or check the App's installation token rather than the actor name. Or — better — don't bypass checks for bots at all; let Dependabot's PRs go through the same gates as humans, and accept the friction.\n\n## When in doubt: split the workflow\n\nThe single most-applicable safety pattern is \"split a workflow that needs to do an untrusted thing AND a privileged thing into two workflows that share a strictly-validated artifact.\" It works for:\n\n- Test results → PR comment\n- Build artifacts → release publish\n- PR metadata → labeling / triage\n- External API call → repo write\n\nThe cost is one extra workflow file and a strict-validation step on the artifact. The benefit is that the privileged side never touches untrusted code.\n\nIf you find yourself recommending mitigations for a single dangerous workflow, stop and recommend the split instead.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":9737,"content_sha256":"93b0af0de6aef1c78db592fd55dff171f049ad94a53b13262d84563edb741c67"},{"filename":"references/triggers.md","content":"# GitHub Actions Triggers Reference\n\nThe trigger determines what context the workflow runs in: what permissions it has by default, whether it has access to secrets, and whether the code it runs can be influenced by fork PR authors. Most CI/CD compromises start with picking the wrong trigger.\n\n## Quick table\n\n| Trigger | Runs with secrets? | Default permissions | Triggerable by fork PR? | Verdict |\n|---|---|---|---|---|\n| `pull_request` | No (forks); Yes (same-repo) | Read-only on forks | Yes | **Safe**. Default for anything touching PR content. |\n| `pull_request_target` | Yes | Write-all (or repo default) | Yes | **Ban**. Privileged workflow + fork-triggerable. Canonical pwn-request vector. |\n| `workflow_run` | Yes | Write-all (or repo default) | Indirectly, via the triggering workflow | **Ban**. Same problem as `pull_request_target`. |\n| `push` | Yes | Repo default | No (only repo writers) | Safe for trusted branches. Be careful with branch filters. |\n| `issue_comment` | Yes | Repo default | Yes (any contributor) | **Dangerous**. Treat all comment content as untrusted input. |\n| `issues` | Yes | Repo default | Yes | **Dangerous**. Issue title/body are attacker-controlled. |\n| `pull_request_review` / `pull_request_review_comment` | Yes | Repo default | Yes | **Dangerous**. Review body is attacker-controlled. |\n| `schedule` | Yes | Repo default | No | Safe. |\n| `workflow_dispatch` | Yes | Repo default | No (requires write access) | Safe. |\n| `release` | Yes | Repo default | No | Safe — but the release process itself needs hardening (see SKILL.md §release). |\n\n## The banned triggers\n\n### `pull_request_target`\n\nWhat it does: runs in the context of the base repo (yours, with secrets and write permissions), but is triggered by PRs from forks.\n\nWhy it's dangerous: every other trigger that's reachable from a fork either runs without secrets (`pull_request`) or runs in a context the fork author can't influence (`push` to your branches). `pull_request_target` is the only one that hands fork authors a path into a privileged execution environment.\n\nThe canonical attack: workflow checks out `${{ github.event.pull_request.head.sha }}` and then runs build/test scripts. An attacker submits a PR that modifies `package.json`'s `postinstall`, or adds a malicious test, or anything else that runs code. The workflow executes it with full secrets.\n\nEven workflows that *don't* check out PR head code are often vulnerable, because attacker input shows up in many other places (PR title, branch name, commit message) and gets interpolated into `run:` blocks.\n\n**The fix:** use `pull_request` instead. If you need to comment on the PR or label it, do that from a separate `workflow_run` (still dangerous, see below) or — better — a GitHub App that lives outside Actions.\n\nIf you absolutely must keep it, the absolute minimum hardening is:\n- `permissions: {}` at top, grant only what's needed\n- Never check out `head.sha` or `head.ref` — only check out `github.ref` (the base) or omit checkout entirely\n- Never interpolate any `github.event.*` into a `run:` block\n- Add `if: github.repository == 'owner/repo'` so the workflow doesn't run in forks\n- Treat this as a finding that needs review every time the workflow changes\n\n### `workflow_run`\n\nWhat it does: triggered after another workflow completes. Runs in the context of the *base* repo with secrets and write permissions.\n\nWhy it's dangerous: same as `pull_request_target` but indirect. If workflow A runs on `pull_request` (safely, unprivileged), and workflow B runs on `workflow_run: A`, then B is a privileged workflow whose execution is downstream of code an attacker can write.\n\nSpecific attack vectors:\n- **Artifact-borne injection**: A uploads a file the attacker controls (e.g. PR number, but the contents are arbitrary). B downloads it and uses it in a `run:` block without validation. Code execution.\n- **Cache poisoning**: A writes to the cache. B reads from it during setup. Cache contents flow into B's privileged context.\n- **`github.event.workflow_run.*` interpolation**: B uses fields from the triggering workflow's metadata. Some of those fields (head_branch, head_commit.message) are attacker-controlled.\n\n**The fix:** the legitimate use case for `workflow_run` is \"I need to do something privileged based on a thing that happened in an unprivileged workflow.\" Almost always, the right answer is to move the privileged step to a GitHub App that listens for webhook events directly. The App framework Astral uses (`astral-sh-bot`) and the Mariatta tutorial linked from their blog are good starting points.\n\nIf you must keep it: validate artifact contents strictly, never use `cache:`, never interpolate `workflow_run` event fields into shell, and use a dedicated deployment environment with approval gating.\n\n## The grey-area triggers\n\n### `issue_comment`, `issues`, `pull_request_review*`\n\nThese run with secrets and write permissions, and they're reachable by anyone who can comment on issues or PRs (which on public repos is anyone with a GitHub account).\n\nThe risk is not \"fork PR runs malicious code in your context\" — that's `pull_request_target`'s problem. The risk is template injection: the comment body or issue title shows up in `${{ ... }}` somewhere and gets interpolated into a `run:` block.\n\n**The rule:** these triggers are OK if you (a) never interpolate any user-controlled field into shell, and (b) keep the workflow's permissions to the minimum needed. A \"label PR if title matches\" workflow on `pull_request_target` should be `permissions: { pull-requests: write }` and nothing else, with the title accessed through an env var.\n\n### `push` with broad branch filters\n\n`push` is safe in the sense that it can only be triggered by someone with write access — but if your branch protection is weak and the trigger fires on `*` or `feature/*`, an attacker who lands a PR can then push a follow-up branch that fires a workflow with full repo permissions.\n\n**The rule:** restrict `push` triggers to specific branches you actually want to build on (`main`, release branches, tags). Don't fire on arbitrary feature branches unless you mean to.\n\n## Safe patterns for common needs\n\n### \"I want to comment on PRs from forks with test results\"\n\nWrong: `pull_request_target` with `pull-requests: write`, checkout head, run tests, post comment.\n\nRight (in order of preference):\n1. Use `GITHUB_STEP_SUMMARY` in a regular `pull_request` workflow. The summary shows up in the PR's checks UI, accessible to everyone, no comment needed.\n2. Just put the results in the workflow logs. Anyone can click through.\n3. If you really need a comment: a small GitHub App that listens for `check_run` events and posts comments from outside Actions.\n\n### \"I want to label PRs based on file paths or title\"\n\nWrong: `pull_request_target` interpolating `github.event.pull_request.title` into a `run:` block.\n\nRight: `pull_request_target` with `permissions: { pull-requests: write }` only, using a vetted action like `actions/labeler` that takes its config from a checked-in file (not from PR contents). No interpolation into shell. No checkout of head.\n\n### \"I want to run benchmarks on PRs with a comparison vs main\"\n\nWrong: `pull_request_target`, checkout head, run benchmark, compare to cached main result, comment with diff.\n\nRight: split into two workflows.\n- Workflow A: `pull_request`, no secrets, runs the benchmark on the PR's code, uploads the result as an artifact (just the numbers, validated as numbers).\n- Workflow B: `workflow_run` chained off A, downloads the artifact, validates that the file contains only digits and a separator, compares to main, posts the comment. No `cache:`. Use `permissions: { pull-requests: write }`.\n\nThis is dangerous enough that a GitHub App is better. But the two-workflow pattern with strict artifact validation is the OpenSSF-blessed compromise.\n\n### \"I want to publish to npm/PyPI on tag push\"\n\nWrong: `on: push` with `tags: ['v*']`, secret `NPM_TOKEN` from repo-level secrets, no environment, no approval.\n\nRight:\n- `on: push` with `tags: ['v*']` to trigger\n- Job uses `environment: release` (configured with required reviewers)\n- Trusted Publishing (OIDC) instead of `NPM_TOKEN` where the registry supports it\n- Tag protection ruleset prevents the tag from being created until the release deploy succeeds (use a separate `release-gate` env if you have many release jobs)\n- No `cache:` in any step\n- Sigstore attestation generated for the artifact\n- Immutable releases enabled on the repo\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8526,"content_sha256":"95c0eb6516348bf42fb8c0c81f4dcac9216700e7dfb03249f71d7f7b78804d43"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"CI/CD Security Scanner","type":"text"}]},{"type":"paragraph","content":[{"text":"This skill turns the model into a workflow-YAML scanner. Read the file, walk the detection rules, report findings with severity and a concrete rewrite. No tools to install, no commands to run — the analysis is the model reading the YAML.","type":"text"}]},{"type":"paragraph","content":[{"text":"The rules encode the current consensus from Astral, OpenSSF, GitHub Security Lab, Chainguard, and the zizmor audit set. The goal is to flag the same patterns those tools would flag, without needing to run them.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Mental model","type":"text"}]},{"type":"paragraph","content":[{"text":"Every workflow sits on a 2x2: ","type":"text"},{"text":"privileged vs unprivileged","type":"text","marks":[{"type":"strong"}]},{"text":" crossed with ","type":"text"},{"text":"trusted vs untrusted code","type":"text","marks":[{"type":"strong"}]},{"text":". Compromise happens at exactly one cell: ","type":"text"},{"text":"privileged workflow running untrusted code","type":"text","marks":[{"type":"strong"}]},{"text":". The rules below are ways to detect when a workflow ends up in that cell.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Privileged","type":"text","marks":[{"type":"strong"}]},{"text":" = has secrets, write permissions, or produces a sensitive artifact (release, deploy, comment, label).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Untrusted code","type":"text","marks":[{"type":"strong"}]},{"text":" = anything a fork PR author can influence: PR source code, PR title, PR body, commit messages, branch names, files the workflow reads, caches, artifacts produced by another untrusted workflow.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"When unsure whether a value is trusted, treat it as untrusted. The cost of a false positive is a code review comment; the cost of a false negative is a supply chain compromise.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Scan procedure","type":"text"}]},{"type":"paragraph","content":[{"text":"For each workflow file the user provides, walk these passes in order. Each pass corresponds to a class of attack.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Pass 1: dangerous triggers","type":"text"}]},{"type":"paragraph","content":[{"text":"Look at the ","type":"text"},{"text":"on:","type":"text","marks":[{"type":"code_inline"}]},{"text":" block. Flag immediately:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"pull_request_target","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — P0 unless explicitly justified. Runs with secrets and write permissions, triggerable by fork PRs. The canonical pwn-request vector. Even without ","type":"text"},{"text":"checkout","type":"text","marks":[{"type":"code_inline"}]},{"text":" of head, attacker input shows up in PR title, branch name, commit messages, and gets interpolated.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"workflow_run","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — P0. Same problem as ","type":"text"},{"text":"pull_request_target","type":"text","marks":[{"type":"code_inline"}]},{"text":" but indirect, via a chained ","type":"text"},{"text":"pull_request","type":"text","marks":[{"type":"code_inline"}]},{"text":" workflow's artifacts or metadata.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"issue_comment","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", ","type":"text","marks":[{"type":"strong"}]},{"text":"issues","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", ","type":"text","marks":[{"type":"strong"}]},{"text":"pull_request_review","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", ","type":"text","marks":[{"type":"strong"}]},{"text":"pull_request_review_comment","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — P1. Run with secrets, reachable by anyone who can comment. Safe only if the workflow does no template interpolation of user-controlled fields into shell.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"push","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" with broad wildcards","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"branches: ['*']","type":"text","marks":[{"type":"code_inline"}]},{"text":" or no branch filter) — P2. An attacker who lands a PR can fire a privileged workflow by pushing a follow-up branch.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"For each finding: name the trigger, explain why it's dangerous in this specific workflow's context, and propose the rewrite (usually ","type":"text"},{"text":"pull_request","type":"text","marks":[{"type":"code_inline"}]},{"text":", sometimes a split into two workflows, sometimes \"this needs a GitHub App not Actions\").","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Pass 2: permissions","type":"text"}]},{"type":"paragraph","content":[{"text":"Look for ","type":"text"},{"text":"permissions:","type":"text","marks":[{"type":"code_inline"}]},{"text":" blocks at workflow and job level.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No top-level ","type":"text","marks":[{"type":"strong"}]},{"text":"permissions:","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" block","type":"text","marks":[{"type":"strong"}]},{"text":" — P1. The default ","type":"text"},{"text":"GITHUB_TOKEN","type":"text","marks":[{"type":"code_inline"}]},{"text":" permissions depend on repo and org settings; can be ","type":"text"},{"text":"write-all","type":"text","marks":[{"type":"code_inline"}]},{"text":" on older repos. Flag with: \"add ","type":"text"},{"text":"permissions: {}","type":"text","marks":[{"type":"code_inline"}]},{"text":" at top, grant per-job.\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"permissions: write-all","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" anywhere — P1.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Job-level ","type":"text","marks":[{"type":"strong"}]},{"text":"permissions:","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" granting more than the job clearly needs","type":"text","marks":[{"type":"strong"}]},{"text":" — P2. e.g. ","type":"text"},{"text":"contents: write","type":"text","marks":[{"type":"code_inline"}]},{"text":" on a job that only runs tests. Recommend least privilege.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Combined with a dangerous trigger from Pass 1","type":"text","marks":[{"type":"strong"}]},{"text":" — escalate severity by one level.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Pass 3: action pinning","type":"text"}]},{"type":"paragraph","content":[{"text":"Look at every ","type":"text"},{"text":"uses:","type":"text","marks":[{"type":"code_inline"}]},{"text":" line.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pinned to a tag","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"uses: actions/checkout@v4","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"@v4.1.1","type":"text","marks":[{"type":"code_inline"}]},{"text":") — P1. Tags are mutable; an attacker who compromises the action repo can force-push the tag.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pinned to a branch","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"uses: actions/checkout@main","type":"text","marks":[{"type":"code_inline"}]},{"text":") — P0. Worse than tag pinning; any commit to that branch flows in instantly.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pinned to a SHA but no version comment","type":"text","marks":[{"type":"strong"}]},{"text":" — P3 style finding. Recommend the format ","type":"text"},{"text":"uses: owner/action@\u003csha> # v4.1.1","type":"text","marks":[{"type":"code_inline"}]},{"text":" so reviews of pin updates stay legible.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pinned to a SHA that looks unusual","type":"text","marks":[{"type":"strong"}]},{"text":" (third-party action, suspicious owner, recently created repo) — flag for manual verification; can't confirm impostor-commit status without the GitHub API, but worth noting.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"The rewrite for tag/branch pinning is always: replace with the full 40-character commit SHA of the version they intended, plus a ","type":"text"},{"text":"# vX.Y.Z","type":"text","marks":[{"type":"code_inline"}]},{"text":" comment.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Pass 4: shell injection (template injection)","type":"text"}]},{"type":"paragraph","content":[{"text":"For every ","type":"text"},{"text":"run:","type":"text","marks":[{"type":"code_inline"}]},{"text":" block, scan for ","type":"text"},{"text":"${{ ... }}","type":"text","marks":[{"type":"code_inline"}]},{"text":" substitutions.","type":"text"}]},{"type":"paragraph","content":[{"text":"Constant or non-attacker-controlled values are fine (e.g. ","type":"text"},{"text":"${{ matrix.os }}","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"${{ secrets.MY_TOKEN }}","type":"text","marks":[{"type":"code_inline"}]},{"text":" though even that's risky in some contexts). The dangerous fields are:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"github.event.pull_request.title","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"body","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"head.ref","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"head.sha","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"head.label","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"github.event.issue.title","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"body","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"github.event.comment.body","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"user.login","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"github.event.review.body","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"github.head_ref","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"github.event.workflow_run.head_branch","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"head_commit.message","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"github.event.commits.*.message","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"author.name","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"author.email","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Any ","type":"text"},{"text":"inputs.*","type":"text","marks":[{"type":"code_inline"}]},{"text":" from ","type":"text"},{"text":"workflow_dispatch","type":"text","marks":[{"type":"code_inline"}]},{"text":" if the workflow runs in a privileged context","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Any field that ultimately came from ","type":"text"},{"text":"actions/github-script","type":"text","marks":[{"type":"code_inline"}]},{"text":", downloaded artifacts, or external API responses","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Detection rule:","type":"text","marks":[{"type":"strong"}]},{"text":" if a ","type":"text"},{"text":"run:","type":"text","marks":[{"type":"code_inline"}]},{"text":" block contains ","type":"text"},{"text":"${{ github.event.* }}","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"${{ github.head_ref }}","type":"text","marks":[{"type":"code_inline"}]},{"text":" directly in the script body, that's P0 template injection. The fix is always:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"yaml"},"content":[{"text":"# vulnerable\n- run: echo \"Branch is ${{ github.head_ref }}\"\n\n# safe\n- env:\n BRANCH: ${{ github.head_ref }}\n run: echo \"Branch is $BRANCH\"","type":"text"}]},{"type":"paragraph","content":[{"text":"Also flag (P1):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"echo \"VAR=${{ untrusted }}\" >> $GITHUB_ENV","type":"text","marks":[{"type":"code_inline"}]},{"text":" — environment file injection. The attacker can break out of the variable by including newlines.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"echo \"::set-env name=VAR::${{ untrusted }}\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" — deprecated workflow command, same problem.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Inline scripts that ","type":"text"},{"text":"cat","type":"text","marks":[{"type":"code_inline"}]},{"text":" an untrusted file into ","type":"text"},{"text":"$GITHUB_ENV","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"$GITHUB_OUTPUT","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Pass 5: untrusted checkout","type":"text"}]},{"type":"paragraph","content":[{"text":"For every ","type":"text"},{"text":"actions/checkout","type":"text","marks":[{"type":"code_inline"}]},{"text":" step:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ref: ${{ github.event.pull_request.head.sha }}","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (or ","type":"text"},{"text":"head.ref","type":"text","marks":[{"type":"code_inline"}]},{"text":") inside a workflow triggered by ","type":"text"},{"text":"pull_request_target","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"workflow_run","type":"text","marks":[{"type":"code_inline"}]},{"text":" — P0. This is the canonical pwn-request: privileged context running fork-author code.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"persist-credentials: true","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (default) on workflows that don't need to push back — P2. Recommend ","type":"text"},{"text":"persist-credentials: false","type":"text","marks":[{"type":"code_inline"}]},{"text":" unless the workflow explicitly needs the embedded token.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Pass 6: caching in privileged contexts","type":"text"}]},{"type":"paragraph","content":[{"text":"For every step using ","type":"text"},{"text":"cache:","type":"text","marks":[{"type":"code_inline"}]},{"text":" input (most commonly on ","type":"text"},{"text":"actions/setup-node","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"setup-python","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"setup-go","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"setup-java","type":"text","marks":[{"type":"code_inline"}]},{"text":", or direct ","type":"text"},{"text":"actions/cache","type":"text","marks":[{"type":"code_inline"}]},{"text":"):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Cache in a release or publish workflow","type":"text","marks":[{"type":"strong"}]},{"text":" — P0. Cache poisoning from any other workflow on the default branch can flow malicious build inputs into release. The Trivy and TeamPCP attacks both routed through this.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Cache in a workflow that handles secrets","type":"text","marks":[{"type":"strong"}]},{"text":" — P1.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Cache where the key isn't scoped to prevent untrusted PR workflows writing the same key as default-branch builds","type":"text","marks":[{"type":"strong"}]},{"text":" — P2.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"The rewrite for release workflows: remove ","type":"text"},{"text":"cache:","type":"text","marks":[{"type":"code_inline"}]},{"text":" entirely, add a comment explaining why (e.g. ","type":"text"},{"text":"# Do not cache: see https://github.com/actions/setup-node/issues/1445","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Pass 7: artifact-borne injection","type":"text"}]},{"type":"paragraph","content":[{"text":"If the workflow downloads artifacts from another workflow (","type":"text"},{"text":"actions/download-artifact","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"dawidd6/action-download-artifact","type":"text","marks":[{"type":"code_inline"}]},{"text":", etc.):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Artifact contents used in ","type":"text","marks":[{"type":"strong"}]},{"text":"run:","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" or ","type":"text","marks":[{"type":"strong"}]},{"text":"$GITHUB_ENV","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" without validation","type":"text","marks":[{"type":"strong"}]},{"text":" — P0 if the producing workflow runs on untrusted code (e.g. ","type":"text"},{"text":"pull_request","type":"text","marks":[{"type":"code_inline"}]},{"text":" from forks). An attacker can put arbitrary content in the artifact.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Recommend strict validation","type":"text","marks":[{"type":"strong"}]},{"text":": if the artifact is supposed to be a PR number, reject anything that isn't digits. If it's a structured file, parse and validate the schema.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Pass 8: release-specific hardening","type":"text"}]},{"type":"paragraph","content":[{"text":"If the workflow looks like a release/publish workflow (publishes to npm, PyPI, crates.io, Docker registries; creates GitHub releases; pushes tags):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No ","type":"text","marks":[{"type":"strong"}]},{"text":"environment:","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" declared on the publish job","type":"text","marks":[{"type":"strong"}]},{"text":" — P1. Release credentials should be scoped to a deployment environment, not repo/org secrets.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Uses long-lived registry tokens","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"secrets.NPM_TOKEN","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"secrets.PYPI_TOKEN","type":"text","marks":[{"type":"code_inline"}]},{"text":") instead of OIDC/Trusted Publishing — P2. Recommend the OIDC path for the relevant registry.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No attestation generation","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"actions/attest-build-provenance","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--provenance","type":"text","marks":[{"type":"code_inline"}]},{"text":" for npm, PEP 740 for PyPI) — P3 hardening recommendation, not a vulnerability.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Caching anywhere in the release path","type":"text","marks":[{"type":"strong"}]},{"text":" — P0, see Pass 6.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Pass 9: self-hosted runners","type":"text"}]},{"type":"paragraph","content":[{"text":"If the workflow uses ","type":"text"},{"text":"runs-on:","type":"text","marks":[{"type":"code_inline"}]},{"text":" with anything other than GitHub-hosted runners (","type":"text"},{"text":"ubuntu-*","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"windows-*","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"macos-*","type":"text","marks":[{"type":"code_inline"}]},{"text":"):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Self-hosted runner reachable by fork PRs","type":"text","marks":[{"type":"strong"}]},{"text":" — P0. Self-hosted runners share state across jobs and have produced critical compromises (PyTorch). Flag for manual review of runner scoping.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"This is outside the default threat model — note the finding and recommend the user verify runner restrictions in GitHub settings.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Finding format","type":"text"}]},{"type":"paragraph","content":[{"text":"Report each finding in this structure. Group by severity, P0 first.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"[P0] template-injection in .github/workflows/ci.yml:23\n Run block interpolates github.event.pull_request.title directly into shell.\n An attacker controls the PR title and can execute arbitrary code in the\n workflow context, which has access to GITHUB_TOKEN.\n\n Vulnerable:\n - run: echo \"Title: ${{ github.event.pull_request.title }}\"\n\n Fix:\n - env:\n TITLE: ${{ github.event.pull_request.title }}\n run: echo \"Title: $TITLE\"","type":"text"}]},{"type":"paragraph","content":[{"text":"If the user pastes raw YAML without a filename, refer to it as \"the workflow\" and use line numbers within the snippet.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Severity scale","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"P0","type":"text","marks":[{"type":"strong"}]},{"text":" — exploitable now, no chain needed. Fork PR authors or arbitrary GitHub users can compromise secrets, repo contents, or releases. Block merge.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"P1","type":"text","marks":[{"type":"strong"}]},{"text":" — exploitable with one extra step (e.g. requires combining with another finding, or requires a maintainer mistake). Block merge if found on a release path.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"P2","type":"text","marks":[{"type":"strong"}]},{"text":" — hardening gap. Not exploitable directly, but reduces blast radius if combined with a future bug. Fix in normal review cycle.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"P3","type":"text","marks":[{"type":"strong"}]},{"text":" — style or consistency finding. Worth fixing for legibility, no security impact.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"When the workflow looks fine","type":"text"}]},{"type":"paragraph","content":[{"text":"After walking all nine passes, if nothing fires:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Say so explicitly. \"No findings against the standard rule set.\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Note what ","type":"text"},{"text":"wasn't","type":"text","marks":[{"type":"em"}]},{"text":" checked: org-level settings (default token permissions, ruleset enforcement, 2FA), repo-level settings (branch protection, tag protection, immutable releases), action source code (whether the pinned actions themselves install mutable binaries at runtime), and runtime behavior of dependencies.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Recommend the user check the items in ","type":"text"},{"text":"references/checklist.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" under \"Per repository\" and \"Per organization\" — those need GitHub settings access, not workflow YAML.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"A clean workflow scan does not mean a clean security posture.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Reference files","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/triggers.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — detailed table of every GitHub Actions trigger, what makes each dangerous or safe, and the safe pattern for common things people use the dangerous ones for. Read this when a workflow uses a trigger the user is asking about specifically, or when you want to explain ","type":"text"},{"text":"why","type":"text","marks":[{"type":"em"}]},{"text":" a trigger is dangerous beyond the one-line summary.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/checklist.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — flat per-workflow / per-repo / per-org checklist. Useful when the user asks for a full audit, when scanning many repos, or when triaging at scale. Includes triage priority order.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/patterns.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — common \"I want to do X safely\" patterns. Read when the user asks how to ","type":"text"},{"text":"replace","type":"text","marks":[{"type":"em"}]},{"text":" a flagged dangerous pattern, not just identify it.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"What this skill won't do","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"It won't install zizmor, pinact, or anything else. The scan is the model reading the YAML and applying these rules.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"It won't recommend installing tools unless the user explicitly asks \"what tools should I run.\" Even then, point to the patterns directly — the rules in this skill ","type":"text"},{"text":"are","type":"text","marks":[{"type":"em"}]},{"text":" the audits those tools encode.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"It won't paper over a ","type":"text"},{"text":"pull_request_target","type":"text","marks":[{"type":"code_inline"}]},{"text":" finding with mitigations. If a workflow uses that trigger and isn't behind a GitHub App, it's an open finding.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"It won't tell the user everything is fine without naming what was checked and what wasn't. Default answer to \"is this safe\" is \"here's what the scan covers and here's what it found.\"","type":"text"}]}]}]}]},"metadata":{"date":"2026-11-02","name":"ci-cd-security","author":"@skillopedia","source":{"stars":80,"repo_name":"skills","origin_url":"https://github.com/superagent-ai/skills/blob/HEAD/skills/ci-cd-security/SKILL.md","repo_owner":"superagent-ai","body_sha256":"9e53530eaee2c0d4e7c17a9a76e59a21b35e191bbadfed6d78de1fed03adcc48","cluster_key":"c310056cc1a972bb5c372fd957e3474b141b2a4d76f5efaaebb986c103bbf7c7","clean_bundle":{"format":"clean-skill-bundle-v1","source":"superagent-ai/skills/skills/ci-cd-security/SKILL.md","text_bytes":24520,"source_commit":"0da315b873ed141025fa601ed6e0ebe0c878d5af","skills_sh_rank":2969,"attachment_count":3,"text_attachments":3,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/ci-cd-security/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"security","category_label":"Security"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"security","import_tag":"skills-sh-top20k-2026-11","description":"Scan GitHub Actions workflow files for security vulnerabilities by reading the YAML and reporting findings directly — no external tools, no installation, no shell execution. Use this skill whenever the user shares a `.github/workflows/` file, pastes workflow YAML, asks for a CI/CD security review, mentions `pull_request_target`, `workflow_run`, action pinning, `GITHUB_TOKEN` permissions, pwn requests, template injection, cache poisoning, secret exfiltration, supply chain risk, or any GitHub Actions hardening topic. Also trigger when the user is hardening an OSS repo, doing a CI/CD red team assessment, evaluating a target for supply-chain scanning, or writing publicly about CI/CD security. Bias toward triggering this skill rather than answering from memory — CI/CD security defaults are wrong almost everywhere and the rules are unintuitive."}},"renderedAt":1793666000170}

CI/CD Security Scanner This skill turns the model into a workflow-YAML scanner. Read the file, walk the detection rules, report findings with severity and a concrete rewrite. No tools to install, no commands to run — the analysis is the model reading the YAML. The rules encode the current consensus from Astral, OpenSSF, GitHub Security Lab, Chainguard, and the zizmor audit set. The goal is to flag the same patterns those tools would flag, without needing to run them. Mental model Every workflow sits on a 2x2: privileged vs unprivileged crossed with trusted vs untrusted code . Compromise happe…