@rightkit/release 0.2.70 → 0.2.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { basename } from "node:path";
4
+
5
+ const SHA256 = /^[a-f0-9]{64}$/;
6
+ const VERSION = /^\d+\.\d+\.\d+$/;
7
+ const COMMIT = /^[a-f0-9]{40,64}$/;
8
+ const IN_TOTO_V1 = "https://in-toto.io/Statement/v1";
9
+ const SLSA_V1 = "https://slsa.dev/provenance/v1";
10
+ const CYCLONEDX_SPEC = "1.6";
11
+
12
+ function canonical(value) {
13
+ if (Array.isArray(value)) return value.map(sortDeep);
14
+ return sortDeep(value);
15
+ }
16
+
17
+ function sortDeep(value) {
18
+ if (Array.isArray(value)) return value.map(sortDeep);
19
+ if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortDeep(value[key])]));
20
+ return value;
21
+ }
22
+
23
+ function stableSha256(value) {
24
+ return createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex");
25
+ }
26
+
27
+ function assertIdentity({ product, version, target, sourceCommit }) {
28
+ if (!/^[a-z][a-z0-9-]+$/.test(product ?? "")) throw new Error("supply-chain product is invalid");
29
+ if (!VERSION.test(version ?? "")) throw new Error("supply-chain version must be stable SemVer");
30
+ if (!/^(?:windows|macos)-(?:x86_64|arm64)$/.test(target ?? "")) throw new Error("supply-chain target is invalid");
31
+ if (!COMMIT.test(sourceCommit ?? "")) throw new Error("supply-chain source commit is invalid");
32
+ }
33
+
34
+ function normalizeFiles(files) {
35
+ if (!Array.isArray(files) || !files.length) throw new Error("supply-chain evidence requires files");
36
+ const seen = new Set();
37
+ return files.map((file) => {
38
+ const name = basename(String(file.name ?? ""));
39
+ if (!name || name !== file.name || seen.has(name)) throw new Error("supply-chain file name must be one unique basename");
40
+ seen.add(name);
41
+ if (!SHA256.test(file.sha256 ?? "")) throw new Error(`supply-chain file digest is invalid: ${name}`);
42
+ if (!Number.isSafeInteger(file.size) || file.size < 1) throw new Error(`supply-chain file size is invalid: ${name}`);
43
+ return { name, sha256: file.sha256, size: file.size };
44
+ }).sort((left, right) => left.name.localeCompare(right.name));
45
+ }
46
+
47
+ function assertIso(value, label) {
48
+ const parsed = new Date(value);
49
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== value) throw new Error(label + " must be exact ISO-8601 UTC");
50
+ }
51
+
52
+ function deterministicUuid(seed) {
53
+ const hex = stableSha256(seed).slice(0, 32).split("");
54
+ hex[12] = "5";
55
+ hex[16] = ((parseInt(hex[16], 16) & 3) | 8).toString(16);
56
+ const value = hex.join("");
57
+ return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
58
+ }
59
+
60
+ export function materializeCycloneDxSbom({ outputPath, product, version, target, sourceCommit, files, createdAt = new Date().toISOString() }) {
61
+ assertIdentity({ product, version, target, sourceCommit });
62
+ assertIso(createdAt, "SBOM timestamp");
63
+ if (!outputPath.endsWith(".cdx.json")) throw new Error("CycloneDX output must end in .cdx.json");
64
+ const normalized = normalizeFiles(files);
65
+ const identity = { product, version, target, sourceCommit, files: normalized };
66
+ const sbom = {
67
+ bomFormat: "CycloneDX",
68
+ specVersion: CYCLONEDX_SPEC,
69
+ serialNumber: `urn:uuid:${deterministicUuid(identity)}`,
70
+ version: 1,
71
+ metadata: {
72
+ timestamp: createdAt,
73
+ component: {
74
+ type: "application",
75
+ name: product,
76
+ version,
77
+ "bom-ref": `pkg:generic/${product}@${version}?target=${encodeURIComponent(target)}`,
78
+ properties: [
79
+ { name: "rightkit:target", value: target },
80
+ { name: "rightkit:sourceCommit", value: sourceCommit },
81
+ ],
82
+ },
83
+ },
84
+ components: normalized.map((file) => ({
85
+ type: "file",
86
+ name: file.name,
87
+ "bom-ref": `urn:sha256:${file.sha256}`,
88
+ hashes: [{ alg: "SHA-256", content: file.sha256 }],
89
+ properties: [{ name: "rightkit:sizeBytes", value: String(file.size) }],
90
+ })),
91
+ };
92
+ writeFileSync(outputPath, JSON.stringify(sbom, null, 2) + "\n");
93
+ validateCycloneDxSbom(outputPath);
94
+ return sbom;
95
+ }
96
+
97
+ export function validateCycloneDxSbom(path, { expectedFile } = {}) {
98
+ if (!path.endsWith(".cdx.json")) throw new Error("CycloneDX evidence must end in .cdx.json");
99
+ const value = JSON.parse(readFileSync(path, "utf8"));
100
+ if (value.bomFormat !== "CycloneDX" || value.specVersion !== CYCLONEDX_SPEC || value.version !== 1) throw new Error("invalid CycloneDX 1.6 document");
101
+ if (!/^urn:uuid:[0-9a-f-]{36}$/.test(value.serialNumber ?? "")) throw new Error("CycloneDX serialNumber is invalid");
102
+ assertIso(value.metadata?.timestamp, "SBOM timestamp");
103
+ if (value.metadata?.component?.type !== "application" || !value.metadata.component.name || !VERSION.test(value.metadata.component.version ?? "")) throw new Error("CycloneDX root component is invalid");
104
+ if (!Array.isArray(value.components) || !value.components.length) throw new Error("CycloneDX components are required");
105
+ for (const component of value.components) {
106
+ if (component.type !== "file" || !component.name || !Array.isArray(component.hashes)) throw new Error("CycloneDX file component is invalid");
107
+ const digest = component.hashes.find((hash) => hash.alg === "SHA-256")?.content;
108
+ if (!SHA256.test(digest ?? "")) throw new Error("CycloneDX file component lacks SHA-256");
109
+ }
110
+ if (expectedFile) {
111
+ const match = value.components.find((component) => component.name === expectedFile.name && component.hashes.some((hash) => hash.alg === "SHA-256" && hash.content === expectedFile.sha256));
112
+ if (!match) throw new Error("CycloneDX document does not bind expected release file");
113
+ }
114
+ return value;
115
+ }
116
+
117
+ export function materializeInTotoSlsaProvenance({
118
+ outputPath,
119
+ product,
120
+ version,
121
+ target,
122
+ sourceCommit,
123
+ sourceRepository,
124
+ subjects,
125
+ startedAt,
126
+ finishedAt = new Date().toISOString(),
127
+ }) {
128
+ assertIdentity({ product, version, target, sourceCommit });
129
+ if (!outputPath.endsWith(".intoto.jsonl")) throw new Error("in-toto output must end in .intoto.jsonl");
130
+ const repository = new URL(sourceRepository);
131
+ if (repository.protocol !== "https:" || repository.hostname !== "github.com") throw new Error("SLSA source repository must be GitHub HTTPS");
132
+ assertIso(startedAt, "SLSA startedAt");
133
+ assertIso(finishedAt, "SLSA finishedAt");
134
+ const files = normalizeFiles(subjects);
135
+ const statementSubjects = files.map((file) => ({ name: file.name, digest: { sha256: file.sha256 } }));
136
+ const invocationId = `urn:sha256:${stableSha256({ product, version, target, sourceCommit, statementSubjects, startedAt, finishedAt })}`;
137
+ const statement = {
138
+ _type: IN_TOTO_V1,
139
+ subject: statementSubjects,
140
+ predicateType: SLSA_V1,
141
+ predicate: {
142
+ buildDefinition: {
143
+ buildType: "https://orthiclabs.com/rightkit/release/direct-bootstrap/v1",
144
+ externalParameters: { product, version, target },
145
+ internalParameters: {},
146
+ resolvedDependencies: [{ uri: `git+${repository.href.replace(/\/$/, "")}@${sourceCommit}`, digest: { gitCommit: sourceCommit } }],
147
+ },
148
+ runDetails: {
149
+ builder: { id: "https://orthiclabs.com/rightkit/release" },
150
+ metadata: { invocationId, startedOn: startedAt, finishedOn: finishedAt },
151
+ },
152
+ },
153
+ };
154
+ writeFileSync(outputPath, JSON.stringify(statement) + "\n");
155
+ validateInTotoSlsaProvenance(outputPath);
156
+ return statement;
157
+ }
158
+
159
+ export function validateInTotoSlsaProvenance(path, { expectedSubject } = {}) {
160
+ if (!path.endsWith(".intoto.jsonl")) throw new Error("in-toto evidence must end in .intoto.jsonl");
161
+ const lines = readFileSync(path, "utf8").trimEnd().split(/\r?\n/);
162
+ if (lines.length !== 1) throw new Error("in-toto evidence must contain exactly one JSONL statement");
163
+ const value = JSON.parse(lines[0]);
164
+ if (value._type !== IN_TOTO_V1 || value.predicateType !== SLSA_V1) throw new Error("invalid in-toto/SLSA v1 statement");
165
+ if (!Array.isArray(value.subject) || !value.subject.length || value.subject.some((subject) => !subject.name || !SHA256.test(subject.digest?.sha256 ?? ""))) throw new Error("in-toto subjects are invalid");
166
+ if (value.predicate?.buildDefinition?.buildType !== "https://orthiclabs.com/rightkit/release/direct-bootstrap/v1") throw new Error("SLSA build type is invalid");
167
+ if (!Array.isArray(value.predicate.buildDefinition.resolvedDependencies) || value.predicate.buildDefinition.resolvedDependencies.length !== 1) throw new Error("SLSA source dependency is invalid");
168
+ if (!/^urn:sha256:[a-f0-9]{64}$/.test(value.predicate?.runDetails?.metadata?.invocationId ?? "")) throw new Error("SLSA invocation identity is invalid");
169
+ assertIso(value.predicate.runDetails.metadata.startedOn, "SLSA startedOn");
170
+ assertIso(value.predicate.runDetails.metadata.finishedOn, "SLSA finishedOn");
171
+ if (expectedSubject && !value.subject.some((subject) => subject.name === expectedSubject.name && subject.digest.sha256 === expectedSubject.sha256)) {
172
+ throw new Error("in-toto statement does not bind expected release subject");
173
+ }
174
+ return value;
175
+ }