Skip to content
s1ns3nz0 | Known Unknowns
Go back

Securing Workflows in CI Pipelines - Secure Build

19 min read

Today, I’ll explain how to meet the requirements of NIST SP 800-204D and secure your build using GitHub Actions and associated tools. You can find the general requirements in Section 5, ‘Integrating SSC Security into CI/CD Pipeline’ of this document. If you’re looking for ways to harden your service’s CI/CD pipeline, this article should be helpful to you

I believe these requirements should alsoow be implemented as policies in your organization, in order to strengthen your company’s overall services and software supply chain, However, I will focus on technical practices in this post

Table of contents

Open Table of contents

Securing Workflows in CI Pipelines

NIST SP 800-204D says,‘The workflows in the CI Pipeline mainly consist of build operations, push/pull operations on repositories (both public and private), software updates, and code commits.

The overall security goals for the framework used for securely running CI pipelines include

Secure Build

The following tasks are required to obtain SSC security assurance in the build process:

Specifiy policies regarding the build, including

Evidence of ‘the use of a secure isolated platform, for performing the build and hardening the build servers’

Examples: The use of a secure isolated platform, for performing the build and hardening the build servers

Evidence of ‘the tools that will be used to perform the build’

Evidence of ‘the authentication/authorization required for the developers performing the build process’

Examples of ‘the authentication/authorization required for the developers performing the build process’

Enforce those build policies using techniques such as an agent and policy enforcement engine.

Examples of build policies usages - OPA

Ensure the concurrent generation of evidence for build attestation to demonstrate compliance with secure build processes during the time of software delivery

Evidence of ‘concurrent generation of evidence for build attestation to demonstrate compliance with secure build processes’

Examples of ‘concurrent generation of evidence for build attestation to demonstrate compliance with secure build processes’

Gating grades
GradeMeaningRole
🟢Consumer can independently re-verify. Holds without trusting the producerSufficient on its own to block
🟡Only meaningful if the producer’s workflow wasn’t compromisedSupporting evidence
🔴Self-asserted, or unprovable from inside the jobLookup only — never gate on it

A signature attests that “this workflow wrote this content.” It says nothing about whether the content is true.

# Even if nothing was actually blocked
echo '{"networkEgress":"blocked"}' > predicate.json
# the signature attaches cleanly and verification passes

🟢 items escape this problem, because the consumer recomputes the value and compares. Record the sha256 of go.sum and a consumer can check out that commit, hash the file themselves, and catch a lie on the spot.

Two caveats:


Environment

Inventory of the system at CI time. Platform components must be hardened, isolated, and secure.

GroupWhat to collectSourceGate
RunnerHosted vs. self-hosted$RUNNER_ENVIRONMENT🟡
RunnerImage family + version$ImageOS, $ImageVersion🟡
RunnerKernel, architectureuname -r, $RUNNER_ARCH🟡
RunnerRunning inside a container?/.dockerenv, /proc/1/cgroup🟡
RunnerEphemerality, clean workspace🔴
ComponentsCompiler binary digestsha256sum $(command -v go)🟢
ComponentsCompiler / interpreter versiongo env -json🟡
ComponentsOS package list digestdpkg -l | sha256sum🟡
HardeningDeclared permissions: blockParse the workflow file🟢
HardeningNames of injected secretsParse the workflow file🟢
HardeningEgress control (declared / observed)Config value / eBPF trace🔴 / 🟡

Process

Programs that transformed source into artifacts, and programs that tested them. Populating this is explicitly “best effort.”

GroupWhat to collectGate
Tool identityBinary digest / container image digest🟢
Tool identityName, version string🟡
InvocationFull argv🟡
InvocationBehavior-affecting env vars (GOFLAGS, CGO_ENABLED, CC, LDFLAGS)🟡
InvocationExit code, start/end timestamps🟡
ConfigConfig file paths + digests (.golangci.yml, semgrep.yml)🟢
ConfigPlugin / extension list🟡

Materials

Raw data of any kind: configuration, source code, dependencies.

GroupWhat to collectGate
SourceRepo URI + commit SHA, submodule SHAs🟢
SourceCommit signature verification status🟢
SourceClean working tree?🟡
DependenciesLockfile digests🟢
DependenciesResolved dependency list (purl + version + digest)🟢
DependenciesBase container image ref + digest🟢
DependenciesRegistry URLs, mirror usage🟡
Hidden inputsExternal assets downloaded during the build + digests🟢
Hidden inputsArtifacts handed over from another step + digests🟢
Hidden inputsDigest of the workflow file itself🟢
Hidden inputsCache restore hit + cache key🟡
Hidden inputsIntegrity of the cache contents🔴

Artifacts

The output of a step, final or intermediate. Scan results count.

GroupWhat to collectGate
OutputName + sha256 + size🟢
IntermediateContainer layer / multi-stage image digests🟢
IntermediateObject files, static library digests🟢
ByproductsSBOM file digest🟢
ByproductsScan result digests (SAST / SCA / secrets)🟢
ByproductsTest report, build log digests🟢
LinkageReference to the producing process step🟡

Drawing the boundaries

Separating Process from Materials is hard, and the source definition admits as much: a file a tool reads may shape how the transformation behaves, or it may end up inside the output.

QuestionCategory
Do its contents end up in the output?Materials
Does it determine how the transformation runs?Process
Both?Record it in both

Don’t force a single choice. Recording twice beats being unable to answer.

FileCategory
Dockerfile, workflow YAMLBoth
.golangci.yml, semgrep.yml, linker scripts, .envProcess
go.mod, go.sum, code generation templatesMaterials

Two more rulings


Linking the steps
[checkout] Artifacts: source tree (D1)
              ↓ D1
[build]    Materials: D1 + go.sum + base image
           Process:   go build (digest) + argv
           Artifacts: my-app (D2), build log (D3)
              ↓ D2
[scan]     Materials: D2
           Process:   trivy (digest) + DB version
           Artifacts: scan.sarif (D4)
              ↓ D2, D4
[release]  Artifacts: Release (D2)

Digests are the glue between steps. Without that linkage a consumer can’t answer this:

“Does a scan result exist for the binary I’m about to deploy — and did that scan target this exact digest?”

Recording only that a scan ran cannot answer it. The scanned binary and the shipped binary may not be the same file, and in practice that happens often. Note that this linkage check is itself 🟢.


Limits
ClaimProvable from inside the job?Alternative
The compiler was genuine✅ digest comparison
Dependencies match the lockfile✅ recompute
The artifact matches what was scanned✅ digest comparison
The runner was ephemeralPlatform control-plane logs
The network was blockedeBPF runtime monitor
The platform was hardenedExternal audit / TEE

Much of Environment attestation is a record, not evidence. When a CVE lands against Go 1.22.3 and below, figuring out which of your 500 internal artifacts are affected works fine on 🟡 data alone. Blocking a deploy on it, however, proves nothing.

To move 🟡 closer to 🟢, push the collection logic into a trusted builder — a reusable workflow whose script the calling repo cannot modify. The signing identity moves there too. That separation, between whoever defines the build and whoever signs it, is also the core requirement of SLSA Build L3.


Wrapping up

Build your blocking gates on 🟢 — and write the code that actually recomputes. A 🟢 you never compare is a 🟡. Layer 🟡 on top, but never alone. The cost it imposes on an attacker is real. Put 🔴 on a dashboard, not in a gate. Unprovable isn’t worthless. It just has a different job.

A reasonable rollout order: Artifacts → Materials digests → Process digests → Environment → trusted builder. Run the consumer gate in observe-only mode for a week or two before enforcing. Flip it on immediately and every existing artifact fails at once, and shipping stops.


name: build-attest

on:
  workflow_dispatch:     # 버튼으로 아무 때나 실행

permissions:
  contents: read
  id-token: write        # Sigstore keyless 서명 (필수)

jobs:
  build:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: '1.22.5'

      - uses: sigstore/cosign-installer@v3
        with:
          cosign-release: 'v2.4.1'

      # ── 빌드 ────────────────────────────────
      - name: Build
        run: |
          set -euo pipefail
          mkdir -p dist
          CGO_ENABLED=0 go build -trimpath -o dist/my-app ./cmd/my-app

      # ── predicate 생성 ──────────────────────
      - name: Create predicate
        run: |
          set -euo pipefail
          jq -n \
            --arg image  "${ImageOS:-unknown}" \
            --arg kernel "$(uname -r)" \
            --arg go     "$(go env GOVERSION)" \
            --arg commit "$GITHUB_SHA" \
            --arg ts     "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
            '{
              runner:      { image: $image, kernel: $kernel },
              toolchain:   { go: $go, buildFlags: "-trimpath" },
              source:      { commit: $commit },
              collectedAt: $ts
            }' > predicate.json
          jq . predicate.json

      # ── 서명 ────────────────────────────────
      - name: Attest
        run: |
          set -euo pipefail
          cosign attest-blob dist/my-app \
            --predicate predicate.json \
            --type https://myorg.dev/attestations/build-env/v1 \
            --bundle my-app.sigstore.json \
            --yes

      # ── 결과 확인 ───────────────────────────
      - name: Inspect bundle
        run: |
          set -euo pipefail
          echo "── in-toto Statement ──"
          jq -r '.dsseEnvelope.payload' my-app.sigstore.json | base64 -d | jq .

      - uses: actions/upload-artifact@v4
        with:
          name: attestation
          path: |
            dist/my-app
            my-app.sigstore.json

Share this post:

Previous Post
Securing Workflows in CI Pipelines - Secure Pull-Push Operations on Repositories
Next Post
Relationship Between NIST SP 800-218 and SP 800-204D