Skip to content

Attack Chains

A single finding rarely captures the full risk of a CI/CD misconfiguration. A pull_request_target trigger is bad on its own; long-lived AWS credentials are bad on their own; but the combination, on the same workflow, is exactly how the PyTorch supply-chain compromise worked. Pipeline-Check's attack chain engine correlates findings into those multi-step narratives and emits one higher-order result per matched chain, mapped to MITRE ATT&CK techniques.

Chains are additive. They never replace a finding. They sit on top of the finding set and highlight the combinations that map to real-world attack paths. Fix any one leg and the chain breaks.

Registered chains

Three families:

  • AC-NNN chains are single-provider correlations. They fire on a normal --pipeline <name> scan.
  • XPC-NNN chains are cross-provider correlations. They fire only when the chain engine sees findings from multiple providers in the same scan, which happens when you pass --pipelines github,oci (plural, comma-separated) instead of single-valued --pipeline. A single-provider run never sees both legs, so the XPC-* rules stay quiet there.
  • CXPC-NNN chains are cross-repo correlations. They fire only during fleet scans (pipeline_check fleet), composing findings from different repos in the same fleet corpus.

Run pipeline_check --list-chains to see the current set at any time. Run pipeline_check --explain-chain AC-001 for the full reference (summary, narrative, MITRE techniques, kill-chain phase, references, recommendation).

Single-provider chains (AC-NNN)

ID Title Severity Providers Triggering checks
AC-001 Fork-PR Credential Theft (pull_request_target) CRITICAL github GHA-002 + GHA-005
AC-002 Script Injection to Unprotected Deploy CRITICAL github GHA-003 + GHA-014
AC-003 Unpinned Action to Credential Exfiltration HIGH github GHA-001 + GHA-005
AC-004 Self-Hosted Runner Persistent Foothold CRITICAL github GHA-002 + GHA-012
AC-005 Unsigned Artifact to Production HIGH (cross-provider) build-side *-006 / SIGN-001 + deploy-gate *-014 / GCB-009 / CP-001 / CP-005
AC-006 Cache Poisoning via Untrusted Trigger HIGH github GHA-002 + GHA-011
AC-007 IAM Privilege Escalation via CodeBuild CRITICAL aws / terraform / cloudformation CB-002 + (IAM-002 or IAM-004)
AC-008 Dependency Confusion Window HIGH github GHA-021 + GHA-029
AC-009 Supply Chain Repo Poisoning CRITICAL github GHA-001 + GHA-002 + GHA-008
AC-010 Self-Hosted Runner Environment Exfiltration CRITICAL github GHA-012 + (GHA-016 or GHA-019)
AC-011 Kubernetes Cluster Takeover via hostPath + cluster-admin CRITICAL kubernetes K8S-013 + K8S-020
AC-012 Reusable Workflow Secret Exfiltration CRITICAL github GHA-025 + GHA-034
AC-013 Caller-Controlled Runner with Token Persistence CRITICAL github GHA-036 + GHA-019
AC-014 Caller-Controlled Runner with Token Persistence (GitLab) CRITICAL gitlab GL-032 + GL-020
AC-015 Helm chart-supply-chain takeover via legacy + unlocked + plaintext CRITICAL helm HELM-001 + HELM-002 + HELM-003
AC-016 OIDC role drift: ungated GitHub trust meets wildcard AWS authority CRITICAL github / aws GHA-030 + IAM-002
AC-017 Build cache poisoning that lands on a mutable ECR tag HIGH github / aws GHA-011 + ECR-002
AC-018 Unpinned action lands on deploy job with no environment gate CRITICAL github GHA-001 + GHA-014
AC-019 Lambda env-secret meets a CI/CD role with PassRole * CRITICAL aws LMB-003 + IAM-004
AC-020 Tekton hostPath build workload meets cluster-admin RBAC CRITICAL tekton / kubernetes TKN-004 + K8S-020
AC-021 Argo default-SA workflow lands on a default-SA RoleBinding HIGH argo / kubernetes ARGO-003 + K8S-029
AC-022 GitLab script injection lands on deploy job with no manual gate CRITICAL gitlab GL-002 + GL-004
AC-023 Tekton param injection lands in a privileged or root step CRITICAL tekton TKN-002 + TKN-003
AC-024 OIDC trust drift lands on a mutable ECR tag CRITICAL github / aws GHA-030 + ECR-002
AC-025 Argo param injection lands in a privileged or root step CRITICAL argo ARGO-002 + ARGO-005
AC-026 Buildkite injection lands on auto-deploy step with no manual gate CRITICAL buildkite BK-003 + BK-007
AC-027 Image bakes a credential file AND exposes a remote-access port CRITICAL dockerfile DF-013 + DF-019
AC-028 npm worm propagation primitive co-located CRITICAL github / npm NPM-004 + (GHA-048 or GHA-049)
AC-029 Untrusted trigger reaches a long-lived publish credential CRITICAL github (GHA-002 or GHA-009 or GHA-013) + (GHA-050 or GHA-005) + (GHA-021 or GHA-029)
AC-030 Argo CD anonymous access meets wildcard RBAC CRITICAL argocd ARGOCD-009 + ARGOCD-004
AC-031 Argo CD untrusted PR generator meets wildcard source repos CRITICAL argocd ARGOCD-006 + ARGOCD-001
AC-032 Cosign-verified-but-not-bound artifact to production deploy CRITICAL github GHA-100 + GHA-098
AC-033 Environment-secret laundering to unprotected deploy job CRITICAL github TAINT-009 + GHA-098
AC-034 Submodule-poisoned PR to credential exfiltration CRITICAL github GHA-102 + (GHA-037 or GHA-004)
AC-035 AI agent is both reviewer and committer CRITICAL github GHA-103 + (GHA-104 or GHA-106)
AC-036 Untrusted-code execution with no runtime egress containment HIGH github (GHA-003 or GHA-016 or GHA-035 or GHA-044) + (GHA-107 or GHA-108)
AC-037 AI agent applies attacker-influenced IaC to the cloud CRITICAL github (GHA-058 or GHA-103) + GHA-111
AC-038 Untrusted branch reaches OIDC trusted publish CRITICAL github GHA-113 + GHA-114
AC-039 Untrusted trigger reaches a bulk-secrets serialization CRITICAL github (GHA-002 or GHA-009 or GHA-013) + GHA-116
AC-040 Prompt-injected agent commits its output with no human review CRITICAL github, gitlab, bitbucket, azure, jenkins, harness (GHA-119 + GHA-123) or (GL-048 + GL-049) or (BB-036 + BB-039) or (ADO-035 + ADO-038) or (JF-037 + JF-038) or (HARNESS-008 + HARNESS-009)
AC-041 Compromised action executed and exfiltrated credentials in the same run CRITICAL runs RUN-006 + (RUN-003 or RUN-004)
AC-042 Fork pipeline executed and exfiltrated credentials in the same pipeline CRITICAL gitlab_runs GLRUN-002 + (GLRUN-003 or GLRUN-004)

Cross-provider chains (XPC-NNN)

These need --pipelines <a>,<b>,… (or auto-detect of two or more providers at cwd) so the chain engine has findings from both legs in one scan.

ID Title Severity Providers Triggering checks
XPC-001 Deploy without verifiable provenance (workflow + image) HIGH github / oci GHA-006 + OCI-002
XPC-002 Tag mutability across pipeline + runtime (Dockerfile + K8s) HIGH dockerfile / kubernetes DF-001 + K8S-001
XPC-003 Unverified Helm release flow (chart + image) HIGH helm / oci HELM-002 + OCI-002
XPC-004 Token persistence on an unprotected default branch CRITICAL scm / github (SCM-001SCM-007) + GHA-019
XPC-005 End-to-end provenance gap: source unsigned, artifact unsigned HIGH scm / github SCM-006 + GHA-006
XPC-006 Unreviewed fork-PR privilege escalation CRITICAL scm / github SCM-002 + GHA-002
XPC-007 Unpinned actions with no automated remediation HIGH scm / github SCM-005 + GHA-001
XPC-008 Unreviewed source ships a mutable runtime image HIGH scm / dockerfile (SCM-001SCM-007) + DF-001
XPC-009 Ingested CVE finding plus mutable runtime image reference HIGH ingest / dockerfile INGEST-trivy-* / INGEST-grype-* / INGEST-snyk-* + DF-001
XPC-010 npm cooldown miss meets Dockerfile lifecycle execution HIGH npm / dockerfile NPM-008 + DF-024

Cross-repo chains (CXPC-NNN)

These fire only during fleet scans when findings from multiple repos are correlated. Each chain pairs a finding in one repo with a finding in a different repo in the same fleet corpus.

ID Title Severity Providers Triggering checks
CXPC-001 npm publish-side cooldown + floating consumer in partner repo HIGH npm NPM-008 + NPM-001 / NPM-002
CXPC-002 Argo CD wildcard sourceRepos + weakened CI gate in partner repo CRITICAL argocd / github ARGOCD-001 + GHA-002 / TAINT-001 / TAINT-002
CXPC-003 Unscoped App token + credential exposure in partner repo HIGH github GHA-061 + GHA-005 / GHA-008
CXPC-004 Tainted reusable workflow producer + consumer in partner repo HIGH github TAINT-001 / TAINT-002 / TAINT-003 + GHA-*

How chains surface in output

  • Terminal: a panel per chain after the findings table, with a colored border matching the chain's severity and the full narrative inline.
  • JSON: chains top-level array carrying every field plus triggering_findings: [{check_id, resource}, …]. Omitted (not empty) when the caller passed --no-chains, so consumers can distinguish "nothing matched" from "not asked for".
  • SARIF: one rule and one result per chain, tagged attack-chain plus mitre/T… for each technique. GitHub Code Scanning surfaces them as top-level alerts.
  • HTML: an Attack Chains section immediately after the score card. Each chain is a bordered card with severity, confidence, narrative, triggering checks, MITRE techniques, and references.
  • Markdown: an Attack Chains H2 between the summary line and the Failures table, so a PR comment reader sees the highest-signal artifact first.

Gating CI on chains

# Fail the gate only on named chains (the team has explicitly
# opted in to blocking these patterns).
pipeline_check --fail-on-chain AC-001 --fail-on-chain AC-007

# Blanket guard: fail if any chain matched at all.
pipeline_check --fail-on-any-chain

Chain gates bypass baseline and ignore-file filtering, a correlated attack path is intrinsically a new finding even when the constituent legs were baselined separately. An AC-001 match that surfaces after an OIDC migration partial-rollout would otherwise hide behind two green baseline suppressions.

Disabling chain evaluation

pipeline_check --no-chains

Drops the chain correlation pass entirely. The chains key is omitted from the JSON payload. Useful when a downstream consumer doesn't understand the field, or to shave a few milliseconds off a CI hot path (chain evaluation is O(findings × rules), cheap in practice).

Reachability-aware chains

Most attack-chain detectors fire on co-occurrence: two trigger findings on the same resource are taken as evidence that the composite attack is possible. That answer is correct as a screening signal but weaker than what's available: each finding knows the job it fired in, and intersecting those job sets confirms whether the two legs share an executable path or only happen to live in the same file.

Chains that have opted in to the model expose two extra fields:

  • confirmed_reachable: booltrue when the trigger findings' job_anchors intersect (or a TAINT-001 / TAINT-002 dataflow path bridges them). false is the default for chains that haven't been migrated yet.
  • via_dataflow: booltrue only when reachability was established by a proven source-to-sink taint path, as opposed to the weaker shared-job co-location fallback. A chain can be confirmed_reachable (co-located) without being via_dataflow (a proven executable path). CI consumers can gate on the stronger tier with --chains-require-dataflow.
  • via_structural: booltrue when reachability rests on a shared structural identity (the two legs reference the same build artifact / image digest, IAM role, ServiceAccount, or repo) rather than job co-location. Like via_dataflow this is a confirmed tier (the rule sets confirmed_reachable=true at HIGH), but the link is established by identity-matching, not a traced taint path, so it is reported separately. A chain sets at most one of via_dataflow / via_structural.
  • reachability_note: str — a short rationale, e.g. "injection and ungated deploy share job 'release'". Empty when the chain isn't confirmed reachable.

Confirmed-reachable chains are promoted to HIGH confidence regardless of their constituent legs. The reporters render the three tiers differently in the terminal / Markdown / HTML outputs: a proven dataflow path shows a green ✓ Reachability confirmed (dataflow) badge and a structural-identity link shows a green ✓ Reachability confirmed (structural) badge, while the shared-job fallback shows a weaker caution ≈ Co-located (unverified) badge so a reader is not told co-location is a proven path.

Migrated chains:

  • AC-002 (GHA: script injection to unprotected deploy) — pilot. Uses GHA-003 / TAINT-001 / TAINT-002GHA-014 job anchors.
  • AC-022 (GitLab: script injection lands on deploy job with no manual gate) — port of the AC-002 pattern. Uses GL-002 / TAINT-004 / TAINT-008GL-004 job anchors. TAINT-004 widens the injection side with sink jobs reachable via artifacts.reports.dotenv propagation; TAINT-008 widens with sink jobs reachable through extends: template-inheritance taint. Either GitLab cross-job dataflow channel, paired with a producer-side GL-002, still confirms reachability when the sink lands in the deploy job.
  • AC-026 (Buildkite: injection lands on auto-deploy step with no manual gate) — port of the AC-002 pattern. Uses BK-003BK-007 step anchors (Buildkite pipelines are a flat list of steps, so the anchor is the step label rather than a job ID). Confirmed when the same step is both the injection sink AND the unmanual deploy. Cross-step widening (meta-data, artifacts) is a future hop; Buildkite has no TAINT-NNN rule family yet.
  • AC-018 (GHA: unpinned action lands on deploy job with no environment gate) — supply-chain leg of the AC-002 family. Uses GHA-001GHA-014 job anchors. Confirmed when the same job both pulls a tag-pinned / branch-pinned uses: AND is the ungated deploy — the tj-actions shape, where the compromised upstream release executes in the deploy job's context with its environment secrets in scope.
  • AC-003 (GHA: unpinned action to credential exfiltration) — uses GHA-001GHA-005 job anchors. Confirmed when the same job both pulls an unpinned upstream action AND can read long-lived $AWS_ACCESS_KEY_ID / $AWS_SECRET_ACCESS_KEY. GHA-005's workflow-level env: is treated as visible from every job (GitHub's actual inheritance semantics), so a top-level static- key declaration anchors against every job in the workflow.
  • AC-006 (GHA: cache poisoning via untrusted trigger) — uses GHA-002GHA-011 job anchors. Confirmed when the same job both checks out PR-head code AND has a poisonable cache key.
  • AC-001 (GHA: fork-PR credential theft via pull_request_target) — uses GHA-002GHA-005 job anchors. Confirmed when the same job both runs PR-head code AND can read long-lived AWS keys — the PyTorch supply-chain shape.
  • AC-004 (GHA: self-hosted runner persistent foothold) — uses GHA-002GHA-012 job anchors. Confirmed when the same job both runs PR-head code AND lands on a non-ephemeral self-hosted runner.
  • AC-010 (GHA: self-hosted runner environment exfil) — uses GHA-012GHA-019 job anchors when GHA-019 fires. The GHA-016 (curl-pipe) branch stays on co-occurrence because the blob scan doesn't carry per-job attribution.
  • AC-013 (GHA: caller-controlled runner + token persistence) — uses GHA-036GHA-019 job anchors.
  • AC-014 (GitLab: caller-controlled tags + CI-token persistence) — uses GL-032GL-020 job anchors.

Add --chains-require-reachability to drop unconfirmed chains entirely, the strictest signal available:

pipeline_check -p github --chains-require-reachability \
    --fail-on-chain AC-002

Confidence inheritance

An unconfirmed chain is only as trustworthy as its weakest leg. Chain.confidence is set to the minimum confidence among the triggering findings, so if one leg comes from a LOW-confidence blob heuristic the chain is reported at LOW confidence even when every other leg is HIGH.

A confirmed-reachable chain is the exception (see "Confirmed-reachable chains are promoted to HIGH" above): the reachability evidence (a proven dataflow path, or shared-job co-location) is what the chain asserts, so its Chain.confidence is set to HIGH regardless of the legs rather than inheriting the minimum.

The --min-confidence filter applies the same way to chains as to findings, comparing against the resolved Chain.confidence: a confirmed-reachable chain (HIGH) survives any threshold, while an unconfirmed chain carrying a LOW-confidence leg is dropped by --min-confidence MEDIUM.

Adding a new chain

Chains are plugin-discovered from pipeline_check/core/chains/rules/. Drop a module named ac<NNN>_<slug>.py exporting a RULE of type ChainRule and a match(findings) -> list[Chain] function. The engine auto-registers it at import time. See the existing ac001_fork_pr_credential_theft.py for the canonical shape, most chains only need group_by_resource(findings, [...]) plus a narrative template.

Chain catalog

Click any chain in the registered chains table above to jump to its detail card below. Each card carries the chain's severity, MITRE ATT&CK techniques, kill-chain phase, summary prose, references, and the remediation that breaks the chain.

AC-001: Fork-PR Credential Theft (pull_request_target)

CRITICAL MITRE T1195.002 MITRE T1078.004 MITRE T1552.001 initial-access -> credential-access -> exfiltration github

A pull_request_target workflow checks out PR-head code while exposing long-lived AWS credentials. A fork-PR opener can run arbitrary code in the privileged context and exfiltrate the credentials before the PR is even reviewed.

References

Recommended action

Break the chain by either (a) switching to pull_request (no write-scope token), or (b) replacing static AWS keys with OIDC role-to-assume scoped to the workflow.

AC-002: Script Injection to Unprotected Deploy

CRITICAL MITRE T1059.004 MITRE T1190 MITRE T1648 initial-access -> execution -> impact github

A workflow interpolates untrusted GitHub event data into a shell command (script-injection) and the same workflow deploys without an environment-gated approval. An attacker with PR/issue access can hijack the deploy.

References

Recommended action

Pipe untrusted input through an env-var (one-shot quoting) and add environment: production with required reviewers to the deploy job. Either fix alone narrows the chain.

AC-003: Unpinned Action to Credential Exfiltration

HIGH MITRE T1195.001 MITRE T1552.001 supply-chain -> credential-access -> exfiltration github

A workflow consumes third-party actions by mutable tag (@v1, @main) AND holds long-lived cloud credentials. An action maintainer (or an attacker who compromises the action repo) can swap in malicious code on the next run and exfiltrate the credentials.

References

Recommended action

Pin every third-party action to a 40-char SHA. Combined with OIDC short-lived credentials this chain becomes infeasible: a compromised action no longer has a valid long-lived secret to steal.

AC-004: Self-Hosted Runner Persistent Foothold

CRITICAL MITRE T1543 MITRE T1078.004 MITRE T1554 initial-access -> persistence -> privilege-escalation github

A self-hosted runner is configured non-ephemerally AND the same workflow accepts a fork-trigger that can run untrusted code. The runner OS persists between jobs, so malicious code from a fork PR can plant a long-lived backdoor that intercepts the next privileged build.

References

Recommended action

Use ephemeral runners (one job, then destroy the host). If ephemeral isn't possible, restrict the workflow trigger to first-party events only, pull_request from forks must land on GitHub-hosted runners exclusively.

AC-005: Unsigned Artifact to Production

HIGH MITRE T1195.002 MITRE T1554 supply-chain -> defense-evasion -> impact

Artifacts are produced without signing or provenance AND the deployment path to production has no manual approval gate. A build-time compromise (compromised dependency, malicious action, runner takeover) reaches prod uninspected and post-incident attribution is impossible.

References

Recommended action

Add a signing step (cosign sign, gh attestation) or SLSA provenance generation, AND require manual approval before production deploys (CodePipeline approval action, GHA environment with required reviewers).

AC-006: Cache Poisoning via Untrusted Trigger

HIGH MITRE T1554 MITRE T1195.002 initial-access -> persistence -> impact github

A workflow accepts an untrusted trigger (fork PR, issue_comment) AND uses an attacker-influenceable cache key. The attacker plants a poisoned cache entry that the next privileged build (push to main, scheduled deploy) restores and trusts.

References

Recommended action

Lock cache keys to verifiable inputs (lockfile hashes, not PR-controlled paths). Restrict caches to push events only and scope by ref. Either fix breaks the chain.

AC-007: IAM Privilege Escalation via CodeBuild

CRITICAL MITRE T1078.004 MITRE T1548.005 MITRE T1098.001 execution -> privilege-escalation -> lateral-movement aws terraform cloudformation

A CodeBuild project runs in privileged mode AND its service role grants wildcard IAM actions or unconstrained PassRole. Anyone who can land a buildspec change (or a poisoned dependency the build pulls) can assume a higher-privileged role and pivot across the account.

References

Recommended action

Strip wildcard actions and unconstrained PassRole from the CodeBuild service role; pin PassRole to specific role ARNs with a build-tag condition. Disable privileged mode unless the build genuinely requires Docker-in-Docker.

AC-008: Dependency Confusion Window

HIGH MITRE T1195.001 supply-chain -> execution github

A workflow installs packages without a lockfile AND without integrity verification. On every run the dependency resolver picks the highest-version match across configured registries, ideal conditions for a dependency-confusion / typosquatting attack to land in the build.

References

Recommended action

Use lockfile-enforcing install commands (npm ci, pip install -r requirements.txt --require-hashes, yarn install --frozen-lockfile). Pin the registry to a private one and disable upstream fall-through.

AC-009: Supply Chain Repo Poisoning

CRITICAL MITRE T1195.002 MITRE T1078.004 initial-access -> credential-access github

A workflow uses unpinned third-party actions (GHA-001), checks out the PR head under a pull_request_target trigger (GHA-002), and carries literal secrets in the YAML (GHA-008). Any one of those is exploitable; the combination gives a fork-PR attacker two independent code-execution paths to the same plaintext credentials.

References

Recommended action

Pin every third-party action to a commit SHA (not a tag). Move secrets out of the YAML and into the GitHub Secrets store, referenced via ${{ secrets.NAME }}. Replace direct interpolation of PR-controlled context (event.*, pull_request.*) into shell with environment-variable indirection.

AC-010: Self-Hosted Runner Environment Exfiltration

CRITICAL MITRE T1552.001 MITRE T1078.004 MITRE T1195.002 execution -> persistence -> credential-access github

A self-hosted runner without ephemeral isolation (GHA-012) executes a workflow that either pipes a remote script into a shell (GHA-016) or persists the GitHub token across jobs (GHA-019). Both legs give an attacker a route to plant persistence on the runner; the runner's filesystem then harvests every secret subsequent workflows expose.

References

Recommended action

Configure self-hosted runners as ephemeral (one job per VM, recycled afterward). For each job, replace remote-script-into-shell idioms (curl ... | bash) with a verified, version-pinned download step, and set persist-credentials: false on every checkout.

AC-011: Kubernetes Cluster Takeover via hostPath + cluster-admin

CRITICAL MITRE T1611 MITRE T1098.003 MITRE T1078 initial-access -> privilege-escalation -> lateral-movement kubernetes

A workload mounts a hostPath volume (K8S-013) AND the cluster carries a ClusterRoleBinding granting cluster-admin (K8S-020). Together those two settings give an attacker who lands code in any pod on a poisoned node both an escape to the host filesystem and the API privileges needed to pivot the entire cluster, read every Secret, deploy privileged workloads across all nodes, impersonate any service account.

References

Recommended action

Replace hostPath volumes with a CSI driver scoped to the specific subtree the workload needs, or use ConfigMap / downwardAPI volumes for non-storage cases. Audit ClusterRoleBindings: cluster-admin should be reserved for a narrow human-operator group with break-glass access, never bound to a ServiceAccount or a broad Group. Even with hostPath in place, removing the cluster-admin grant breaks the API-pivot leg of this chain.

AC-012: Reusable Workflow Secret Exfiltration

CRITICAL MITRE T1195.002 MITRE T1552.001 MITRE T1078 initial-access -> credential-access -> exfiltration github

A workflow calls a reusable workflow whose uses: ref is mutable (tag / branch) AND passes secrets: inherit. The owner of the upstream repo can repoint the tag to malicious code; the next caller-side run hands every caller secret to that code under cover of normal reusable-workflow plumbing.

References

Recommended action

Break either leg of the chain. (a) Replace the mutable ref (@v2 / @main) with a 40-char commit SHA so an upstream tag move can't repoint to attacker code. (b) Replace secrets: inherit with an explicit allowlist (secrets: { NPM_TOKEN: ${{ secrets.NPM_TOKEN }} }) so a compromised callee can't reach unrelated credentials. Doing (a) closes the supply-chain leg; (b) limits blast radius even if (a) is somehow bypassed.

AC-013: Caller-Controlled Runner with Token Persistence

CRITICAL MITRE T1078 MITRE T1552.001 MITRE T1133 initial-access -> credential-access -> exfiltration github

A workflow's runs-on: is computed from an attacker-controllable expression (GHA-036) AND a step in the same workflow writes GITHUB_TOKEN to persistent storage (GHA-019). The caller (or PR sender) picks which runner the workflow lands on; the workflow then drops its short-lived token onto that runner's filesystem; whoever owns the picked runner harvests the token and acts as the workflow inside the repo.

References

Recommended action

Break either leg of the chain. (a) Hard-code runs-on: or validate the input against an allowlist of known-good labels before the job runs, so the caller can't pick an attacker-controlled runner. (b) Stop writing GITHUB_TOKEN to disk, use it inline via ${{ secrets.GITHUB_TOKEN }} in the step that needs it. Doing (a) closes the targeting leg; (b) limits blast radius even if (a) is somehow bypassed because the token no longer outlives the step that consumes it.

AC-014: Caller-Controlled Runner with Token Persistence (GitLab)

CRITICAL MITRE T1078 MITRE T1552.001 MITRE T1133 initial-access -> credential-access -> exfiltration gitlab

A pipeline's tags: is computed from an attacker-controllable CI variable (GL-032) AND a script line in the same job writes CI_JOB_TOKEN (or another CI-managed credential) to persistent storage (GL-020). The pipeline trigger picks which tagged runner the job lands on; the job then drops its short-lived token onto that runner's filesystem; whoever owns the picked runner harvests the token and acts as the pipeline against the GitLab API.

References

Recommended action

Break either leg of the chain. (a) Hard-code tags: to a specific runner-tag list, or validate the value against an allowlist in a rules: guard before the job runs, so the trigger can't pick an attacker-controlled runner. (b) Stop writing CI_JOB_TOKEN (or other CI-managed credentials) to disk, use the token inline in the command that needs it and let GitLab revoke it automatically when the job finishes. Doing (a) closes the targeting leg; (b) limits blast radius even if (a) is somehow bypassed because the token no longer outlives the step that consumes it.

AC-015: Helm chart-supply-chain takeover via legacy + unlocked + plaintext

CRITICAL MITRE T1195.002 MITRE T1557 MITRE T1078.004 initial-access -> execution -> persistence helm

A Helm chart simultaneously declares the legacy v1 schema (HELM-001), ships dependencies without Chart.lock digest verification (HELM-002), and lists at least one dependency on a non-HTTPS repository (HELM-003). An attacker on the path to helm dependency build substitutes the dependency tarball; nothing in the chart's metadata can detect or reject the swap, so the substituted code runs in every cluster the chart deploys to.

References

Recommended action

Bump every chart to apiVersion: v2 so the in-tree Chart.lock mechanism is available. Re-run helm dependency update to populate per-dependency sha256: digests in the lock and commit it alongside Chart.yaml. Switch each dependencies[].repository to https://, oci://, or a file:// sibling. Helm 3.8+ pulls OCI-hosted charts over HTTPS by default and is the recommended distribution shape. Removing any one of these three legs breaks this chain (the lock catches a swap on the next update; HTTPS catches it before the tarball lands; v2 makes the lock possible in the first place).

AC-016: OIDC role drift: ungated GitHub trust meets wildcard AWS authority

CRITICAL MITRE T1078.004 MITRE T1556 MITRE T1098.003 initial-access -> credential-access -> privilege-escalation github aws

A GitHub Actions workflow requests an OIDC token without an environment: gate (GHA-030) AND the AWS IAM role it assumes carries a wildcard Action (IAM-002). Together, any branch, including a fork PR if the workflow is fork-runnable, can mint a token that maps to a role with broad authority over the account.

References

Recommended action

Close either leg to break the chain. On the GitHub side: require an environment: key on every job that uses id-token: write, and configure that environment with required reviewers + deployment-branch restrictions. On the AWS side: scope the role's policies to specific actions and resources, replace Action: '*' with the narrow set the workflow actually needs. Best is both: environment gate + least-privilege role + a token.actions.githubusercontent.com:sub condition in the role's trust policy that names the specific repo/ref.

AC-017: Build cache poisoning that lands on a mutable ECR tag

HIGH MITRE T1195.001 MITRE T1546 MITRE T1078.004 initial-access -> persistence -> impact github aws

A GitHub Actions workflow's cache key derives from attacker-controllable input (GHA-011) AND the ECR repository it pushes to has mutable image tags (ECR-002). A fork-PR-driven cache poisoning lands compiled artifacts on the cache; the next default-branch build restores them and pushes the resulting image under a tag that consumers pull by name, replacing the previous content for every downstream deployment.

References

Recommended action

Close either leg to break the chain. On the GitHub side: the cache key must be deterministic from the build's own inputs (lockfile hash, source-tree hash), never from PR-controlled context (github.head_ref, github.event.*.title, etc.). On the AWS side: set imageTagMutability=IMMUTABLE on the ECR repository and reference images by digest in deployment manifests. Best is both: deterministic cache keys + immutable tags + digest-pinned consumers.

AC-018: Unpinned action lands on deploy job with no environment gate

CRITICAL MITRE T1195.002 MITRE T1098.003 MITRE T1556 initial-access -> execution -> impact github

A workflow uses a third-party action pinned by tag rather than commit SHA (GHA-001) AND its deploy job has no environment: binding (GHA-014). A compromise of the upstream action maintainer's account, or a malicious release re-tagged under the existing version, runs in the deploy job's context without a required-reviewer gate, shipping attacker-controlled code to production on the next workflow trigger.

References

Recommended action

Pin every third-party action to a 40-char commit SHA (actions/checkout@<sha> # v4.1.0) and put deploy jobs behind a GitHub Environment that requires reviewer approval and restricts deployment branches. Either fix alone breaks the chain, the SHA pin removes the supply-chain leg, the environment gate removes the unattended-deploy leg. Best is both, plus a deployment-branch restriction so only main / release/* can reach the gated environment.

AC-019: Lambda env-secret meets a CI/CD role with PassRole *

CRITICAL MITRE T1552.001 MITRE T1098.003 MITRE T1078.004 credential-access -> privilege-escalation -> lateral-movement aws

A Lambda function holds a credential-shaped literal in its env vars (LMB-003) AND a CI/CD service role in the same account grants iam:PassRole with Resource: '*' (IAM-004). The first leak gives any read-account principal the credential; the second turns that credential into a role-hop primitive against any IAM role in the account.

References

Recommended action

Close either leg. On the Lambda side: move every env-var credential into Secrets Manager or SSM SecureString and fetch it at function init; the env vars then carry only the secret's ARN, not the value. On the IAM side: scope iam:PassRole with Resource: <specific-role-ARNs> and add an iam:PassedToService condition. The credential leak is its own compliance failure; the PassRole wildcard is its own; the chain stops being a chain when either is fixed.

AC-020: Tekton hostPath build workload meets cluster-admin RBAC

CRITICAL MITRE T1611 MITRE T1098.003 MITRE T1078 initial-access -> privilege-escalation -> lateral-movement tekton kubernetes

A Tekton Task mounts a hostPath volume or shares host namespaces (TKN-004) AND the cluster carries a ClusterRoleBinding granting cluster-admin (K8S-020). Anyone who can land code in a TaskRun has both an escape to the host filesystem and the API privileges needed to pivot the entire cluster, read every Secret, deploy privileged workloads across all nodes, impersonate any service account.

References

Recommended action

Replace the Task's hostPath volume with a Workspace (workspaces declaration + per-TaskRun persistentVolumeClaim / emptyDir binding). Tekton's native shape for sharing files between steps without exposing the node filesystem. Audit cluster ClusterRoleBindings: cluster-admin should be reserved for a narrow human-operator group with break-glass access, never bound to a ServiceAccount or a broad Group. Even with hostPath in place, removing the cluster-admin grant breaks the API-pivot leg of this chain.

AC-021: Argo default-SA workflow lands on a default-SA RoleBinding

HIGH MITRE T1078 MITRE T1098.003 initial-access -> privilege-escalation argo kubernetes

An Argo Workflow runs as the namespace default ServiceAccount (ARGO-003) AND a RoleBinding grants permissions to that default SA (K8S-029). Anyone who can submit a Workflow into the namespace runs code under whatever verbs the binding grants, turning ARGO-003 from a hygiene gap into a concrete privilege-escalation primitive.

References

Recommended action

On the Argo side: set spec.serviceAccountName: <workflow-runner> on every Workflow / WorkflowTemplate and bind that SA to a least-privilege Role. On the Kubernetes side: never grant verbs to default, every RoleBinding's subjects should name a workflow-specific SA. The fix on either side breaks the chain. Best is both: explicit per-workflow SAs across every namespace, plus deny rules / OPA policies that block any RoleBinding subject named default at admission time.

AC-022: GitLab script injection lands on deploy job with no manual gate

CRITICAL MITRE T1059 MITRE T1078 MITRE T1556 initial-access -> execution -> impact gitlab

A .gitlab-ci.yml job interpolates an attacker-controlled context field directly into its script: (GL-002) AND a deploy job in the same file lacks a manual approval / protected environment: gate (GL-004). A crafted commit title or MR description from any branch the pipeline runs on injects a shell command into the build stage; the deploy stage then ships the resulting artifacts to production unattended.

References

Recommended action

On the injection side: never interpolate $CI_COMMIT_* / $CI_MERGE_REQUEST_* directly into a shell command. Bind the field to a job-scoped variables: entry and reference the variable inside double quotes (echo "$TITLE"), so the shell sees one literal argument rather than interpreted syntax. On the deploy side: gate every job that publishes artifacts, applies infrastructure, or pushes to a registry behind when: manual plus an environment: mapped to a protected environment in GitLab settings, and use rules:/only: to limit the job to the default branch. Either fix breaks the chain; doing both also closes off the same primitive against future rule additions.

AC-023: Tekton param injection lands in a privileged or root step

CRITICAL MITRE T1059 MITRE T1068 MITRE T1611 initial-access -> execution -> privilege-escalation tekton

A Tekton Task interpolates $(params.<name>) directly into a step's script: body without quoting (TKN-003) AND the same step runs privileged: true / runAsUser: 0 / with node-level capabilities.add (TKN-002). A crafted PipelineRun param value, supplied via a webhook payload, GitOps merge, or fork-PR-triggered EventListener, injects a shell command that executes inside a kernel-privileged container, the two ingredients for a Kubernetes node escape.

References

Recommended action

On the injection side: stop interpolating $(params.<name>) directly into a step's shell body. Pass the param through env:. Tekton substitutes the param into the env value at run time, and the shell then sees a quoted variable ("$FOO") rather than syntax it can interpret. On the privilege side: drop securityContext.privileged: true, set runAsNonRoot: true + a non-zero runAsUser, and list only the specific Linux capabilities the step needs (most build tooling needs none). Either fix breaks the chain, a non-privileged container makes the injection a hygiene smell rather than a node-escape primitive, and a quoted param removes the injection regardless of container capabilities. Best is both, plus a Pod Security Admission restricted label on the namespace to enforce the privilege side at admission time.

AC-024: OIDC trust drift lands on a mutable ECR tag

CRITICAL MITRE T1078.004 MITRE T1195.002 MITRE T1525 initial-access -> credential-access -> impact github aws

A GitHub Actions workflow requests an OIDC token without an environment-protected job (GHA-030) AND an ECR repository has mutable image tags (ECR-002). Any branch or fork PR that triggers the workflow obtains short-lived AWS credentials with no required-reviewer gate; if those credentials reach an ECR push role, the mutable-tag policy lets the workflow overwrite an existing tag (:latest, :v1.2.3) and the substituted image propagates to every downstream consumer that pulls by name.

References

Recommended action

Either fix breaks the chain. On the GitHub side: bind any job that requests id-token: write to a GitHub Environment with required-reviewer protection, and pin the IAM trust policy's token.actions.githubusercontent.com:sub claim to a specific repo + ref pattern (repo:owner/repo:ref:refs/heads/main) so a fork PR can't redeem the role. On the AWS side: set imageTagMutability=IMMUTABLE on every ECR repository consumed in production, and reference images by digest (@sha256:...) in deployment manifests so tag substitution can't propagate even if a push slips through. Best is both: gated OIDC + immutable tags + digest-pinned consumers.

AC-025: Argo param injection lands in a privileged or root step

CRITICAL MITRE T1059 MITRE T1068 MITRE T1611 initial-access -> execution -> privilege-escalation argo

An Argo Workflow / WorkflowTemplate interpolates {{inputs.parameters.<name>}} / {{workflow.parameters.<name>}} directly into a template's script.source or container command/args without quoting (ARGO-005) AND the same template runs privileged: true / runAsUser: 0 / with node-level capabilities.add (ARGO-002). A crafted param value supplied via an Argo Events Sensor webhook, a CronWorkflow trigger, or a WorkflowEventBinding fork-PR path injects a shell command that executes inside a kernel-privileged container, the two ingredients for a Kubernetes node escape, regardless of what the workflow's ServiceAccount can reach via the API.

References

Recommended action

On the injection side: stop interpolating {{inputs.parameters.<name>}} / {{workflow.parameters.<name>}} directly into a template's shell body. Bind the param to a template env: entry (env: [{name: FOO, value: '{{inputs.parameters.foo}}'}]) and reference the env var inside double quotes (echo "$FOO"). Argo substitutes into env values, the shell then sees one literal argument rather than interpreted syntax. On the privilege side: drop securityContext.privileged: true, set runAsNonRoot: true + a non-zero runAsUser, and list only the specific Linux capabilities the step needs. Either fix breaks the chain, a non-privileged container makes the injection a hygiene smell rather than a node-escape primitive, and a quoted param removes the injection regardless of container capabilities. Best is both, plus a Pod Security Admission restricted label on the namespace to enforce the privilege side at admission time.

AC-026: Buildkite injection lands on auto-deploy step with no manual gate

CRITICAL MITRE T1059 MITRE T1078 MITRE T1556 initial-access -> execution -> impact buildkite

A pipeline.yml interpolates an untrusted Buildkite variable ($BUILDKITE_MESSAGE, $BUILDKITE_BRANCH, $BUILDKITE_PULL_REQUEST_TITLE, etc.) into a step's command: body (BK-003) AND a deploy-named step in the same pipeline runs without a manual: or input: gate (BK-007). The combination converts a fork-controllable injection point into an unattended production push, the Buildkite analog of AC-002 / AC-022 on the GitHub and GitLab surfaces.

References

Recommended action

On the injection side: stop interpolating Buildkite metadata variables directly into command: bodies. Bind the value through env: instead (env: { MSG: "$BUILDKITE_MESSAGE" } then reference "$MSG" inside the command) so the shell sees a quoted variable rather than syntax it can interpret. On the gate side: every deploy-named step should carry a manual: block (or be preceded by a separate input: step) so a human reviewer acknowledges the deploy. Configure the manual block's branches: filter and the surrounding step's branches: filter together so a fork PR build can't trigger production. Either fix breaks the chain; both is best.

AC-027: Image bakes a credential file AND exposes a remote-access port

CRITICAL MITRE T1552.001 MITRE T1078 MITRE T1190 credential-access -> initial-access -> lateral-movement dockerfile

A Dockerfile COPY / ADD source path names a credential file (id_rsa, .aws/credentials, .npmrc, .kube/config, etc.: DF-019) AND the same image EXPOSE s a sensitive remote-access port (22, 23, 21, 3389, 5900, common database / cache / search ports: DF-013). The image ships a key and a way to reach it from the outside; pulling a public mirror or exfiltrating a single CI build artifact yields both halves of the credential-and-listener pair.

References

Recommended action

Move the credential out of the image. Mount it at runtime: a Kubernetes secret (or projected SA token), AWS Secrets Manager / GCP Secret Manager / Vault for cloud creds, or a container-level env var sourced from the orchestrator. The image stops being a leak surface the moment the credential isn't baked in. Drop the EXPOSE for the remote-access daemon: the container runtime's exec path (docker exec / kubectl exec) covers every legitimate debugging use without opening a port or shipping an extra daemon. Either fix breaks the chain on its own. Add a .dockerignore rule to keep credential files out of build context as a third layer; the COPY can't bake in what the build never sees.

AC-028: npm worm propagation primitive co-located

CRITICAL MITRE T1195.002 MITRE T1078.004 MITRE T1546 initial-access -> execution -> lateral-movement github npm

A repo carries both halves of the Shai-Hulud-class npm worm propagation primitive: a package.json with install-time lifecycle scripts (NPM-004) sits alongside a GitHub Actions workflow that authors sibling workflow files (GHA-048) or pushes to parameterized external repos (GHA-049). The combination is the topology the Shai-Hulud npm worm used to spread, postinstall harvests credentials from every consumer; the workflow leg writes the next stage of the worm into every repo the stolen token can reach.

References

Recommended action

Break either leg: (a) move install-time logic out of preinstall / install / postinstall / prepare into a documented CLI subcommand consumers invoke deliberately, OR (b) remove the workflow's ability to author workflow YAML on the runner and to push to non-allow-listed external repos. With either leg severed the worm has no propagation primitive in this repo. Long-term: rotate every credential the repo's CI can reach if the GHA-048 / GHA-049 finding suggests the workflow has already executed once.

AC-029: Untrusted trigger reaches a long-lived publish credential

CRITICAL MITRE T1195.002 MITRE T1078.004 MITRE T1606 initial-access -> credential-access -> impact github

A single workflow file combines an attacker-influenced trigger (GHA-002 / GHA-009 / GHA-013), a long-lived publish or cloud credential (GHA-050 / GHA-005), and an unguarded dependency-install path (GHA-021 / GHA-029). The combination is the Ultralytics / s1ngularity attack lane: an attacker lands code via PR or comment, the same workflow publishes their payload to a public registry under the victim's identity.

References

Recommended action

Break the lane at any one leg. Either: (a) re-trigger publish on tag-only / push-to-default-branch (drop pull_request_target / issue_comment / workflow_run from the publish workflow), (b) swap the long-lived token for OIDC Trusted Publishing (PyPI) / a federated identity (AWS) / GitHub's id-token: write flow, (c) enforce a committed lockfile and registry-integrity verification on the dep install. Doing all three is the long-term posture; doing any one breaks the chain.

AC-030: Argo CD anonymous access meets wildcard RBAC

CRITICAL MITRE T1190 MITRE T1078.001 MITRE T1098.003 initial-access -> privilege-escalation -> impact argocd

argocd-cm enables anonymous access (ARGOCD-009) AND argocd-rbac-cm carries at least one wildcard or role:admin grant (ARGOCD-004). The combination collapses to a zero-auth control-plane takeover, an unauthenticated caller routes through the anonymous principal into the broad RBAC grant and drives Argo CD's sync engine, the manifests it applies, and every credential its application controllers can read.

References

Recommended action

Break either leg, both is best: 1. Disable anonymous access (ARGOCD-009). Remove the users.anonymous.enabled key from argocd-cm or set it to "false". With anonymous off, any wildcard grant in argocd-rbac-cm still requires an authenticated subject before it can be exercised. 2. Scope the RBAC policy (ARGOCD-004). Replace p, <role>, *, *, *, allow and g, <subject>, role:admin with explicit per-resource per-project grants tied to a named SSO group. Set policy.default to a deny / least-privilege role rather than leaving it implicit. If anonymous access is a deliberate design choice (e.g. a read-only public dashboard), the RBAC matrix MUST hold no wildcard / admin grants and policy.default must be the narrowest role the dashboard's use case allows.

AC-031: Argo CD untrusted PR generator meets wildcard source repos

CRITICAL MITRE T1195.002 MITRE T1199 MITRE T1078.004 initial-access (fork / contributor PR) -> execution (manifest render) -> impact argocd

An ApplicationSet uses a pullRequest / scmProvider generator without a project allowlist (ARGOCD-006) AND at least one AppProject has sourceRepos: ['*'] (ARGOCD-001). Any PR in the matched organization materializes a fresh Application that inherits the wildcard source-repo allowlist; the attacker's manifests render into the cluster on the next sync. The default out-of-the-box AppProject ships with sourceRepos: ['*'], so the chain fires on most unconfigured Argo CD installs where a PR generator is introduced without a tightened project.

References

Recommended action

Break either leg, both is best: 1. Tighten the AppProject's sourceRepos (ARGOCD-001). Replace ['*'] with the explicit list of repository URLs the project is allowed to render. Set spec.sourceRepos: ['https://github.com/org/payments-*'] and keep sourceNamespaces / destinations similarly scoped. 2. Scope the ApplicationSet generator (ARGOCD-006). Pin template.spec.project to a single static project name (not default, not a {{...}} placeholder) and constrain the generator with filters: / labels: ['preview'] / branchMatch: so PRs from untrusted authors do not synthesize Applications. If PR-driven preview environments are a deliberate design, the AppProject the PR-driven Applications resolve to MUST carry an explicit sourceRepos allowlist and a narrow destination, the chain's premise is unbounded authority, not the PR-preview pattern itself.

AC-032: Cosign-verified-but-not-bound artifact to production deploy

CRITICAL MITRE T1195.002 MITRE T1036.005 initial-access (artifact replacement) -> defense-evasion (unbound cosign verify passes) -> impact (production deploy) github

A cosign verify invocation lacks certificate identity binding (GHA-100) AND the same workflow deploys without a security-scan gate (GHA-098) or environment protection (GHA-014). An attacker who replaces the artifact can mint their own valid Sigstore signature, pass the unbound verification, and reach production through the unguarded deploy step.

References

Recommended action

Break either leg: 1. Bind the cosign verify identity (GHA-100): add --certificate-identity(-regexp) AND --certificate-oidc-issuer(-regexp) pinned to the expected build workflow. 2. Gate the deploy step (GHA-098): require a security scan or manual approval environment before the deploy job runs. Both fixes together give defense-in-depth: even if a future signing key compromise occurs, the deploy gate catches unsigned or unexpected artifacts.

AC-033: Environment-secret laundering to unprotected deploy job

CRITICAL MITRE T1078.004 MITRE T1548 privilege-escalation (environment gate bypass) -> lateral-movement (secret in unprotected job) -> impact (ungated deploy) github

A protected environment secret flows through jobs.<id>.outputs: to a consumer job without environment: binding (TAINT-009) AND the workflow deploys without a security-scan gate (GHA-098). The environment's review gates are bypassed: the secret reaches an unprotected job that performs a production deploy.

References

Recommended action

Break either leg: 1. Add an environment: binding to the consuming job (TAINT-009): every job that touches the secret must go through the same protection gate. 2. Add a security-scan gate before the deploy step (GHA-098): a scan dependency ensures the deploy job doesn't run without validation. Best: restructure the workflow so the secret never leaves the environment-bound job's boundary. Perform the deploy operation in the same protected job.

AC-034: Submodule-poisoned PR to credential exfiltration

CRITICAL MITRE T1195.002 MITRE T1078.004 MITRE T1059 initial-access (PR with modified .gitmodules) -> execution (submodule lifecycle scripts) -> credential-access (persisted GITHUB_TOKEN) -> impact (repo write / secret exfiltration) github

A PR-triggered workflow clones submodules from an attacker-controllable .gitmodules (GHA-102) AND persists credentials or runs with overly broad permissions (GHA-037 / GHA-004). The attacker's submodule code executes with access to the GITHUB_TOKEN at write scope, enabling pushes to the base repo, release creation, or secret exfiltration.

References

Recommended action

Break either leg: 1. Remove submodules: recursive from PR-triggered checkout steps (GHA-102). If submodules are required, validate submodule origins before the build step. 2. Set persist-credentials: false on the checkout step (GHA-037) AND scope permissions: to the minimum needed (GHA-004). Without a persisted token or write scope, the attacker's code can't push or exfiltrate. Both fixes together are best: no submodule clone means no attacker code; no credentials means no blast radius.

AC-035: AI agent is both reviewer and committer

CRITICAL MITRE T1195.002 MITRE T1059 MITRE T1078.004 initial-access (prompt injection via untrusted PR / comment) -> execution (AI agent follows the injected instruction) -> defense-evasion (the AI is its own reviewer) -> impact (agent commits / pushes without human review) github

An AI review bot runs on an untrusted trigger without an environment gate (GHA-103) AND the same workflow lets the agent write back, by pushing commits directly (GHA-104) or by holding a write-scoped GITHUB_TOKEN (GHA-106). A prompt-injection payload in the PR or comment makes the AI approve and commit its own malicious change with no human review in the loop.

References

Recommended action

Break either leg: 1. Don't run the AI bot on an untrusted trigger with write scope (GHA-103): move review to pull_request with a read-only token, or gate the privileged job behind a protected environment:. 2. Take away the agent's write path: route its output through a reviewable PR instead of a direct push (GHA-104), and scope the job to contents: read (GHA-106). Best: never let one workflow both feed an agent untrusted input and grant it the ability to write back. Split review (read-only) from any apply step (human-approved).

AC-036: Untrusted-code execution with no runtime egress containment

HIGH MITRE T1059 MITRE T1552 MITRE T1041 execution (attacker-influenced code runs on the runner) -> credential-access (reads the OIDC token / GITHUB_TOKEN / secrets) -> exfiltration (no egress allowlist blocks the outbound connection) github

A workflow runs attacker-influenced or remotely-fetched code (script injection, github-script injection, curl | bash, or build-tool lifecycle scripts on an untrusted trigger) AND has no enforced egress allowlist: harden-runner is absent on an OIDC/deploy workflow (GHA-108) or present but in audit mode (GHA-107). The executing code can read the runner's OIDC token, GITHUB_TOKEN, or secrets and exfiltrate them with nothing at the network layer to stop it.

References

Recommended action

Break either leg: 1. Close the execution primitive: route untrusted input through an env: var instead of inlining ${{ github.event.* }} (GHA-003 / GHA-035), pin and verify actions, and replace curl | bash (GHA-016) / untrusted build-tool scripts (GHA-044) with pinned, checksummed installs. 2. Add an enforced egress allowlist: run step-security/harden-runner as the first step with egress-policy: block and an allowed-endpoints list (GHA-107 / GHA-108). Either fix narrows the chain; do both. Egress blocking is the defense-in-depth layer that contains an execution primitive you missed.

AC-037: AI agent applies attacker-influenced IaC to the cloud

CRITICAL MITRE T1195.002 MITRE T1059 MITRE T1078.004 initial-access (prompt injection via untrusted PR / comment) -> execution (AI agent follows the injected instruction and edits the IaC) -> impact (unattended apply realizes the malicious infrastructure in the cloud account, no human review) github

An agentic CLI reads attacker-controlled input, via permission-bypass flags or a PR-checkout topology (GHA-058) or an AI review bot on an untrusted trigger (GHA-103), AND the same workflow runs an agent alongside an unattended IaC apply (GHA-111). A prompt-injection payload in the PR or comment makes the agent write malicious Terraform / CloudFormation that the apply pushes straight to the cloud account, no human reviewing the plan.

References

Recommended action

Break either leg: 1. Cut the untrusted-input path: don't run an agentic CLI on an untrusted trigger or over a checked-out fork PR, and don't pass attacker-authored text into the prompt (GHA-058 / GHA-103). 2. Take the apply away from the agent's job: have the agent only propose changes into a reviewable PR, and run terraform apply / cloudformation deploy from a separate job on the merged, human-reviewed plan behind a protected environment: (GHA-111). Best: never let one workflow both feed an agent untrusted input and apply that agent's infrastructure changes unattended.

AC-038: Untrusted branch reaches OIDC trusted publish

CRITICAL MITRE T1195.002 MITRE T1199 MITRE T1606 initial-access (push a counterfeit publish workflow to a throwaway branch) -> credential-access (mint an OIDC token the registry accepts because it validates only org + repo + workflow filename) -> impact (publish a malicious package version as the trusted maintainer, no human or branch gate) github

A package-publish job mints an OIDC token with no environment gate (GHA-113) AND the workflow is reachable from an unrestricted push trigger (GHA-114). The publish token mints from any branch with no human or branch gate, so a counterfeit workflow on a throwaway branch publishes a malicious version as the trusted maintainer (the Red Hat npm 'untrusted branch' compromise).

References

Recommended action

Break either leg: 1. Gate the trigger (GHA-114): publish only from a tag (on: push: tags:), a release: published event, or workflow_dispatch, not a branch push to any ref. 2. Gate the token (GHA-113): bind the publish job to a protected environment: whose deployment-branch rule pins the release ref, so the OIDC token mints only from that ref. Best: do both, and keep id-token: write scoped to the publish job. Either gate alone stops a throwaway branch from minting a publish token.

AC-039: Untrusted trigger reaches a bulk-secrets serialization

CRITICAL MITRE T1195.002 MITRE T1552 MITRE T1567.002 initial-access -> credential-access -> exfiltration github

A single workflow combines an attacker-influenced trigger (GHA-002 / GHA-009 / GHA-013) with a step that serializes the whole secrets context via toJSON(secrets) (GHA-116). An external attacker who opens a fork PR or posts a comment triggers a run that dumps every secret the workflow can read into a world-readable log, the reachable form of the 2025 tj-actions / GhostAction secret-harvesting attacks.

References

Recommended action

Break the lane at either leg. Either: (a) drop the untrusted trigger from this workflow (re-trigger on push to the default branch / a tag, or gate pull_request_target / issue_comment / workflow_run behind an environment with required reviewers), or (b) stop serializing the whole secrets context, reference only the specific secrets each step needs by name and prefer short-lived OIDC tokens. Doing (b) is the stronger fix because toJSON(secrets) is dangerous on any trigger; removing the untrusted trigger alone still leaves the full-secret dump a push away.

AC-040: Prompt-injected agent commits its output with no human review

CRITICAL MITRE T1195.002 MITRE T1059 MITRE T1078.004 initial-access (prompt injection via untrusted PR / branch / commit) -> execution (the agent follows the injected instruction and edits the tree) -> defense-evasion (no human reviews the diff) -> impact (the autoland step pushes or merges the agent's change to a branch) github gitlab bitbucket azure jenkins harness circleci

Untrusted PR / branch / commit context reaches an agentic CLI's prompt (GHA-119 / GL-048 / BB-036 / ADO-035 / JF-037 / HARNESS-008 / CC-037) AND the same pipeline lands that agent's output with no review gate (GHA-123 / GL-049 / BB-039 / ADO-038 / JF-038 / HARNESS-009 / CC-038): a git push, an auto-merge, or a push-action. A prompt-injection line in the PR or commit makes the agent write a malicious change that the autoland step commits or merges, with no human between the untrusted input and the push.

References

Recommended action

Break either leg: 1. Cut the untrusted-input path: don't pass attacker-authored text (a PR title / branch name / commit message) into an agentic CLI's prompt; if the agent must see PR content, run it on a job with no write credentials and no tool / shell access (GHA-119 / GL-048 / BB-036 / ADO-035 / JF-037 / HARNESS-008 / CC-037). 2. Take away the no-review landing: have the agent only open a pull request for human review, and drop the in-job git push / auto-merge / push-action (GHA-123 / GL-049 / BB-039 / ADO-038 / JF-038 / HARNESS-009 / CC-038). Best: never let one pipeline both feed an agent untrusted input and land that agent's output without a human reviewing the diff.

AC-041: Compromised action executed and exfiltrated credentials in the same run

CRITICAL MITRE T1195.002 MITRE T1552 MITRE T1567 initial-access (a compromised third-party action is pulled into the run) -> execution (the malicious action runs) -> credential-access (a secret is exposed in the run, or an OIDC token is minted) -> exfiltration (the compromised action ships the credential out, the leak / mint is the evidence) runs

A known-compromised action actually executed in a workflow run (RUN-006) AND the same run exposed a credential: a secret-shaped string leaked in its logs (RUN-003) or it minted a cloud OIDC token (RUN-004). Both legs on one run is the run-history confirmation that the supply-chain attack succeeded, the malicious action ran and a credential left the run in the same execution (the tj-actions/changed-files pattern).

References

Recommended action

Respond as to a confirmed breach: rotate every credential and token the affected run could reach (the leaked secret, the federated cloud role for an OIDC mint, the GITHUB_TOKEN), review the run's outbound network and any pushes / deployments it made, and audit downstream systems the credential can reach. Then close the entry point: pin the action to a known-good commit SHA (GHA-040 / RUN-006) and stop the credential reaching the log (RUN-003) or scope the OIDC role's trust policy (RUN-004).

AC-042: Fork pipeline executed and exfiltrated credentials in the same pipeline

CRITICAL MITRE T1199 MITRE T1552 MITRE T1078.004 MITRE T1567 initial-access (a fork merge request opens, untrusted contributor code) -> execution (its pipeline runs in the project's CI) -> credential-access (a secret leaks in the job trace, or the pipeline mints a cloud OIDC token) -> exfiltration (the untrusted code ships the credential out, the leak / mint is the evidence) gitlab_runs

A fork merge-request pipeline actually executed in the project's CI (GLRUN-002) AND the same pipeline exposed a credential: a secret-shaped string leaked in its job trace past GitLab's masking (GLRUN-003) or it minted a cloud OIDC token (GLRUN-004). Both legs on one pipeline is the run-history confirmation that untrusted fork code reached a credential in a single execution, the GitLab face of the poisoned-pipeline-execution class confirmed to have succeeded.

References

Recommended action

Respond as to a confirmed breach: rotate every credential and token the affected pipeline could reach (the leaked secret, the federated cloud role for an OIDC mint, the CI job token), review the pipeline's outbound network and any pushes / deployments it made, and audit downstream systems the credential can reach. Then close the entry point: keep protected CI/CD variables and runners away from fork merge-request pipelines and require approval before they run (GLRUN-002), stop the credential reaching the trace (GLRUN-003) or scope the cloud trust policy so a fork ref cannot assume the role (GLRUN-004).

CXPC-001: npm publish-side cooldown + floating consumer in partner repo

HIGH MITRE T1195.002 MITRE T1078.004 initial-access -> lateral-movement npm

One repo recently published an npm package (NPM-008) and another repo in the fleet consumes npm packages with a floating version range (NPM-001) or without integrity hashes (NPM-002). If the publish-side repo is compromised, the floating consumer pulls the poisoned version on the next install.

References

Recommended action

Pin exact versions in the consumer repo's package.json and commit a lock file with integrity hashes (NPM-002). On the publish side, enforce 2FA-on-publish and review the cooldown window flagged by NPM-008.

CXPC-002: Argo CD wildcard sourceRepos + weakened CI gate in partner repo

CRITICAL MITRE T1195.002 MITRE T1199 MITRE T1078.004 initial-access -> execution -> persistence argocd github

An Argo CD AppProject accepts any source repo (ARGOCD-001) and a partner repo has a weakened CI gate (GHA-002 / TAINT-001 / TAINT-002) that allows PR-level code injection. An attacker's PR in the weakened repo lands code that Argo CD's wildcard trust deploys into the cluster.

References

Recommended action

Restrict the AppProject's sourceRepos to an explicit allowlist of trusted repositories. In the partner repo, fix the CI gate: avoid checking out PR-head code in pull_request_target workflows (GHA-002) and remediate tainted dataflows (TAINT-001 / TAINT-002).

CXPC-003: Unscoped App token + credential exposure in partner repo

HIGH MITRE T1078.004 MITRE T1098.001 credential-access -> lateral-movement github

One repo mints an unscoped GitHub App token (GHA-061) whose installation likely covers other repos in the same org. A partner repo exposes credentials (GHA-005 / GHA-008). The App token from the first repo can reach the second; credential exposure in the second gives the attacker a lateral-movement foothold.

References

Recommended action

Scope the App token in repo A by passing an explicit permissions map to the token-mint action (GHA-061). In repo B, rotate and remove plaintext credentials (GHA-005) and hardcoded secrets (GHA-008), replacing them with GitHub Actions secrets or OIDC federation.

CXPC-004: Tainted reusable workflow producer + GitHub Actions consumer in partner repo

HIGH MITRE T1195.002 MITRE T1199 initial-access -> execution github

One repo has a workflow with an exploitable taint path (TAINT-001 / TAINT-002 / TAINT-003) and another repo in the fleet uses GitHub Actions. If the consumer calls the producer's reusable workflows, it inherits the taint. The cross-repo split means the consumer's maintainers may not see the vulnerability.

References

Recommended action

Remediate the taint path in the producer repo's workflow (TAINT-001 / TAINT-002 / TAINT-003). Consumer repos should pin reusable workflow references to a specific commit SHA and review the producer's workflow for untrusted-input interpolation before calling it.

XPC-001: Deploy without verifiable provenance (workflow + image)

HIGH MITRE T1195.002 MITRE T1525 build -> distribution (no provenance link between them) github oci

The CI workflow doesn't emit SLSA provenance and the image it deploys ships without a build-attestation manifest. The verifier-side contract is broken on both ends, so a downstream consumer pulling the image has no way to prove it came from this workflow's build.

References

Recommended action

Close the verifier loop on both ends. In the workflow, add a provenance-emitting step (actions/attest-build-provenance or the SLSA generic-generator). In the image build, pass --attest=type=provenance,mode=max to docker buildx build so the manifest carries a BuildKit attestation manifest. Verify post-deploy with cosign verify-attestation against the workflow's OIDC identity.

XPC-002: Tag mutability across pipeline + runtime (Dockerfile + K8s)

HIGH MITRE T1195.002 MITRE T1525 build -> deploy (tag mutation propagates through both) dockerfile kubernetes

Both the Dockerfile's FROM line and the Kubernetes workload manifest reference floating image tags. An attacker who pushes a malicious blob under a known tag (stolen registry credentials, compromised upstream CI) affects the build artifact AND the running workload at the same time, with no separate fix-once-and-it's-done place to break the chain.

References

Recommended action

Pin both ends to @sha256:<digest>. In the Dockerfile, rewrite FROM python:3.12 to FROM python:3.12@sha256:<digest>. In the Kubernetes manifest, rewrite image: my-org/app:1 to image: my-org/app:1@sha256:<digest> (and configure imagePullPolicy: IfNotPresent so the kubelet doesn't re-resolve on every pod restart). Capture the digest with crane digest or docker buildx imagetools inspect and update the digest deliberately in version control when the upstream version moves.

XPC-003: Unverified Helm release flow (chart + image)

HIGH MITRE T1195.001 MITRE T1525 package -> distribution -> deploy (no provenance link at any of the three boundaries) helm oci

The Helm chart's Chart.lock doesn't pin per-dependency digests AND the image the chart deploys lacks a build attestation manifest. Neither the chart contents nor the image bytes are independently verifiable, so a downstream consumer running helm install has no signed chain of custody between chart authoring and image runtime.

References

Recommended action

Pin both ends of the release flow. In the Helm chart, regenerate Chart.lock after every dependency update so every entry carries a digest, and gate consumers behind helm install --verify to enforce the lock at install time. In the image build, pass --attest=type=provenance,mode=max to docker buildx build so the manifest carries a BuildKit attestation manifest. Verify post-deploy with cosign verify-attestation against the workflow's OIDC identity. Both legs together close the producer-to-verifier loop the chart-image pipeline currently has open at every step.

XPC-004: Token persistence on an unprotected default branch

CRITICAL MITRE T1552.001 MITRE T1078.004 MITRE T1195.002 credential-access -> persistence (write to default branch -> harvest from artifact) github scm

A workflow persists a CI token or secret into build artifacts (or logs, cache, $GITHUB_OUTPUT) on a repo whose default branch is either unprotected (no protection rule) or allows force-pushes. The combination collapses the attack primitive from 'compromise the build runtime' to 'open a PR that lands a malicious change on main, then fetch the next build's artifacts.' Either leg alone is fixable in isolation; together, the secret is reachable to anyone with write access to the repo.

References

Recommended action

Two fixes, either alone breaks the chain: 1. Add a branch protection rule on the default branch with required pull-request reviews and force-push denial (SCM-001 + SCM-007). This forces any change to go through review before it can run with full CI permissions. 2. Stop persisting tokens to build artifacts (GHA-019). Use OIDC federation with short-lived credentials, mask secret values in logs, and audit any ::set-output:: / $GITHUB_OUTPUT write that includes ${{ secrets.* }} or ${{ github.token }}. Best to fix both — branch protection is the durable control even when a future workflow change reintroduces credential persistence.

XPC-005: End-to-end provenance gap: source unsigned, artifact unsigned

HIGH MITRE T1195.002 MITRE T1554 supply-chain (source tampering -> build tampering, no compensating control at either boundary) github scm

The repo doesn't require signed commits AND the workflow doesn't sign release artifacts. There is no cryptographic chain of custody at either boundary: a tampered commit can land under any contributor's name, and a tampered artifact can ship from any compromised build runtime. Consumers downstream cannot verify what built from what — every release is trust-on-first-use.

References

Recommended action

Two fixes; either alone narrows the chain, both close it: 1. Enable Require signed commits on the default branch protection rule (SCM-006). Configure GPG / SSH / S/MIME signing for every contributor so commits land with a verifiable identity. 2. Add a signing step to the release workflow (GHA-006). slsa-framework/slsa-github-generator produces a verifiable SLSA L3 provenance attestation; sigstore/cosign signs the artifact with a keyless Fulcio identity. Publish the signature alongside the artifact and document the verification command in the release notes. Best to fix both: a signed commit landing in an unsigned release still leaves the build-runtime tampering vector open, and a signed artifact built from unsigned commits still has provenance ambiguity at the source boundary.

XPC-006: Unreviewed fork-PR privilege escalation

CRITICAL MITRE T1078.004 MITRE T1199 MITRE T1195.002 MITRE T1078.003 initial-access -> execution (single-identity introduction of the pwn-request primitive; ongoing fork-PR exploitation) github scm

A workflow uses pull_request_target and checks out the PR head (CRITICAL fork-PR privilege escalation primitive) AND the default branch's protection rule does not require approving reviews. A single insider can introduce or keep the vulnerability alive solo — there is no review gate between a compromised maintainer account and a fork-PR-exploitable workflow on the default branch.

References

Recommended action

Two fixes; either alone narrows the chain, both close it: 1. Replace pull_request_target with pull_request for any workflow that runs fork-PR code, OR split the workflow so the privileged half (write-scope token, secrets) does NOT check out the PR head and the build half runs in the unprivileged pull_request context (GHA-002). 2. Set required_approving_review_count >= 1 in the default branch protection rule so a second identity must acknowledge any change to the workflow file before it merges (SCM-002). Pair with require_last_push_approval (SCM-014) so a force-push after approval doesn't smuggle the malicious diff back in. Best to fix both: GHA-002 is the active exploit primitive (every fork PR is a trigger), SCM-002 is the durable control that prevents reintroduction. Without the second, a future commit can reopen the door silently.

XPC-007: Unpinned actions with no automated remediation

HIGH MITRE T1195.002 MITRE T1195.001 MITRE T1078.004 supply-chain (mutable ingestion -> no automated detection / patch path; manual triage measured in days) github scm

Workflow uses: references aren't SHA-pinned (so an upstream maintainer compromise propagates to the next workflow run automatically) AND the repo has Dependabot security updates disabled (so the team has no automated alert + PR when the public CVE lands). The exposure window between upstream compromise and remediation is maximized.

References

Recommended action

Two fixes; either alone narrows the chain, both close it: 1. Pin every uses: reference to a 40-char commit SHA (GHA-001). The Renovate / Dependabot version-update config keeps the pins fresh while preserving review of every move. Tag pins (@v4, @main) accept silent upstream rewrites; SHA pins do not. 2. Enable Dependabot security updates on the repo (SCM-005). The bot opens a PR with the minimum-required upgrade against every open advisory on an in-use dependency, so a maintainer is paged within hours of the CVE landing instead of days when someone notices. Best to fix both: SHA pins remove the immediate exposure to upstream tag rewrites; Dependabot remediation closes the post-disclosure window during which a CVE is published but no fix is in flight. The tj-actions March 2025 compromise demonstrated both halves of the failure mode in the same incident.

XPC-008: Unreviewed source ships a mutable runtime image

HIGH MITRE T1195.002 MITRE T1525 MITRE T1078.004 supply-chain (insider source change -> mutable upstream ingestion at build-time) dockerfile scm

The repo's default branch is unprotected (or allows force-pushes) AND the Dockerfile pulls its base image by floating tag rather than digest. An insider can land a tampered FROM reference change in a single self-merge, AND every subsequent build inherits whatever bytes the upstream registry currently serves under the named tag. Neither the team's review process nor any lockfile has visibility into the runtime image's actual content.

References

Recommended action

Two fixes; either alone narrows the chain, both close it: 1. Add a branch protection rule on the default branch with required pull-request reviews and force-push denial (SCM-001 / SCM-007). This forces any change to the Dockerfile (and every other source file) to go through review before it can affect the build. 2. Pin the Dockerfile's FROM to a digest (FROM python:3.12@sha256:<hex>) (DF-001). The build then uses the exact bytes the digest names; an upstream tag rewrite has no effect until a maintainer deliberately updates the digest in the Dockerfile. Best to fix both: branch protection is the durable control preventing the insider-introduction half, and digest pinning is the durable control preventing the upstream-ingestion half. Either alone leaves the other open.

XPC-009: Ingested CVE finding plus mutable runtime image reference

HIGH MITRE T1195.002 MITRE T1525 supply-chain (current-image vulnerability + unbounded future-image content) dockerfile

A SARIF feed (Trivy, Grype, Snyk, etc.) reports at least one CVE against the current image AND the Dockerfile pins its base by floating tag rather than digest. Today's vulnerability set is known; tomorrow's is unbounded. Pinning to a digest keeps the vulnerability snapshot reproducible across builds; updating the digest is then a deliberate, auditable action.

References

Recommended action

Two fixes; both are needed to close the chain: 1. Pin the Dockerfile's FROM to a digest (FROM python:3.12@sha256:<hex>) (DF-001). The build then uses the exact bytes the digest names; no upstream tag-rewrite changes the vulnerability set. 2. Update the digest to a known-clean upstream version the SARIF scanner clears. Capture the digest with crane digest or docker buildx imagetools inspect and update the FROM line in version control. The next build then uses the patched image AND keeps the snapshot consistent across subsequent runs. Optional but valuable: wire Dependabot or Renovate to auto-PR the digest update when a new clean version publishes (SCM-005 + this chain together close the loop).

XPC-010: npm cooldown miss meets Dockerfile lifecycle execution

HIGH MITRE T1195.002 MITRE T1078.004 MITRE T1546 supply-chain (fresh upstream release) -> execution (lifecycle script in build container) npm dockerfile

A package.json pinned a freshly published exact dependency version (NPM-008) AND the Dockerfile's install step runs lifecycle scripts (DF-024). The next image build executes the new release's postinstall with the builder's NPM_TOKEN / GH_TOKEN / AWS_* in scope, the consumer-side Shai-Hulud / TanStack blast radius. Either leg alone is hygiene debt; together they are the execution primitive lined up with a time window the registry has not yet had a chance to close.

References

Recommended action

Two fixes; either alone narrows the chain, both close it: 1. Hold back the bump, pin the dependency to the most recent release older than the cooldown window (NPM-008). pipeline_check --pipeline npm --resolve-remote will surface the publish dates so the team can choose a safe anchor. 2. Disable lifecycle scripts in the Dockerfile install (DF-024). Pass --ignore-scripts on every npm / yarn / pnpm install line, or set ENV NPM_CONFIG_IGNORE_SCRIPTS=true / ENV YARN_ENABLE_SCRIPTS=false before the install. Re-enable per package via a scoped RUN npm rebuild <pkg> line only when a native-module build genuinely needs it. Best to fix both, the cooldown gate is a default-safe policy applied at bump time, and --ignore-scripts is the durable execution-primitive control that protects every other dep too.