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

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
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

The XPC-NNN family is cross-provider. It only fires 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. Single-provider runs never see both check IDs and these chains stay quiet.

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).

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).

Confidence inheritance

A chain is only as trustworthy as its weakest leg. Chain.confidence is set to the minimum confidence among the triggering findings, if one leg comes from a LOW-confidence blob heuristic, the chain is reported at LOW confidence even when every other leg is HIGH. The --min-confidence filter applies the same way to chains as to findings.

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), interpolates untrusted PR context into a shell run: block (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.

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).