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
| Attestor | What it captures | When to enable it |
|---|---|---|
sbom | Parses 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. |
sarif | Parses 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:
| Evidence | What it establishes | What it does not establish |
|---|---|---|
command-run/v0.2 | The 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.3 | The digest of each file the step produced. | Who or what wrote those bytes. |
sbom / sarif | The 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 signature | The 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.jsonWhat happens:
- build step: material attestor records source-file digests.
go buildruns as CI/lock's direct child — its argv is recorded bycommand-run/v0.2.bin/myapplands inproduct/v0.3as a Merkle leaf. - sbom step: material attestor digests the working tree after build, so
bin/myappis captured as the SBOM step's input.syftruns as CI/lock's direct child. The CycloneDX SBOM lands inproduct/v0.3; thesbomattestor parses it and emits ahttps://cyclonedx.org/bompredicate. - A policy can now require that the SBOM was generated against the exact binary the build step produced: the
sbomstep declaresartifactsFrom: ["build"]andrequiredArtifacts: ["bin/myapp"], so verification compares the SBOM step's recordedbin/myappwith 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:
| Tool | Command (no shell wrapper) |
|---|---|
| gosec | gosec -no-fail -fmt=sarif -out=results.sarif ./... |
| Semgrep | semgrep --config p/security-audit --sarif --output=results.sarif . |
| CodeQL | Wrap 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 fs | trivy fs --format sarif --output results.sarif . |
| Checkov | checkov -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.jsonEach 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
--outfileoutside the checkout, for example under$RUNNER_TEMPas 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 withrequired 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
sbomandsastcollections 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/myappto thebuildandsbomsteps. - If the SBOM step scanned a different binary, it fails with
mismatched digests for bin/myapp. - Leaving out the
sastenvelope fails withno 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
- SBOM attestor schema upstream
- SARIF attestor schema upstream
- GitHub Actions tutorial, full six-step pipeline using these patterns
Reference generated from the product documentation. Match commands and support details to your installed release.