Artifact provenance and SBOM verification lab
Exercises offline artifact digest, provenance identity, source, builder, and SBOM policy decisions.
DevSecOps 3 min read
Implementation: Partially tested
Implementation
labs/supply-chain/verify-provenance.js
1#!/usr/bin/env node
2"use strict";
3
4const crypto = require("node:crypto");
5const fs = require("node:fs");
6const { isDeepStrictEqual } = require("node:util");
7
8const VERIFIER_RESULT_MEDIA_TYPE =
9 "application/vnd.cybersecurity-writeups.provenance-verifier-result+json;version=1";
10
11function stop(message) {
12 console.error(`VERIFICATION_FAILED: ${message}`);
13 process.exit(4);
14}
15
16function readJson(file) {
17 try {
18 return JSON.parse(fs.readFileSync(file, "utf8"));
19 } catch (error) {
20 stop(`cannot parse ${file}: ${error.message}`);
21 }
22}
23
24function sha256(bytes) {
25 return crypto.createHash("sha256").update(bytes).digest("hex");
26}
27
28const [, , artifactPath, statementPath, verifierResultPath, policyPath] = process.argv;
29if (!artifactPath || !statementPath || !verifierResultPath || !policyPath) {
30 stop(
31 "usage: node verify-provenance.js " +
32 "<artifact> <statement.json> <verifier-result.json> <policy.json>",
33 );
34}
35
36if (!fs.existsSync(artifactPath)) stop("artifact is missing");
37const statement = readJson(statementPath);
38const verifierResult = readJson(verifierResultPath);
39const policy = readJson(policyPath);
40const artifactDigest = sha256(fs.readFileSync(artifactPath));
41
42if (verifierResult.mediaType !== VERIFIER_RESULT_MEDIA_TYPE) {
43 stop("unsupported external verifier-result format");
44}
45if (!isDeepStrictEqual(verifierResult.statement, statement)) {
46 stop("external verifier result is not bound to this provenance statement");
47}
48if (verifierResult.issuer !== policy.expectedIssuer) {
49 stop("attestation issuer is not authorized");
50}
51if (verifierResult.verified !== true) {
52 stop("external cryptographic verification did not succeed");
53}
54
55if (statement._type !== policy.statementType) {
56 stop(`unexpected statement type ${String(statement._type)}`);
57}
58if (statement.predicateType !== policy.predicateType) {
59 stop(`unexpected predicate type ${String(statement.predicateType)}`);
60}
61if (!Array.isArray(statement.subject) || statement.subject.length !== 1) {
62 stop("statement must contain exactly one subject");
63}
64if (statement.subject[0].digest?.sha256 !== artifactDigest) {
65 stop("artifact digest does not match statement subject");
66}
67
68const buildDefinition = statement.predicate?.buildDefinition;
69if (!buildDefinition || typeof buildDefinition !== "object") {
70 stop("buildDefinition is required");
71}
72if (buildDefinition.buildType !== policy.expectedBuildType) {
73 stop("build type is not authorized");
74}
75
76const runDetails = statement.predicate?.runDetails;
77if (!runDetails || typeof runDetails !== "object") {
78 stop("runDetails is required");
79}
80if (runDetails.builder?.id !== policy.expectedBuilderId) {
81 stop("builder ID is not authorized");
82}
83
84const workflowParameters = buildDefinition.externalParameters?.workflow;
85const sourceFromParameters =
86 workflowParameters &&
87 `git+${workflowParameters.repository}@${workflowParameters.ref}`;
88if (sourceFromParameters !== policy.expectedSourceUri) {
89 stop("source repository/ref is not authorized");
90}
91const builderFromParameters =
92 workflowParameters &&
93 `${workflowParameters.repository}/${workflowParameters.path}@${workflowParameters.ref}`;
94if (builderFromParameters !== policy.expectedBuilderId) {
95 stop("workflow parameters do not match the authorized builder ID");
96}
97
98const dependencies = buildDefinition.resolvedDependencies;
99if (
100 !Array.isArray(dependencies) ||
101 !dependencies.some((dependency) => dependency?.uri === policy.expectedSourceUri)
102) {
103 stop("source repository/ref is not authorized");
104}
105
106console.log(
107 "VERIFICATION_PASSED: artifact digest, statement type, builder ID, build type, " +
108 `source, issuer, and external verification result matched policy (${artifactDigest}).`,
109);
1{
2 "statementType": "https://in-toto.io/Statement/v1",
3 "predicateType": "https://slsa.dev/provenance/v1",
4 "expectedBuilderId": "https://github.com/jasonachkar/cybersecurity-writeups/.github/workflows/release.yml@refs/heads/main",
5 "expectedBuildType": "https://actions.github.io/buildtypes/workflow/v1",
6 "expectedSourceUri": "git+https://github.com/jasonachkar/cybersecurity-writeups@refs/heads/main",
7 "expectedIssuer": "https://token.actions.githubusercontent.com"
8}
1"use strict";
2
3const assert = require("node:assert/strict");
4const fs = require("node:fs");
5const os = require("node:os");
6const path = require("node:path");
7const { spawnSync } = require("node:child_process");
8
9const lab = path.resolve(__dirname, "..");
10const verifier = path.join(lab, "verify-provenance.js");
11const artifact = path.join(lab, "artifact", "release.txt");
12const validStatement = path.join(lab, "provenance.valid.json");
13const validVerifierResult = path.join(lab, "verifier-result.valid.json");
14const policy = path.join(lab, "policy.json");
15const temporaryDirectory = fs.mkdtempSync(
16 path.join(os.tmpdir(), `supply-chain-negative-${process.pid}-`),
17);
18
19function verify(statement, verifierResult, expectedStatus, marker) {
20 const result = spawnSync(
21 process.execPath,
22 [verifier, artifact, statement, verifierResult, policy],
23 { encoding: "utf8" },
24 );
25 assert.equal(result.status, expectedStatus, result.stderr || result.stdout);
26 assert.match(`${result.stdout}\n${result.stderr}`, marker);
27}
28
29function verifierResultFor(statement, name, overrides = {}) {
30 const result = JSON.parse(fs.readFileSync(validVerifierResult, "utf8"));
31 result.statement = JSON.parse(fs.readFileSync(statement, "utf8"));
32 Object.assign(result, overrides);
33 const output = path.join(temporaryDirectory, `${name}.verifier-result.json`);
34 fs.writeFileSync(output, `${JSON.stringify(result, null, 2)}\n`, "utf8");
35 return output;
36}
37
38try {
39 verify(validStatement, validVerifierResult, 0, /VERIFICATION_PASSED/);
40 const crlfStatement = path.join(temporaryDirectory, "provenance.valid.crlf.json");
41 fs.writeFileSync(
42 crlfStatement,
43 fs.readFileSync(validStatement, "utf8").replace(/\r?\n/g, "\r\n"),
44 "utf8",
45 );
46 verify(crlfStatement, validVerifierResult, 0, /VERIFICATION_PASSED/);
47
48 const policyNegativeStatements = [
49 ["provenance.bad-digest.json", /artifact digest does not match/],
50 ["provenance.wrong-builder-id.json", /builder ID is not authorized/],
51 ["provenance.wrong-build-type.json", /build type is not authorized/],
52 ["provenance.missing-run-details.json", /runDetails is required/],
53 ["provenance.wrong-source.json", /source repository\/ref is not authorized/],
54 ];
55
56 for (const [file, marker] of policyNegativeStatements) {
57 const statement = path.join(lab, file);
58 verify(statement, verifierResultFor(statement, file), 4, marker);
59 }
60
61 verify(
62 validStatement,
63 verifierResultFor(validStatement, "wrong-issuer", {
64 issuer: "https://issuer.example.invalid",
65 }),
66 4,
67 /attestation issuer is not authorized/,
68 );
69 verify(
70 validStatement,
71 verifierResultFor(validStatement, "failed-verification", { verified: false }),
72 4,
73 /cryptographic verification did not succeed/,
74 );
75
76 const mismatchedResult = verifierResultFor(validStatement, "mismatched-statement");
77 const mismatched = JSON.parse(fs.readFileSync(mismatchedResult, "utf8"));
78 mismatched.statement.subject[0].name = "different-artifact.txt";
79 fs.writeFileSync(mismatchedResult, `${JSON.stringify(mismatched, null, 2)}\n`, "utf8");
80 verify(validStatement, mismatchedResult, 4, /not bound to this provenance statement/);
81
82 const temporaryArtifact = path.join(temporaryDirectory, "tampered-release.txt");
83 fs.writeFileSync(temporaryArtifact, "tampered\n", "utf8");
84 const tampered = spawnSync(
85 process.execPath,
86 [verifier, temporaryArtifact, validStatement, validVerifierResult, policy],
87 { encoding: "utf8" },
88 );
89 assert.equal(tampered.status, 4);
90 assert.match(tampered.stderr, /artifact digest does not match/);
91
92 const sbom = JSON.parse(fs.readFileSync(path.join(lab, "sbom.cdx.json"), "utf8"));
93 assert.equal(sbom.bomFormat, "CycloneDX");
94 assert.equal(sbom.specVersion, "1.7");
95 console.log(
96 "PASS: provenance builder/build-type/source/digest policy, external-verifier, " +
97 "tamper, and SBOM fixture tests completed.",
98 );
99} finally {
100 fs.rmSync(temporaryDirectory, { recursive: true, force: true });
101}
Run it
node labs/supply-chain/tests/run-tests.js
SLSA v1.2 uses tracks and track-specific levels, not one universal maturity score.
This offline lab demonstrates policy checks across four deliberately separate inputs:
- the artifact bytes;
- a SLSA v1 provenance statement;
- the trusted output of an external cryptographic verifier; and
- organization policy.
The policy independently constrains predicate.runDetails.builder.id, predicate.buildDefinition.buildType, the canonical source URI, and the attestation issuer. The external verifier result must return the authenticated statement, which must deep-match the separately supplied provenance. The statement subject must then match the locally calculated artifact digest.
Prerequisites and run command
- Node.js 24.12.0 (compatible Node.js 22+ should also work).
- Repository dependencies installed using
npm ci --ignore-scripts.
node labs/supply-chain/tests/run-tests.js
Expected output ends in PASS. Cases the policy should reject:
- wrong builder ID;
- wrong build type;
- missing
runDetails; - wrong source;
- wrong issuer;
- failed cryptographic-verification result;
- a verifier result bound to another statement;
- a provenance subject with the wrong digest; and
- modified artifact bytes.
SLSA field model
SLSA v1.2 assigns different meanings to fields that must not be conflated:
buildDefinition.buildTypeidentifies the parameterized build template/process;runDetails.builder.ididentifies the trusted build platform for that invocation;subject[].digestbinds provenance to output bytes; and- signature, certificate, issuer, and transparency checks happen on the attestation envelope before provenance policy is applied.
policy.json therefore uses explicit expectedBuilderId, expectedBuildType, expectedSourceUri, and expectedIssuer fields.
External verifier boundary
verifier-result.valid.json is a tested pedagogical adapter contract, not a Sigstore, Cosign, or GitHub-defined file format. It represents data returned over a trusted in-process boundary after a real verifier has checked the signed envelope, certificate chain or key, signer identity, and any required transparency evidence. I create separate temporary verifier results to test wrong issuer, failed verification, and statement mismatch. Neither the tracked fixture nor temporary results can establish cryptographic validity merely by containing "verified": true.
Production code must invoke and authenticate a supported verifier, consume its result without allowing the build under test to forge or replace it, and then enforce the same statement and policy constraints. The fixture carries the statement returned by the external verifier; the harness requires it to deep-match the policy-evaluated statement, so a successful result cannot be replayed for modified provenance.
Example production commands, not executed by this offline lab:
cosign verify-attestation \
--type slsaprovenance \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com \
--certificate-identity-regexp='^https://github.com/jasonachkar/cybersecurity-writeups/' \
<artifact-reference>
gh attestation verify <artifact-path> \
--repo jasonachkar/cybersecurity-writeups
Pin the expected artifact digest and narrow workflow identity; do not treat a valid signature from any identity as authorization.
Failure modes and limitations
An SBOM is an inventory, not evidence that components are vulnerability-free. Provenance describes a build path, not developer intent or source safety. This lab does not implement DSSE parsing, certificate/key validation, transparency-log checks, certificate revocation semantics, reusable-workflow delegation, private-repository identity, key-compromise response, or the production attestation envelope. Those belong to the selected external verifier and platform integration.
The lab binds source in both the GitHub workflow external parameters and resolvedDependencies; it also requires the external workflow repository, path, and ref to reconstruct the authorized builder ID. A production GitHub build-type policy should additionally reject every unexpected or unrecognized external parameter.
Cleanup
Tests remove their temporary verifier results and tampered artifact. No service or cloud resource is created.