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,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>;
}