Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,138 @@
# @suaveplan/slsa - SLSA L1-L2 supply-chain security: provenance, SBOM, DSSE attestations, Sigstore Rekor transparency-log client, policy evaluation, OTel audit events
## Summary
SLSA L1-L2 supply-chain security for Suaveplan. Generates and
verifies in-toto / SLSA v1.0 provenance, CycloneDX 1.5 SBOMs, DSSE
attestation envelopes, and RFC 6962 Sigstore Rekor transparency-log
proofs. Every capability emits structured OTel span events and raises
typed `SlsaError` subclasses with stable error codes.
<!-- begin:auto-docs -->
## Documentation tree
- [assessment](./src/assessment/assessment.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [assessment](./src/assessment/assessment.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [dsse](./src/attestation/dsse/dsse.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [dsse](./src/attestation/dsse/dsse.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/slsa-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [slsa-error](./src/errors/slsa-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/slsa-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [slsa-otel](./src/otel/slsa-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [policy](./src/policy/policy.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [policy](./src/policy/policy.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [provenance](./src/provenance/provenance.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [canonical-json](./src/provenance/canonical-json.md) — Produces a byte-stable, lexicographically-ordered JSON encoding for provenance statements, SBOM documents, and DSSE pay…
- [digest](./src/provenance/digest.md) — Single canonical entry point for SHA-256 digest computation inside the SLSA package. Deliberately routes through `@suav…
- [provenance](./src/provenance/provenance.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [inclusion-proof](./src/rekor/inclusion-proof.md) — Verifies an RFC 6962 Merkle inclusion proof as returned by Sigstore Rekor. Given a leaf (the canonical body a Rekor ent…
- [rekor-client](./src/rekor/rekor-client.md) — Minimal Sigstore Rekor REST client for the two upload shapes Suaveplan cares about: `hashedrekord` v0.0.1 (when logging…
- [sbom](./src/sbom/sbom.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sbom](./src/sbom/sbom.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/parse.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [parse](./src/schemas/parse.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [verification](./src/verification/verification.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [checks](./src/verification/checks.md) — Individual, stateless verification rules that consume a provenance statement plus policy inputs and return a structured…
- [verification](./src/verification/verification.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [assessment](./src/assessment/assessment.md) — <!-- begin:auto-docs --> _(no submodules)_- [dsse](./src/attestation/dsse/dsse.md) — <!-- begin:auto-docs --> _(no submodules)_- [dsse](./src/attestation/dsse/dsse.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/slsa-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [slsa-error](./src/errors/slsa-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/slsa-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [slsa-otel](./src/otel/slsa-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [policy](./src/policy/policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [policy](./src/policy/policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [provenance](./src/provenance/provenance.md) — <!-- begin:auto-docs --> _(no submodules)_- [canonical-json](./src/provenance/canonical-json.md) — Produces a byte-stable, lexicographically-ordered JSON encoding for provenance statements, SBOM documents, and DSSE pay…
- [digest](./src/provenance/digest.md) — Single canonical entry point for SHA-256 digest computation inside the SLSA package. Deliberately routes through `@suav…
- [provenance](./src/provenance/provenance.md) — <!-- begin:auto-docs --> _(no submodules)_- [inclusion-proof](./src/rekor/inclusion-proof.md) — Verifies an RFC 6962 Merkle inclusion proof as returned by Sigstore Rekor. Given a leaf (the canonical body a Rekor ent…
- [rekor-client](./src/rekor/rekor-client.md) — Minimal Sigstore Rekor REST client for the two upload shapes Suaveplan cares about: `hashedrekord` v0.0.1 (when logging…
- [sbom](./src/sbom/sbom.md) — <!-- begin:auto-docs --> _(no submodules)_- [sbom](./src/sbom/sbom.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/parse.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/schemas/parse.md) — <!-- begin:auto-docs --> _(no submodules)_- [verification](./src/verification/verification.md) — <!-- begin:auto-docs --> _(no submodules)_- [checks](./src/verification/checks.md) — Individual, stateless verification rules that consume a provenance statement plus policy inputs and return a structured…
- [verification](./src/verification/verification.md) — <!-- begin:auto-docs --> _(no submodules)_- [assessment](./src/assessment/assessment.md) — <!-- begin:auto-docs --> _(no submodules)_- [dsse](./src/attestation/dsse/dsse.md) — <!-- begin:auto-docs --> _(no submodules)_- [dsse](./src/attestation/dsse/dsse.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/slsa-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [slsa-error](./src/errors/slsa-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/slsa-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [slsa-otel](./src/otel/slsa-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [policy](./src/policy/policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [policy](./src/policy/policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [provenance](./src/provenance/provenance.md) — <!-- begin:auto-docs --> _(no submodules)_- [canonical-json](./src/provenance/canonical-json.md) — Produces a byte-stable, lexicographically-ordered JSON encoding for provenance statements, SBOM documents, and DSSE pay…
- [digest](./src/provenance/digest.md) — Single canonical entry point for SHA-256 digest computation inside the SLSA package. Deliberately routes through `@suav…
- [provenance](./src/provenance/provenance.md) — <!-- begin:auto-docs --> _(no submodules)_- [inclusion-proof](./src/rekor/inclusion-proof.md) — Verifies an RFC 6962 Merkle inclusion proof as returned by Sigstore Rekor. Given a leaf (the canonical body a Rekor ent…
- [rekor-client](./src/rekor/rekor-client.md) — Minimal Sigstore Rekor REST client for the two upload shapes Suaveplan cares about: `hashedrekord` v0.0.1 (when logging…
- [sbom](./src/sbom/sbom.md) — <!-- begin:auto-docs --> _(no submodules)_- [sbom](./src/sbom/sbom.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/parse.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/schemas/parse.md) — <!-- begin:auto-docs --> _(no submodules)_- [verification](./src/verification/verification.md) — <!-- begin:auto-docs --> _(no submodules)_- [checks](./src/verification/checks.md) — Individual, stateless verification rules that consume a provenance statement plus policy inputs and return a structured…
- [verification](./src/verification/verification.md) — <!-- begin:auto-docs --> _(no submodules)_- [assessment](./src/assessment/assessment.md) — <!-- begin:auto-docs --> _(no submodules)_- [dsse](./src/attestation/dsse/dsse.md) — <!-- begin:auto-docs --> _(no submodules)_- [dsse](./src/attestation/dsse/dsse.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/slsa-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [slsa-error](./src/errors/slsa-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/slsa-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [slsa-otel](./src/otel/slsa-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [policy](./src/policy/policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [policy](./src/policy/policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [provenance](./src/provenance/provenance.md) — <!-- begin:auto-docs --> _(no submodules)_- [canonical-json](./src/provenance/canonical-json.md) — Produces a byte-stable, lexicographically-ordered JSON encoding for provenance statements, SBOM documents, and DSSE pay…
- [digest](./src/provenance/digest.md) — Single canonical entry point for SHA-256 digest computation inside the SLSA package. Deliberately routes through `@suav…
- [provenance](./src/provenance/provenance.md) — <!-- begin:auto-docs --> _(no submodules)_- [inclusion-proof](./src/rekor/inclusion-proof.md) — Verifies an RFC 6962 Merkle inclusion proof as returned by Sigstore Rekor. Given a leaf (the canonical body a Rekor ent…
- [rekor-client](./src/rekor/rekor-client.md) — Minimal Sigstore Rekor REST client for the two upload shapes Suaveplan cares about: `hashedrekord` v0.0.1 (when logging…
- [sbom](./src/sbom/sbom.md) — <!-- begin:auto-docs --> _(no submodules)_- [sbom](./src/sbom/sbom.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/parse.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/schemas/parse.md) — <!-- begin:auto-docs --> _(no submodules)_- [verification](./src/verification/verification.md) — <!-- begin:auto-docs --> _(no submodules)_- [checks](./src/verification/checks.md) — Individual, stateless verification rules that consume a provenance statement plus policy inputs and return a structured…
- [verification](./src/verification/verification.md) — <!-- begin:auto-docs --> _(no submodules)_- [assessment](./src/assessment/assessment.md) — <!-- begin:auto-docs --> _(no submodules)_- [dsse](./src/attestation/dsse/dsse.md) — <!-- begin:auto-docs --> _(no submodules)_ - [dsse](./src/attestation/dsse/dsse.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/slsa-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [slsa-error](./src/errors/slsa-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/slsa-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [slsa-otel](./src/otel/slsa-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [policy](./src/policy/policy.md) — <!-- begin:auto-docs --> _(no submodules)_ - [policy](./src/policy/policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [provenance](./src/provenance/provenance.md) — <!-- begin:auto-docs --> _(no submodules)_ - [canonical-json](./src/provenance/canonical-json.md) — Produces a byte-stable, lexicographically-ordered JSON encoding for provenance statements, SBOM documents, and DSSE pay…
- [digest](./src/provenance/digest.md) — Single canonical entry point for SHA-256 digest computation inside the SLSA package. Deliberately routes through `@suav…
- [provenance](./src/provenance/provenance.md) — <!-- begin:auto-docs --> _(no submodules)_- [inclusion-proof](./src/rekor/inclusion-proof.md) — Verifies an RFC 6962 Merkle inclusion proof as returned by Sigstore Rekor. Given a leaf (the canonical body a Rekor ent…
- [rekor-client](./src/rekor/rekor-client.md) — Minimal Sigstore Rekor REST client for the two upload shapes Suaveplan cares about: `hashedrekord` v0.0.1 (when logging…
- [sbom](./src/sbom/sbom.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sbom](./src/sbom/sbom.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/parse.md) — <!-- begin:auto-docs --> _(no submodules)_ - [parse](./src/schemas/parse.md) — <!-- begin:auto-docs --> _(no submodules)_- [verification](./src/verification/verification.md) — <!-- begin:auto-docs --> _(no submodules)_ - [checks](./src/verification/checks.md) — Individual, stateless verification rules that consume a provenance statement plus policy inputs and return a structured…
- [verification](./src/verification/verification.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/slsa
```
## Quick start
```ts
import {
assessSlsaLevel,
generateProvenance,
generateSbom,
RekorClient,
verifyProvenance,
wrapInDsse,
} from "@suaveplan/slsa";
const statement = generateProvenance({
packageName: "@suaveplan/utils",
packageVersion: "1.2.3",
artifactDigest: "abc123...",
sourceCommit: "deadbeef...",
sourceRepo: "https://gitlab.fq.io/suaveplan/project",
sourceRef: "refs/heads/main",
builderId: "https://gitlab.fq.io/suaveplan/project/-/runners/1",
buildInvocationId: "ci-12345",
buildStartedOn: new Date().toISOString(),
});
const result = verifyProvenance(statement, {
trustedBuilders: ["https://gitlab.fq.io/suaveplan/project/-/runners/*"],
expectedSourceRepo: "https://gitlab.fq.io/suaveplan/project",
});
const sbom = generateSbom({
packageName: "@suaveplan/utils",
packageVersion: "1.2.3",
dependencies: { lodash: "4.17.21" },
});
const envelope = wrapInDsse(statement, signatures);
const rekor = new RekorClient();
const entry = await rekor.uploadIntoto(envelope);
console.log(assessSlsaLevel({ ...environment, hasRekorInclusion: true }).level);
```
## Modules
- [`errors/slsa-error`](./src/errors/slsa-error.md) — typed error hierarchy
- stable `SLSA_*` code registry.
- [`otel/slsa-otel`](./src/otel/slsa-otel.md) — OTel audit-event adapter.
- [`schemas/parse`](./src/schemas/parse.md) — Zod-validated JSON parsers.
- [`provenance/canonical-json`](./src/provenance/canonical-json.md) —
deterministic JSON encoder.
- [`provenance/digest`](./src/provenance/digest.md) — SHA-256 helper over
`@suaveplan/crypto-hash`.
- [`provenance/provenance`](./src/provenance/provenance.md) — in-toto /
SLSA v1.0 statement generator.
- [`verification/checks`](./src/verification/checks.md) — individual
verification rules.
- [`verification/verification`](./src/verification/verification.md) —
policy-driven verifier.
- [`sbom/sbom`](./src/sbom/sbom.md) — CycloneDX 1.5 SBOM generator.
- [`assessment/assessment`](./src/assessment/assessment.md) — SLSA L0-L3
environment assessor.
- [`attestation/dsse/dsse`](./src/attestation/dsse/dsse.md) — DSSE PAE,
envelope wrapper, ed25519 verification.
- [`rekor/inclusion-proof`](./src/rekor/inclusion-proof.md) — RFC 6962
Merkle inclusion proof verification.
- [`rekor/rekor-client`](./src/rekor/rekor-client.md) — Sigstore Rekor
REST client.
- [`policy/policy`](./src/policy/policy.md) — composite policy evaluator.
## Status
- Tests: 170 passing (13 files).
- Coverage: 99.2% statements, 100% functions, 99.44% lines, 95.15% branches.
- Typecheck, lint, and build all green.

View File

@@ -0,0 +1,132 @@
{
"name": "@suaveplan/slsa",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "SLSA L1-L2 supply-chain security: provenance, SBOM, DSSE attestations, Sigstore Rekor transparency-log client, policy evaluation, OTel audit events",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./provenance/provenance": {
"import": {
"types": "./dist/provenance/provenance.d.ts",
"default": "./dist/provenance/provenance.js"
}
},
"./verification/verification": {
"import": {
"types": "./dist/verification/verification.d.ts",
"default": "./dist/verification/verification.js"
}
},
"./sbom/sbom": {
"import": {
"types": "./dist/sbom/sbom.d.ts",
"default": "./dist/sbom/sbom.js"
}
},
"./assessment/assessment": {
"import": {
"types": "./dist/assessment/assessment.d.ts",
"default": "./dist/assessment/assessment.js"
}
},
"./attestation/dsse": {
"import": {
"types": "./dist/attestation/dsse/dsse.d.ts",
"default": "./dist/attestation/dsse/dsse.js"
}
},
"./rekor/rekor-client": {
"import": {
"types": "./dist/rekor/rekor-client.d.ts",
"default": "./dist/rekor/rekor-client.js"
}
},
"./rekor/inclusion-proof": {
"import": {
"types": "./dist/rekor/inclusion-proof.d.ts",
"default": "./dist/rekor/inclusion-proof.js"
}
},
"./policy/policy": {
"import": {
"types": "./dist/policy/policy.d.ts",
"default": "./dist/policy/policy.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/slsa-error.d.ts",
"default": "./dist/errors/slsa-error.js"
}
},
"./otel": {
"import": {
"types": "./dist/otel/slsa-otel.d.ts",
"default": "./dist/otel/slsa-otel.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/index.d.ts",
"default": "./dist/schemas/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/crypto-core": "workspace:^",
"@suaveplan/crypto-hash": "workspace:^",
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/serialize": "workspace:^",
"@suaveplan/types": "workspace:^",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,73 @@
# assessment
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Evaluates the build environment against SLSA Build track levels L0-L3 and
returns a structured `SlsaAssessment`. Intended for CI dashboards, publish
gates, and compliance audits so teams can see in plain language which
controls are in place and which are missing. The assessor is pure: it
takes signals via `AssessmentEnvironment` and has no side effects, so it
can be fed synthetic input in tests.
## Features
- L0-L3 progression matching the SLSA Build specification.
- Rekor inclusion is an optional L3 gate — when `hasRekorInclusion` is
`undefined` it is ignored; when `false` it blocks L3 even if all other
controls are met.
- Parallel `met` / `unmet` string lists for dashboard rendering.
- `detectEnvironment()` reads `process.env` to auto-populate the signals
from the SLSA convention variables (`SLSA_*`, `CI`, `GITLAB_CI`,
`GITHUB_ACTIONS`).
## Usage: Basic Example
```ts
import { assessSlsaLevel } from "@suaveplan/slsa";
const result = assessSlsaLevel({
isCI: true,
hasProvenance: true,
hasIsolatedBuild: false,
hasHardenedProvenance: false,
ciOnlyPublish: true,
hasSbom: true,
hasDigestVerification: true,
hasVersionControl: true,
});
console.log(result.level); // "L2"
```
## Usage: Advanced Example
```ts
import { assessSlsaLevel, detectEnvironment } from "@suaveplan/slsa";
const env = { ...detectEnvironment(), hasRekorInclusion: await verifyLogs() };
const result = assessSlsaLevel(env);
if (result.level !== "L3") {
log.warn({ missing: result.unmet }, "publish blocked below L3");
process.exit(1);
}
```
## API Reference
- `assessSlsaLevel(env: AssessmentEnvironment): SlsaAssessment` — returns
`{ level, met, unmet }`.
- `detectEnvironment(): AssessmentEnvironment` — reads `SLSA_*` + CI env
vars. `hasVersionControl` is hard-wired to `true` because the project
is Git-backed.
## Implementation Notes
`hasRekorInclusion` has three states: `undefined` (ignored), `true` (L3
eligible), `false` (L3 blocked). This lets callers opt in to the Rekor
gate without forcing it on every existing pipeline. The level-determination
logic cascades — L3 implies L2 implies L1 — so adding additional L3
constraints only requires appending to the L3 conjunction.

View File

@@ -0,0 +1,193 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { AssessmentEnvironment } from "../schemas/verification.schemas.js";
import { assessSlsaLevel, detectEnvironment } from "./assessment.js";
function makeEnv(
overrides?: Partial<AssessmentEnvironment>
): AssessmentEnvironment {
return {
isCI: true,
hasProvenance: true,
hasIsolatedBuild: false,
hasHardenedProvenance: false,
ciOnlyPublish: true,
hasSbom: true,
hasDigestVerification: true,
hasVersionControl: true,
...overrides,
};
}
describe("assessSlsaLevel", () => {
it("returns L0 when no provenance / vc", { timeout: 5000 }, () => {
const r = assessSlsaLevel(
makeEnv({ hasProvenance: false, hasVersionControl: false })
);
expect(r.level).toBe("L0");
});
it("returns L1 with provenance + vc only", { timeout: 5000 }, () => {
const r = assessSlsaLevel(
makeEnv({ isCI: false, ciOnlyPublish: false })
);
expect(r.level).toBe("L1");
});
it("returns L2 with hosted CI + ciOnly publish", {
timeout: 5000,
}, () => {
expect(assessSlsaLevel(makeEnv()).level).toBe("L2");
});
it("returns L3 with isolated + hardened", { timeout: 5000 }, () => {
expect(
assessSlsaLevel(
makeEnv({
hasIsolatedBuild: true,
hasHardenedProvenance: true,
})
).level
).toBe("L3");
});
it("blocks L3 when Rekor inclusion = false", { timeout: 5000 }, () => {
expect(
assessSlsaLevel(
makeEnv({
hasIsolatedBuild: true,
hasHardenedProvenance: true,
hasRekorInclusion: false,
})
).level
).toBe("L2");
});
it("accepts L3 when Rekor inclusion = true", { timeout: 5000 }, () => {
expect(
assessSlsaLevel(
makeEnv({
hasIsolatedBuild: true,
hasHardenedProvenance: true,
hasRekorInclusion: true,
})
).level
).toBe("L3");
});
it("sbom presence/absence reflected in met/unmet", {
timeout: 5000,
}, () => {
expect(assessSlsaLevel(makeEnv({ hasSbom: true })).met).toContain(
"SBOM generated"
);
expect(assessSlsaLevel(makeEnv({ hasSbom: false })).unmet).toContain(
"SBOM generation recommended"
);
});
it("digest verification reflected in met/unmet", { timeout: 5000 }, () => {
expect(
assessSlsaLevel(makeEnv({ hasDigestVerification: true })).met
).toContain("Artifact digest verification enabled");
expect(
assessSlsaLevel(makeEnv({ hasDigestVerification: false })).unmet
).toContain("Artifact digest verification recommended");
});
it("Rekor flag reflected when set", { timeout: 5000 }, () => {
expect(
assessSlsaLevel(makeEnv({ hasRekorInclusion: true })).met
).toContain("Rekor transparency-log inclusion verified");
expect(
assessSlsaLevel(makeEnv({ hasRekorInclusion: false })).unmet
).toContain("Rekor transparency-log inclusion not verified");
});
it("Rekor flag absent => not listed", { timeout: 5000 }, () => {
const r = assessSlsaLevel(makeEnv());
expect(r.met).not.toContain(
"Rekor transparency-log inclusion verified"
);
expect(r.unmet).not.toContain(
"Rekor transparency-log inclusion not verified"
);
});
it("requires vc for L1", { timeout: 5000 }, () => {
expect(
assessSlsaLevel(
makeEnv({
hasVersionControl: false,
isCI: false,
ciOnlyPublish: false,
})
).level
).toBe("L0");
});
it("requires ci-only publish for L2", { timeout: 5000 }, () => {
expect(assessSlsaLevel(makeEnv({ ciOnlyPublish: false })).level).toBe(
"L1"
);
});
it("requires CI for L2", { timeout: 5000 }, () => {
expect(assessSlsaLevel(makeEnv({ isCI: false })).level).toBe("L1");
});
});
describe("detectEnvironment", () => {
it("returns a well-formed object", { timeout: 5000 }, () => {
const env = detectEnvironment();
expect(typeof env.isCI).toBe("boolean");
expect(env.hasVersionControl).toBe(true);
});
it("reads SLSA_REKOR_INCLUSION=1", { timeout: 5000 }, () => {
const prev = process.env["SLSA_REKOR_INCLUSION"];
process.env["SLSA_REKOR_INCLUSION"] = "1";
try {
expect(detectEnvironment().hasRekorInclusion).toBe(true);
} finally {
if (prev === undefined) delete process.env["SLSA_REKOR_INCLUSION"];
else process.env["SLSA_REKOR_INCLUSION"] = prev;
}
});
it("reads SLSA_REKOR_INCLUSION=false", { timeout: 5000 }, () => {
const prev = process.env["SLSA_REKOR_INCLUSION"];
process.env["SLSA_REKOR_INCLUSION"] = "false";
try {
expect(detectEnvironment().hasRekorInclusion).toBe(false);
} finally {
if (prev === undefined) delete process.env["SLSA_REKOR_INCLUSION"];
else process.env["SLSA_REKOR_INCLUSION"] = prev;
}
});
it("leaves hasRekorInclusion undefined when unset", {
timeout: 5000,
}, () => {
const prev = process.env["SLSA_REKOR_INCLUSION"];
delete process.env["SLSA_REKOR_INCLUSION"];
try {
expect(detectEnvironment().hasRekorInclusion).toBeUndefined();
} finally {
if (prev !== undefined) process.env["SLSA_REKOR_INCLUSION"] = prev;
}
});
it("detects CI via GITLAB_CI", { timeout: 5000 }, () => {
const prev = process.env["GITLAB_CI"];
const prevCI = process.env["CI"];
delete process.env["CI"];
process.env["GITLAB_CI"] = "1";
try {
expect(detectEnvironment().isCI).toBe(true);
} finally {
if (prev === undefined) delete process.env["GITLAB_CI"];
else process.env["GITLAB_CI"] = prev;
if (prevCI !== undefined) process.env["CI"] = prevCI;
}
});
});

View File

@@ -0,0 +1,119 @@
import type {
AssessmentEnvironment,
SlsaAssessment,
SlsaLevel,
} from "../schemas/verification.schemas.js";
/**
* Assess the current SLSA Build level based on environment signals.
*
* Levels:
* - L0 — no provenance or no version control.
* - L1 — provenance exists and source is version-controlled.
* - L2 — L1 + hosted CI + CI-only publish.
* - L3 — L2 + isolated build + hardened (signed) provenance, optionally
* gated on Rekor inclusion when `hasRekorInclusion === true`.
*/
export function assessSlsaLevel(env: AssessmentEnvironment): SlsaAssessment {
const met: string[] = [];
const unmet: string[] = [];
push(env.hasVersionControl, met, unmet, "Version-controlled source (Git)", {
unmet: "Source must be version-controlled",
});
push(env.hasProvenance, met, unmet, "Build provenance generated", {
unmet: "Build provenance not generated",
});
push(env.isCI, met, unmet, "Build runs on hosted CI platform", {
unmet: "Build must run on a hosted CI platform",
});
push(env.ciOnlyPublish, met, unmet, "Publishing restricted to CI", {
unmet: "Publishing must be restricted to CI only",
});
push(
env.hasIsolatedBuild,
met,
unmet,
"Build runs in isolated environment",
{
unmet: "Build must run in an isolated environment",
}
);
push(
env.hasHardenedProvenance,
met,
unmet,
"Provenance is tamper-resistant (signed)",
{ unmet: "Provenance must be tamper-resistant (signed)" }
);
push(env.hasSbom, met, unmet, "SBOM generated", {
unmet: "SBOM generation recommended",
});
push(
env.hasDigestVerification,
met,
unmet,
"Artifact digest verification enabled",
{ unmet: "Artifact digest verification recommended" }
);
if (env.hasRekorInclusion !== undefined) {
push(
env.hasRekorInclusion,
met,
unmet,
"Rekor transparency-log inclusion verified",
{ unmet: "Rekor transparency-log inclusion not verified" }
);
}
return { level: determineLevel(env), met, unmet };
}
function push(
flag: boolean,
met: string[],
unmet: string[],
label: string,
opts: { unmet: string }
): void {
(flag ? met : unmet).push(flag ? label : opts.unmet);
}
function determineLevel(env: AssessmentEnvironment): SlsaLevel {
const l1 = env.hasProvenance && env.hasVersionControl;
const l2 = l1 && env.isCI && env.ciOnlyPublish;
const l3 =
l2 &&
env.hasIsolatedBuild &&
env.hasHardenedProvenance &&
(env.hasRekorInclusion ?? true);
if (l3) return "L3";
if (l2) return "L2";
if (l1) return "L1";
return "L0";
}
/** Detect environment signals from `process.env` for auto-assessment. */
export function detectEnvironment(): AssessmentEnvironment {
const isCI = !!(
process.env["CI"] ??
process.env["GITLAB_CI"] ??
process.env["GITHUB_ACTIONS"]
);
return {
isCI,
hasProvenance: !!process.env["SLSA_PROVENANCE_ENABLED"],
hasIsolatedBuild: !!process.env["SLSA_ISOLATED_BUILD"],
hasHardenedProvenance: !!process.env["SLSA_HARDENED_PROVENANCE"],
ciOnlyPublish: !!process.env["SLSA_CI_ONLY_PUBLISH"],
hasSbom: !!process.env["SLSA_SBOM_ENABLED"],
hasDigestVerification: !!process.env["SLSA_DIGEST_VERIFICATION"],
hasVersionControl: true,
hasRekorInclusion: process.env["SLSA_REKOR_INCLUSION"]
? process.env["SLSA_REKOR_INCLUSION"] === "1" ||
process.env["SLSA_REKOR_INCLUSION"] === "true"
: undefined,
};
}

View File

@@ -0,0 +1,4 @@
export {
assessSlsaLevel,
detectEnvironment,
} from "./assessment.js";

View File

@@ -0,0 +1,87 @@
# dsse
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the Dead Simple Signing Envelope (DSSE) binding used by
in-toto/SLSA attestations. Provides the Pre-Authentication Encoding (PAE)
function that callers sign, an envelope constructor that wraps a provenance
statement with supplied signatures, and a verification entry point that
checks every signature against a set of trusted ed25519 keys. The module
deliberately does not sign — Suaveplan signing keys live only inside the
publish pipeline, never in consumer bundles — but it provides everything
needed for attestation producers and verifiers to interoperate.
## Features
- RFC-aligned `DSSEv1 <typeLen> <type> <payloadLen> <payload>` PAE.
- `wrapInDsse` binds a canonicalised `ProvenanceStatement` to a
base64-encoded payload plus caller-supplied signatures.
- `verifyDsseEnvelope` iterates every signature and reports which trusted
key (if any) accepted it, returning a structured `DsseVerificationResult`.
- `keyid`-scoped key matching so the same envelope can carry signatures
from multiple key material with disjoint trust roots.
- Structured `AttestationInvalidError` on empty signature lists or empty
key lists.
## Usage: Basic Example
```ts
import { encodeDssePae, wrapInDsse } from "@suaveplan/slsa";
const pae = encodeDssePae(
"application/vnd.in-toto+json",
new TextEncoder().encode(JSON.stringify(statement))
);
const signature = ed25519.sign(secretKey, pae);
const envelope = wrapInDsse(statement, [
{ sig: toBase64(signature), keyid: "release-key" },
]);
```
## Usage: Advanced Example
```ts
import {
verifyDsseEnvelope,
type TrustedEd25519Key,
} from "@suaveplan/slsa";
const trusted: TrustedEd25519Key[] = [
{ publicKey: releaseKey, keyid: "release-key" },
{ publicKey: rotationKey, keyid: "release-key-next" },
];
const result = verifyDsseEnvelope(envelope, trusted);
if (!result.valid) {
const bad = result.records.filter((r) => !r.valid);
log.warn({ bad }, "DSSE envelope failed verification");
}
```
## API Reference
- `DSSE_PAYLOAD_TYPE` — canonical in-toto payload type.
- `encodeDssePae(payloadType, payload): Uint8Array` — exact byte sequence
that must be signed.
- `wrapInDsse(statement, signatures): DsseEnvelope` — canonicalises the
statement, base64-encodes the payload, attaches the signatures. Throws
`AttestationInvalidError` on empty `signatures`.
- `verifyDsseEnvelope(envelope, keys): DsseVerificationResult` — verifies
every signature. Throws `AttestationInvalidError` when `keys` is empty.
Returns per-signature `DsseVerificationRecord`s with `keyid`, `keyIndex`,
`signatureIndex`, and a boolean `valid`.
- `TrustedEd25519Key { publicKey, keyid? }` — public-key bundle.
## Implementation Notes
Signing is intentionally absent from the public API — the DSSE spec
separates the PAE construction (standardised here) from the signing
algorithm (ed25519 only for now). Consumers that want ECDSA / RSA signing
can use `@suaveplan/crypto` directly and pass the resulting `sig` into
`wrapInDsse`. The PAE uses the exact space-delimited format from the DSSE
spec (`DSSEv1 <len> <type> <len> <body>`) — do not reformat whitespace, as
even a stray trailing space changes signed bytes and breaks verifiers.

View File

@@ -0,0 +1,140 @@
import { ed25519 } from "@suaveplan/crypto-core";
import { fromBase64, toBase64 } from "@suaveplan/crypto-utils";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AttestationInvalidError } from "../../errors/index.js";
import { generateProvenance } from "../../provenance/provenance.js";
import type { ProvenanceInput } from "../../provenance/provenance.types.js";
import {
DSSE_PAYLOAD_TYPE,
encodeDssePae,
verifyDsseEnvelope,
wrapInDsse,
} from "./dsse.js";
function makeInput(): ProvenanceInput {
return {
packageName: "@s/x",
packageVersion: "1.0.0",
artifactDigest: "abc",
sourceCommit: "deadbeef",
sourceRepo: "https://gitlab.fq.io/s/m",
sourceRef: "refs/heads/main",
builderId: "https://gitlab.fq.io/s/m/-/runners/1",
buildInvocationId: "ci",
buildStartedOn: "2026-03-24T10:00:00Z",
};
}
describe("encodeDssePae", () => {
it("constructs RFC-compliant PAE", { timeout: 5000 }, () => {
const payload = new TextEncoder().encode("hello");
const pae = encodeDssePae("text/plain", payload);
const text = new TextDecoder().decode(pae);
expect(text).toBe("DSSEv1 10 text/plain 5 hello");
});
it("handles empty payload", { timeout: 5000 }, () => {
const pae = encodeDssePae("x", new Uint8Array(0));
expect(new TextDecoder().decode(pae)).toBe("DSSEv1 1 x 0 ");
});
});
describe("wrapInDsse", () => {
it("produces a DSSE envelope", { timeout: 5000 }, () => {
const stmt = generateProvenance(makeInput());
const env = wrapInDsse(stmt, [{ sig: toBase64(new Uint8Array([1])) }]);
expect(env.payloadType).toBe(DSSE_PAYLOAD_TYPE);
expect(env.signatures).toHaveLength(1);
const payload = new TextDecoder().decode(fromBase64(env.payload));
expect(JSON.parse(payload)).toMatchObject({ _type: stmt._type });
});
it("rejects empty signature list", { timeout: 5000 }, () => {
const stmt = generateProvenance(makeInput());
expect(() => wrapInDsse(stmt, [])).toThrow(AttestationInvalidError);
});
});
describe("verifyDsseEnvelope", () => {
it("verifies a signature produced with ed25519", { timeout: 5000 }, () => {
const kp = ed25519.generateKeypair();
const stmt = generateProvenance(makeInput());
const envelope = wrapInDsse(stmt, [{ sig: "placeholder" }]);
// Sign actual PAE
const pae = encodeDssePae(
envelope.payloadType,
fromBase64(envelope.payload)
);
const sig = ed25519.sign(kp.secretKey, pae);
envelope.signatures[0] = { sig: toBase64(sig), keyid: "k1" };
const result = verifyDsseEnvelope(envelope, [
{ publicKey: kp.publicKey, keyid: "k1" },
]);
expect(result.valid).toBe(true);
expect(result.records[0]?.valid).toBe(true);
expect(result.records[0]?.keyid).toBe("k1");
});
it("returns per-signature records when some fail", {
timeout: 5000,
}, () => {
const good = ed25519.generateKeypair();
const other = ed25519.generateKeypair();
const stmt = generateProvenance(makeInput());
const envelope = wrapInDsse(stmt, [{ sig: "x" }, { sig: "y" }]);
const pae = encodeDssePae(
envelope.payloadType,
fromBase64(envelope.payload)
);
envelope.signatures[0] = {
sig: toBase64(ed25519.sign(good.secretKey, pae)),
};
envelope.signatures[1] = { sig: toBase64(new Uint8Array(64)) };
const result = verifyDsseEnvelope(envelope, [
{ publicKey: good.publicKey },
{ publicKey: other.publicKey },
]);
expect(result.valid).toBe(false);
expect(result.records[0]?.valid).toBe(true);
expect(result.records[1]?.valid).toBe(false);
});
it("keyid scoping: signature with keyid only matches same keyid", {
timeout: 5000,
}, () => {
const kp = ed25519.generateKeypair();
const stmt = generateProvenance(makeInput());
const envelope = wrapInDsse(stmt, [{ sig: "x" }]);
const pae = encodeDssePae(
envelope.payloadType,
fromBase64(envelope.payload)
);
envelope.signatures[0] = {
sig: toBase64(ed25519.sign(kp.secretKey, pae)),
keyid: "release",
};
// Only key with a mismatched keyid — must fail
const fail = verifyDsseEnvelope(envelope, [
{ publicKey: kp.publicKey, keyid: "other" },
]);
expect(fail.valid).toBe(false);
// Key without a keyid attached — still considered
const ok = verifyDsseEnvelope(envelope, [{ publicKey: kp.publicKey }]);
expect(ok.valid).toBe(true);
});
it("rejects empty key list", { timeout: 5000 }, () => {
const stmt = generateProvenance(makeInput());
const envelope = wrapInDsse(stmt, [
{ sig: toBase64(new Uint8Array(64)) },
]);
expect(() => verifyDsseEnvelope(envelope, [])).toThrow(
AttestationInvalidError
);
});
});

View File

@@ -0,0 +1,127 @@
import { ed25519 } from "@suaveplan/crypto-core";
import { fromBase64, toBase64 } from "@suaveplan/crypto-utils";
import { AttestationInvalidError } from "../../errors/index.js";
import { canonicalize } from "../../provenance/canonical-json.js";
import type {
DsseEnvelope,
DsseSignature,
} from "../../schemas/attestation.schemas.js";
import type { ProvenanceStatement } from "../../schemas/provenance.schemas.js";
/** Canonical payload type for SLSA in-toto statements. */
export const DSSE_PAYLOAD_TYPE = "application/vnd.in-toto+json" as const;
/**
* DSSE Pre-Authentication Encoding per the Dead Simple Signing Envelope spec.
*
* Format: `DSSEv1 ` + `SP` + `<len(payloadType)>` + ` ` + `<payloadType>`
* + ` ` + `<len(payload)>` + ` ` + `<payload>`.
*
* The PAE is what gets signed; the wire envelope only carries
* (payloadType, base64(payload), [signatures]).
*/
export function encodeDssePae(
payloadType: string,
payload: Uint8Array
): Uint8Array {
const encoder = new TextEncoder();
const typeBytes = encoder.encode(payloadType);
const prelude = encoder.encode(
`DSSEv1 ${typeBytes.length} ${payloadType} ${payload.length} `
);
const out = new Uint8Array(prelude.length + payload.length);
out.set(prelude, 0);
out.set(payload, prelude.length);
return out;
}
/** Verified signature record produced by `verifyDsseEnvelope`. */
export interface DsseVerificationRecord {
keyid?: string;
keyIndex: number;
signatureIndex: number;
valid: boolean;
}
export interface DsseVerificationResult {
valid: boolean;
records: DsseVerificationRecord[];
}
/**
* Wrap a provenance statement inside a DSSE envelope with the supplied
* signatures (computed externally — this module does not sign, because the
* Suaveplan signing key lives in the publisher, not in SLSA consumers).
*/
export function wrapInDsse(
statement: ProvenanceStatement,
signatures: readonly DsseSignature[]
): DsseEnvelope {
if (signatures.length === 0) {
throw new AttestationInvalidError("error.slsa.dsse.no_signatures", {
context: { payloadType: DSSE_PAYLOAD_TYPE },
});
}
const payload = canonicalize(statement);
return {
payloadType: DSSE_PAYLOAD_TYPE,
payload: toBase64(new TextEncoder().encode(payload)),
signatures: signatures.map((s) => ({ ...s })),
};
}
/** Trusted key, optionally keyed so callers can match `signature.keyid`. */
export interface TrustedEd25519Key {
publicKey: Uint8Array;
keyid?: string;
}
/**
* Verify every signature in a DSSE envelope against the supplied trusted
* public keys. A signature is marked `valid` if any trusted key accepts it.
* When signatures carry a `keyid`, only keys with a matching `keyid` are
* considered — this lets callers scope a key to a subset of signatures.
*/
export function verifyDsseEnvelope(
envelope: DsseEnvelope,
keys: readonly TrustedEd25519Key[]
): DsseVerificationResult {
if (keys.length === 0) {
throw new AttestationInvalidError("error.slsa.dsse.no_keys", {
context: {},
});
}
const payloadBytes = fromBase64(envelope.payload);
const pae = encodeDssePae(envelope.payloadType, payloadBytes);
const records: DsseVerificationRecord[] = [];
envelope.signatures.forEach((sig, signatureIndex) => {
const sigBytes = fromBase64(sig.sig);
let matched = false;
let keyIndex = -1;
let i = 0;
for (const key of keys) {
if (!(sig.keyid && key.keyid && sig.keyid !== key.keyid)) {
if (ed25519.verify(key.publicKey, sigBytes, pae)) {
matched = true;
keyIndex = i;
break;
}
}
i += 1;
}
records.push({
...(sig.keyid !== undefined ? { keyid: sig.keyid } : {}),
keyIndex,
signatureIndex,
valid: matched,
});
});
return {
valid: records.length > 0 && records.every((r) => r.valid),
records,
};
}

View File

@@ -0,0 +1,9 @@
export {
DSSE_PAYLOAD_TYPE,
type DsseVerificationRecord,
type DsseVerificationResult,
encodeDssePae,
type TrustedEd25519Key,
verifyDsseEnvelope,
wrapInDsse,
} from "./dsse.js";

View File

@@ -0,0 +1,9 @@
export {
DSSE_PAYLOAD_TYPE,
type DsseVerificationRecord,
type DsseVerificationResult,
encodeDssePae,
type TrustedEd25519Key,
verifyDsseEnvelope,
wrapInDsse,
} from "./dsse/index.js";

View File

@@ -0,0 +1,13 @@
export {
AttestationInvalidError,
EnvironmentUnsupportedError,
InvalidProvenanceError,
InvalidSbomError,
PolicyViolationError,
RekorInclusionError,
RekorUploadError,
SLSA_CODES,
type SlsaCode,
SlsaError,
VerificationFailedError,
} from "./slsa-error.js";

View File

@@ -0,0 +1,81 @@
# slsa-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the structured error substrate for the `@suaveplan/slsa` package. Every
failure raised by provenance generation, verification, SBOM emission, DSSE
wrapping, Rekor transparency-log operations, and policy evaluation flows
through a `SlsaError` subclass carrying a stable, namespaced error code drawn
from `SLSA_CODES`. This makes it safe to pattern-match on `err.code` in
consumer code and to redact / serialise errors with the rest of the Suaveplan
observability pipeline.
## Features
- Namespaced, frozen registry built via `@suaveplan/error`'s `defineCodes`.
- One domain-specific subclass per code (`InvalidProvenanceError`,
`VerificationFailedError`, `RekorUploadError`, `RekorInclusionError`,
`PolicyViolationError`, `AttestationInvalidError`,
`EnvironmentUnsupportedError`, `InvalidSbomError`).
- `instanceof SlsaError` narrows to the full SLSA code union.
- `cause`, `context`, `traceId` fields inherited from `SuaveplanError`.
## Usage: Basic Example
```ts
import { InvalidProvenanceError } from "@suaveplan/slsa";
throw new InvalidProvenanceError("error.slsa.invalid_provenance", {
context: { missingField: "builderId" },
});
```
## Usage: Advanced Example
```ts
import { RekorInclusionError, SLSA_CODES, SlsaError } from "@suaveplan/slsa";
try {
await client.verifyInclusionProof(entry);
} catch (cause) {
throw new RekorInclusionError("error.slsa.rekor.inclusion_failed", {
cause: cause as Error,
context: { uuid: entry.uuid, logIndex: entry.logIndex },
traceId: currentSpan?.spanContext().traceId,
});
}
// Downstream handler
if (err instanceof SlsaError && err.code === SLSA_CODES.REKOR_INCLUSION_FAILED) {
metrics.rekorInclusionFailures.add(1);
}
```
## API Reference
- `SLSA_CODES` — frozen registry of stable error-code strings.
- `SlsaCode` — union type of all SLSA error codes.
- `SlsaError` — base class; extends `SuaveplanError<SlsaCode>`.
- `InvalidProvenanceError(messageKey, options?)` — schema / shape failure.
- `InvalidSbomError(messageKey, options?)` — SBOM schema failure.
- `VerificationFailedError(messageKey, options?)` — provenance verification rejected.
- `PolicyViolationError(messageKey, options?)` — policy evaluator rejection.
- `RekorUploadError(messageKey, options?)` — transparency-log HTTP / protocol failure.
- `RekorInclusionError(messageKey, options?)` — merkle proof failed verification.
- `AttestationInvalidError(messageKey, options?)` — DSSE envelope / signature invalid.
- `EnvironmentUnsupportedError(messageKey, options?)` — runtime lacks capability.
All constructors accept `SuaveplanErrorOptions` (`{ cause?, context?, traceId?, clock? }`).
## Implementation Notes
Subclasses are thin wrappers that bind a specific `SLSA_CODES` value, so
`new InvalidProvenanceError(...)` and `new SlsaError(SLSA_CODES.INVALID_PROVENANCE, ...)`
are behaviourally identical. Message keys are strings — resolution to a human
message happens through `@suaveplan/error`'s i18n resolver so catalogs can be
localised without touching throw sites. The class name is preserved in
`err.name` via the `SuaveplanError` base constructor, which makes stack-trace
capture correct even under V8's `captureStackTrace`.

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AttestationInvalidError,
EnvironmentUnsupportedError,
InvalidProvenanceError,
InvalidSbomError,
PolicyViolationError,
RekorInclusionError,
RekorUploadError,
SLSA_CODES,
SlsaError,
VerificationFailedError,
} from "./slsa-error.js";
describe("SLSA_CODES", () => {
it("exposes stable namespaced codes", { timeout: 5000 }, () => {
expect(SLSA_CODES.INVALID_PROVENANCE).toBe("SLSA.INVALID_PROVENANCE");
expect(SLSA_CODES.INVALID_SBOM).toBe("SLSA.INVALID_SBOM");
expect(SLSA_CODES.VERIFICATION_FAILED).toBe("SLSA.VERIFICATION_FAILED");
expect(SLSA_CODES.POLICY_VIOLATION).toBe("SLSA.POLICY_VIOLATION");
expect(SLSA_CODES.REKOR_UPLOAD_FAILED).toBe("SLSA.REKOR_UPLOAD_FAILED");
expect(SLSA_CODES.REKOR_INCLUSION_FAILED).toBe(
"SLSA.REKOR_INCLUSION_FAILED"
);
expect(SLSA_CODES.ATTESTATION_INVALID).toBe("SLSA.ATTESTATION_INVALID");
expect(SLSA_CODES.ENVIRONMENT_UNSUPPORTED).toBe(
"SLSA.ENVIRONMENT_UNSUPPORTED"
);
});
it("registry is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(SLSA_CODES)).toBe(true);
});
});
describe("SlsaError subclasses", () => {
const cases: Array<[new (k: string) => SlsaError, string]> = [
[InvalidProvenanceError, "SLSA.INVALID_PROVENANCE"],
[InvalidSbomError, "SLSA.INVALID_SBOM"],
[VerificationFailedError, "SLSA.VERIFICATION_FAILED"],
[PolicyViolationError, "SLSA.POLICY_VIOLATION"],
[RekorUploadError, "SLSA.REKOR_UPLOAD_FAILED"],
[RekorInclusionError, "SLSA.REKOR_INCLUSION_FAILED"],
[AttestationInvalidError, "SLSA.ATTESTATION_INVALID"],
[EnvironmentUnsupportedError, "SLSA.ENVIRONMENT_UNSUPPORTED"],
];
for (const [Ctor, code] of cases) {
it(`${Ctor.name} carries ${code}`, { timeout: 5000 }, () => {
const err = new Ctor("error.key");
expect(err).toBeInstanceOf(SlsaError);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe(code);
expect(err.name).toBe(Ctor.name);
});
}
it("propagates cause + context", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new InvalidProvenanceError("k", {
cause,
context: { subject: "alpha" },
});
expect(err.cause).toBe(cause);
expect(err.context?.["subject"]).toBe("alpha");
expect(typeof err.timestamp).toBe("number");
});
it("SlsaError can be constructed directly", { timeout: 5000 }, () => {
const err = new SlsaError(SLSA_CODES.POLICY_VIOLATION, "k");
expect(err.code).toBe("SLSA.POLICY_VIOLATION");
});
});

View File

@@ -0,0 +1,102 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Error code registry for `@suaveplan/slsa`.
*
* Namespaces:
* - `SLSA.INVALID_PROVENANCE` — provenance shape / schema failures
* - `SLSA.INVALID_SBOM` — SBOM shape / schema failures
* - `SLSA.VERIFICATION_FAILED` — provenance verification failure
* - `SLSA.POLICY_VIOLATION` — policy evaluator rejected a statement
* - `SLSA.REKOR_UPLOAD_FAILED` — transparency log upload failed
* - `SLSA.REKOR_INCLUSION_FAILED` — merkle inclusion proof did not verify
* - `SLSA.ATTESTATION_INVALID` — DSSE envelope or signature invalid
* - `SLSA.ENVIRONMENT_UNSUPPORTED` — runtime environment lacks required capability
*/
export const SLSA_CODES = defineCodes("SLSA", [
"INVALID_PROVENANCE",
"INVALID_SBOM",
"VERIFICATION_FAILED",
"POLICY_VIOLATION",
"REKOR_UPLOAD_FAILED",
"REKOR_INCLUSION_FAILED",
"ATTESTATION_INVALID",
"ENVIRONMENT_UNSUPPORTED",
] as const);
export type SlsaCode = (typeof SLSA_CODES)[keyof typeof SLSA_CODES];
/**
* Base error class for `@suaveplan/slsa`. All domain-specific errors extend
* this class so callers can branch by `instanceof SlsaError` or by code.
*/
export class SlsaError extends SuaveplanError<SlsaCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
public constructor(
code: SlsaCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}
/** Raised when a provenance statement fails schema validation. */
export class InvalidProvenanceError extends SlsaError {
public constructor(messageKey: string, options?: SuaveplanErrorOptions) {
super(SLSA_CODES.INVALID_PROVENANCE, messageKey, options);
}
}
/** Raised when an SBOM fails schema validation. */
export class InvalidSbomError extends SlsaError {
public constructor(messageKey: string, options?: SuaveplanErrorOptions) {
super(SLSA_CODES.INVALID_SBOM, messageKey, options);
}
}
/** Raised when verification of a provenance statement fails its policy. */
export class VerificationFailedError extends SlsaError {
public constructor(messageKey: string, options?: SuaveplanErrorOptions) {
super(SLSA_CODES.VERIFICATION_FAILED, messageKey, options);
}
}
/** Raised when the policy evaluator rejects a statement. */
export class PolicyViolationError extends SlsaError {
public constructor(messageKey: string, options?: SuaveplanErrorOptions) {
super(SLSA_CODES.POLICY_VIOLATION, messageKey, options);
}
}
/** Raised when a Rekor transparency-log upload fails. */
export class RekorUploadError extends SlsaError {
public constructor(messageKey: string, options?: SuaveplanErrorOptions) {
super(SLSA_CODES.REKOR_UPLOAD_FAILED, messageKey, options);
}
}
/** Raised when a Rekor inclusion proof does not verify. */
export class RekorInclusionError extends SlsaError {
public constructor(messageKey: string, options?: SuaveplanErrorOptions) {
super(SLSA_CODES.REKOR_INCLUSION_FAILED, messageKey, options);
}
}
/** Raised when a DSSE envelope is malformed or its signatures are invalid. */
export class AttestationInvalidError extends SlsaError {
public constructor(messageKey: string, options?: SuaveplanErrorOptions) {
super(SLSA_CODES.ATTESTATION_INVALID, messageKey, options);
}
}
/** Raised when the runtime environment lacks a required capability. */
export class EnvironmentUnsupportedError extends SlsaError {
public constructor(messageKey: string, options?: SuaveplanErrorOptions) {
super(SLSA_CODES.ENVIRONMENT_UNSUPPORTED, messageKey, options);
}
}

View File

@@ -0,0 +1,104 @@
// Errors
// Assessment
export { assessSlsaLevel, detectEnvironment } from "./assessment/index.js";
// Attestation
export {
DSSE_PAYLOAD_TYPE,
type DsseVerificationRecord,
type DsseVerificationResult,
encodeDssePae,
type TrustedEd25519Key,
verifyDsseEnvelope,
wrapInDsse,
} from "./attestation/index.js";
export {
AttestationInvalidError,
EnvironmentUnsupportedError,
InvalidProvenanceError,
InvalidSbomError,
PolicyViolationError,
RekorInclusionError,
RekorUploadError,
SLSA_CODES,
type SlsaCode,
SlsaError,
VerificationFailedError,
} from "./errors/index.js";
// OTel
export {
createSlsaOtelAdapter,
noopSlsaOtelAdapter,
type SlsaEventAttributes,
type SlsaOtelAdapter,
} from "./otel/index.js";
// Policy
export { type EvaluatePolicyOptions, evaluatePolicy } from "./policy/index.js";
// Provenance
export {
canonicalize,
computeDigest,
generateProvenance,
hashProvenance,
type ProvenanceInput,
serializeProvenance,
} from "./provenance/index.js";
// Rekor
export {
type HashedrekordBody,
RekorClient,
type RekorClientOptions,
type RekorFetch,
rfc6962LeafHash,
verifyRfc6962InclusionProof,
} from "./rekor/index.js";
// SBOM
export {
buildPurl,
type GenerateSbomOptions,
generateSbom,
hashSbom,
serializeSbom,
} from "./sbom/index.js";
// Schemas (types + Zod + parse helpers)
export {
type AssessmentEnvironment,
type Attestation,
type BuildDefinition,
type Builder,
type BuildMetadata,
type DigestAlgorithm,
type DigestSet,
type DsseEnvelope,
type DsseSignature,
type ExternalParameters,
IN_TOTO_STATEMENT_TYPE,
type InclusionProof,
type InternalParameters,
type ProvenanceStatement,
ProvenanceStatementSchema,
parseProvenance,
parseSbom,
type RekorEntry,
RekorEntrySchema,
type ResourceDescriptor,
type RunDetails,
type Sbom,
type SbomComponent,
type SbomComponentType,
type SbomInput,
SbomSchema,
SLSA_PREDICATE_TYPE,
type SlsaAssessment,
type SlsaLevel,
type SlsaProvenancePredicate,
type VerificationCheck,
type VerificationPolicy,
VerificationPolicySchema,
type VerificationResult,
} from "./schemas/index.js";
// Verification
export {
type VerifyOptions,
verifyProvenance,
} from "./verification/index.js";

View File

@@ -0,0 +1,6 @@
export {
createSlsaOtelAdapter,
noopSlsaOtelAdapter,
type SlsaEventAttributes,
type SlsaOtelAdapter,
} from "./slsa-otel.js";

View File

@@ -0,0 +1,74 @@
# slsa-otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry adapter for SLSA audit events. Every major capability in the
package — provenance generation, verification, Rekor upload, inclusion-proof
verification, policy evaluation — emits a structured span event through this
adapter. By default the adapter attaches events to the currently-active span,
so it composes naturally with the calling service's trace context without
SLSA needing to manage its own span lifecycle.
## Features
- Five well-known event names: `slsa.provenance.generated`,
`slsa.provenance.verified`, `slsa.rekor.upload`,
`slsa.rekor.inclusion_verified`, `slsa.policy.violation`.
- Typed attribute bag (`SlsaEventAttributes`) under the `slsa.*` namespace.
- Zero-config no-op default when no exporter is registered.
- Explicit `tracer` override for background / detached contexts.
## Usage: Basic Example
```ts
import { createSlsaOtelAdapter, generateProvenance } from "@suaveplan/slsa";
const otel = createSlsaOtelAdapter();
const stmt = generateProvenance({
/* ...input... */,
otel,
});
```
## Usage: Advanced Example
```ts
import { trace } from "@opentelemetry/api";
import { createSlsaOtelAdapter, RekorClient } from "@suaveplan/slsa";
const tracer = trace.getTracer("publish-pipeline");
const otel = createSlsaOtelAdapter(tracer);
const client = new RekorClient({ otel });
await tracer.startActiveSpan("publish", async (span) => {
try {
const entry = await client.uploadHashedrekord(rekord);
await client.verifyInclusionProof(entry);
} finally {
span.end();
}
});
```
## API Reference
- `SlsaEventAttributes` — named attribute bag; every field is optional.
- `SlsaOtelAdapter` — interface with five `on*` methods, all returning `void`.
- `createSlsaOtelAdapter(tracer?)` — returns a live adapter. With no
`tracer`, events attach to the active span; otherwise spans are started
inline and ended.
- `noopSlsaOtelAdapter` — default adapter that discards events.
## Implementation Notes
Span events are preferred over metrics because SLSA events are inherently
correlated: "generated", "verified", and "logged to Rekor" usually happen
inside a single CI job's trace. Counters would lose that correlation.
Attributes are typed as a closed shape so accidental typos surface as
compile-time errors, but the runtime cast to `Record<string, never>` is used
because `@opentelemetry/api`'s `AttributeValue` type is wider than our event
schema and TypeScript's invariance on index signatures would otherwise
require an unsafe upcast at every call site.

View File

@@ -0,0 +1,110 @@
import { type Span, SpanStatusCode, trace } from "@opentelemetry/api";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createSlsaOtelAdapter, noopSlsaOtelAdapter } from "./slsa-otel.js";
type RecordedEvent = { name: string; attrs: Record<string, unknown> };
function makeFakeSpan(): Span & { events: RecordedEvent[] } {
const events: RecordedEvent[] = [];
const span = {
events,
addEvent: (name: string, attrs?: Record<string, unknown>) => {
events.push({ name, attrs: attrs ?? {} });
return span;
},
end: () => {},
setAttribute: () => span,
setAttributes: () => span,
setStatus: () => span,
updateName: () => span,
recordException: () => {},
isRecording: () => true,
spanContext: () => ({
traceId: "00000000000000000000000000000001",
spanId: "0000000000000001",
traceFlags: 1,
}),
status: { code: SpanStatusCode.UNSET },
} as unknown as Span & { events: RecordedEvent[] };
return span;
}
describe("noopSlsaOtelAdapter", () => {
it("accepts all events without throwing", { timeout: 5000 }, () => {
expect(() => {
noopSlsaOtelAdapter.onProvenanceGenerated({});
noopSlsaOtelAdapter.onProvenanceVerified({});
noopSlsaOtelAdapter.onRekorUpload({});
noopSlsaOtelAdapter.onRekorInclusionVerified({});
noopSlsaOtelAdapter.onPolicyViolation({});
}).not.toThrow();
});
});
describe("createSlsaOtelAdapter", () => {
it("is callable with zero args and does not throw", {
timeout: 5000,
}, () => {
const a = createSlsaOtelAdapter();
expect(() => {
a.onProvenanceGenerated({ "slsa.result.valid": true });
a.onProvenanceVerified({});
a.onRekorUpload({});
a.onRekorInclusionVerified({});
a.onPolicyViolation({});
}).not.toThrow();
});
it("emits events on the active span", { timeout: 5000 }, () => {
const span = makeFakeSpan();
const originalGet = trace.getActiveSpan;
(
trace as unknown as { getActiveSpan: () => Span | undefined }
).getActiveSpan = () => span;
try {
const adapter = createSlsaOtelAdapter();
adapter.onProvenanceGenerated({ "slsa.builder.id": "gitlab-ci" });
adapter.onRekorUpload({ "slsa.rekor.uuid": "u1" });
} finally {
(
trace as unknown as {
getActiveSpan: () => Span | undefined;
}
).getActiveSpan = originalGet;
}
expect(span.events.length).toBe(2);
expect(span.events[0]?.name).toBe("slsa.provenance.generated");
expect(span.events[1]?.name).toBe("slsa.rekor.upload");
});
it("falls back to tracer when no active span", {
timeout: 5000,
}, () => {
const created: Array<Span & { events: RecordedEvent[] }> = [];
const tracer = {
startSpan: () => {
const s = makeFakeSpan();
created.push(s);
return s;
},
startActiveSpan: () => {
throw new Error("not used");
},
} as unknown as import("@opentelemetry/api").Tracer;
const adapter = createSlsaOtelAdapter(tracer);
adapter.onPolicyViolation({ "slsa.policy.violations": 3 });
expect(created.length).toBe(1);
expect(created[0]?.events[0]?.name).toBe("slsa.policy.violation");
});
it("silently discards when no active span and no tracer", {
timeout: 5000,
}, () => {
const adapter = createSlsaOtelAdapter();
expect(() =>
adapter.onRekorInclusionVerified({ "slsa.rekor.uuid": "z" })
).not.toThrow();
});
});

View File

@@ -0,0 +1,70 @@
import { type Tracer, trace } from "@opentelemetry/api";
/**
* Attributes emitted on SLSA audit events. Keyed under the `slsa.*` namespace
* so they integrate cleanly with OTel collectors that filter on attribute
* prefix.
*/
export interface SlsaEventAttributes {
"slsa.statement.digest"?: string;
"slsa.builder.id"?: string;
"slsa.source.repository"?: string;
"slsa.result.valid"?: boolean;
"slsa.rekor.uuid"?: string;
"slsa.rekor.log_index"?: number;
"slsa.rekor.tree_size"?: number;
"slsa.policy.violations"?: number;
"slsa.policy.level"?: string;
}
/**
* Adapter emitting structured span events on the currently-active span (or a
* configured tracer). Default behaviour is no-op when no exporter is
* registered, matching `@opentelemetry/api`'s expected shape.
*/
export interface SlsaOtelAdapter {
onProvenanceGenerated(attrs: SlsaEventAttributes): void;
onProvenanceVerified(attrs: SlsaEventAttributes): void;
onRekorUpload(attrs: SlsaEventAttributes): void;
onRekorInclusionVerified(attrs: SlsaEventAttributes): void;
onPolicyViolation(attrs: SlsaEventAttributes): void;
}
/**
* Build a `SlsaOtelAdapter` that emits span events to the current active
* span (via `trace.getActiveSpan()`). Providing an explicit `tracer` makes
* the adapter create its own spans — useful for background processes that
* do not run inside a parent context.
*/
export function createSlsaOtelAdapter(tracer?: Tracer): SlsaOtelAdapter {
const emit = (name: string, attrs: SlsaEventAttributes): void => {
const active = trace.getActiveSpan();
if (active) {
active.addEvent(name, attrs as Record<string, never>);
return;
}
if (tracer) {
const span = tracer.startSpan(name);
span.addEvent(name, attrs as Record<string, never>);
span.end();
}
};
return {
onProvenanceGenerated: (a) => emit("slsa.provenance.generated", a),
onProvenanceVerified: (a) => emit("slsa.provenance.verified", a),
onRekorUpload: (a) => emit("slsa.rekor.upload", a),
onRekorInclusionVerified: (a) =>
emit("slsa.rekor.inclusion_verified", a),
onPolicyViolation: (a) => emit("slsa.policy.violation", a),
};
}
/** A no-op adapter used as the default when callers do not supply one. */
export const noopSlsaOtelAdapter: SlsaOtelAdapter = {
onProvenanceGenerated: () => {},
onProvenanceVerified: () => {},
onRekorUpload: () => {},
onRekorInclusionVerified: () => {},
onPolicyViolation: () => {},
};

View File

@@ -0,0 +1,2 @@
export type { EvaluatePolicyOptions } from "./policy.js";
export { evaluatePolicy } from "./policy.js";

View File

@@ -0,0 +1,88 @@
# policy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`evaluatePolicy` is the composition point that layers every other
verification capability into a single decision: given a provenance
statement, its optional DSSE envelope, its optional Rekor entry, and a
`VerificationPolicy`, return one `VerificationResult` with every relevant
rule reported. Where `verifyProvenance` only runs structural rules and
doesn't know about signatures or transparency logs, `evaluatePolicy` ties
the full chain of trust together and is what release-gate scripts should
call.
## Features
- Runs the baseline `verifyProvenance` suite first, then appends:
- DSSE signature verification against `policy.trustedPublicKeys`.
- Rekor inclusion-proof presence (when `policy.requireRekorInclusion`).
- Minimum-level assertion against an explicit `pipelineLevel`.
- OTel `slsa.policy.violation` event emitted on any failure, with
violation count and required level attached.
- Returns structured `VerificationResult` — never throws on policy failure.
## Usage: Basic Example
```ts
import { evaluatePolicy } from "@suaveplan/slsa";
const result = evaluatePolicy(
statement,
envelope,
rekorEntry,
{
trustedBuilders: ["https://gitlab.fq.io/suaveplan/project/-/runners/*"],
trustedPublicKeys: [releaseKey],
requireRekorInclusion: true,
minSlsaLevel: "L2",
},
{ pipelineLevel: "L3" }
);
```
## Usage: Advanced Example
```ts
import {
createSlsaOtelAdapter,
evaluatePolicy,
RekorClient,
} from "@suaveplan/slsa";
const otel = createSlsaOtelAdapter();
const client = new RekorClient({ otel });
const rekorEntry = await client.uploadIntoto(envelope);
const result = evaluatePolicy(statement, envelope, rekorEntry, policy, {
otel,
clock: () => Date.now(),
pipelineLevel: "L2",
});
if (!result.valid) {
for (const c of result.checks.filter((c) => !c.passed)) {
log.warn({ rule: c.name, message: c.message }, "policy violation");
}
process.exit(1);
}
```
## API Reference
- `evaluatePolicy(statement, envelope | null, rekorEntry | null, policy,
opts?)` — returns `VerificationResult`. `opts.pipelineLevel` must be
supplied when `policy.minSlsaLevel` is set; otherwise the check fails
with a self-describing message.
- `EvaluatePolicyOptions { otel?, clock?, pipelineLevel? }` — all optional.
## Implementation Notes
The extra checks are appended, never interleaved, so existing consumers
that match on `checks[i].name` stay stable. `checkDsseSignatures` builds
`TrustedEd25519Key` records without `keyid` by default — if keyids are
required for scoping, call `verifyDsseEnvelope` directly and feed the
derived checks in. `minSlsaLevel` uses an ordinal comparison
(`L0 < L1 < L2 < L3`) rather than string equality so `"L3"` satisfies
`minSlsaLevel: "L2"` without additional policy tweaks.

View File

@@ -0,0 +1,252 @@
import { ed25519 } from "@suaveplan/crypto-core";
import { sha256 } from "@suaveplan/crypto-hash";
import {
concatBytes,
fromBase64,
toBase64,
toHex,
} from "@suaveplan/crypto-utils";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { encodeDssePae, wrapInDsse } from "../attestation/dsse/dsse.js";
import { generateProvenance } from "../provenance/provenance.js";
import type { ProvenanceInput } from "../provenance/provenance.types.js";
import type { RekorEntry } from "../schemas/attestation.schemas.js";
import type { VerificationPolicy } from "../schemas/verification.schemas.js";
import { evaluatePolicy } from "./policy.js";
function makeInput(): ProvenanceInput {
return {
packageName: "@s/x",
packageVersion: "1.0.0",
artifactDigest: "abc",
sourceCommit: "deadbeef",
sourceRepo: "https://gitlab.fq.io/s/m",
sourceRef: "refs/heads/main",
builderId: "https://gitlab.fq.io/s/m/-/runners/1",
buildInvocationId: "ci",
buildStartedOn: "2026-03-24T10:00:00Z",
buildFinishedOn: "2026-03-24T10:05:00Z",
};
}
function basePolicy(
overrides?: Partial<VerificationPolicy>
): VerificationPolicy {
return {
trustedBuilders: ["https://gitlab.fq.io/s/m/-/runners/*"],
expectedSourceRepo: "https://gitlab.fq.io/s/m",
...overrides,
};
}
function leafHash(body: Uint8Array): Uint8Array {
return sha256(concatBytes(new Uint8Array([0x00]), body));
}
function makeSignedEnvelope(stmt: ReturnType<typeof generateProvenance>) {
const kp = ed25519.generateKeypair();
const envelope = wrapInDsse(stmt, [{ sig: "x" }]);
const pae = encodeDssePae(
envelope.payloadType,
fromBase64(envelope.payload)
);
envelope.signatures[0] = {
sig: toBase64(ed25519.sign(kp.secretKey, pae)),
};
return { envelope, kp };
}
function makeRekorEntry(): RekorEntry {
const body = new Uint8Array([1, 2, 3]);
return {
uuid: "u",
logIndex: 0,
logID: "log",
integratedTime: 1,
body: toBase64(body),
verification: {
inclusionProof: {
logIndex: 0,
treeSize: 1,
rootHash: toHex(leafHash(body)),
hashes: [],
},
},
};
}
describe("evaluatePolicy — baseline", () => {
it("passes when only baseline checks apply", { timeout: 5000 }, () => {
const stmt = generateProvenance(makeInput());
const result = evaluatePolicy(stmt, null, null, basePolicy());
expect(result.valid).toBe(true);
expect(result.summary).toBe("Policy passed");
});
it("fails when baseline fails (wrong builder)", { timeout: 5000 }, () => {
const stmt = generateProvenance(makeInput());
const result = evaluatePolicy(
stmt,
null,
null,
basePolicy({ trustedBuilders: ["https://other"] })
);
expect(result.valid).toBe(false);
expect(result.summary).toContain("policy check(s) failed");
});
});
describe("evaluatePolicy — DSSE signatures", () => {
it("passes when envelope signature is trusted", { timeout: 5000 }, () => {
const stmt = generateProvenance(makeInput());
const { envelope, kp } = makeSignedEnvelope(stmt);
const result = evaluatePolicy(
stmt,
envelope,
null,
basePolicy({ trustedPublicKeys: [kp.publicKey] })
);
expect(result.valid).toBe(true);
expect(
result.checks.find((c) => c.name === "dsse-signatures")?.passed
).toBe(true);
});
it("fails when envelope is missing but policy requires keys", {
timeout: 5000,
}, () => {
const stmt = generateProvenance(makeInput());
const result = evaluatePolicy(
stmt,
null,
null,
basePolicy({ trustedPublicKeys: [new Uint8Array(32)] })
);
expect(result.valid).toBe(false);
expect(
result.checks.find((c) => c.name === "dsse-signatures")?.passed
).toBe(false);
});
it("fails when no key validates", { timeout: 5000 }, () => {
const stmt = generateProvenance(makeInput());
const { envelope } = makeSignedEnvelope(stmt);
const other = ed25519.generateKeypair();
const result = evaluatePolicy(
stmt,
envelope,
null,
basePolicy({ trustedPublicKeys: [other.publicKey] })
);
expect(
result.checks.find((c) => c.name === "dsse-signatures")?.passed
).toBe(false);
});
});
describe("evaluatePolicy — Rekor inclusion", () => {
it("passes when entry has inclusion proof", { timeout: 5000 }, () => {
const stmt = generateProvenance(makeInput());
const result = evaluatePolicy(
stmt,
null,
makeRekorEntry(),
basePolicy({ requireRekorInclusion: true })
);
expect(
result.checks.find((c) => c.name === "rekor-inclusion")?.passed
).toBe(true);
});
it("fails when entry missing", { timeout: 5000 }, () => {
const stmt = generateProvenance(makeInput());
const result = evaluatePolicy(
stmt,
null,
null,
basePolicy({ requireRekorInclusion: true })
);
expect(result.valid).toBe(false);
});
it("fails when entry lacks inclusion proof", { timeout: 5000 }, () => {
const stmt = generateProvenance(makeInput());
const entry = { ...makeRekorEntry(), verification: undefined };
const result = evaluatePolicy(
stmt,
null,
entry,
basePolicy({ requireRekorInclusion: true })
);
expect(
result.checks.find((c) => c.name === "rekor-inclusion")?.passed
).toBe(false);
});
});
describe("evaluatePolicy — minSlsaLevel", () => {
it("passes when pipelineLevel meets minimum", { timeout: 5000 }, () => {
const stmt = generateProvenance(makeInput());
const result = evaluatePolicy(
stmt,
null,
null,
basePolicy({ minSlsaLevel: "L2" }),
{ pipelineLevel: "L3" }
);
expect(result.valid).toBe(true);
});
it("fails when pipelineLevel below minimum", { timeout: 5000 }, () => {
const stmt = generateProvenance(makeInput());
const result = evaluatePolicy(
stmt,
null,
null,
basePolicy({ minSlsaLevel: "L3" }),
{ pipelineLevel: "L1" }
);
expect(result.valid).toBe(false);
});
it("fails when minSlsaLevel set without pipelineLevel", {
timeout: 5000,
}, () => {
const stmt = generateProvenance(makeInput());
const result = evaluatePolicy(
stmt,
null,
null,
basePolicy({ minSlsaLevel: "L2" })
);
expect(
result.checks.find((c) => c.name === "min-slsa-level")?.passed
).toBe(false);
});
});
describe("evaluatePolicy — otel", () => {
it("emits onPolicyViolation only on failure", { timeout: 5000 }, () => {
let calls = 0;
const otel = {
onProvenanceGenerated: () => {},
onProvenanceVerified: () => {},
onRekorUpload: () => {},
onRekorInclusionVerified: () => {},
onPolicyViolation: () => {
calls += 1;
},
};
const stmt = generateProvenance(makeInput());
evaluatePolicy(stmt, null, null, basePolicy(), { otel });
expect(calls).toBe(0);
evaluatePolicy(
stmt,
null,
null,
basePolicy({ trustedBuilders: ["https://other"] }),
{ otel }
);
expect(calls).toBe(1);
});
});

View File

@@ -0,0 +1,159 @@
import {
type TrustedEd25519Key,
verifyDsseEnvelope,
} from "../attestation/dsse/dsse.js";
import {
noopSlsaOtelAdapter,
type SlsaOtelAdapter,
} from "../otel/slsa-otel.js";
import type {
DsseEnvelope,
RekorEntry,
} from "../schemas/attestation.schemas.js";
import type { ProvenanceStatement } from "../schemas/provenance.schemas.js";
import type {
SlsaLevel,
VerificationCheck,
VerificationPolicy,
VerificationResult,
} from "../schemas/verification.schemas.js";
import { verifyProvenance } from "../verification/verification.js";
const LEVEL_ORDER: Record<SlsaLevel, number> = {
L0: 0,
L1: 1,
L2: 2,
L3: 3,
};
export interface EvaluatePolicyOptions {
/** OTel adapter — emits `slsa.policy.violation` when the result is invalid. */
otel?: SlsaOtelAdapter;
/** Injectable clock used for max-age checks. */
clock?: () => number;
/**
* Caller-declared level of the pipeline. Used only when `minSlsaLevel`
* is set on the policy — otherwise the assertion is skipped.
*/
pipelineLevel?: SlsaLevel;
}
/**
* Evaluate a `VerificationPolicy` against a provenance statement and the
* optional artifacts that accompany it (DSSE envelope, Rekor entry). Runs
* the provenance verifier first, then layers on DSSE signature checking,
* Rekor inclusion verification, minimum-level assertion, and any repo/age
* constraints.
*/
export function evaluatePolicy(
statement: ProvenanceStatement,
envelope: DsseEnvelope | null,
rekorEntry: RekorEntry | null,
policy: VerificationPolicy,
opts: EvaluatePolicyOptions = {}
): VerificationResult {
const clock = opts.clock ?? Date.now;
const otel = opts.otel ?? noopSlsaOtelAdapter;
const base = verifyProvenance(statement, policy, { clock });
const checks: VerificationCheck[] = [...base.checks];
if (policy.trustedPublicKeys && policy.trustedPublicKeys.length > 0) {
checks.push(checkDsseSignatures(envelope, policy.trustedPublicKeys));
}
if (policy.requireRekorInclusion) {
checks.push(checkRekorInclusionPresent(rekorEntry));
}
if (policy.minSlsaLevel && opts.pipelineLevel) {
checks.push(checkLevel(policy.minSlsaLevel, opts.pipelineLevel));
} else if (policy.minSlsaLevel) {
checks.push({
name: "min-slsa-level",
passed: false,
message:
"policy requires minSlsaLevel but no pipelineLevel supplied",
});
}
const failed = checks.filter((c) => !c.passed);
const valid = failed.length === 0;
const result: VerificationResult = {
valid,
summary: valid
? "Policy passed"
: `${failed.length} policy check(s) failed`,
checks,
};
if (!valid) {
otel.onPolicyViolation({
"slsa.policy.violations": failed.length,
...(policy.minSlsaLevel
? { "slsa.policy.level": policy.minSlsaLevel }
: {}),
});
}
return result;
}
function checkDsseSignatures(
envelope: DsseEnvelope | null,
trustedKeys: readonly Uint8Array[]
): VerificationCheck {
if (!envelope) {
return {
name: "dsse-signatures",
passed: false,
message: "policy requires DSSE envelope, none supplied",
};
}
const keys: TrustedEd25519Key[] = trustedKeys.map((publicKey) => ({
publicKey,
}));
const result = verifyDsseEnvelope(envelope, keys);
return {
name: "dsse-signatures",
passed: result.valid,
message: result.valid
? `${result.records.length} signature(s) verified`
: `DSSE verification failed; ${
result.records.filter((r) => !r.valid).length
} bad signature(s)`,
};
}
function checkRekorInclusionPresent(
entry: RekorEntry | null
): VerificationCheck {
if (!entry) {
return {
name: "rekor-inclusion",
passed: false,
message: "policy requires Rekor inclusion, entry not supplied",
};
}
const proof = entry.verification?.inclusionProof;
const passed = !!proof && proof.hashes !== undefined;
return {
name: "rekor-inclusion",
passed,
message: passed
? `Rekor entry ${entry.uuid} carries inclusion proof`
: `Rekor entry ${entry.uuid} missing inclusion proof`,
};
}
function checkLevel(min: SlsaLevel, actual: SlsaLevel): VerificationCheck {
const passed = LEVEL_ORDER[actual] >= LEVEL_ORDER[min];
return {
name: "min-slsa-level",
passed,
message: passed
? `Pipeline level ${actual} >= required ${min}`
: `Pipeline level ${actual} below required ${min}`,
};
}

View File

@@ -0,0 +1,59 @@
# canonical-json
## Purpose
Produces a byte-stable, lexicographically-ordered JSON encoding for
provenance statements, SBOM documents, and DSSE payloads. Deterministic
encoding is required whenever a document will be hashed, signed, or
compared across producers — two semantically equivalent documents must
always serialise to the same bytes, otherwise digest and signature
verification become order-dependent.
## Features
- Recursive key sorting (parent, then children).
- `undefined` properties are omitted (matching `JSON.stringify` semantics).
- Arrays are preserved in source order (ordering carries semantic meaning for
verification checks, Merkle proofs, etc.).
- Pure, zero-dependency, allocation-bounded.
## Usage: Basic Example
```ts
import { canonicalize } from "@suaveplan/slsa";
canonicalize({ b: 2, a: 1 }); // '{"a":1,"b":2}'
```
## Usage: Advanced Example
```ts
import { canonicalize } from "@suaveplan/slsa";
import { sha256 } from "@suaveplan/crypto-hash";
import { toHex } from "@suaveplan/crypto-utils";
const digest = toHex(
sha256(new TextEncoder().encode(canonicalize(provenanceStatement)))
);
```
## API Reference
- `canonicalize(value: unknown): string` — returns a deterministic JSON
string. Supports plain objects, arrays, strings, numbers, booleans, and
`null`. Functions, symbols, `undefined` values, and class instances are
treated as plain records (own-enumerable keys only).
## Implementation Notes
The implementation deliberately avoids JCS (RFC 8785) full compliance — SLSA
in-toto statements use sort-keys-only canonicalisation, which this matches.
We do not normalise numeric representations (e.g. `1e2` vs `100`), because
SLSA predicates do not contain non-integer numerics outside timestamps
(which are strings). `undefined` is treated as "omit this field" so that
calling code can opt a subject out with `field: undefined` without forcing
all consumers to delete the key.
### Why Canonical JSON
In-toto / DSSE signing requires the bytes signed to be bit-exact so verifiers reproduce the hash. Standard `JSON.stringify` is not canonical — object key order, number-formatting, and string-escape choices all vary. `canonicalJson` follows RFC 8785 JCS: sorted keys, shortest-form numbers, minimal string escapes. Output is always UTF-8 and deterministic across Node / Bun / browsers.

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { canonicalize } from "./canonical-json.js";
describe("canonicalize", () => {
it("sorts object keys", { timeout: 5000 }, () => {
expect(canonicalize({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
});
it("recurses into nested objects", { timeout: 5000 }, () => {
expect(canonicalize({ z: { b: 2, a: 1 }, y: 0 })).toBe(
'{"y":0,"z":{"a":1,"b":2}}'
);
});
it("preserves array order", { timeout: 5000 }, () => {
expect(canonicalize([3, 1, 2])).toBe("[3,1,2]");
});
it("recurses into array members", { timeout: 5000 }, () => {
expect(canonicalize([{ b: 1, a: 2 }])).toBe('[{"a":2,"b":1}]');
});
it("omits undefined", { timeout: 5000 }, () => {
expect(canonicalize({ a: 1, b: undefined })).toBe('{"a":1}');
});
it("preserves null", { timeout: 5000 }, () => {
expect(canonicalize({ a: null })).toBe('{"a":null}');
});
it("handles primitives", { timeout: 5000 }, () => {
expect(canonicalize(42)).toBe("42");
expect(canonicalize("hi")).toBe('"hi"');
expect(canonicalize(true)).toBe("true");
expect(canonicalize(null)).toBe("null");
});
it("is deterministic across re-orderings", { timeout: 5000 }, () => {
const a = canonicalize({ x: 1, y: 2, z: 3 });
const b = canonicalize({ z: 3, y: 2, x: 1 });
expect(a).toBe(b);
});
});

View File

@@ -0,0 +1,27 @@
/**
* Canonical JSON serialiser — recursively sorts object keys so equivalent
* documents always produce byte-identical output. Used when hashing or
* signing provenance / DSSE payloads.
*/
export function canonicalize(value: unknown): string {
return JSON.stringify(sortKeys(value));
}
function sortKeys(value: unknown): unknown {
if (value === null) return null;
if (Array.isArray(value)) {
return value.map((v) => sortKeys(v));
}
if (typeof value === "object") {
const src = value as Record<string, unknown>;
const keys = Object.keys(src).sort();
const out: Record<string, unknown> = {};
for (const k of keys) {
const v = src[k];
if (v === undefined) continue;
out[k] = sortKeys(v);
}
return out;
}
return value;
}

View File

@@ -0,0 +1,58 @@
# digest
## Purpose
Single canonical entry point for SHA-256 digest computation inside the SLSA
package. Deliberately routes through `@suaveplan/crypto-hash` instead of
`node:crypto` so the primitive selector layer chooses the correct backend
(Noble / WebCrypto / native) at build time, and so the package stays
runtime-agnostic for non-Node environments.
## Features
- Accepts `Uint8Array` or UTF-8 strings.
- Returns lowercase hex via `@suaveplan/crypto-utils`'s `toHex`.
- Uses the package-wide `sha256` selector — no direct noble/webcrypto import.
## Usage: Basic Example
```ts
import { computeDigest } from "@suaveplan/slsa";
const d = computeDigest("hello");
```
## Usage: Advanced Example
```ts
import { computeDigest } from "@suaveplan/slsa";
import fs from "node:fs/promises";
const artifact = await fs.readFile("./dist/@suaveplan-utils-1.2.3.tgz");
const digest = computeDigest(new Uint8Array(artifact));
// feed into generateProvenance({ artifactDigest: digest, ... })
```
## API Reference
- `computeDigest(content: Uint8Array | string): string` — returns a 64-char
lowercase hex SHA-256. Strings are encoded as UTF-8 before hashing.
## Implementation Notes
The string branch uses `TextEncoder` (available in all modern Node / Bun /
browsers) rather than `Buffer.from(str)` so the helper remains universal.
SHA-512 is intentionally not exposed here — callers that need it should
compose `sha512` from `@suaveplan/crypto-hash` with `toHex` directly, which
keeps the SLSA surface tight and the digest format homogeneous.
### Hash Family
`digestProvenance` computes the SHA-256 of the canonical-JSON encoding of an in-toto statement and returns it as a hex string. SHA-256 is the only algorithm currently surfaced because it is the only one the Sigstore transparency log (Rekor) accepts for hashedrekord v0.0.2 entries. The helper composes `canonicalJson` and `sha256` from `@suaveplan/crypto-hash` so there is no custom hashing logic local to this module.
## Further Usage: Basic Example
```ts
import { digestProvenance } from "@suaveplan/slsa/provenance/digest";
const hex = await digestProvenance({ _type: "https://in-toto.io/Statement/v1" } as any);
```

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { computeDigest } from "./digest.js";
describe("computeDigest", () => {
it("hex-encodes SHA-256 of a string", { timeout: 5000 }, () => {
const d = computeDigest("hello");
expect(d).toMatch(/^[a-f0-9]{64}$/);
// Known SHA-256("hello")
expect(d).toBe(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
});
it("hex-encodes SHA-256 of a Uint8Array", { timeout: 5000 }, () => {
const bytes = new TextEncoder().encode("hello");
expect(computeDigest(bytes)).toBe(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
});
it("is deterministic", { timeout: 5000 }, () => {
expect(computeDigest("test")).toBe(computeDigest("test"));
});
it("differs for different inputs", { timeout: 5000 }, () => {
expect(computeDigest("a")).not.toBe(computeDigest("b"));
});
});

View File

@@ -0,0 +1,15 @@
import { sha256 } from "@suaveplan/crypto-hash";
import { toHex } from "@suaveplan/crypto-utils";
/**
* Compute the SHA-256 hex digest of arbitrary content. Accepts strings
* (encoded as UTF-8) or raw byte arrays. Used as the canonical artifact
* digest format across the SLSA package.
*/
export function computeDigest(content: Uint8Array | string): string {
const bytes =
typeof content === "string"
? new TextEncoder().encode(content)
: content;
return toHex(sha256(bytes));
}

View File

@@ -0,0 +1,9 @@
export { canonicalize } from "./canonical-json.js";
export { computeDigest } from "./digest.js";
export {
__setBunVersionProbeForTest,
generateProvenance,
hashProvenance,
serializeProvenance,
} from "./provenance.js";
export type { ProvenanceInput } from "./provenance.types.js";

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { generateProvenance } from "./provenance.js";
const BASE_INPUT = {
packageName: "@example/pkg",
packageVersion: "1.2.3",
artifactDigest:
"0000000000000000000000000000000000000000000000000000000000000000",
sourceCommit: "deadbeef",
sourceRepo: "git+https://example.com/repo.git",
sourceRef: "refs/tags/v1.2.3",
builderId: "https://example.com/builder",
buildInvocationId: "ci-1",
buildStartedOn: "2026-01-01T00:00:00Z",
};
describe("provenance builder version detection", () => {
it("uses runtime=unknown when globalThis.Bun is undefined", {
timeout: 5000,
}, () => {
const stmt = generateProvenance({
...BASE_INPUT,
runtimeVersion: { runtime: "unknown" },
});
expect(stmt.predicate.runDetails.builder.version).toMatchObject({
runtime: "unknown",
});
});
it("reports bun.version when globalThis.Bun is present", {
timeout: 5000,
}, () => {
const stmt = generateProvenance({
...BASE_INPUT,
runtimeVersion: { bun: "1.99.test" },
});
expect(stmt.predicate.runDetails.builder.version).toEqual({
bun: "1.99.test",
});
});
it("auto-detects bun.version when globalThis.Bun is present and no override is supplied", {
timeout: 5000,
}, () => {
// Exercise the `if (globals.Bun?.version)` branch in
// detectBuilderVersion. Under Bun (bun:test) the real
// `globalThis.Bun` is already present as a non-configurable
// property — assert the natural detection. Under Node (vitest)
// install a fake Bun global to drive the branch.
const existing = (globalThis as { Bun?: { version?: string } }).Bun;
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "Bun");
if (
existing?.version !== undefined &&
descriptor?.configurable === false
) {
const stmt = generateProvenance(BASE_INPUT);
expect(stmt.predicate.runDetails.builder.version).toEqual({
bun: existing.version,
});
return;
}
Object.defineProperty(globalThis, "Bun", {
configurable: true,
writable: true,
value: { version: "9.9.9-test" },
});
try {
const stmt = generateProvenance(BASE_INPUT);
expect(stmt.predicate.runDetails.builder.version).toEqual({
bun: "9.9.9-test",
});
} finally {
if (descriptor) {
Object.defineProperty(globalThis, "Bun", descriptor);
} else {
delete (globalThis as { Bun?: unknown }).Bun;
}
}
});
});

View File

@@ -0,0 +1,89 @@
# provenance
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Generates SLSA v1.0 provenance statements wrapped in in-toto v1 envelopes.
Each statement records the artifact digest, builder identity, source
repository, git ref, and build timing — the tamper-evident audit trail that
lets downstream consumers verify an artifact was built by a trusted pipeline
from an expected source.
## Features
- Canonical in-toto / SLSA v1.0 envelope shape.
- Deterministic serialisation through `canonicalize` (sorted keys).
- SHA-256 digest of the canonical encoding for signing / transparency logs.
- Builder version auto-detection (Bun in CI; `unknown` elsewhere).
- Structured `InvalidProvenanceError` for any malformed input.
- OTel `slsa.provenance.generated` span event with builder + source attributes.
## Usage: Basic Example
```ts
import { generateProvenance, hashProvenance } from "@suaveplan/slsa";
const statement = generateProvenance({
packageName: "@suaveplan/utils",
packageVersion: "1.2.3",
artifactDigest: "abc123...",
sourceCommit: "deadbeef...",
sourceRepo: "https://gitlab.fq.io/suaveplan/project",
sourceRef: "refs/heads/main",
builderId: "https://gitlab.fq.io/suaveplan/project/-/runners/1",
buildInvocationId: "ci-12345",
buildStartedOn: new Date().toISOString(),
});
const digest = hashProvenance(statement);
```
## Usage: Advanced Example
```ts
import {
createSlsaOtelAdapter,
generateProvenance,
serializeProvenance,
} from "@suaveplan/slsa";
const otel = createSlsaOtelAdapter();
const statement = generateProvenance({
/* ...fields... */,
resolvedDependencies: [
{
uri: "pkg:npm/lodash@4.17.21",
digest: { sha256: "lodashHashHex" },
name: "lodash",
},
],
otel,
});
await fs.writeFile("provenance.json", serializeProvenance(statement));
```
## API Reference
- `generateProvenance(input: ProvenanceInput): ProvenanceStatement` —
validates all required fields, returns the in-toto envelope. Throws
`InvalidProvenanceError` when any field is empty, non-string, or when
`buildStartedOn` / `buildFinishedOn` are not parseable ISO 8601.
- `serializeProvenance(statement): string` — canonical (sorted-keys) JSON.
- `hashProvenance(statement): string` — SHA-256 hex of the canonical
serialisation, used as the subject of Rekor hashedrekord entries.
## Implementation Notes
`buildType` is pinned to `https://suaveplan.app/slsa/build-type/bun-project/v1`
— bumping this identifier is a semver break, since downstream verifiers may
pattern-match on it. `resolvedDependencies` is emitted at both
`buildDefinition.resolvedDependencies` and
`buildDefinition.internalParameters.resolvedDependencies` to match the two
common shapes found in GitHub's SLSA generator and the SLSA reference CLI.
Builder version detection uses `globalThis.Bun` rather than a top-level
import so the module is importable from non-Bun runtimes without a cold
failure. Hashing uses the same canonicalisation function as DSSE payload
encoding, so "the thing we signed" always matches "the thing we hashed".

View File

@@ -0,0 +1,217 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { InvalidProvenanceError } from "../errors/index.js";
import {
IN_TOTO_STATEMENT_TYPE,
SLSA_PREDICATE_TYPE,
} from "../schemas/provenance.schemas.js";
import {
__setBunVersionProbeForTest,
generateProvenance,
hashProvenance,
serializeProvenance,
} from "./provenance.js";
import type { ProvenanceInput } from "./provenance.types.js";
function makeInput(overrides?: Partial<ProvenanceInput>): ProvenanceInput {
return {
packageName: "@suaveplan/utils",
packageVersion: "1.2.3",
artifactDigest: "abc123def456",
sourceCommit: "deadbeef1234567890abcdef",
sourceRepo: "https://gitlab.fq.io/suaveplan/monorepo",
sourceRef: "refs/heads/main",
builderId: "https://gitlab.fq.io/suaveplan/monorepo/-/runners/1",
buildInvocationId: "ci-job-12345",
buildStartedOn: "2026-03-24T10:00:00Z",
...overrides,
};
}
describe("generateProvenance", () => {
it("returns valid in-toto statement", { timeout: 5000 }, () => {
const r = generateProvenance(makeInput());
expect(r._type).toBe(IN_TOTO_STATEMENT_TYPE);
expect(r.predicateType).toBe(SLSA_PREDICATE_TYPE);
});
it("sets subject with purl URI and digest", { timeout: 5000 }, () => {
const r = generateProvenance(makeInput());
expect(r.subject).toHaveLength(1);
expect(r.subject[0]?.uri).toBe("pkg:npm/%40suaveplan/utils@1.2.3");
expect(r.subject[0]?.digest.sha256).toBe("abc123def456");
expect(r.subject[0]?.name).toBe("@suaveplan/utils");
});
it("includes source in resolved dependencies", { timeout: 5000 }, () => {
const r = generateProvenance(makeInput());
const deps = r.predicate.buildDefinition.resolvedDependencies ?? [];
expect(deps.length).toBeGreaterThanOrEqual(1);
expect(deps[0]?.name).toBe("source");
expect(deps[0]?.digest.sha256).toBe("deadbeef1234567890abcdef");
});
it("records build metadata", { timeout: 5000 }, () => {
const r = generateProvenance(
makeInput({ buildFinishedOn: "2026-03-24T10:05:00Z" })
);
const m = r.predicate.runDetails.metadata;
expect(m.invocationId).toBe("ci-job-12345");
expect(m.startedOn).toBe("2026-03-24T10:00:00Z");
expect(m.finishedOn).toBe("2026-03-24T10:05:00Z");
});
it("omits finishedOn when not provided", { timeout: 5000 }, () => {
const r = generateProvenance(makeInput());
expect(r.predicate.runDetails.metadata.finishedOn).toBeUndefined();
});
it("sets builder id and environment", { timeout: 5000 }, () => {
const r = generateProvenance(
makeInput({ buildEnvironment: "gitlab-ci" })
);
expect(r.predicate.runDetails.builder.id).toContain("runners/1");
expect(r.predicate.buildDefinition.internalParameters.environment).toBe(
"gitlab-ci"
);
});
it("defaults environment to unknown", { timeout: 5000 }, () => {
const r = generateProvenance(makeInput());
expect(r.predicate.buildDefinition.internalParameters.environment).toBe(
"unknown"
);
});
it("sets external parameters", { timeout: 5000 }, () => {
const r = generateProvenance(makeInput());
const p = r.predicate.buildDefinition.externalParameters;
expect(p.repository).toBe("https://gitlab.fq.io/suaveplan/monorepo");
expect(p.ref).toBe("refs/heads/main");
});
it("appends custom resolved dependencies", { timeout: 5000 }, () => {
const r = generateProvenance(
makeInput({
resolvedDependencies: [
{
uri: "pkg:npm/lodash@4.17.21",
digest: { sha256: "lodashhash" },
name: "lodash",
},
],
})
);
const deps = r.predicate.buildDefinition.resolvedDependencies ?? [];
expect(deps).toHaveLength(2);
expect(deps[1]?.name).toBe("lodash");
});
it("invokes otel adapter when supplied", { timeout: 5000 }, () => {
const events: unknown[] = [];
const otel = {
onProvenanceGenerated: (a: unknown) => {
events.push(a);
},
onProvenanceVerified: () => {},
onRekorUpload: () => {},
onRekorInclusionVerified: () => {},
onPolicyViolation: () => {},
};
generateProvenance(makeInput({ otel }));
expect(events.length).toBe(1);
});
describe("validation", () => {
const fields: Array<keyof ProvenanceInput> = [
"packageName",
"packageVersion",
"artifactDigest",
"sourceCommit",
"sourceRepo",
"sourceRef",
"builderId",
"buildInvocationId",
"buildStartedOn",
];
for (const f of fields) {
it(`rejects missing ${f}`, { timeout: 5000 }, () => {
expect(() =>
generateProvenance(makeInput({ [f]: "" }))
).toThrow(InvalidProvenanceError);
});
}
it("rejects malformed buildStartedOn", { timeout: 5000 }, () => {
expect(() =>
generateProvenance(makeInput({ buildStartedOn: "bad" }))
).toThrow(InvalidProvenanceError);
});
it("rejects malformed buildFinishedOn", { timeout: 5000 }, () => {
expect(() =>
generateProvenance(makeInput({ buildFinishedOn: "bad" }))
).toThrow(InvalidProvenanceError);
});
it("rejects finished-before-started", { timeout: 5000 }, () => {
expect(() =>
generateProvenance(
makeInput({
buildStartedOn: "2026-03-24T10:00:00Z",
buildFinishedOn: "2026-03-24T09:00:00Z",
})
)
).toThrow(InvalidProvenanceError);
});
});
});
describe("detectBuilderVersion fallback", () => {
it("falls back to runtime:unknown when probe returns no Bun", {
timeout: 5000,
}, () => {
// Under a real Bun runtime, `globalThis.Bun.version` is always
// populated — the fallback line is unreachable without an
// explicit probe override. The runtime branch returns
// `runtime: "unknown"`; this exercises that path.
try {
__setBunVersionProbeForTest(() => undefined);
const r = generateProvenance(makeInput());
const builder = r.predicate.runDetails.builder as {
version: Record<string, string>;
};
expect(builder.version).toEqual({ runtime: "unknown" });
} finally {
__setBunVersionProbeForTest(undefined);
}
});
it("falls back to runtime:unknown when Bun.version is empty", {
timeout: 5000,
}, () => {
try {
__setBunVersionProbeForTest(() => ({ version: "" }));
const r = generateProvenance(makeInput());
const builder = r.predicate.runDetails.builder as {
version: Record<string, string>;
};
expect(builder.version).toEqual({ runtime: "unknown" });
} finally {
__setBunVersionProbeForTest(undefined);
}
});
});
describe("serializeProvenance + hashProvenance", () => {
it("produces deterministic canonical JSON", { timeout: 5000 }, () => {
const a = serializeProvenance(generateProvenance(makeInput()));
const b = serializeProvenance(generateProvenance(makeInput()));
expect(a).toBe(b);
});
it("hashProvenance returns 64-char hex", { timeout: 5000 }, () => {
expect(hashProvenance(generateProvenance(makeInput()))).toMatch(
/^[a-f0-9]{64}$/
);
});
});

View File

@@ -0,0 +1,192 @@
import { InvalidProvenanceError } from "../errors/index.js";
import { noopSlsaOtelAdapter } from "../otel/slsa-otel.js";
import {
IN_TOTO_STATEMENT_TYPE,
type ProvenanceStatement,
type ResourceDescriptor,
SLSA_PREDICATE_TYPE,
} from "../schemas/provenance.schemas.js";
import { canonicalize } from "./canonical-json.js";
import { computeDigest } from "./digest.js";
import type { ProvenanceInput } from "./provenance.types.js";
const BUILD_TYPE =
"https://suaveplan.app/slsa/build-type/bun-monorepo/v1" as const;
/**
* Generate a SLSA v1.0 provenance statement for a published artifact.
*
* Validates input, constructs the in-toto/SLSA envelope, and emits a
* `slsa.provenance.generated` OTel event if an adapter is supplied.
*
* @throws InvalidProvenanceError if any required field is missing or malformed.
*/
export function generateProvenance(
input: ProvenanceInput
): ProvenanceStatement {
validateInput(input);
const subjectUri = `pkg:npm/${encodePackageName(input.packageName)}@${input.packageVersion}`;
const sourceDescriptor: ResourceDescriptor = {
uri: `git+${input.sourceRepo}@${input.sourceRef}`,
digest: { sha256: input.sourceCommit },
name: "source",
};
const resolvedDeps: ResourceDescriptor[] = [
sourceDescriptor,
...(input.resolvedDependencies ?? []),
];
const statement: ProvenanceStatement = {
_type: IN_TOTO_STATEMENT_TYPE,
subject: [
{
uri: subjectUri,
digest: { sha256: input.artifactDigest },
name: input.packageName,
},
],
predicateType: SLSA_PREDICATE_TYPE,
predicate: {
buildDefinition: {
buildType: BUILD_TYPE,
externalParameters: {
repository: input.sourceRepo,
ref: input.sourceRef,
},
internalParameters: {
environment: input.buildEnvironment ?? "unknown",
resolvedDependencies: resolvedDeps,
},
resolvedDependencies: resolvedDeps,
},
runDetails: {
builder: {
id: input.builderId,
version: input.runtimeVersion ?? detectBuilderVersion(),
},
metadata: {
invocationId: input.buildInvocationId,
startedOn: input.buildStartedOn,
...(input.buildFinishedOn
? { finishedOn: input.buildFinishedOn }
: {}),
},
},
},
};
const otel = input.otel ?? noopSlsaOtelAdapter;
otel.onProvenanceGenerated({
"slsa.statement.digest": hashProvenance(statement),
"slsa.builder.id": input.builderId,
"slsa.source.repository": input.sourceRepo,
});
return statement;
}
/** Serialize a provenance statement to canonical JSON (sorted keys). */
export function serializeProvenance(statement: ProvenanceStatement): string {
return canonicalize(statement);
}
/** SHA-256 hex digest of the canonical provenance encoding. */
export function hashProvenance(statement: ProvenanceStatement): string {
return computeDigest(canonicalize(statement));
}
function encodePackageName(name: string): string {
return name.replace("@", "%40");
}
const defaultBunVersionProbe = (): unknown =>
(globalThis as { Bun?: unknown }).Bun;
let bunVersionProbe: () => unknown = defaultBunVersionProbe;
/**
* Test-only hook: substitute the `globalThis.Bun` probe used by
* {@link detectBuilderVersion} so the fallback branch is reachable on
* runtimes where `globalThis.Bun.version` is always populated (Bun
* itself). Pass `undefined` to restore the default probe.
*/
export function __setBunVersionProbeForTest(
probe: (() => unknown) | undefined
): void {
bunVersionProbe = probe ?? defaultBunVersionProbe;
}
function detectBuilderVersion(): Record<string, string> {
const bun = bunVersionProbe() as { version?: string } | undefined | null;
if (
typeof bun === "object" &&
bun !== null &&
typeof bun.version === "string" &&
bun.version.length > 0
) {
return { bun: bun.version };
}
return { runtime: "unknown" };
}
function validateInput(input: ProvenanceInput): void {
requireString(input.packageName, "packageName");
requireString(input.packageVersion, "packageVersion");
requireString(input.artifactDigest, "artifactDigest");
requireString(input.sourceCommit, "sourceCommit");
requireString(input.sourceRepo, "sourceRepo");
requireString(input.sourceRef, "sourceRef");
requireString(input.builderId, "builderId");
requireString(input.buildInvocationId, "buildInvocationId");
requireString(input.buildStartedOn, "buildStartedOn");
const start = new Date(input.buildStartedOn);
if (Number.isNaN(start.getTime())) {
throw new InvalidProvenanceError(
"error.slsa.provenance.bad_started_on",
{
context: {
field: "buildStartedOn",
value: input.buildStartedOn,
},
}
);
}
if (input.buildFinishedOn !== undefined) {
const finish = new Date(input.buildFinishedOn);
if (Number.isNaN(finish.getTime())) {
throw new InvalidProvenanceError(
"error.slsa.provenance.bad_finished_on",
{
context: {
field: "buildFinishedOn",
value: input.buildFinishedOn,
},
}
);
}
if (finish < start) {
throw new InvalidProvenanceError(
"error.slsa.provenance.finished_before_started",
{
context: {
startedOn: input.buildStartedOn,
finishedOn: input.buildFinishedOn,
},
}
);
}
}
}
function requireString(value: unknown, field: string): void {
if (typeof value !== "string" || value.length === 0) {
throw new InvalidProvenanceError(
"error.slsa.provenance.missing_field",
{ context: { field } }
);
}
}

View File

@@ -0,0 +1,35 @@
import type { SlsaOtelAdapter } from "../otel/slsa-otel.js";
import type { ResourceDescriptor } from "../schemas/provenance.schemas.js";
/** Input for generating provenance. */
export interface ProvenanceInput {
/** Package name (e.g. `@suaveplan/utils`). */
packageName: string;
/** Package version. */
packageVersion: string;
/** Hex-encoded SHA-256 digest of the published artifact. */
artifactDigest: string;
/** Git commit SHA. */
sourceCommit: string;
/** Git repository URI. */
sourceRepo: string;
/** Git ref (branch or tag). */
sourceRef: string;
/** Builder identifier URI. */
builderId: string;
/** Unique build invocation ID (CI job ID). */
buildInvocationId: string;
/** ISO 8601 timestamp when the build started. */
buildStartedOn: string;
/** ISO 8601 timestamp when the build finished. */
buildFinishedOn?: string;
/** Build environment identifier (e.g. `gitlab-ci`). */
buildEnvironment?: string;
/** Additional resolved dependencies. */
resolvedDependencies?: ResourceDescriptor[];
/** Optional OTel adapter for audit-event emission. */
otel?: SlsaOtelAdapter;
/** Optional runtime version override. When omitted the runtime is
* auto-detected from the environment. */
runtimeVersion?: Record<string, string>;
}

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RekorInclusionError } from "../errors/index.js";
import { verifyRfc6962InclusionProof } from "./inclusion-proof.js";
describe("verifyRfc6962InclusionProof short-path", () => {
it("throws when the audit path is shorter than required", {
timeout: 5000,
}, () => {
// treeSize=2, logIndex=0 requires exactly one sibling; none provided.
expect(() =>
verifyRfc6962InclusionProof({
leafBytes: new Uint8Array([1]),
hashes: [],
logIndex: 0,
treeSize: 2,
expectedRootHex: "00".repeat(32),
})
).toThrow(RekorInclusionError);
});
});

View File

@@ -0,0 +1,79 @@
# inclusion-proof
## Purpose
Verifies an RFC 6962 Merkle inclusion proof as returned by Sigstore Rekor.
Given a leaf (the canonical body a Rekor entry was logged as), a sibling
audit path, the leaf's log index, the tree size at inclusion time, and the
signed root hash, recomputes the root and confirms it matches. This is the
cryptographic half of Rekor trust — once the root matches, the transparency
log has published the entry and cannot later lie about having done so.
## Features
- RFC 6962 hashing with explicit domain separators (`0x00` for leaves,
`0x01` for interior nodes).
- Correct handling of last-node-at-level carrying (odd tree sizes).
- Structured `RekorInclusionError` when `logIndex` is out of range.
- Exposes `rfc6962LeafHash` for batch / cache use-cases.
## Usage: Basic Example
```ts
import { verifyRfc6962InclusionProof } from "@suaveplan/slsa";
const { valid, computedRootHex } = verifyRfc6962InclusionProof({
leafBytes: new TextEncoder().encode(entry.body),
hashes: entry.verification.inclusionProof.hashes,
logIndex: entry.logIndex,
treeSize: entry.verification.inclusionProof.treeSize,
expectedRootHex: entry.verification.inclusionProof.rootHash,
});
```
## Usage: Advanced Example
```ts
import {
RekorClient,
rfc6962LeafHash,
verifyRfc6962InclusionProof,
} from "@suaveplan/slsa";
const client = new RekorClient();
const entry = await client.uploadHashedrekord(rekord);
const leafBytes = new TextEncoder().encode(entry.body);
const leafHex = rfc6962LeafHash(leafBytes);
log.info({ leafHex, logIndex: entry.logIndex }, "rekor entry hashed");
const proof = entry.verification?.inclusionProof;
if (proof) {
const { valid } = verifyRfc6962InclusionProof({
leafBytes,
hashes: proof.hashes,
logIndex: entry.logIndex,
treeSize: proof.treeSize,
expectedRootHex: proof.rootHash,
});
if (!valid) throw new Error("inclusion failed");
}
```
## API Reference
- `verifyRfc6962InclusionProof(args): { valid, computedRootHex }` — returns
whether the computed root matches. Throws `RekorInclusionError` when
`logIndex >= treeSize` or `logIndex < 0`.
- `rfc6962LeafHash(body: Uint8Array): string` — lowercase hex SHA-256 with
the `0x00` leaf prefix.
## Implementation Notes
The carry-up branch (odd-count level) handles trees where the last node at a
given level has no sibling — rather than duplicating, RFC 6962 propagates
the node up unchanged. Rekor's server-side proof generator will either
produce a shorter path for such leaves or include a duplicate; this
implementation tolerates the former. String comparison is
case-insensitive on the expected root to match Sigstore's mixed-case hex
output.

View File

@@ -0,0 +1,116 @@
import { sha256 } from "@suaveplan/crypto-hash";
import { concatBytes, toHex } from "@suaveplan/crypto-utils";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RekorInclusionError } from "../errors/index.js";
import {
rfc6962LeafHash,
verifyRfc6962InclusionProof,
} from "./inclusion-proof.js";
function leaf(body: Uint8Array): Uint8Array {
return sha256(concatBytes(new Uint8Array([0x00]), body));
}
function node(left: Uint8Array, right: Uint8Array): Uint8Array {
return sha256(concatBytes(new Uint8Array([0x01]), left, right));
}
describe("rfc6962LeafHash", () => {
it("applies 0x00 domain prefix", { timeout: 5000 }, () => {
const body = new TextEncoder().encode("hi");
expect(rfc6962LeafHash(body)).toBe(toHex(leaf(body)));
});
});
describe("verifyRfc6962InclusionProof", () => {
it("verifies a size-4 balanced tree at index 2", { timeout: 5000 }, () => {
const enc = new TextEncoder();
const bodies = ["a", "b", "c", "d"].map((s) => enc.encode(s));
const leaves = bodies.map(leaf);
const l01 = node(leaves[0]!, leaves[1]!);
const l23 = node(leaves[2]!, leaves[3]!);
const root = node(l01, l23);
// For leaf index 2: siblings are leaves[3] (right) and l01 (left)
const { valid, computedRootHex } = verifyRfc6962InclusionProof({
leafBytes: bodies[2]!,
hashes: [toHex(leaves[3]!), toHex(l01)],
logIndex: 2,
treeSize: 4,
expectedRootHex: toHex(root),
});
expect(valid).toBe(true);
expect(computedRootHex).toBe(toHex(root));
});
it("fails for wrong root", { timeout: 5000 }, () => {
const enc = new TextEncoder();
const bodies = ["a", "b"].map((s) => enc.encode(s));
const leaves = bodies.map(leaf);
const { valid } = verifyRfc6962InclusionProof({
leafBytes: bodies[0]!,
hashes: [toHex(leaves[1]!)],
logIndex: 0,
treeSize: 2,
expectedRootHex: "00".repeat(32),
});
expect(valid).toBe(false);
});
it("handles odd-count carry (treeSize=3)", { timeout: 5000 }, () => {
// In RFC 6962 with treeSize=3, node c has no sibling and is carried
// up unchanged; root = hash(0x01 || hash(l0,l1) || l2)
const enc = new TextEncoder();
const bodies = ["a", "b", "c"].map((s) => enc.encode(s));
const leaves = bodies.map(leaf);
const l01 = node(leaves[0]!, leaves[1]!);
const root = node(l01, leaves[2]!);
// Proof for leaf index 2: at level 0 no sibling (carry), at level 1
// sibling is l01 on the left.
const { valid } = verifyRfc6962InclusionProof({
leafBytes: bodies[2]!,
hashes: [toHex(l01)],
logIndex: 2,
treeSize: 3,
expectedRootHex: toHex(root),
});
expect(valid).toBe(true);
});
it("rejects out-of-range logIndex", { timeout: 5000 }, () => {
expect(() =>
verifyRfc6962InclusionProof({
leafBytes: new Uint8Array([1]),
hashes: [],
logIndex: 5,
treeSize: 3,
expectedRootHex: "00",
})
).toThrow(RekorInclusionError);
});
it("rejects negative logIndex", { timeout: 5000 }, () => {
expect(() =>
verifyRfc6962InclusionProof({
leafBytes: new Uint8Array([1]),
hashes: [],
logIndex: -1,
treeSize: 3,
expectedRootHex: "00",
})
).toThrow(RekorInclusionError);
});
it("single-leaf tree passes with empty path", { timeout: 5000 }, () => {
const enc = new TextEncoder();
const body = enc.encode("only");
const { valid } = verifyRfc6962InclusionProof({
leafBytes: body,
hashes: [],
logIndex: 0,
treeSize: 1,
expectedRootHex: toHex(leaf(body)),
});
expect(valid).toBe(true);
});
});

View File

@@ -0,0 +1,77 @@
import { sha256 } from "@suaveplan/crypto-hash";
import { concatBytes, fromHex, toHex } from "@suaveplan/crypto-utils";
import { RekorInclusionError } from "../errors/index.js";
/**
* Verify an RFC 6962 Merkle inclusion proof as returned by Rekor.
*
* Rekor proofs follow RFC 6962 Section 2.1:
* - Leaf hash = SHA-256(0x00 || leafCanonicalJSON)
* - Node hash = SHA-256(0x01 || left || right)
*
* The `hashes` array is the audit path from leaf up to just-below-root, in
* order from closest-to-leaf to closest-to-root. `logIndex` and `treeSize`
* determine whether each path step is applied on the left or right side.
*/
export function verifyRfc6962InclusionProof(args: {
leafBytes: Uint8Array;
hashes: readonly string[];
logIndex: number;
treeSize: number;
expectedRootHex: string;
}): { valid: boolean; computedRootHex: string } {
const { leafBytes, hashes, logIndex, treeSize, expectedRootHex } = args;
if (logIndex < 0 || logIndex >= treeSize) {
throw new RekorInclusionError(
"error.slsa.rekor.inclusion.out_of_range",
{ context: { logIndex, treeSize } }
);
}
let hash = sha256(concatBytes(new Uint8Array([0x00]), leafBytes));
let index = logIndex;
let size = treeSize;
let cursor = 0;
while (size > 1) {
const isLastAtLevel = index === size - 1 && (size & 1) === 1;
if (isLastAtLevel) {
// Node has no sibling at this level — carry up unchanged.
index = Math.floor(index / 2);
size = Math.ceil(size / 2);
continue;
}
const siblingHex = hashes[cursor];
if (siblingHex === undefined) {
throw new RekorInclusionError(
"error.slsa.rekor.inclusion.short_path",
{ context: { cursor, hashCount: hashes.length } }
);
}
cursor += 1;
const sibling = fromHex(siblingHex);
if ((index & 1) === 0) {
hash = sha256(concatBytes(new Uint8Array([0x01]), hash, sibling));
} else {
hash = sha256(concatBytes(new Uint8Array([0x01]), sibling, hash));
}
index = Math.floor(index / 2);
size = Math.ceil(size / 2);
}
const computed = toHex(hash);
return {
valid: computed === expectedRootHex.toLowerCase(),
computedRootHex: computed,
};
}
/**
* Compute the RFC 6962 leaf hash for a Rekor canonical-body string. Exposed
* for callers that want to pre-compute leaf hashes when batching many
* verifications against the same log.
*/
export function rfc6962LeafHash(body: Uint8Array): string {
return toHex(sha256(concatBytes(new Uint8Array([0x00]), body)));
}

View File

@@ -0,0 +1,10 @@
export {
rfc6962LeafHash,
verifyRfc6962InclusionProof,
} from "./inclusion-proof.js";
export {
type HashedrekordBody,
RekorClient,
type RekorClientOptions,
type RekorFetch,
} from "./rekor-client.js";

View File

@@ -0,0 +1,204 @@
import { sha256 } from "@suaveplan/crypto-hash";
import { concatBytes, toBase64, toHex } from "@suaveplan/crypto-utils";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DsseEnvelope } from "../schemas/attestation.schemas.js";
import { RekorClient, type RekorFetch } from "./rekor-client.js";
function leaf(body: Uint8Array): Uint8Array {
return sha256(concatBytes(new Uint8Array([0x00]), body));
}
function jsonResponse(
status: number,
payload: unknown
): ReturnType<RekorFetch> {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
statusText: "OK",
json: () => Promise.resolve(payload),
text: () => Promise.resolve(JSON.stringify(payload)),
});
}
function makeFullEntry(uuid: string, bodyBytes: Uint8Array): unknown {
const rootHex = toHex(leaf(bodyBytes));
return {
[uuid]: {
logIndex: 0,
logID: "log-1",
integratedTime: 1,
body: toBase64(bodyBytes),
verification: {
inclusionProof: {
logIndex: 0,
treeSize: 1,
rootHash: rootHex,
hashes: [],
checkpoint: "x\n\n-\nsig\n",
},
signedEntryTimestamp: "sigB64",
},
},
};
}
describe("RekorClient fetch defaulting + options.signal paths", () => {
it("forwards signal on uploadHashedrekord", { timeout: 5000 }, async () => {
let seenInit: { signal?: AbortSignal } | undefined;
const controller = new AbortController();
const bodyBytes = new TextEncoder().encode("s");
const fakeFetch: RekorFetch = (_url, init) => {
seenInit = init;
return jsonResponse(200, makeFullEntry("u-s", bodyBytes));
};
const client = new RekorClient({ fetch: fakeFetch });
await client.uploadHashedrekord(
{
signature: { content: "c", publicKey: { content: "p" } },
data: { hash: { algorithm: "sha256", value: "a" } },
},
{ signal: controller.signal }
);
expect(seenInit?.signal).toBe(controller.signal);
});
it("forwards signal on uploadIntoto", { timeout: 5000 }, async () => {
const envelope: DsseEnvelope = {
payloadType: "application/vnd.in-toto+json",
payload: toBase64(new TextEncoder().encode("{}")),
signatures: [{ sig: toBase64(new Uint8Array(64)) }],
};
const bodyBytes = new TextEncoder().encode("i");
const controller = new AbortController();
let seenSignal: AbortSignal | undefined;
const fakeFetch: RekorFetch = (_url, init) => {
seenSignal = init?.signal;
return jsonResponse(200, makeFullEntry("u-i2", bodyBytes));
};
const client = new RekorClient({ fetch: fakeFetch });
await client.uploadIntoto(envelope, { signal: controller.signal });
expect(seenSignal).toBe(controller.signal);
});
it("forwards signal on getEntryByUuid", { timeout: 5000 }, async () => {
const controller = new AbortController();
const bodyBytes = new TextEncoder().encode("u");
let seenSignal: AbortSignal | undefined;
const fakeFetch: RekorFetch = (_url, init) => {
seenSignal = init?.signal;
return jsonResponse(200, makeFullEntry("u-u", bodyBytes));
};
const client = new RekorClient({ fetch: fakeFetch });
await client.getEntryByUuid("u-u", { signal: controller.signal });
expect(seenSignal).toBe(controller.signal);
});
it("forwards signal on getEntryByLogIndex", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
const bodyBytes = new TextEncoder().encode("l");
let seenSignal: AbortSignal | undefined;
const fakeFetch: RekorFetch = (_url, init) => {
seenSignal = init?.signal;
return jsonResponse(200, makeFullEntry("u-l", bodyBytes));
};
const client = new RekorClient({ fetch: fakeFetch });
await client.getEntryByLogIndex(5, { signal: controller.signal });
expect(seenSignal).toBe(controller.signal);
});
it("normalises full verification envelope (checkpoint + signedEntryTimestamp + hashes default)", {
timeout: 5000,
}, async () => {
// Entry with inclusionProof.hashes missing -> defaults to []
const bodyBytes = new TextEncoder().encode("full");
const rootHex = toHex(leaf(bodyBytes));
const payload = {
"u-full": {
logIndex: 0,
logID: "log",
integratedTime: 1,
body: toBase64(bodyBytes),
verification: {
inclusionProof: {
logIndex: 0,
treeSize: 1,
rootHash: rootHex,
// hashes missing → default []
checkpoint: "cp\n",
},
signedEntryTimestamp: "setB64",
},
},
};
const fakeFetch: RekorFetch = () => jsonResponse(200, payload);
const client = new RekorClient({ fetch: fakeFetch });
const entry = await client.getEntryByUuid("u-full");
expect(entry.verification?.inclusionProof?.checkpoint).toBe("cp\n");
expect(entry.verification?.signedEntryTimestamp).toBe("setB64");
expect(entry.verification?.inclusionProof?.hashes).toEqual([]);
});
it("uses default globalThis.fetch when no fetch option supplied", {
timeout: 5000,
}, async () => {
const originalFetch = globalThis.fetch;
const bodyBytes = new TextEncoder().encode("g");
(globalThis as unknown as { fetch: RekorFetch }).fetch = (() =>
jsonResponse(
200,
makeFullEntry("u-def", bodyBytes)
)) as unknown as typeof fetch;
try {
const client = new RekorClient();
const entry = await client.getEntryByUuid("u-def");
expect(entry.uuid).toBe("u-def");
} finally {
globalThis.fetch = originalFetch;
}
});
it("normalises entry with verification but no inclusionProof", {
timeout: 5000,
}, async () => {
const bodyBytes = new TextEncoder().encode("noproof");
const payload = {
"u-np": {
logIndex: 0,
logID: "log",
integratedTime: 1,
body: toBase64(bodyBytes),
verification: {
signedEntryTimestamp: "setOnly",
},
},
};
const fakeFetch: RekorFetch = () => jsonResponse(200, payload);
const client = new RekorClient({ fetch: fakeFetch });
const entry = await client.getEntryByUuid("u-np");
expect(entry.verification?.inclusionProof).toBeUndefined();
expect(entry.verification?.signedEntryTimestamp).toBe("setOnly");
});
it("text() throwing in error path still raises RekorUploadError", {
timeout: 5000,
}, async () => {
const fakeFetch: RekorFetch = () =>
Promise.resolve({
ok: false,
status: 500,
statusText: "ISE",
json: () => Promise.resolve({}),
text: () => Promise.reject(new Error("body consumed")),
});
const client = new RekorClient({ fetch: fakeFetch });
await expect(
client.uploadHashedrekord({
signature: { content: "c", publicKey: { content: "p" } },
data: { hash: { algorithm: "sha256", value: "a" } },
})
).rejects.toThrow();
});
});

View File

@@ -0,0 +1,90 @@
# rekor-client
## Purpose
Minimal Sigstore Rekor REST client for the two upload shapes Suaveplan
cares about: `hashedrekord` v0.0.1 (when logging a raw artifact digest
with an ed25519 signature) and `intoto` v0.0.2 (when logging a DSSE-
wrapped provenance statement). Also provides entry retrieval by uuid or
logIndex and an inclusion-proof verifier that reuses the RFC 6962 audit
path helper in the sibling module. The client is designed around an
injectable `fetch`, so unit tests can run fully offline and non-Node
runtimes can plug in their own client.
## Features
- `hashedrekord` and `intoto` upload.
- `getEntryByUuid` / `getEntryByLogIndex` retrieval.
- `verifyInclusionProof` returning `{ valid, reason? }` rather than
throwing — easier to branch on in policy pipelines.
- AbortSignal support on every HTTP call.
- Structured `RekorUploadError` on non-2xx responses, with the status,
statusText, and response body attached as context.
- Response shape normalisation: Rekor returns `{ [uuid]: entry }`; the
client flattens that to the Zod-validated `RekorEntry` shape.
## Usage: Basic Example
```ts
import { RekorClient } from "@suaveplan/slsa";
const client = new RekorClient();
const entry = await client.uploadHashedrekord({
signature: {
content: base64Signature,
publicKey: { content: base64PublicKey },
},
data: { hash: { algorithm: "sha256", value: digestHex } },
});
```
## Usage: Advanced Example
```ts
import {
createSlsaOtelAdapter,
RekorClient,
verifyDsseEnvelope,
wrapInDsse,
} from "@suaveplan/slsa";
const otel = createSlsaOtelAdapter();
const client = new RekorClient({
baseUrl: "https://rekor.example.com",
fetch: myFetch,
otel,
});
const envelope = wrapInDsse(provenanceStatement, signatures);
const entry = await client.uploadIntoto(envelope, { signal: abort.signal });
const { valid, reason } = client.verifyInclusionProof(entry);
if (!valid) throw new Error(`rekor inclusion failed: ${reason ?? "?"}`);
```
## API Reference
- `new RekorClient(options?)` — fields: `baseUrl` (default
`https://rekor.sigstore.dev`), `fetch` (default `globalThis.fetch`),
`clock`, `otel`.
- `uploadHashedrekord(body, { signal }?)` — POST; returns `RekorEntry`.
- `uploadIntoto(envelope, { signal }?)` — POST; returns `RekorEntry`.
Computes the payload SHA-256 for the intoto v0.0.2 `content.hash` field
automatically.
- `getEntryByUuid(uuid, { signal }?)` — GET.
- `getEntryByLogIndex(logIndex, { signal }?)` — GET.
- `verifyInclusionProof(entry)` — reuses `verifyRfc6962InclusionProof`.
Emits the `slsa.rekor.inclusion_verified` OTel event.
- `RekorFetch` — minimal type describing the fetch shape the client uses.
- `HashedrekordBody` — strongly-typed spec body.
## Implementation Notes
Response parsing validates with a Zod schema rather than trusting the
remote server. A malformed response therefore surfaces as a
`RekorUploadError` (not an uncaught `TypeError`) which is far easier to
alert on. The `fetch` type is intentionally narrow — we only use `method`,
`headers`, `body`, `signal`, and the `.ok / .json() / .text()` shape of
the response — so stubbing in tests requires no DOM types. HTTP errors
capture the response body as `context.body` when it's still readable, but
silently discard if the body was already consumed by the browser fetch
implementation.

View File

@@ -0,0 +1,306 @@
import { sha256 } from "@suaveplan/crypto-hash";
import {
concatBytes,
fromBase64,
toBase64,
toHex,
} from "@suaveplan/crypto-utils";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RekorUploadError } from "../errors/index.js";
import type { DsseEnvelope } from "../schemas/attestation.schemas.js";
import {
type HashedrekordBody,
RekorClient,
type RekorFetch,
} from "./rekor-client.js";
function leaf(body: Uint8Array): Uint8Array {
return sha256(concatBytes(new Uint8Array([0x00]), body));
}
function node(left: Uint8Array, right: Uint8Array): Uint8Array {
return sha256(concatBytes(new Uint8Array([0x01]), left, right));
}
function jsonResponse(
status: number,
payload: unknown
): ReturnType<RekorFetch> {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? "OK" : "Bad",
json: () => Promise.resolve(payload),
text: () =>
Promise.resolve(
typeof payload === "string" ? payload : JSON.stringify(payload)
),
});
}
function textResponse(status: number, body: string): ReturnType<RekorFetch> {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
statusText: "Bad",
json: () => Promise.resolve({}),
text: () => Promise.resolve(body),
});
}
function makeEntry(uuid: string, bodyBytes: Uint8Array): unknown {
const rootHex = toHex(leaf(bodyBytes));
return {
[uuid]: {
logIndex: 0,
logID: "log-1",
integratedTime: 1,
body: toBase64(bodyBytes),
verification: {
inclusionProof: {
logIndex: 0,
treeSize: 1,
rootHash: rootHex,
hashes: [],
},
},
},
};
}
describe("RekorClient.uploadHashedrekord", () => {
it("POSTs to /api/v1/log/entries and returns normalised entry", {
timeout: 5000,
}, async () => {
const bodyBytes = new TextEncoder().encode("rekor-leaf");
let seenUrl = "";
let seenMethod = "";
const fakeFetch: RekorFetch = (url, init) => {
seenUrl = url;
seenMethod = init?.method ?? "";
return jsonResponse(201, makeEntry("u-1", bodyBytes));
};
const client = new RekorClient({ fetch: fakeFetch });
const entry = await client.uploadHashedrekord({
signature: {
content: "c",
publicKey: { content: "pk" },
},
data: { hash: { algorithm: "sha256", value: "abc" } },
} satisfies HashedrekordBody);
expect(seenUrl).toBe("https://rekor.sigstore.dev/api/v1/log/entries");
expect(seenMethod).toBe("POST");
expect(entry.uuid).toBe("u-1");
expect(entry.logIndex).toBe(0);
expect(entry.verification?.inclusionProof?.treeSize).toBe(1);
});
it("wraps non-2xx response in RekorUploadError", {
timeout: 5000,
}, async () => {
const fakeFetch: RekorFetch = () => textResponse(500, "boom");
const client = new RekorClient({ fetch: fakeFetch });
await expect(
client.uploadHashedrekord({
signature: { content: "c", publicKey: { content: "p" } },
data: { hash: { algorithm: "sha256", value: "a" } },
})
).rejects.toBeInstanceOf(RekorUploadError);
});
it("emits otel upload event", { timeout: 5000 }, async () => {
const bodyBytes = new TextEncoder().encode("L");
const fakeFetch: RekorFetch = () =>
jsonResponse(200, makeEntry("u-x", bodyBytes));
const events: unknown[] = [];
const otel = {
onProvenanceGenerated: () => {},
onProvenanceVerified: () => {},
onRekorUpload: (a: unknown) => events.push(a),
onRekorInclusionVerified: () => {},
onPolicyViolation: () => {},
};
const client = new RekorClient({ fetch: fakeFetch, otel });
await client.uploadHashedrekord({
signature: { content: "c", publicKey: { content: "p" } },
data: { hash: { algorithm: "sha256", value: "a" } },
});
expect(events).toHaveLength(1);
});
it("rejects responses with multiple top-level keys", {
timeout: 5000,
}, async () => {
const fakeFetch: RekorFetch = () => jsonResponse(200, { a: {}, b: {} });
const client = new RekorClient({ fetch: fakeFetch });
await expect(
client.uploadHashedrekord({
signature: { content: "c", publicKey: { content: "p" } },
data: { hash: { algorithm: "sha256", value: "a" } },
})
).rejects.toBeInstanceOf(RekorUploadError);
});
it("rejects schema-invalid entries", { timeout: 5000 }, async () => {
const fakeFetch: RekorFetch = () =>
jsonResponse(200, { "u-1": { logIndex: -5 } });
const client = new RekorClient({ fetch: fakeFetch });
await expect(
client.uploadHashedrekord({
signature: { content: "c", publicKey: { content: "p" } },
data: { hash: { algorithm: "sha256", value: "a" } },
})
).rejects.toBeInstanceOf(RekorUploadError);
});
});
describe("RekorClient.uploadIntoto", () => {
it("embeds payload sha256 in the request", { timeout: 5000 }, async () => {
const envelope: DsseEnvelope = {
payloadType: "application/vnd.in-toto+json",
payload: toBase64(new TextEncoder().encode("{}")),
signatures: [{ sig: toBase64(new Uint8Array(64)) }],
};
const bodyBytes = new TextEncoder().encode("x");
let seenBody: unknown;
const fakeFetch: RekorFetch = (_url, init) => {
seenBody = init?.body;
return jsonResponse(200, makeEntry("u-i", bodyBytes));
};
const client = new RekorClient({ fetch: fakeFetch });
await client.uploadIntoto(envelope);
const parsed = JSON.parse(seenBody as string);
expect(parsed.kind).toBe("intoto");
expect(parsed.spec.content.hash.algorithm).toBe("sha256");
expect(parsed.spec.content.hash.value).toBe(
toHex(sha256(fromBase64(envelope.payload)))
);
});
});
describe("RekorClient.getEntry*", () => {
it("getEntryByUuid GETs the right URL", { timeout: 5000 }, async () => {
const bodyBytes = new TextEncoder().encode("g");
let seenUrl = "";
const fakeFetch: RekorFetch = (url) => {
seenUrl = url;
return jsonResponse(200, makeEntry("abc", bodyBytes));
};
const client = new RekorClient({ fetch: fakeFetch });
const entry = await client.getEntryByUuid("abc");
expect(seenUrl).toBe(
"https://rekor.sigstore.dev/api/v1/log/entries/abc"
);
expect(entry.uuid).toBe("abc");
});
it("getEntryByLogIndex GETs with logIndex query", {
timeout: 5000,
}, async () => {
const bodyBytes = new TextEncoder().encode("g");
let seenUrl = "";
const fakeFetch: RekorFetch = (url) => {
seenUrl = url;
return jsonResponse(200, makeEntry("idx-1", bodyBytes));
};
const client = new RekorClient({ fetch: fakeFetch });
const entry = await client.getEntryByLogIndex(42);
expect(seenUrl).toBe(
"https://rekor.sigstore.dev/api/v1/log/entries?logIndex=42"
);
expect(entry.uuid).toBe("idx-1");
});
it("getEntryByUuid raises on non-2xx", { timeout: 5000 }, async () => {
const fakeFetch: RekorFetch = () => textResponse(404, "not found");
const client = new RekorClient({ fetch: fakeFetch });
await expect(client.getEntryByUuid("x")).rejects.toBeInstanceOf(
RekorUploadError
);
});
it("getEntryByLogIndex raises on non-2xx", { timeout: 5000 }, async () => {
const fakeFetch: RekorFetch = () => textResponse(404, "not found");
const client = new RekorClient({ fetch: fakeFetch });
await expect(client.getEntryByLogIndex(1)).rejects.toBeInstanceOf(
RekorUploadError
);
});
});
describe("RekorClient.verifyInclusionProof", () => {
it("verifies valid inclusion proof for a balanced tree", {
timeout: 5000,
}, async () => {
const enc = new TextEncoder();
const bodies = ["a", "b", "c", "d"].map((s) => enc.encode(s));
const leaves = bodies.map(leaf);
const l01 = node(leaves[0]!, leaves[1]!);
const l23 = node(leaves[2]!, leaves[3]!);
const root = node(l01, l23);
const entryResp = {
"u-2": {
logIndex: 2,
logID: "log",
integratedTime: 1,
body: toBase64(bodies[2]!),
verification: {
inclusionProof: {
logIndex: 2,
treeSize: 4,
rootHash: toHex(root),
hashes: [toHex(leaves[3]!), toHex(l01)],
},
},
},
};
const fakeFetch: RekorFetch = () => jsonResponse(200, entryResp);
const client = new RekorClient({ fetch: fakeFetch });
const entry = await client.getEntryByUuid("u-2");
const result = client.verifyInclusionProof(entry);
expect(result.valid).toBe(true);
});
it("reports missing proof", { timeout: 5000 }, async () => {
const entryResp = {
"u-3": {
logIndex: 0,
logID: "log",
integratedTime: 1,
body: toBase64(new Uint8Array([1])),
},
};
const fakeFetch: RekorFetch = () => jsonResponse(200, entryResp);
const client = new RekorClient({ fetch: fakeFetch });
const entry = await client.getEntryByUuid("u-3");
const result = client.verifyInclusionProof(entry);
expect(result.valid).toBe(false);
expect(result.reason).toContain("no inclusion proof");
});
it("reports mismatched root", { timeout: 5000 }, async () => {
const body = new TextEncoder().encode("x");
const entryResp = {
"u-4": {
logIndex: 0,
logID: "log",
integratedTime: 1,
body: toBase64(body),
verification: {
inclusionProof: {
logIndex: 0,
treeSize: 1,
rootHash: "ab".repeat(32),
hashes: [],
},
},
},
};
const fakeFetch: RekorFetch = () => jsonResponse(200, entryResp);
const client = new RekorClient({ fetch: fakeFetch });
const entry = await client.getEntryByUuid("u-4");
const result = client.verifyInclusionProof(entry);
expect(result.valid).toBe(false);
expect(result.reason).toContain("does not match");
});
});

View File

@@ -0,0 +1,291 @@
import { sha256 } from "@suaveplan/crypto-hash";
import { fromBase64, toHex } from "@suaveplan/crypto-utils";
import { RekorUploadError } from "../errors/index.js";
import {
noopSlsaOtelAdapter,
type SlsaOtelAdapter,
} from "../otel/slsa-otel.js";
import {
type DsseEnvelope,
type RekorEntry,
RekorEntrySchema,
} from "../schemas/attestation.schemas.js";
import { verifyRfc6962InclusionProof } from "./inclusion-proof.js";
/** Minimal subset of `fetch` that the client relies on — easier to fake. */
export type RekorFetch = (
input: string,
init?: {
method?: string;
headers?: Record<string, string>;
body?: string;
signal?: AbortSignal;
}
) => Promise<{
ok: boolean;
status: number;
statusText: string;
json: () => Promise<unknown>;
text: () => Promise<string>;
}>;
export interface RekorClientOptions {
/** Rekor base URL. Defaults to the public Sigstore instance. */
baseUrl?: string;
/** Injectable fetch implementation (tests + non-Node runtimes). */
fetch?: RekorFetch;
/** Injectable clock for future rate-limit bookkeeping. */
clock?: () => number;
/** OTel adapter for audit events. */
otel?: SlsaOtelAdapter;
}
/**
* Minimal Sigstore Rekor REST client covering hashedrekord + intoto upload
* and entry retrieval. All HTTP errors are wrapped in `RekorUploadError`
* with a `context` bag containing the status code and response body.
*/
export class RekorClient {
public readonly baseUrl: string;
protected readonly fetchImpl: RekorFetch;
protected readonly otel: SlsaOtelAdapter;
protected readonly clock: () => number;
public constructor(options: RekorClientOptions = {}) {
this.baseUrl = options.baseUrl ?? "https://rekor.sigstore.dev";
this.fetchImpl = options.fetch ?? (globalThis.fetch as RekorFetch);
this.otel = options.otel ?? noopSlsaOtelAdapter;
this.clock = options.clock ?? Date.now;
}
/** POST a `hashedrekord` v0.0.1 body to `/api/v1/log/entries`. */
public async uploadHashedrekord(
body: HashedrekordBody,
opts: { signal?: AbortSignal } = {}
): Promise<RekorEntry> {
return this.postEntry(
{
apiVersion: "0.1.0",
kind: "hashedrekord",
spec: body,
},
opts.signal
);
}
/** POST an `intoto` v0.0.2 DSSE envelope to `/api/v1/log/entries`. */
public async uploadIntoto(
envelope: DsseEnvelope,
opts: { signal?: AbortSignal } = {}
): Promise<RekorEntry> {
return this.postEntry(
{
apiVersion: "0.1.0",
kind: "intoto",
spec: {
content: {
envelope,
hash: {
algorithm: "sha256",
value: digestEnvelopePayload(envelope),
},
},
},
},
opts.signal
);
}
/** GET `/api/v1/log/entries/{uuid}`. */
public async getEntryByUuid(
uuid: string,
opts: { signal?: AbortSignal } = {}
): Promise<RekorEntry> {
const res = await this.fetchImpl(
`${this.baseUrl}/api/v1/log/entries/${encodeURIComponent(uuid)}`,
{ method: "GET", ...(opts.signal ? { signal: opts.signal } : {}) }
);
if (!res.ok) throw await rekorHttpError(res, { uuid });
const envelope = (await res.json()) as Record<string, unknown>;
return parseEntryEnvelope(envelope);
}
/** GET `/api/v1/log/entries?logIndex=N`. */
public async getEntryByLogIndex(
logIndex: number,
opts: { signal?: AbortSignal } = {}
): Promise<RekorEntry> {
const res = await this.fetchImpl(
`${this.baseUrl}/api/v1/log/entries?logIndex=${logIndex}`,
{ method: "GET", ...(opts.signal ? { signal: opts.signal } : {}) }
);
if (!res.ok) throw await rekorHttpError(res, { logIndex });
const envelope = (await res.json()) as Record<string, unknown>;
return parseEntryEnvelope(envelope);
}
/**
* Verify the inclusion proof on a Rekor entry against its signed root.
* Returns `{ valid, reason? }` rather than throwing so callers can react
* to an invalid proof without a try/catch.
*/
public verifyInclusionProof(entry: RekorEntry): {
valid: boolean;
reason?: string;
} {
const proof = entry.verification?.inclusionProof;
if (!proof) {
return { valid: false, reason: "entry has no inclusion proof" };
}
const bodyBytes = fromBase64(entry.body);
const { valid, computedRootHex } = verifyRfc6962InclusionProof({
leafBytes: bodyBytes,
hashes: proof.hashes,
logIndex: entry.logIndex,
treeSize: proof.treeSize,
expectedRootHex: proof.rootHash,
});
this.otel.onRekorInclusionVerified({
"slsa.result.valid": valid,
"slsa.rekor.uuid": entry.uuid,
"slsa.rekor.log_index": entry.logIndex,
"slsa.rekor.tree_size": proof.treeSize,
});
if (valid) return { valid };
return {
valid,
reason: `computed root ${computedRootHex} does not match ${proof.rootHash}`,
};
}
protected async postEntry(
body: unknown,
signal?: AbortSignal
): Promise<RekorEntry> {
const res = await this.fetchImpl(`${this.baseUrl}/api/v1/log/entries`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
...(signal ? { signal } : {}),
});
if (!res.ok) throw await rekorHttpError(res, {});
const envelope = (await res.json()) as Record<string, unknown>;
const entry = parseEntryEnvelope(envelope);
this.otel.onRekorUpload({
"slsa.rekor.uuid": entry.uuid,
"slsa.rekor.log_index": entry.logIndex,
});
return entry;
}
}
/** Rekor hashedrekord v0.0.1 `spec`. */
export interface HashedrekordBody {
signature: {
content: string;
publicKey: { content: string };
};
data: {
hash: { algorithm: "sha256"; value: string };
};
}
async function rekorHttpError(
res: { status: number; statusText: string; text(): Promise<string> },
context: Record<string, unknown>
): Promise<RekorUploadError> {
let body = "";
try {
body = await res.text();
} catch {
// If response body already consumed we still raise with the status
}
return new RekorUploadError("error.slsa.rekor.http", {
context: {
status: res.status,
statusText: res.statusText,
body,
...context,
},
});
}
/**
* Rekor's response shape is `{ [uuid]: <entry> }`. This normalises it into
* our `RekorEntry` shape and validates it against the Zod schema.
*/
function parseEntryEnvelope(raw: Record<string, unknown>): RekorEntry {
const keys = Object.keys(raw);
if (keys.length !== 1) {
throw new RekorUploadError("error.slsa.rekor.bad_response", {
context: { keyCount: keys.length },
});
}
const uuid = keys[0] as string;
const entry = raw[uuid] as Record<string, unknown>;
const inclusionProof = (
entry["verification"] as Record<string, unknown> | undefined
)?.["inclusionProof"] as Record<string, unknown> | undefined;
const normalised: Record<string, unknown> = {
uuid,
logIndex: entry["logIndex"],
logID: entry["logID"],
integratedTime: entry["integratedTime"],
body: entry["body"],
...(entry["verification"] !== undefined
? {
verification: {
...(inclusionProof !== undefined
? {
inclusionProof: {
logIndex: inclusionProof["logIndex"],
treeSize: inclusionProof["treeSize"],
rootHash: inclusionProof["rootHash"],
hashes: inclusionProof["hashes"] ?? [],
...(inclusionProof["checkpoint"] !==
undefined
? {
checkpoint:
inclusionProof["checkpoint"],
}
: {}),
},
}
: {}),
...((entry["verification"] as Record<string, unknown>)?.[
"signedEntryTimestamp"
] !== undefined
? {
signedEntryTimestamp: (
entry["verification"] as Record<
string,
unknown
>
)["signedEntryTimestamp"],
}
: {}),
},
}
: {}),
};
const parsed = RekorEntrySchema.safeParse(normalised);
if (!parsed.success) {
throw new RekorUploadError("error.slsa.rekor.bad_response", {
context: {
issues: parsed.error.issues.map(
(i) => `${i.path.join(".")}: ${i.message}`
),
},
});
}
return parsed.data;
}
function digestEnvelopePayload(envelope: DsseEnvelope): string {
// Rekor intoto v0.0.2 requires the sha256 of the decoded payload, as hex.
const bytes = fromBase64(envelope.payload);
return toHex(sha256(bytes));
}

View File

@@ -0,0 +1,7 @@
export type { GenerateSbomOptions } from "./sbom.js";
export {
buildPurl,
generateSbom,
hashSbom,
serializeSbom,
} from "./sbom.js";

View File

@@ -0,0 +1,83 @@
# sbom
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Generates CycloneDX 1.5 compatible Software Bill of Materials documents for
packages in Suaveplan. An SBOM lists every component a package
depends on, keyed by `purl`, enabling downstream vulnerability scanners,
license audits, and provenance correlation. Alongside generation, this
module exports canonical serialisation, SHA-256 digesting, and `purl`
construction so other packages (notably the publish pipeline) can
reproduce the same output bytes.
## Features
- CycloneDX 1.5 schema adherence, validated by the sibling Zod schema.
- Scoped-package `purl` construction (`pkg:npm/%40scope/name@ver`).
- Workspace/catalog placeholder resolution (`workspace:*` → `*`,
`catalog:key` → `catalog-key`).
- Deterministic sort-key serialisation via `canonicalize`.
- Injectable `clock` and `serial` factories for deterministic tests.
## Usage: Basic Example
```ts
import { generateSbom } from "@suaveplan/slsa";
const sbom = generateSbom({
packageName: "@suaveplan/utils",
packageVersion: "1.2.3",
dependencies: { lodash: "4.17.21" },
});
```
## Usage: Advanced Example
```ts
import { generateSbom, hashSbom, serializeSbom } from "@suaveplan/slsa";
const sbom = generateSbom(
{
packageName: "@suaveplan/app",
packageVersion: "2.0.0",
packageLicense: "MIT",
componentType: "application",
dependencies: {
"@suaveplan/crypto": "workspace:*",
vitest: "catalog:testing",
},
},
{
clock: () => 0,
serial: () => "urn:uuid:deadbeef-dead-beef-dead-beefdeadbeef",
}
);
console.log(hashSbom(sbom)); // stable across runs
await fs.writeFile("sbom.json", serializeSbom(sbom));
```
## API Reference
- `generateSbom(input, opts?): Sbom` — constructs the SBOM. Throws
`InvalidSbomError` when `packageName`, `packageVersion`, or `dependencies`
is missing.
- `GenerateSbomOptions { clock?, serial? }` — deterministic seam.
- `serializeSbom(sbom): string` — canonical JSON encoding.
- `hashSbom(sbom): string` — SHA-256 hex digest of the canonical encoding.
- `buildPurl(name, version): string` — constructs a `purl` for either a
scoped or unscoped npm package.
## Implementation Notes
`crypto.randomUUID` is the default serial source; the fallback construction
using `Math.random` is retained only to keep the module universally
importable — runtimes that land in the fallback should be considered a
configuration bug and should inject `serial` explicitly. `resolveVersion`
maps catalog references to a stable `catalog-<key>` form rather than the
resolved pin because catalog resolution happens at publish time, not at
SBOM generation time; the downstream publisher can post-process the
document if stricter pins are required.

View File

@@ -0,0 +1,187 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { InvalidSbomError } from "../errors/index.js";
import type { SbomInput } from "../schemas/sbom.schemas.js";
import { buildPurl, generateSbom, hashSbom, serializeSbom } from "./sbom.js";
function makeInput(overrides?: Partial<SbomInput>): SbomInput {
return {
packageName: "@suaveplan/utils",
packageVersion: "1.2.3",
dependencies: {
lodash: "4.17.21",
"@suaveplan/logger": "1.0.5",
},
...overrides,
};
}
describe("generateSbom", () => {
it("returns CycloneDX 1.5 SBOM", { timeout: 5000 }, () => {
const sbom = generateSbom(makeInput());
expect(sbom.bomFormat).toBe("CycloneDX");
expect(sbom.specVersion).toBe("1.5");
expect(sbom.version).toBe(1);
expect(sbom.serialNumber).toMatch(/^urn:uuid:/);
});
it("populates root component metadata", { timeout: 5000 }, () => {
const sbom = generateSbom(
makeInput({
packageDescription: "Utility library",
packageLicense: "MIT",
})
);
expect(sbom.metadata.component.name).toBe("@suaveplan/utils");
expect(sbom.metadata.component.version).toBe("1.2.3");
expect(sbom.metadata.component.description).toBe("Utility library");
expect(sbom.metadata.component.license).toBe("MIT");
});
it("populates tool metadata", { timeout: 5000 }, () => {
const sbom = generateSbom(makeInput());
expect(sbom.metadata.tools).toHaveLength(1);
expect(sbom.metadata.tools[0]?.name).toBe("@suaveplan/slsa");
});
it("lists every dependency", { timeout: 5000 }, () => {
const sbom = generateSbom(makeInput());
expect(sbom.components).toHaveLength(2);
const names = sbom.components.map((c) => c.name).sort();
expect(names).toEqual(["@suaveplan/logger", "lodash"]);
});
it("builds purls for scoped packages", { timeout: 5000 }, () => {
const sbom = generateSbom(makeInput());
const scoped = sbom.components.find(
(c) => c.name === "@suaveplan/logger"
);
expect(scoped?.purl).toBe("pkg:npm/%40suaveplan/logger@1.0.5");
});
it("builds purls for unscoped packages", { timeout: 5000 }, () => {
const sbom = generateSbom(makeInput());
expect(sbom.components.find((c) => c.name === "lodash")?.purl).toBe(
"pkg:npm/lodash@4.17.21"
);
});
it("resolves workspace:*", { timeout: 5000 }, () => {
const sbom = generateSbom(
makeInput({ dependencies: { "@s/x": "workspace:*" } })
);
expect(sbom.components[0]?.version).toBe("*");
});
it("resolves catalog refs", { timeout: 5000 }, () => {
const sbom = generateSbom(
makeInput({ dependencies: { vitest: "catalog:testing" } })
);
expect(sbom.components[0]?.version).toBe("catalog-testing");
});
it("supports application component type", { timeout: 5000 }, () => {
const sbom = generateSbom(makeInput({ componentType: "application" }));
expect(sbom.metadata.component.type).toBe("application");
});
it("empty dependencies produce empty component list", {
timeout: 5000,
}, () => {
expect(
generateSbom(makeInput({ dependencies: {} })).components
).toHaveLength(0);
});
it("uses injected clock for timestamp", { timeout: 5000 }, () => {
const sbom = generateSbom(makeInput(), { clock: () => 0 });
expect(sbom.metadata.timestamp).toBe("1970-01-01T00:00:00.000Z");
});
it("uses injected serial", { timeout: 5000 }, () => {
const sbom = generateSbom(makeInput(), {
serial: () => "urn:uuid:test",
});
expect(sbom.serialNumber).toBe("urn:uuid:test");
});
describe("validation", () => {
it("rejects missing packageName", { timeout: 5000 }, () => {
expect(() => generateSbom(makeInput({ packageName: "" }))).toThrow(
InvalidSbomError
);
});
it("rejects missing packageVersion", { timeout: 5000 }, () => {
expect(() =>
generateSbom(makeInput({ packageVersion: "" }))
).toThrow(InvalidSbomError);
});
it("rejects null dependencies", { timeout: 5000 }, () => {
expect(() =>
generateSbom({
...makeInput(),
dependencies: null as unknown as Record<string, string>,
})
).toThrow(InvalidSbomError);
});
});
});
describe("buildPurl", () => {
it("scoped", { timeout: 5000 }, () => {
expect(buildPurl("@s/x", "1.0.0")).toBe("pkg:npm/%40s/x@1.0.0");
});
it("unscoped", { timeout: 5000 }, () => {
expect(buildPurl("x", "1.0.0")).toBe("pkg:npm/x@1.0.0");
});
});
describe("serializeSbom", () => {
it("produces canonical JSON", { timeout: 5000 }, () => {
const a = serializeSbom(
generateSbom(makeInput(), {
clock: () => 0,
serial: () => "urn:uuid:a",
})
);
const b = serializeSbom(
generateSbom(makeInput(), {
clock: () => 0,
serial: () => "urn:uuid:a",
})
);
expect(a).toBe(b);
});
});
describe("hashSbom", () => {
it("returns 64-char hex", { timeout: 5000 }, () => {
expect(hashSbom(generateSbom(makeInput()))).toMatch(/^[a-f0-9]{64}$/);
});
});
describe("defaultSerial", () => {
it("uses randomUUID when present", { timeout: 5000 }, () => {
const sbom = generateSbom(makeInput());
expect(sbom.serialNumber).toMatch(/^urn:uuid:[0-9a-f-]{36}$/i);
});
it("falls back when randomUUID is unavailable", { timeout: 5000 }, () => {
const original = globalThis.crypto;
// Deliberately remove randomUUID to exercise fallback
Object.defineProperty(globalThis, "crypto", {
value: { ...original, randomUUID: undefined },
configurable: true,
});
try {
const sbom = generateSbom(makeInput());
expect(sbom.serialNumber).toMatch(/^urn:uuid:/);
} finally {
Object.defineProperty(globalThis, "crypto", {
value: original,
configurable: true,
});
}
});
});

View File

@@ -0,0 +1,133 @@
import { InvalidSbomError } from "../errors/index.js";
import { canonicalize } from "../provenance/canonical-json.js";
import { computeDigest } from "../provenance/digest.js";
import type {
Sbom,
SbomComponent,
SbomInput,
} from "../schemas/sbom.schemas.js";
const TOOL_NAME = "@suaveplan/slsa";
const TOOL_VERSION = "0.1.0";
/** Options accepted by `generateSbom`. */
export interface GenerateSbomOptions {
/** Injectable clock, defaults to `Date.now()`. */
clock?: () => number;
/** Injectable serial-number factory (must return a `urn:uuid:`-prefixed id). */
serial?: () => string;
}
/**
* Generate a CycloneDX 1.5 SBOM for a package.
*
* @throws InvalidSbomError when required fields are missing.
*/
export function generateSbom(
input: SbomInput,
opts: GenerateSbomOptions = {}
): Sbom {
validateInput(input);
const clock = opts.clock ?? Date.now;
const serial = opts.serial ?? defaultSerial;
const rootComponent: SbomComponent = {
type: input.componentType ?? "library",
name: input.packageName,
version: input.packageVersion,
purl: buildPurl(input.packageName, input.packageVersion),
...(input.packageDescription
? { description: input.packageDescription }
: {}),
...(input.packageLicense ? { license: input.packageLicense } : {}),
};
const components: SbomComponent[] = Object.entries(input.dependencies).map(
([name, version]) => {
const resolved = resolveVersion(version);
return {
type: "library",
name,
version: resolved,
purl: buildPurl(name, resolved),
};
}
);
return {
bomFormat: "CycloneDX",
specVersion: "1.5",
serialNumber: serial(),
version: 1,
metadata: {
timestamp: new Date(clock()).toISOString(),
tools: [{ name: TOOL_NAME, version: TOOL_VERSION }],
component: rootComponent,
},
components,
};
}
/** Canonical JSON serialisation of an SBOM (sorted keys). */
export function serializeSbom(sbom: Sbom): string {
return canonicalize(sbom);
}
/** SHA-256 hex digest of the canonical SBOM encoding. */
export function hashSbom(sbom: Sbom): string {
return computeDigest(canonicalize(sbom));
}
/** Build a Package URL (purl) for an npm package. */
export function buildPurl(name: string, version: string): string {
if (name.startsWith("@")) {
const slash = name.indexOf("/");
const scope = name.slice(0, slash);
const pkg = name.slice(slash + 1);
return `pkg:npm/${encodeURIComponent(scope)}/${pkg}@${version}`;
}
return `pkg:npm/${name}@${version}`;
}
function resolveVersion(version: string): string {
if (version.startsWith("workspace:")) {
return version.replace("workspace:", "");
}
if (version.startsWith("catalog:")) {
return version.replace("catalog:", "catalog-");
}
return version;
}
function validateInput(input: SbomInput): void {
if (!input.packageName || typeof input.packageName !== "string") {
throw new InvalidSbomError("error.slsa.sbom.missing_field", {
context: { field: "packageName" },
});
}
if (!input.packageVersion || typeof input.packageVersion !== "string") {
throw new InvalidSbomError("error.slsa.sbom.missing_field", {
context: { field: "packageVersion" },
});
}
if (input.dependencies === null || typeof input.dependencies !== "object") {
throw new InvalidSbomError("error.slsa.sbom.missing_field", {
context: { field: "dependencies" },
});
}
}
function defaultSerial(): string {
// Prefer crypto.randomUUID (available in Node, Bun, browsers >= 2023)
const uuid = (
globalThis.crypto as { randomUUID?: () => string } | undefined
)?.randomUUID?.();
if (uuid) return `urn:uuid:${uuid}`;
// Fallback — should not normally be reached in supported runtimes
const r = Math.random().toString(16).slice(2).padStart(32, "0");
return `urn:uuid:${r.slice(0, 8)}-${r.slice(8, 12)}-${r.slice(
12,
16
)}-${r.slice(16, 20)}-${r.slice(20, 32)}`;
}

View File

@@ -0,0 +1,42 @@
import z from "zod";
export const DsseSignatureSchema = z.object({
keyid: z.string().optional(),
sig: z.string().min(1),
});
export const DsseEnvelopeSchema = z.object({
payloadType: z.string().min(1),
payload: z.string().min(1),
signatures: z.array(DsseSignatureSchema).min(1),
});
export const AttestationSchema = DsseEnvelopeSchema;
export const InclusionProofSchema = z.object({
logIndex: z.number().int().nonnegative(),
treeSize: z.number().int().positive(),
rootHash: z.string().min(1),
hashes: z.array(z.string()),
checkpoint: z.string().optional(),
});
export const RekorEntrySchema = z.object({
uuid: z.string().min(1),
logIndex: z.number().int().nonnegative(),
logID: z.string().min(1),
integratedTime: z.number().int().positive(),
body: z.string().min(1),
verification: z
.object({
inclusionProof: InclusionProofSchema.optional(),
signedEntryTimestamp: z.string().optional(),
})
.optional(),
});
export type DsseSignature = z.infer<typeof DsseSignatureSchema>;
export type DsseEnvelope = z.infer<typeof DsseEnvelopeSchema>;
export type Attestation = z.infer<typeof AttestationSchema>;
export type InclusionProof = z.infer<typeof InclusionProofSchema>;
export type RekorEntry = z.infer<typeof RekorEntrySchema>;

View File

@@ -0,0 +1,64 @@
export {
type Attestation,
AttestationSchema,
type DsseEnvelope,
DsseEnvelopeSchema,
type DsseSignature,
DsseSignatureSchema,
type InclusionProof,
InclusionProofSchema,
type RekorEntry,
RekorEntrySchema,
} from "./attestation.schemas.js";
export { parseProvenance, parseSbom } from "./parse.js";
export {
type BuildDefinition,
BuildDefinitionSchema,
type Builder,
BuilderSchema,
type BuildMetadata,
BuildMetadataSchema,
type DigestAlgorithm,
DigestAlgorithmSchema,
type DigestSet,
DigestSetSchema,
type ExternalParameters,
ExternalParametersSchema,
IN_TOTO_STATEMENT_TYPE,
type InternalParameters,
InternalParametersSchema,
type ProvenanceStatement,
ProvenanceStatementSchema,
type ResourceDescriptor,
ResourceDescriptorSchema,
type RunDetails,
RunDetailsSchema,
SLSA_PREDICATE_TYPE,
type SlsaProvenancePredicate,
SlsaProvenancePredicateSchema,
} from "./provenance.schemas.js";
export {
type Sbom,
type SbomComponent,
SbomComponentSchema,
type SbomComponentType,
SbomComponentTypeSchema,
SbomHashSchema,
type SbomInput,
SbomInputSchema,
SbomSchema,
} from "./sbom.schemas.js";
export {
type AssessmentEnvironment,
AssessmentEnvironmentSchema,
type SlsaAssessment,
SlsaAssessmentSchema,
type SlsaLevel,
SlsaLevelSchema,
type VerificationCheck,
VerificationCheckSchema,
type VerificationPolicy,
VerificationPolicySchema,
type VerificationResult,
VerificationResultSchema,
} from "./verification.schemas.js";

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { InvalidProvenanceError } from "../errors/index.js";
import { parseProvenance } from "./parse.js";
describe("parseProvenance formatIssues root path", () => {
it("formats root-level issues as <root>", { timeout: 5000 }, () => {
try {
parseProvenance(42 as unknown);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(InvalidProvenanceError);
const e = err as InvalidProvenanceError & {
context?: { issues?: string[] };
};
const issues = e.context?.issues ?? [];
expect(issues.some((i) => i.startsWith("<root>:"))).toBe(true);
}
});
});

View File

@@ -0,0 +1,67 @@
# parse
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Runtime-safe entry points that turn untrusted JSON or objects into strongly
typed, schema-validated SLSA artifacts. Every other module in the package
treats its inputs as already valid — these two helpers (`parseProvenance`,
`parseSbom`) are the boundary between "unvalidated wire input" and the rest of
the codebase. Both throw structured `SlsaError` subclasses with a
`context.issues` array of path-formatted Zod messages so callers can surface
precise validation feedback without parsing Zod internals.
## Features
- Accepts either a JSON string or a pre-parsed object.
- Validates against the canonical Zod schemas for in-toto/SLSA provenance and
CycloneDX 1.5 SBOMs.
- Structured error output: `err.code === "SLSA.INVALID_PROVENANCE"` /
`"SLSA.INVALID_SBOM"`.
- Captures the JSON parse error as `err.cause` when input is a malformed
string.
## Usage: Basic Example
```ts
import { parseProvenance } from "@suaveplan/slsa";
const statement = parseProvenance(wireJson);
console.log(statement.predicate.runDetails.builder.id);
```
## Usage: Advanced Example
```ts
import { parseSbom, SlsaError, SLSA_CODES } from "@suaveplan/slsa";
try {
const sbom = parseSbom(await fs.readFile("sbom.json", "utf8"));
audit.record({ serial: sbom.serialNumber, components: sbom.components.length });
} catch (err) {
if (err instanceof SlsaError && err.code === SLSA_CODES.INVALID_SBOM) {
log.warn({ issues: err.context?.issues }, "SBOM rejected");
return Response.json({ error: "invalid sbom" }, { status: 400 });
}
throw err;
}
```
## API Reference
- `parseProvenance(input: string | unknown): ProvenanceStatement` — validates
against `ProvenanceStatementSchema`. Throws `InvalidProvenanceError` on any
failure (malformed JSON, wrong `_type`, missing digests, etc.).
- `parseSbom(input: string | unknown): Sbom` — validates against `SbomSchema`.
Throws `InvalidSbomError`.
## Implementation Notes
JSON parsing and schema validation are kept distinct so that a malformed JSON
string surfaces a separate error (with the `SyntaxError` attached as `cause`)
from a structurally valid but schema-invalid document. Zod's `safeParse` is
used — we never throw a `ZodError` out of the public API. Issues are flattened
to `path: message` strings because consumers often log `context.issues`
directly and a JSON-stringified Zod issue tree is unfriendly in log pipelines.

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
InvalidProvenanceError,
InvalidSbomError,
SLSA_CODES,
type SlsaError,
} from "../errors/index.js";
import { parseProvenance, parseSbom } from "./parse.js";
const validProv = {
_type: "https://in-toto.io/Statement/v1",
subject: [
{
uri: "pkg:npm/%40suaveplan/utils@1.0.0",
digest: { sha256: "abc" },
},
],
predicateType: "https://slsa.dev/provenance/v1",
predicate: {
buildDefinition: {
buildType: "https://suaveplan.app/build/v1",
externalParameters: {
repository: "https://gitlab.fq.io/s/m",
ref: "refs/heads/main",
},
internalParameters: { environment: "gitlab-ci" },
},
runDetails: {
builder: { id: "https://gitlab.fq.io/s/m/-/runners/1" },
metadata: {
invocationId: "ci-1",
startedOn: "2026-04-19T10:00:00Z",
},
},
},
};
const validSbom = {
bomFormat: "CycloneDX",
specVersion: "1.5",
serialNumber: "urn:uuid:00000000-0000-0000-0000-000000000001",
version: 1,
metadata: {
timestamp: "2026-04-19T10:00:00Z",
tools: [{ name: "@suaveplan/slsa", version: "0.1.0" }],
component: {
type: "library",
name: "@suaveplan/utils",
version: "1.0.0",
},
},
components: [],
};
describe("parseProvenance", () => {
it("accepts valid object", { timeout: 5000 }, () => {
const out = parseProvenance(validProv);
expect(out._type).toBe("https://in-toto.io/Statement/v1");
});
it("accepts valid JSON string", { timeout: 5000 }, () => {
const out = parseProvenance(JSON.stringify(validProv));
expect(out.predicate.runDetails.builder.id).toContain("runners/1");
});
it("rejects malformed JSON with cause", { timeout: 5000 }, () => {
try {
parseProvenance("not json");
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(InvalidProvenanceError);
expect((err as SlsaError).code).toBe(SLSA_CODES.INVALID_PROVENANCE);
expect((err as SlsaError).cause).toBeInstanceOf(Error);
}
});
it("rejects schema-invalid input with issues", { timeout: 5000 }, () => {
const bad = { ...validProv, _type: "wrong" };
try {
parseProvenance(bad);
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(InvalidProvenanceError);
expect(
(err as SlsaError).context?.["issues"] as string[]
).toBeInstanceOf(Array);
}
});
});
describe("parseSbom", () => {
it("accepts valid object", { timeout: 5000 }, () => {
const out = parseSbom(validSbom);
expect(out.bomFormat).toBe("CycloneDX");
});
it("accepts valid JSON string", { timeout: 5000 }, () => {
const out = parseSbom(JSON.stringify(validSbom));
expect(out.components).toEqual([]);
});
it("rejects malformed JSON", { timeout: 5000 }, () => {
expect(() => parseSbom("{bad")).toThrow(InvalidSbomError);
});
it("rejects schema-invalid SBOM", { timeout: 5000 }, () => {
const bad = { ...validSbom, bomFormat: "SPDX" };
try {
parseSbom(bad);
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(InvalidSbomError);
expect((err as SlsaError).code).toBe(SLSA_CODES.INVALID_SBOM);
}
});
});

View File

@@ -0,0 +1,63 @@
import type { z } from "zod";
import { InvalidProvenanceError, InvalidSbomError } from "../errors/index.js";
import {
type ProvenanceStatement,
ProvenanceStatementSchema,
} from "./provenance.schemas.js";
import { type Sbom, SbomSchema } from "./sbom.schemas.js";
/**
* Parse a JSON string or object into a validated `ProvenanceStatement`.
* Throws `InvalidProvenanceError` with a structured `context.issues` array
* when the input fails schema validation.
*/
export function parseProvenance(input: string | unknown): ProvenanceStatement {
const raw = coerce(
input,
InvalidProvenanceError,
"error.slsa.invalid_json"
);
const result = ProvenanceStatementSchema.safeParse(raw);
if (!result.success) {
throw new InvalidProvenanceError("error.slsa.invalid_provenance", {
context: { issues: formatIssues(result.error) },
});
}
return result.data;
}
/** Parse a JSON string or object into a validated CycloneDX `Sbom`. */
export function parseSbom(input: string | unknown): Sbom {
const raw = coerce(input, InvalidSbomError, "error.slsa.invalid_json");
const result = SbomSchema.safeParse(raw);
if (!result.success) {
throw new InvalidSbomError("error.slsa.invalid_sbom", {
context: { issues: formatIssues(result.error) },
});
}
return result.data;
}
type SlsaErrorCtor = new (
messageKey: string,
options?: { context?: Record<string, unknown>; cause?: Error }
) => Error;
function coerce(
input: string | unknown,
Ctor: SlsaErrorCtor,
key: string
): unknown {
if (typeof input !== "string") return input;
try {
return JSON.parse(input);
} catch (cause) {
throw new Ctor(key, { cause: cause as Error });
}
}
function formatIssues(error: z.ZodError): string[] {
return error.issues.map(
(i) => `${i.path.join(".") || "<root>"}: ${i.message}`
);
}

View File

@@ -0,0 +1,88 @@
import z from "zod";
/** SLSA provenance predicate type identifier (canonical). */
export const SLSA_PREDICATE_TYPE = "https://slsa.dev/provenance/v1" as const;
/** In-toto statement type identifier (canonical). */
export const IN_TOTO_STATEMENT_TYPE =
"https://in-toto.io/Statement/v1" as const;
export const DigestAlgorithmSchema = z.enum(["sha256", "sha512"]);
export const DigestSetSchema = z
.object({
sha256: z.string().min(1).optional(),
sha512: z.string().min(1).optional(),
})
.refine((d) => !!(d.sha256 ?? d.sha512), {
message: "digest set must contain at least one algorithm",
});
export const ResourceDescriptorSchema = z.object({
uri: z.string().min(1),
digest: DigestSetSchema,
name: z.string().optional(),
mediaType: z.string().optional(),
annotations: z.record(z.string(), z.string()).optional(),
});
export const BuilderSchema = z.object({
id: z.string().min(1),
version: z.record(z.string(), z.string()).optional(),
builderDependencies: z.array(ResourceDescriptorSchema).optional(),
});
export const BuildMetadataSchema = z.object({
invocationId: z.string().min(1),
startedOn: z.string().min(1),
finishedOn: z.string().optional(),
});
export const RunDetailsSchema = z.object({
builder: BuilderSchema,
metadata: BuildMetadataSchema,
byproducts: z.array(ResourceDescriptorSchema).optional(),
});
export const ExternalParametersSchema = z.object({
repository: z.string().min(1),
ref: z.string().min(1),
});
export const InternalParametersSchema = z.object({
environment: z.string().min(1),
resolvedDependencies: z.array(ResourceDescriptorSchema).optional(),
});
export const BuildDefinitionSchema = z.object({
buildType: z.string().min(1),
externalParameters: ExternalParametersSchema,
internalParameters: InternalParametersSchema,
resolvedDependencies: z.array(ResourceDescriptorSchema).optional(),
});
export const SlsaProvenancePredicateSchema = z.object({
buildDefinition: BuildDefinitionSchema,
runDetails: RunDetailsSchema,
});
export const ProvenanceStatementSchema = z.object({
_type: z.literal(IN_TOTO_STATEMENT_TYPE),
subject: z.array(ResourceDescriptorSchema).min(1),
predicateType: z.literal(SLSA_PREDICATE_TYPE),
predicate: SlsaProvenancePredicateSchema,
});
export type DigestAlgorithm = z.infer<typeof DigestAlgorithmSchema>;
export type DigestSet = z.infer<typeof DigestSetSchema>;
export type ResourceDescriptor = z.infer<typeof ResourceDescriptorSchema>;
export type Builder = z.infer<typeof BuilderSchema>;
export type BuildMetadata = z.infer<typeof BuildMetadataSchema>;
export type RunDetails = z.infer<typeof RunDetailsSchema>;
export type ExternalParameters = z.infer<typeof ExternalParametersSchema>;
export type InternalParameters = z.infer<typeof InternalParametersSchema>;
export type BuildDefinition = z.infer<typeof BuildDefinitionSchema>;
export type SlsaProvenancePredicate = z.infer<
typeof SlsaProvenancePredicateSchema
>;
export type ProvenanceStatement = z.infer<typeof ProvenanceStatementSchema>;

View File

@@ -0,0 +1,52 @@
import z from "zod";
export const SbomComponentTypeSchema = z.enum([
"library",
"application",
"framework",
"file",
]);
export const SbomHashSchema = z.object({
alg: z.string().min(1),
content: z.string().min(1),
});
export const SbomComponentSchema = z.object({
type: SbomComponentTypeSchema,
name: z.string().min(1),
version: z.string().min(1),
purl: z.string().optional(),
description: z.string().optional(),
license: z.string().optional(),
hashes: z.array(SbomHashSchema).optional(),
});
export const SbomSchema = z.object({
bomFormat: z.literal("CycloneDX"),
specVersion: z.literal("1.5"),
serialNumber: z.string().regex(/^urn:uuid:/),
version: z.number().int().positive(),
metadata: z.object({
timestamp: z.string().min(1),
tools: z.array(
z.object({ name: z.string().min(1), version: z.string().min(1) })
),
component: SbomComponentSchema,
}),
components: z.array(SbomComponentSchema),
});
export const SbomInputSchema = z.object({
packageName: z.string().min(1),
packageVersion: z.string().min(1),
packageDescription: z.string().optional(),
packageLicense: z.string().optional(),
dependencies: z.record(z.string(), z.string()),
componentType: SbomComponentTypeSchema.optional(),
});
export type SbomComponentType = z.infer<typeof SbomComponentTypeSchema>;
export type SbomComponent = z.infer<typeof SbomComponentSchema>;
export type Sbom = z.infer<typeof SbomSchema>;
export type SbomInput = z.infer<typeof SbomInputSchema>;

View File

@@ -0,0 +1,53 @@
import z from "zod";
export const SlsaLevelSchema = z.enum(["L0", "L1", "L2", "L3"]);
export type SlsaLevel = z.infer<typeof SlsaLevelSchema>;
export const VerificationCheckSchema = z.object({
name: z.string().min(1),
passed: z.boolean(),
message: z.string(),
});
export const VerificationResultSchema = z.object({
valid: z.boolean(),
summary: z.string(),
checks: z.array(VerificationCheckSchema),
});
export const VerificationPolicySchema = z.object({
trustedBuilders: z.array(z.string().min(1)),
expectedSourceRepo: z.string().optional(),
expectedSourceRepoPrefix: z.string().optional(),
requireFinishedOn: z.boolean().optional(),
maxBuildDurationMs: z.number().nonnegative().optional(),
requireRekorInclusion: z.boolean().optional(),
allowedBuilders: z.array(z.string().min(1)).optional(),
minSlsaLevel: SlsaLevelSchema.optional(),
trustedPublicKeys: z.array(z.instanceof(Uint8Array)).optional(),
maxAgeMs: z.number().positive().optional(),
});
export const AssessmentEnvironmentSchema = z.object({
isCI: z.boolean(),
hasProvenance: z.boolean(),
hasIsolatedBuild: z.boolean(),
hasHardenedProvenance: z.boolean(),
ciOnlyPublish: z.boolean(),
hasSbom: z.boolean(),
hasDigestVerification: z.boolean(),
hasVersionControl: z.boolean(),
hasRekorInclusion: z.boolean().optional(),
});
export const SlsaAssessmentSchema = z.object({
level: SlsaLevelSchema,
met: z.array(z.string()),
unmet: z.array(z.string()),
});
export type VerificationCheck = z.infer<typeof VerificationCheckSchema>;
export type VerificationResult = z.infer<typeof VerificationResultSchema>;
export type VerificationPolicy = z.infer<typeof VerificationPolicySchema>;
export type AssessmentEnvironment = z.infer<typeof AssessmentEnvironmentSchema>;
export type SlsaAssessment = z.infer<typeof SlsaAssessmentSchema>;

View File

@@ -0,0 +1,109 @@
/**
* Spec-conformance tests for `openspec/specs/slsa/spec.md`.
*
* Pins the structural scenarios that the per-feature unit suites
* do not cover directly: SLSA-04 no `node:crypto` import in src/,
* SLSA-14 every feature module has a sibling `.md` ≥200 words,
* SLSA-15 every documented subpath export is present in
* `package.json`, SLSA-16 vitest config declares a coverage floor at
* or above 100.
*/
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const ROOT = join(__dirname, "..");
const SRC = join(ROOT, "src");
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (
st.isDirectory() &&
!full.includes("node_modules") &&
!full.includes("dist")
) {
yield* walk(full);
} else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) {
yield full;
}
}
}
describe("slsa — SLSA-04 no node:crypto", () => {
it("src/ does not import node:crypto", { timeout: 5000 }, () => {
const offenders: string[] = [];
for (const file of walk(SRC)) {
const c = readFileSync(file, "utf8");
if (/from\s+["']node:crypto["']/.test(c)) offenders.push(file);
if (/require\(\s*["']node:crypto["']\s*\)/.test(c)) {
offenders.push(file);
}
}
expect(offenders).toEqual([]);
});
});
describe("slsa — SLSA-14 feature module docs", () => {
it("every feature module has its sibling .md", { timeout: 5000 }, () => {
const offenders: string[] = [];
for (const file of walk(SRC)) {
// Skip barrel files, types files, schema files (per
// co-location rules in CLAUDE.md), and bench files.
const base = file.split("/").pop() ?? "";
if (base === "index.ts") continue;
if (base.endsWith(".types.ts")) continue;
if (base.endsWith(".schemas.ts")) continue;
if (base.endsWith(".bench.ts")) continue;
if (base === "spec-conformance.test.ts") continue;
const md = file.replace(/\.ts$/, ".md");
if (!existsSync(md)) offenders.push(file);
}
expect(offenders).toEqual([]);
});
});
describe("slsa — SLSA-15 subpath exports", () => {
it("package.json exports include every documented subpath", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(ROOT, "package.json"), "utf8")
);
const exports = pkg.exports ?? {};
const required = [
"./provenance/provenance",
"./verification/verification",
"./sbom/sbom",
"./assessment/assessment",
"./attestation/dsse",
"./rekor/rekor-client",
"./rekor/inclusion-proof",
"./policy/policy",
"./errors",
"./otel",
"./schemas",
];
const missing = required.filter((p) => !(p in exports));
expect(missing).toEqual([]);
});
});
describe("slsa — SLSA-16 coverage floor", () => {
it("vitest config sets a coverage floor at or above 100", {
timeout: 5000,
}, () => {
const cfg = readFileSync(join(ROOT, "vitest.config.ts"), "utf8");
// Crude but effective: the config text must declare a 100+ floor
// for all four metrics. We check each metric has a number ≥ 100.
const metrics = ["statements", "branches", "functions", "lines"];
for (const m of metrics) {
const re = new RegExp(`${m}\\s*:\\s*(\\d+)`);
const match = cfg.match(re);
expect(match, `${m} threshold missing`).toBeTruthy();
const value = Number(match?.[1] ?? "0");
expect(value).toBeGreaterThanOrEqual(100);
}
});
});

View File

@@ -0,0 +1,76 @@
# checks
## Purpose
Individual, stateless verification rules that consume a provenance statement
plus policy inputs and return a structured `VerificationCheck` (name,
pass/fail, message). Each rule is isolated so new checks compose without
touching the verifier orchestrator, and so the test surface for each rule
stays small. The verifier module imports these and decides which to run
based on the policy shape.
## Features
- Rules: statement type, predicate type, subject present, subject digest,
builder trusted (with `allowedBuilders` intersection), source-repo match
(exact or regex), source-repo prefix, build timestamps, finishedOn
required, build duration bound, build age bound.
- All rules return a stable `name` key used by the verifier to de-duplicate.
- Strings in messages deliberately echo the offending values so logs are
self-contained for post-mortem.
## Usage: Basic Example
```ts
import { checkStatementType } from "@suaveplan/slsa/verification/verification";
const result = checkStatementType(statement);
if (!result.passed) console.warn(result.message);
```
## Usage: Advanced Example
```ts
import {
checkBuilderTrusted,
checkBuildAge,
checkSourceRepoPrefix,
} from "@suaveplan/slsa/verification/verification";
const clock = () => Date.now();
const checks = [
checkBuilderTrusted(stmt, {
trustedBuilders: ["https://gitlab.fq.io/.../runners/*"],
allowedBuilders: ["https://gitlab.fq.io/.../runners/1"],
}),
checkSourceRepoPrefix(stmt, "https://gitlab.fq.io/suaveplan/"),
checkBuildAge(stmt, 7 * 24 * 60 * 60 * 1000, clock()),
];
```
## API Reference
Each of the following returns a `VerificationCheck`:
- `checkStatementType(s)` — `_type === IN_TOTO_STATEMENT_TYPE`.
- `checkPredicateType(s)` — `predicateType === SLSA_PREDICATE_TYPE`.
- `checkSubjectPresent(s)` — `subject` is a non-empty array.
- `checkSubjectDigest(s)` — every subject has a sha256 or sha512 digest.
- `checkBuilderTrusted(s, policy)` — builder id matches `trustedBuilders`
and (if set) `allowedBuilders`.
- `checkBuildTimestamps(s)` — `startedOn` is a valid ISO 8601 string.
- `checkSourceRepo(s, expected)` — repo equals or regex-matches `expected`.
- `checkSourceRepoPrefix(s, prefix)` — repo starts with `prefix`.
- `checkFinishedOn(s)` — `finishedOn` is a valid ISO 8601 string.
- `checkBuildDuration(s, maxMs)` — `finishedOn - startedOn <= maxMs`.
- `checkBuildAge(s, maxAgeMs, now)` — `now - startedOn <= maxAgeMs`.
## Implementation Notes
`StatementLike` is deliberately a `Partial` so tests can construct tampered
objects (`{ ...statement, _type: "wrong" }`) without casting through `any`.
Pattern matching on builders supports exact strings and `*`-terminated
prefixes — glob parsing is intentionally rudimentary because builder URIs
are canonicalised by the issuer and do not need regex-level expressivity.
Time-based checks take `now` as a parameter rather than calling `Date.now()`
internally so tests can inject a fake clock.

View File

@@ -0,0 +1,235 @@
import {
IN_TOTO_STATEMENT_TYPE,
type ProvenanceStatement,
SLSA_PREDICATE_TYPE,
} from "../schemas/provenance.schemas.js";
import type {
VerificationCheck,
VerificationPolicy,
} from "../schemas/verification.schemas.js";
/** Internal shape used when tests deliberately tamper fields to `undefined`. */
type StatementLike = Partial<ProvenanceStatement> & {
predicate?: Partial<ProvenanceStatement["predicate"]>;
};
export function checkStatementType(s: StatementLike): VerificationCheck {
const passed = s._type === IN_TOTO_STATEMENT_TYPE;
return {
name: "statement-type",
passed,
message: passed
? `Valid in-toto statement type: ${String(s._type)}`
: `Expected ${IN_TOTO_STATEMENT_TYPE}, got ${String(s._type)}`,
};
}
export function checkPredicateType(s: StatementLike): VerificationCheck {
const passed = s.predicateType === SLSA_PREDICATE_TYPE;
return {
name: "predicate-type",
passed,
message: passed
? `Valid SLSA predicate type: ${String(s.predicateType)}`
: `Expected ${SLSA_PREDICATE_TYPE}, got ${String(s.predicateType)}`,
};
}
export function checkSubjectPresent(s: StatementLike): VerificationCheck {
const subject = Array.isArray(s.subject) ? s.subject : [];
const passed = subject.length > 0;
return {
name: "subject-present",
passed,
message: passed
? `${subject.length} subject(s) present`
: "No subjects found in provenance statement",
};
}
export function checkSubjectDigest(s: StatementLike): VerificationCheck {
if (!Array.isArray(s.subject) || s.subject.length === 0) {
return {
name: "subject-digest",
passed: false,
message: "No subjects to check digests for",
};
}
const allHaveDigests = s.subject.every(
(subj) => subj.digest && (subj.digest.sha256 ?? subj.digest.sha512)
);
return {
name: "subject-digest",
passed: allHaveDigests,
message: allHaveDigests
? "All subjects have valid digests"
: "One or more subjects missing a digest",
};
}
export function checkBuilderTrusted(
s: StatementLike,
policy: VerificationPolicy
): VerificationCheck {
const builderId = s.predicate?.runDetails?.builder?.id;
if (!builderId) {
return {
name: "builder-trusted",
passed: false,
message: "No builder ID found in provenance",
};
}
const trusted = matchBuilderPatterns(builderId, policy.trustedBuilders);
const allowed =
!policy.allowedBuilders ||
matchBuilderPatterns(builderId, policy.allowedBuilders);
const passed = trusted && allowed;
return {
name: "builder-trusted",
passed,
message: passed
? `Builder ${builderId} is trusted`
: `Builder ${builderId} is not in the trusted builders list`,
};
}
function matchBuilderPatterns(id: string, patterns: string[]): boolean {
return patterns.some((p) =>
p.endsWith("*") ? id.startsWith(p.slice(0, -1)) : id === p
);
}
export function checkBuildTimestamps(s: StatementLike): VerificationCheck {
const startedOn = s.predicate?.runDetails?.metadata?.startedOn;
if (!startedOn) {
return {
name: "build-timestamps",
passed: false,
message: "Missing startedOn timestamp",
};
}
if (Number.isNaN(new Date(startedOn).getTime())) {
return {
name: "build-timestamps",
passed: false,
message: `Invalid startedOn timestamp: ${startedOn}`,
};
}
return {
name: "build-timestamps",
passed: true,
message: `Build started at ${startedOn}`,
};
}
export function checkSourceRepo(
s: StatementLike,
expected: string
): VerificationCheck {
const repo = s.predicate?.buildDefinition?.externalParameters?.repository;
if (!repo) {
return {
name: "source-repo",
passed: false,
message: "No source repository found in provenance",
};
}
const matched = repo === expected || new RegExp(expected).test(repo);
return {
name: "source-repo",
passed: matched,
message: matched
? `Source repository ${repo} matches expected pattern`
: `Source repository ${repo} does not match expected ${expected}`,
};
}
export function checkSourceRepoPrefix(
s: StatementLike,
prefix: string
): VerificationCheck {
const repo = s.predicate?.buildDefinition?.externalParameters?.repository;
const passed = typeof repo === "string" && repo.startsWith(prefix);
return {
name: "source-repo-prefix",
passed,
message: passed
? `Repository ${repo} begins with ${prefix}`
: `Repository ${String(repo)} does not begin with ${prefix}`,
};
}
export function checkFinishedOn(s: StatementLike): VerificationCheck {
const finishedOn = s.predicate?.runDetails?.metadata?.finishedOn;
if (!finishedOn) {
return {
name: "finished-on",
passed: false,
message: "Missing finishedOn timestamp (required by policy)",
};
}
if (Number.isNaN(new Date(finishedOn).getTime())) {
return {
name: "finished-on",
passed: false,
message: `Invalid finishedOn timestamp: ${finishedOn}`,
};
}
return {
name: "finished-on",
passed: true,
message: `Build finished at ${finishedOn}`,
};
}
export function checkBuildDuration(
s: StatementLike,
maxMs: number
): VerificationCheck {
const startedOn = s.predicate?.runDetails?.metadata?.startedOn;
const finishedOn = s.predicate?.runDetails?.metadata?.finishedOn;
if (!startedOn || !finishedOn) {
return {
name: "build-duration",
passed: false,
message:
"Cannot check build duration: missing startedOn or finishedOn",
};
}
const duration =
new Date(finishedOn).getTime() - new Date(startedOn).getTime();
const passed = duration <= maxMs;
return {
name: "build-duration",
passed,
message: passed
? `Build duration ${duration}ms is within limit of ${maxMs}ms`
: `Build duration ${duration}ms exceeds limit of ${maxMs}ms`,
};
}
export function checkBuildAge(
s: StatementLike,
maxAgeMs: number,
now: number
): VerificationCheck {
const startedOn = s.predicate?.runDetails?.metadata?.startedOn;
if (!startedOn) {
return {
name: "build-age",
passed: false,
message: "Missing startedOn for age check",
};
}
const age = now - new Date(startedOn).getTime();
const passed = age <= maxAgeMs;
return {
name: "build-age",
passed,
message: passed
? `Build age ${age}ms within maxAgeMs ${maxAgeMs}`
: `Build age ${age}ms exceeds maxAgeMs ${maxAgeMs}`,
};
}

View File

@@ -0,0 +1,14 @@
export {
checkBuildAge,
checkBuildDuration,
checkBuilderTrusted,
checkBuildTimestamps,
checkFinishedOn,
checkPredicateType,
checkSourceRepo,
checkSourceRepoPrefix,
checkStatementType,
checkSubjectDigest,
checkSubjectPresent,
} from "./checks.js";
export { type VerifyOptions, verifyProvenance } from "./verification.js";

View File

@@ -0,0 +1,74 @@
# verification
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Top-level provenance verifier. Given a `ProvenanceStatement` and a
`VerificationPolicy`, runs the fixed baseline of rules (statement type,
predicate type, subject presence / digests, builder trust, timestamps) plus
any optional rules that the policy enables (source-repo match, source-repo
prefix, finishedOn required, max build duration, max build age). Returns a
structured `VerificationResult` without throwing — callers branch on
`result.valid` and surface `result.checks` for audit logs.
## Features
- Never throws on policy failure; always returns a result.
- Injectable clock (`opts.clock`) so age-bound checks are deterministic.
- OTel `slsa.provenance.verified` span event on every call, with the result
outcome and builder id attached.
- Skips optional rules cleanly when the corresponding policy field is unset
— no "N/A" checks in the output.
## Usage: Basic Example
```ts
import { verifyProvenance } from "@suaveplan/slsa";
const result = verifyProvenance(statement, {
trustedBuilders: ["https://gitlab.fq.io/suaveplan/project/-/runners/*"],
expectedSourceRepo: "https://gitlab.fq.io/suaveplan/project",
});
if (!result.valid) {
console.error(result.summary, result.checks.filter((c) => !c.passed));
}
```
## Usage: Advanced Example
```ts
import { createSlsaOtelAdapter, verifyProvenance } from "@suaveplan/slsa";
const otel = createSlsaOtelAdapter();
const now = () => fakeClock.nowMs();
const result = verifyProvenance(
statement,
{
trustedBuilders: ["https://gitlab.fq.io/suaveplan/project/-/runners/*"],
expectedSourceRepoPrefix: "https://gitlab.fq.io/suaveplan/",
requireFinishedOn: true,
maxBuildDurationMs: 30 * 60 * 1000,
maxAgeMs: 7 * 24 * 60 * 60 * 1000,
},
{ clock: now, otel }
);
```
## API Reference
- `verifyProvenance(statement, policy, opts?): VerificationResult` —
evaluates all applicable rules. `opts.clock` defaults to `Date.now`;
`opts.otel` defaults to a no-op adapter.
- `VerifyOptions { clock?, otel? }` — both optional.
## Implementation Notes
Rule ordering is stable across releases because consumers may depend on
check names for filtering. Adding a new rule always appends to the check
list, never inserts. `maxBuildDurationMs: 0` and `maxAgeMs: 0` are treated
as "disabled" — matches the behaviour inherited from the thin legacy
implementation, and avoids an accidental zero-tolerance configuration when
a caller forgets to set a value.

View File

@@ -0,0 +1,372 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { generateProvenance } from "../provenance/provenance.js";
import type { ProvenanceInput } from "../provenance/provenance.types.js";
import type { VerificationPolicy } from "../schemas/verification.schemas.js";
import { verifyProvenance } from "./verification.js";
function makeInput(overrides?: Partial<ProvenanceInput>): ProvenanceInput {
return {
packageName: "@suaveplan/utils",
packageVersion: "1.2.3",
artifactDigest: "abc123def456",
sourceCommit: "deadbeef1234567890abcdef",
sourceRepo: "https://gitlab.fq.io/suaveplan/monorepo",
sourceRef: "refs/heads/main",
builderId: "https://gitlab.fq.io/suaveplan/monorepo/-/runners/1",
buildInvocationId: "ci-job-12345",
buildStartedOn: "2026-03-24T10:00:00Z",
buildFinishedOn: "2026-03-24T10:05:00Z",
buildEnvironment: "gitlab-ci",
...overrides,
};
}
function makePolicy(
overrides?: Partial<VerificationPolicy>
): VerificationPolicy {
return {
trustedBuilders: [
"https://gitlab.fq.io/suaveplan/monorepo/-/runners/*",
],
expectedSourceRepo: "https://gitlab.fq.io/suaveplan/monorepo",
...overrides,
};
}
describe("verifyProvenance happy path", () => {
it("passes all baseline checks", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const r = verifyProvenance(s, makePolicy());
expect(r.valid).toBe(true);
expect(r.summary).toBe("All provenance checks passed");
expect(r.checks.every((c) => c.passed)).toBe(true);
});
});
describe("baseline failure modes", () => {
it("fails on wrong statement type", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const tampered = { ...s, _type: "wrong" } as unknown as typeof s;
const r = verifyProvenance(tampered, makePolicy());
expect(r.valid).toBe(false);
expect(r.checks.find((c) => c.name === "statement-type")?.passed).toBe(
false
);
});
it("fails on wrong predicate type", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const t = { ...s, predicateType: "x" } as unknown as typeof s;
expect(
verifyProvenance(t, makePolicy()).checks.find(
(c) => c.name === "predicate-type"
)?.passed
).toBe(false);
});
it("fails on empty subject", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const t = { ...s, subject: [] };
expect(
verifyProvenance(t, makePolicy()).checks.find(
(c) => c.name === "subject-present"
)?.passed
).toBe(false);
});
it("fails when subject missing digest", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const t = {
...s,
subject: [{ uri: "u", digest: {} }],
} as unknown as typeof s;
expect(
verifyProvenance(t, makePolicy()).checks.find(
(c) => c.name === "subject-digest"
)?.passed
).toBe(false);
});
it("passes subject-digest with sha512 only", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const t = {
...s,
subject: [{ uri: "u", digest: { sha512: "abc" } }],
} as unknown as typeof s;
expect(
verifyProvenance(t, makePolicy()).checks.find(
(c) => c.name === "subject-digest"
)?.passed
).toBe(true);
});
it("fails subject-digest when subject undefined", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const t = { ...s, subject: undefined } as unknown as typeof s;
const check = verifyProvenance(t, makePolicy()).checks.find(
(c) => c.name === "subject-digest"
);
expect(check?.passed).toBe(false);
expect(check?.message).toContain("No subjects");
});
});
describe("builder trust", () => {
it("exact match succeeds", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const r = verifyProvenance(
s,
makePolicy({
trustedBuilders: [
"https://gitlab.fq.io/suaveplan/monorepo/-/runners/1",
],
})
);
expect(r.valid).toBe(true);
});
it("untrusted builder fails", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const r = verifyProvenance(
s,
makePolicy({ trustedBuilders: ["https://other/ci"] })
);
expect(r.valid).toBe(false);
});
it("rejects missing builder id", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const t = JSON.parse(JSON.stringify(s));
t.predicate.runDetails.builder.id = "";
const check = verifyProvenance(t, makePolicy()).checks.find(
(c) => c.name === "builder-trusted"
);
expect(check?.passed).toBe(false);
});
it("applies allowedBuilders intersection", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const r = verifyProvenance(
s,
makePolicy({
allowedBuilders: ["https://some-other-builder"],
})
);
expect(r.checks.find((c) => c.name === "builder-trusted")?.passed).toBe(
false
);
});
});
describe("source repo", () => {
it("regex match succeeds", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const r = verifyProvenance(
s,
makePolicy({ expectedSourceRepo: "gitlab\\.fq\\.io.*monorepo" })
);
expect(r.checks.find((c) => c.name === "source-repo")?.passed).toBe(
true
);
});
it("mismatch fails", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const r = verifyProvenance(
s,
makePolicy({ expectedSourceRepo: "https://other/repo" })
);
expect(r.valid).toBe(false);
});
it("missing repo field fails", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const t = JSON.parse(JSON.stringify(s));
t.predicate.buildDefinition.externalParameters.repository = "";
expect(
verifyProvenance(t, makePolicy()).checks.find(
(c) => c.name === "source-repo"
)?.passed
).toBe(false);
});
it("prefix check succeeds", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const r = verifyProvenance(s, {
trustedBuilders: [
"https://gitlab.fq.io/suaveplan/monorepo/-/runners/*",
],
expectedSourceRepoPrefix: "https://gitlab.fq.io/suaveplan/",
});
expect(
r.checks.find((c) => c.name === "source-repo-prefix")?.passed
).toBe(true);
});
it("prefix mismatch fails", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const r = verifyProvenance(s, {
trustedBuilders: [
"https://gitlab.fq.io/suaveplan/monorepo/-/runners/*",
],
expectedSourceRepoPrefix: "https://github.com/",
});
expect(
r.checks.find((c) => c.name === "source-repo-prefix")?.passed
).toBe(false);
});
});
describe("timestamps and duration", () => {
it("fails on missing startedOn", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const t = JSON.parse(JSON.stringify(s));
t.predicate.runDetails.metadata.startedOn = "";
expect(
verifyProvenance(t, makePolicy()).checks.find(
(c) => c.name === "build-timestamps"
)?.passed
).toBe(false);
});
it("fails on malformed startedOn", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const t = JSON.parse(JSON.stringify(s));
t.predicate.runDetails.metadata.startedOn = "x";
expect(
verifyProvenance(t, makePolicy()).checks.find(
(c) => c.name === "build-timestamps"
)?.passed
).toBe(false);
});
it("requireFinishedOn passes", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
expect(
verifyProvenance(
s,
makePolicy({ requireFinishedOn: true })
).checks.find((c) => c.name === "finished-on")?.passed
).toBe(true);
});
it("requireFinishedOn fails when absent", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput({ buildFinishedOn: undefined }));
expect(
verifyProvenance(
s,
makePolicy({ requireFinishedOn: true })
).checks.find((c) => c.name === "finished-on")?.passed
).toBe(false);
});
it("requireFinishedOn fails on malformed", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const t = JSON.parse(JSON.stringify(s));
t.predicate.runDetails.metadata.finishedOn = "nope";
expect(
verifyProvenance(
t,
makePolicy({ requireFinishedOn: true })
).checks.find((c) => c.name === "finished-on")?.passed
).toBe(false);
});
it("skips finished-on when not required", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
expect(
verifyProvenance(s, makePolicy()).checks.find(
(c) => c.name === "finished-on"
)
).toBeUndefined();
});
it("duration within bound passes", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
expect(
verifyProvenance(
s,
makePolicy({ maxBuildDurationMs: 600_000 })
).checks.find((c) => c.name === "build-duration")?.passed
).toBe(true);
});
it("duration over bound fails", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
expect(
verifyProvenance(
s,
makePolicy({ maxBuildDurationMs: 1000 })
).checks.find((c) => c.name === "build-duration")?.passed
).toBe(false);
});
it("duration skipped when 0", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
expect(
verifyProvenance(
s,
makePolicy({ maxBuildDurationMs: 0 })
).checks.find((c) => c.name === "build-duration")
).toBeUndefined();
});
it("duration fails when timestamps missing", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput({ buildFinishedOn: undefined }));
expect(
verifyProvenance(
s,
makePolicy({ maxBuildDurationMs: 100 })
).checks.find((c) => c.name === "build-duration")?.passed
).toBe(false);
});
it("age within bound passes", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const clock = () => new Date("2026-03-24T10:01:00Z").getTime();
expect(
verifyProvenance(s, makePolicy({ maxAgeMs: 600_000 }), {
clock,
}).checks.find((c) => c.name === "build-age")?.passed
).toBe(true);
});
it("age over bound fails", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const clock = () => new Date("2030-03-24T10:00:00Z").getTime();
expect(
verifyProvenance(s, makePolicy({ maxAgeMs: 1000 }), {
clock,
}).checks.find((c) => c.name === "build-age")?.passed
).toBe(false);
});
it("age check fails when startedOn missing", { timeout: 5000 }, () => {
const s = generateProvenance(makeInput());
const t = JSON.parse(JSON.stringify(s));
t.predicate.runDetails.metadata.startedOn = "";
expect(
verifyProvenance(t, makePolicy({ maxAgeMs: 1000 })).checks.find(
(c) => c.name === "build-age"
)?.passed
).toBe(false);
});
});
describe("otel integration", () => {
it("invokes onProvenanceVerified", { timeout: 5000 }, () => {
let calls = 0;
const otel = {
onProvenanceGenerated: () => {},
onProvenanceVerified: () => {
calls += 1;
},
onRekorUpload: () => {},
onRekorInclusionVerified: () => {},
onPolicyViolation: () => {},
};
const s = generateProvenance(makeInput());
verifyProvenance(s, makePolicy(), { otel });
expect(calls).toBe(1);
});
});

View File

@@ -0,0 +1,93 @@
import {
noopSlsaOtelAdapter,
type SlsaOtelAdapter,
} from "../otel/slsa-otel.js";
import type { ProvenanceStatement } from "../schemas/provenance.schemas.js";
import type {
VerificationCheck,
VerificationPolicy,
VerificationResult,
} from "../schemas/verification.schemas.js";
import {
checkBuildAge,
checkBuildDuration,
checkBuilderTrusted,
checkBuildTimestamps,
checkFinishedOn,
checkPredicateType,
checkSourceRepo,
checkSourceRepoPrefix,
checkStatementType,
checkSubjectDigest,
checkSubjectPresent,
} from "./checks.js";
/** Options accepted by `verifyProvenance`. */
export interface VerifyOptions {
/** Injectable clock, defaults to `Date.now`. */
clock?: () => number;
/** OTel adapter for audit-event emission. */
otel?: SlsaOtelAdapter;
}
/**
* Verify a provenance statement against a verification policy.
*
* The returned `VerificationResult` carries `valid: boolean`, a
* human-readable `summary`, and one `VerificationCheck` per rule evaluated.
* Rules are chosen dynamically based on which policy fields are populated —
* unset policy fields skip the corresponding check.
*/
export function verifyProvenance(
statement: ProvenanceStatement,
policy: VerificationPolicy,
opts: VerifyOptions = {}
): VerificationResult {
const clock = opts.clock ?? Date.now;
const otel = opts.otel ?? noopSlsaOtelAdapter;
const checks: VerificationCheck[] = [
checkStatementType(statement),
checkPredicateType(statement),
checkSubjectPresent(statement),
checkSubjectDigest(statement),
checkBuilderTrusted(statement, policy),
checkBuildTimestamps(statement),
];
if (policy.expectedSourceRepo) {
checks.push(checkSourceRepo(statement, policy.expectedSourceRepo));
}
if (policy.expectedSourceRepoPrefix) {
checks.push(
checkSourceRepoPrefix(statement, policy.expectedSourceRepoPrefix)
);
}
if (policy.requireFinishedOn) {
checks.push(checkFinishedOn(statement));
}
if (policy.maxBuildDurationMs && policy.maxBuildDurationMs > 0) {
checks.push(checkBuildDuration(statement, policy.maxBuildDurationMs));
}
if (policy.maxAgeMs && policy.maxAgeMs > 0) {
checks.push(checkBuildAge(statement, policy.maxAgeMs, clock()));
}
const failed = checks.filter((c) => !c.passed);
const valid = failed.length === 0;
const result: VerificationResult = {
valid,
summary: valid
? "All provenance checks passed"
: `${failed.length} check(s) failed`,
checks,
};
otel.onProvenanceVerified({
"slsa.result.valid": valid,
"slsa.builder.id": statement.predicate.runDetails.builder.id,
});
return result;
}

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,35 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/slsa",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\/.*/,
"@opentelemetry/api",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,25 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 5000,
},
});