Skip to content

Writing

Hardening the deployment pipeline: supply chain, secrets, containers and the edge

Where a pipeline is actually attacked, in order of likelihood — and the specific GitHub Actions and AWS controls that close each one, including the permission default almost every repository gets wrong.

6 min read

Most "secure your pipeline" advice is a list of scanners. Scanners are useful and they are not where pipelines get compromised. Pipelines get compromised through credentials with too much scope and dependencies nobody chose, and both have specific, unglamorous fixes.

This is ordered by how often each thing is the actual entry point.

The default that is wrong in almost every repository

By default, GITHUB_TOKEN in a workflow has broad write permissions. Any step in that workflow — including one inside a third-party action you did not write — can use it.

yaml
# At the top of every workflow. Nothing else, unless a job needs it.
permissions:
  contents: read
 
jobs:
  release:
    permissions:
      contents: write       # granted where it is needed, and nowhere else
      id-token: write       # for OIDC

Pin your actions to a commit, not a tag

uses: some/action@v3 resolves a tag, and a tag is mutable. Whoever controls that repository can move it, and your pipeline runs the new code on the next build with no diff anywhere.

yaml
# ✗ mutable
- uses: actions/checkout@v4
 
# ✓ immutable, with the version in a comment so a bot can update it
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

Let Dependabot propose the bumps. The point is not to never update — it is that an update arrives as a reviewable change rather than silently.

Stop storing long-lived cloud credentials

An AWS_SECRET_ACCESS_KEY in repository secrets is a permanent credential sitting in a system that runs arbitrary code from pull requests. OIDC removes it entirely: GitHub mints a short-lived token, AWS trusts the issuer, and nothing long-lived exists to leak.

yaml
permissions:
  id-token: write
  contents: read
 
steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/github-deploy
      aws-region: eu-west-1

The trust policy is where the security actually lives, and the condition below is the one people get wrong:

json
{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
    },
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:teispace/app:ref:refs/heads/main"
    }
  }
}

pull_request_target is the footgun

pull_request workflows run without secrets, which is correct: the code is untrusted. pull_request_target runs with secrets, in the context of the base repository — and if it also checks out the pull request's code, you have executed a stranger's code with your credentials.

  1. Prefer pull_request, and accept that it has no secrets

    That constraint is the security property, not an inconvenience to work around.

  2. If you must use pull_request_target, do not check out the PR ref

    Use it only for things that need no untrusted code — labelling, commenting.

  3. Require approval for first-time contributors

    A repository setting, and it costs nothing.

Dependencies: the fixes that matter more than the scanner

  1. Commit the lockfile and install with a frozen resolution

    yarn install --immutable or npm ci. An install that is allowed to resolve differently in CI than locally means CI tested something else.

  2. Disable lifecycle scripts where you can

    postinstall is arbitrary code execution at install time, and it is the most direct supply chain path there is.

  3. Keep the runtime surface small, deliberately

    This site ships nine runtime dependencies. Everything else — bundler plugins, icon generation, syntax highlighting, the MDX toolchain — is a devDependency, so it runs at build and reaches no user. That is a security property as much as a performance one.

  4. Then add the scanners

    Dependency review on pull requests, secret scanning with push protection, CodeQL on a schedule. They are worth having, and they catch less than the four items above.

Containers: the base image is most of your CVE count

  1. Use a distroless or minimal runtime image

    Most vulnerabilities in a scan are in packages your application never calls — a shell, a package manager, coreutils. Removing them removes the findings and the exploitation path.

  2. Multi-stage, so build tooling never ships

    Compilers and dev dependencies belong in a stage that is discarded.

  3. Run as a non-root user, with a read-only root filesystem

    Both are one line and both meaningfully limit what a compromise can do.

  4. Scan the built image, and fail on high severity in what you actually run

    Scanning the base image alone misses what your own layers added.

dockerfile
FROM node:22-slim AS build
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --immutable
COPY . .
RUN yarn build
 
FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=build /app/.next/standalone ./
USER nonroot
CMD ["server.js"]

The edge: WAF as a rate limiter first

A managed WAF rule set is worth enabling and is not the main value. The main value is that the edge is the cheapest place to drop traffic — before it costs you a container, a database connection or a third-party API call.

ControlStops
Rate limit per IP per pathCredential stuffing, scraping, accidental self-DoS
Tighter limit on auth endpointsBrute force — a much lower limit than the rest of the site
Request size capMemory exhaustion via large bodies
Bot control on expensive pathsSearch and report endpoints being crawled
Managed rule setsGeneric injection and traversal attempts
Ordered by what actually causes incidents. Rule sets are last because a well-written application is not vulnerable to most of what they block.

Response headers are the cheapest control on this list

Set once, in one place, and they close whole categories:

ts
{
  'Content-Security-Policy': "default-src 'self'; object-src 'none'; base-uri 'self'",
  'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
  'X-Content-Type-Options': 'nosniff',
  'Referrer-Policy': 'strict-origin-when-cross-origin',
  'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
}

CSP is the one that takes real work, because it requires knowing what your page actually loads — which is itself a useful audit. Run it in report-only mode until the reports are quiet.

The order to do this in

  1. Set explicit permissions on every workflow

    Five lines, and it is the single highest-value change.

  2. Replace static cloud keys with OIDC, and constrain the sub claim to a branch

    A wildcard sub is the same as no protection.

  3. Pin actions to commit SHAs and let a bot propose updates

    Updates should arrive as diffs.

  4. Freeze installs, disable lifecycle scripts, keep the runtime surface small

    Then add scanners.

  5. Distroless runtime, non-root, read-only, scan the built image

    Most CVEs are in the base you did not need.

  6. Rate limit at the edge; deploy every rule in count mode first

    And set the response headers, which cost nothing.

Start here

Tell us what you are building

Or what is breaking, or what has to go faster. You will get a straight answer from an engineer who would do the work.