Skip to main content
View Markdown ↗

Copy this page

Select and copy the Markdown below, then paste it into your LLM.

On this page

Capturing SBOM and SARIF as signed evidence

This tutorial wires two of the highest-value security attestors (sbom and sarif) into your CI pipeline. The goal isn't just to generate SBOMs and security findings, it's to make their existence provable, so a release-gate policy can enforce "this artifact must have a signed SBOM and SARIF attached, or it doesn't ship."

The patterns below follow TestifySec's reference implementation at github.com/testifysec/dropbox-clone.

What each attestor does

AttestorWhat it capturesWhen to enable it
sbomParses any CycloneDX or SPDX JSON file in the captured products and embeds the document into the attestation.Steps that produce an SBOM file, typically the build step, after running syft, trivy sbom, or another generator.
sarifParses any SARIF result file in the captured products.Steps that run a SAST scanner, gosec, CodeQL, Semgrep, Trivy fs scan, etc.

Both are post-product attestors, they run after the wrapped command finishes and inspect the products it produced. So the trick is making sure the SBOM/SARIF file lands in the products glob.

Both attestors parse whatever matching file is in the step's products. Neither one checks which program wrote that file. What a step's evidence establishes depends on how the report got there:

EvidenceWhat it establishesWhat it does not establish
command-run/v0.2The argv and exit code of the process CI/lock started for this step.Anything about programs that ran before the step or outside it.
product/v0.3The digest of each file the step produced.Who or what wrote those bytes.
sbom / sarifThe file parsed as an SBOM or SARIF document; its digest and contents, including the tool name the document claims.That the named tool produced it. tool.driver.name is a field in the file, not an observation.
The DSSE signatureThe signer produced this collection at the recorded time.The origin of a report the step copied in.

So a report counts as observed scanner execution only when the scanner itself is the command CI/lock wrapped in that step, and your policy checks that step's command-run argv. A report produced elsewhere and copied into a step is an imported report: the evidence shows it was present and well-formed when it was collected, nothing more. If you need execution provenance for an imported report, require independently verifiable provenance from the system that produced it (its own signed attestation), not just the copy.

The snippets below run the plain CLI in GitHub Actions steps, with cilock installed on PATH outside the checkout as the release promotion gate does. They do not use cilock-action v1.0.4: its evidence records command-run/v0.1 and keeps no file list, so it cannot pass the policy at the end of this page (what v1.0.4 evidence cannot do). They show only the flags that matter for each pattern. In a real workflow, also pass --platform-url with your administrator-approved origin on every step. Without it, CI/lock signs and uploads to the hosted platform by default.

Pattern 1: SBOM from a Go build with syft

Use two CI/lock steps — build then SBOM — so each tool's argv lands in its own command-run/v0.2 attestation, and the SBOM step's material/v0.3 Merkle root captures the build artifact as input to the SBOM:

- name: build
  env:
    CGO_ENABLED: "0"
  run: |
    cilock run --step build -a environment,git,github \
      --material-manifest --attestor-product-include-glob "bin/*" \
      -o "$RUNNER_TEMP/build.json" \
      -- go build -o bin/myapp ./cmd/myapp

- name: sbom
  run: |
    cilock run --step sbom -a environment,git,github,sbom \
      --material-manifest --attestor-product-include-glob "bin/*" \
      -o "$RUNNER_TEMP/sbom.json" \
      -- syft bin/myapp -o cyclonedx-json=bin/bom.cdx.json

What happens:

  1. build step: material attestor records source-file digests. go build runs as CI/lock's direct child — its argv is recorded by command-run/v0.2. bin/myapp lands in product/v0.3 as a Merkle leaf.
  2. sbom step: material attestor digests the working tree after build, so bin/myapp is captured as the SBOM step's input. syft runs as CI/lock's direct child. The CycloneDX SBOM lands in product/v0.3; the sbom attestor parses it and emits a https://cyclonedx.org/bom predicate.
  3. A policy can now require that the SBOM was generated against the exact binary the build step produced: the sbom step declares artifactsFrom: ["build"] and requiredArtifacts: ["bin/myapp"], so verification compares the SBOM step's recorded bin/myapp with the build step's product, file by file. The policy below does this.

Don't chain go build && syft inside a single bash -c — that collapses two tools into one command-run attestation, drops the build's product-vs-SBOM-material cross-step link, and breaks the supply-chain BackRef graph CI/lock is meant to produce.

Pattern 2: SARIF from a SAST scanner

Same shape, different attestor. The trick is letting the SAST tool fail without failing the CI/lock step itself (you want the SARIF report regardless):

- name: sast
  run: |
    cilock run --step sast -a environment,git,github,sarif \
      --material-manifest --attestor-product-include-glob "*.sarif" \
      -o "$RUNNER_TEMP/sast.json" \
      -- gosec -no-fail -fmt=sarif -out=gosec-results.sarif ./...

The -no-fail flag tells gosec to return 0 even when it finds issues — without it, CI/lock's command-run/v0.2 attestor records a failed step and downstream attestors skip. The SARIF still carries the findings; the policy gate is the Rego over the captured SARIF, not the tool's exit code. (See tools/gosec for the full per-tool walkthrough.)

Adapting for other SAST tools — each has a comparable "don't fail on findings" flag so CI/lock's argv stays clean:

ToolCommand (no shell wrapper)
gosecgosec -no-fail -fmt=sarif -out=results.sarif ./...
Semgrepsemgrep --config p/security-audit --sarif --output=results.sarif .
CodeQLWrap the CodeQL CLI so the analysis is the observed command: one step runs codeql database create codeql-db --language=go, and a second runs codeql database analyze codeql-db --format=sarif-latest --output=codeql.sarif (add your query suite). Do not capture a report that github/codeql-action/analyze already wrote outside CI/lock and present it as a scan: that evidence shows only that an imported file was collected (see the table above).
Trivy fstrivy fs --format sarif --output results.sarif .
Checkovcheckov -d . -s -o sarif --output-file-path . (writes results_sarif.sarif; -s is the soft-fail flag)

Pattern 3: SBOM from a container image

For OCI images, generate the SBOM from the saved image tarball alongside the build:

- name: docker-build
  run: |
    cilock run --step docker-build -a environment,git,github,docker,oci \
      --material-manifest --attestor-product-include-glob "{docker-metadata.json,image.tar}" \
      -o "$RUNNER_TEMP/docker-build.json" \
      -- docker buildx build --metadata-file docker-metadata.json -t myapp:test -o type=docker,dest=image.tar .

- name: docker-sbom
  run: |
    cilock run --step docker-sbom -a environment,git,github,sbom \
      --material-manifest --attestor-product-include-glob "image-bom.cdx.json" \
      -o "$RUNNER_TEMP/docker-sbom.json" \
      -- syft image.tar -o cyclonedx-json=image-bom.cdx.json

Each step gets its own signed attestation: docker-build carries the buildx metadata, the OCI image (via the oci attestor), and the image tarball as a product; docker-sbom carries the SBOM with the image tarball as material — so a release-gate policy can verify the SBOM was generated against the image that buildx actually produced.

Verifying SBOM and SARIF presence in policy

The whole point is making absence a build-blocker. Each requirement goes on the step that produces the evidence: the SBOM comes from the sbom step, not build. The sbom attestor reports the document's own type, https://cyclonedx.org/bom for CycloneDX (https://spdx.dev/Document for SPDX), so that is the type the policy requires. A policy fragment that requires both, and links the SBOM to the build:

{
  "steps": {
    "build": {
      "name": "build",
      "attestations": [
        { "type": "https://aflock.ai/attestations/material/v0.3" },
        { "type": "https://aflock.ai/attestations/command-run/v0.2" },
        { "type": "https://aflock.ai/attestations/product/v0.3" }
      ],
      "functionaries": [{ "type": "publickey", "publickeyid": "<your-key>" }]
    },
    "sbom": {
      "name": "sbom",
      "artifactsFrom": ["build"],
      "requiredArtifacts": ["bin/myapp"],
      "attestations": [
        { "type": "https://aflock.ai/attestations/command-run/v0.2" },
        { "type": "https://cyclonedx.org/bom" }
      ],
      "functionaries": [{ "type": "publickey", "publickeyid": "<your-key>" }]
    },
    "sast": {
      "name": "sast",
      "attestations": [
        { "type": "https://aflock.ai/attestations/command-run/v0.2" },
        { "type": "https://aflock.ai/attestations/sarif/v0.1" }
      ],
      "functionaries": [{ "type": "publickey", "publickeyid": "<your-key>" }]
    }
  }
}

artifactsFrom is strict: every file the sbom step read must be a product or material of build with the same digest. Three things keep a real pipeline inside that rule (tested with cilock v4.6.2 on the direct CLI):

  • Keep evidence out of the working tree. Write each step's --outfile outside the checkout, for example under $RUNNER_TEMP as the snippets above do. Otherwise the build's attestation file is an untracked input of the next step, and verification refuses it.

  • Retain the file inventory. The comparison needs each step's full material inventory, which is why every snippet above passes --material-manifest. Without it, verification stops with required file inventory is unavailable: material details were omitted.

  • Name every step's evidence and artifact at verify time. Verify needs a collection for every step the policy lists. So pass all three envelopes, point it at the binary, and pass the digests of the SBOM and the SARIF so the sbom and sast collections are found too:

    cilock verify -p policy-signed.json -k policy-pub.pem \
      -a build.json -a sbom.json -a sast.json \
      -f bin/myapp \
      --subjects sha256:$(shasum -a 256 bin/bom.cdx.json | cut -d' ' -f1) \
      --subjects sha256:$(shasum -a 256 gosec-results.sarif | cut -d' ' -f1)

Tested with cilock v4.6.2 against the three-step policy above:

  • The command passes and binds bin/myapp to the build and sbom steps.
  • If the SBOM step scanned a different binary, it fails with mismatched digests for bin/myapp.
  • Leaving out the sast envelope fails with no collection passed verification for step sast.

If the sbom step ran but the SBOM file wasn't produced (or wasn't captured by the product glob), its collection has no https://cyclonedx.org/bom attestation and cilock verify fails the step.

Requiring the attestation types proves presence, not origin. To require that the sast step's report came from the scanner running in that step, add a Rego rule on its command-run attestation that checks the argv (for example, that input.cmd[0] is the scanner you expect), so a step that merely copied a report in does not satisfy the policy.

A subtle but important distinction

On its own, sarif shows that a well-formed SARIF report was among the step's products, and it captures that report's findings. It shows a SAST tool ran only when the scanner was the step's wrapped command and your policy checks that step's command-run argv; for an imported report, see what each attestation establishes. Either way, it does not prove the tool passed with zero findings. To enforce "no high-severity findings," combine the SARIF attestor with an OPA Rego rule:

package sast.results
import rego.v1

deny contains msg if {
    some run in input.report.runs
    some result in run.results
    result.level == "error"
    msg := sprintf("SARIF error-level finding: %s", [result.message.text])
}

Note the input.report.runs path: the SARIF attestor wraps the SARIF document under a report field alongside reportFileName and reportDigestSet, so the policy needs to traverse one extra level versus a bare SARIF file.

Embed the base64-encoded module under regopolicies for the sarif attestation in your policy. Same model for SBOM-based rules (e.g., "deny if the SBOM contains a known-bad component", traversing input.components[] on the CycloneDX BOM).

See also

Reference generated from the product documentation. Match commands and support details to your installed release.