Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
```
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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".
|
||||
@@ -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}$/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user