A CI job that checks out untrusted pull-request code while holding a write-capable token can turn a small documentation change into a repository compromise. The safer default is not another scanner: it is a workflow in which every job receives only the token, secret, artifact, and deployment authority it can prove it needs.

That distinction matters because CI/CD security failures are often composition failures. A broadly scoped GITHUB_TOKEN is risky; a dependency with a malicious install script is risky; an artifact without integrity checks is risky. Put all three in one release workflow and an attacker needs only one weak boundary to reach production.

The baseline below maps concrete controls to the OWASP CI/CD Top 10, especially inadequate identity and access management, poisoned pipeline execution, dependency chain abuse, ungoverned third-party services, improper artifact integrity validation, and insufficient logging and visibility. It is deliberately small enough to review in one pull request.

Start with the trust boundaries, not a list of controls

Before editing YAML, split the pipeline into three trust zones: pull-request validation, trusted branch builds, and production deployment. A pull request is code supplied by someone other than the release process. The main branch is trusted only after branch protection and review rules have done their job. Production is a separate authority boundary and should not inherit the build job’s credentials.

This produces a useful decision rule: if a job executes contributor-controlled code, it gets no deployment credentials, no write token, and no production environment access. That rule applies even when the contributor is an internal engineer. A compromised developer workstation can create a malicious branch just as effectively as an external fork.

OWASP CI/CD risk Failure mode in Actions Baseline control
CICD-SEC-2: Inadequate IAM Every job receives broad repository permissions. Set workflow defaults to read-only; add job permissions explicitly.
CICD-SEC-3: Dependency Chain Abuse Build dependencies or Actions execute unexpected code. Use lockfiles, review dependency updates, and pin Actions to commit SHAs.
CICD-SEC-4: Poisoned Pipeline Execution Untrusted PR code runs with secrets or a write-capable token. Keep PR validation separate from deployment and avoid privileged PR triggers.
CICD-SEC-8: Ungoverned Third-Party Services An action tag changes upstream without repository review. Allowlist suppliers and review every SHA update like a dependency update.
CICD-SEC-9: Improper Artifact Integrity Validation Deploy a wrong, stale, or altered build output. Package one artifact, publish its checksum, and verify before deployment.
CICD-SEC-10: Insufficient Logging and Visibility No one can answer which workflow deployed which revision. Preserve run logs, deployment history, artifact metadata, and audit events.

A copyable baseline workflow with security controls in place

This workflow validates every pull request and builds plus deploys only pushes to main. The deployment job uses the GitHub environment named production; configure its approval and branch rules in repository settings rather than trying to reproduce those rules in shell code.

name: build-and-deploy

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - name: Check out the exact triggering revision
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683

      - name: Install locked dependencies
        run: npm ci --ignore-scripts

      - name: Run tests
        run: npm test

      - name: Build release files
        run: npm run build

      - name: Package and checksum the release
        run: |
          tar -czf dist.tar.gz dist/
          sha256sum dist.tar.gz > dist.sha256

      - name: Upload one deployable payload
        uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08
        with:
          name: web-dist-${{ github.sha }}
          path: |
            dist.tar.gz
            dist.sha256
          if-no-files-found: error

  deploy:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    needs: build
    runs-on: ubuntu-latest
    environment: production
    permissions:
      actions: read
      contents: read
      deployments: write
      id-token: write

    steps:
      - name: Download the artifact from this workflow run
        uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16
        with:
          name: web-dist-${{ github.sha }}
          path: release

      - name: Verify the payload before deployment
        working-directory: release
        run: |
          sha256sum --check dist.sha256
          tar -tzf dist.tar.gz > /dev/null

      - name: Deploy with short-lived cloud identity
        working-directory: release
        run: |
          echo "Authenticate to the deployment target with OIDC here"
          echo "Deploy dist.tar.gz here"

The two full commit SHAs are intentional. A tag such as actions/checkout@v4 is convenient, but the tag is a mutable reference. A SHA points to a specific action revision, so an update becomes an explicit code-review event.

Make least privilege visible in the YAML

The top-level permissions block is the most important line in this file. Setting contents: read creates a restrictive default, which means a newly added job cannot silently acquire permission to create releases, modify issues, push commits, or request OpenID Connect tokens.

The build job has only repository read access. It runs package installation, tests, and a build, all of which can execute code from the repository or package ecosystem. That is precisely why it should not receive a cloud credential, an npm publishing token, or contents: write.

The deploy job gets three additional capabilities for narrowly defined reasons:

  • actions: read allows it to retrieve the artifact produced by this workflow.
  • deployments: write supports deployment status reporting.
  • id-token: write permits an OIDC token request for a cloud provider that trusts this repository and environment.

id-token: write does not grant cloud access by itself. The cloud-side identity policy must still restrict which repository, branch, workflow, and environment can assume the role. That second half is where teams often lose the benefit of OIDC: replacing a long-lived secret with an OIDC role that every repository can assume is not a meaningful reduction in blast radius.

Do not put production secrets in repository-level secrets when environment-level secrets will do. Attach a secret to the production environment, require reviewers for that environment, and restrict deployment branches to main. GitHub then pauses the deploy job before environment secrets are exposed. The practical tradeoff is intentional: a release may wait for approval, but a test job never waits and never sees production credentials.

Validate artifacts, but understand what a checksum does not prove

The workflow creates exactly two release inputs: dist.tar.gz and dist.sha256. The deploy job downloads the artifact named with github.sha, checks its SHA-256 value, and lists the archive before calling the deployment tool. This prevents an accidental filename mismatch, a damaged transfer, or a deploy script that picks up an unrelated local file.

The artifact name matters as much as the checksum. A generic name such as build-output makes it easier for a later workflow edit to download the wrong build. Including the commit SHA creates a direct association between the source revision and the payload the deploy job accepts.

There is an important limitation: a checksum generated by a compromised build job can still match a malicious artifact. Checksums validate consistency between build and deploy; they do not independently prove that the build itself was trustworthy. For releases where that distinction matters, add a trusted signing or provenance system and require verification in the deployment environment.

Also avoid rebuilding during deployment. If deploy runs npm ci and npm run build again, the code tested in the build job is no longer necessarily the code shipped. “Build once, promote the same artifact” is both a reproducibility control and an operational debugging advantage.

Treat every action as a reviewed software supplier

GitHub Actions are executable dependencies. GitHub-owned actions deserve less operational suspicion than an unknown publisher, but they still belong in the dependency inventory. OWASP’s CICD-SEC-8 is not solved by choosing popular marketplace actions; it is solved by governing which suppliers and revisions can run in your repository.

Use this review rule for every uses: line: pin to a full SHA, identify the action owner, inspect the change when updating the SHA, and remove the action if a shell command is simpler. An action that only runs one curl command can add more supply-chain surface than it saves in YAML.

  • Prefer official GitHub Actions where they meet the requirement, then pin them exactly as shown.
  • For third-party actions, review source code, declared inputs, network behavior, and token requirements before first use.
  • Keep SHA upgrades in dedicated pull requests so reviewers can compare the old and new upstream revisions.
  • Use a dependency update tool only if its pull requests still receive human review; automatic merging of action SHA changes defeats the review boundary.
  • Do not pass secrets as command-line arguments when an action supports environment-based credentials, because process arguments can leak into logs or diagnostics.

Package dependencies need comparable discipline. npm ci enforces the lockfile rather than resolving a fresh dependency tree. The --ignore-scripts setting is a conservative baseline for test and build pipelines; remove it only for packages that demonstrably require lifecycle scripts, and document that exception in the pull request that adds it.

Use logs as release evidence, not just debugging output

When an incident occurs, you need four answers: who approved the deployment, what commit triggered it, what artifact was used, and which identity reached the production target. The workflow above creates a starting chain: the push identifies github.sha, the artifact embeds that SHA in its name, the environment records deployment activity, and the job log shows checksum verification.

In GitHub, keep Actions logs and artifacts long enough to cover your realistic incident investigation window. For organizations with audit logging available, review events involving workflow changes, secret changes, environment protection changes, repository permission changes, and runner configuration. These are high-value configuration changes because they alter what future workflow runs are allowed to do.

A useful operational control is a monthly query-free review: open the production environment’s deployment history, select the last five releases, and verify that each one has an expected commit, a reviewer where required, and a matching workflow run. Five releases is small enough to finish in 10 minutes and catches broken assumptions faster than an annual policy review.

Apply this repository checklist this week

Do not try to rewrite every workflow at once. Start with the workflow that can deploy, publish a package, create a release, or access a cloud account. That workflow has the highest consequence if CICD-SEC-2, CICD-SEC-4, or CICD-SEC-6 credential-hygiene failures occur.

  1. Add top-level permissions: contents: read, then grant additional permissions job by job until the workflow works.
  2. Ensure pull-request jobs cannot access deployment environments, production secrets, or write-capable tokens.
  3. Create a production environment with required reviewers and a branch rule limited to the release branch.
  4. Replace each mutable @v1, @v4, or branch reference in uses: with a reviewed full commit SHA.
  5. Package one build artifact, name it with the commit SHA, generate a checksum, and verify it immediately before deployment.
  6. Record the cloud identity, artifact name, and commit SHA in the deployment tool’s release metadata where supported.
  7. Review the repository’s workflow files and environment settings after every change to deployment credentials or runner configuration.

The maintenance cost is real: pinned actions require updates, protected environments add an approval step, and least-privilege permissions expose hidden dependencies in old workflows. Those are useful failures. Each one turns an implicit trust relationship into a visible decision that can be reviewed before it becomes an incident.