@rightkit/release 0.2.71 → 0.2.73

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,223 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { createHash } from "node:crypto";
6
+
7
+ export const RIGHTRELEASE_MANIFEST_SIGNER = Object.freeze({
8
+ id: "azure-artifact-signing-damned-ventures-v1",
9
+ subject: "CN=Damned Ventures LLC",
10
+ algorithm: "cms-sha256",
11
+ });
12
+
13
+ const EXCLUDE_CREDENTIALS = [
14
+ "ManagedIdentityCredential",
15
+ "WorkloadIdentityCredential",
16
+ "SharedTokenCacheCredential",
17
+ "VisualStudioCredential",
18
+ "VisualStudioCodeCredential",
19
+ "AzurePowerShellCredential",
20
+ "AzureDeveloperCliCredential",
21
+ "InteractiveBrowserCredential",
22
+ ];
23
+
24
+ const CERTIFICATE_SHA256 = /^[a-f0-9]{64}$/i;
25
+
26
+ /**
27
+ * Verify detached CMS over exact manifest bytes & bind signer identity to the
28
+ * shared Azure Artifact Signing result. `commandRunner` is an injectable
29
+ * system-process seam; it cannot replace CMS verification because production
30
+ * verification script/arguments are always issued here.
31
+ */
32
+ export function verifyDetachedCmsSignature({
33
+ manifestPath,
34
+ signaturePath,
35
+ expectedSigner,
36
+ commandRunner = spawnSync,
37
+ platform = process.platform,
38
+ powershellPath = "powershell",
39
+ opensslPath = "openssl",
40
+ }) {
41
+ const manifest = resolve(manifestPath);
42
+ const signature = resolve(signaturePath);
43
+ if (!existsSync(manifest) || !statSync(manifest).isFile()) throw new Error("CMS manifest bytes are missing");
44
+ if (!existsSync(signature) || !statSync(signature).isFile() || statSync(signature).size < 1) throw new Error("detached CMS signature is missing");
45
+ if (!expectedSigner || expectedSigner.id !== RIGHTRELEASE_MANIFEST_SIGNER.id || expectedSigner.subject !== RIGHTRELEASE_MANIFEST_SIGNER.subject || expectedSigner.algorithm !== RIGHTRELEASE_MANIFEST_SIGNER.algorithm || !CERTIFICATE_SHA256.test(expectedSigner.certificateSha256 ?? "")) {
46
+ throw new Error("CMS verification requires exact Azure signer identity");
47
+ }
48
+ const inspection = platform === "win32"
49
+ ? verifyCmsWithPowerShell({ manifestPath: manifest, signaturePath: signature, commandRunner, powershellPath })
50
+ : verifyCmsWithOpenSsl({ manifestPath: manifest, signaturePath: signature, commandRunner, opensslPath });
51
+ if (!inspection.verified) throw new Error("detached CMS signature is invalid");
52
+ if (normalizeSubject(inspection.subject) !== normalizeSubject(expectedSigner.subject)) throw new Error("CMS signer subject mismatch");
53
+ const certificateSha256 = String(inspection.certificateSha256 ?? "").replaceAll(":", "").toLowerCase();
54
+ if (!CERTIFICATE_SHA256.test(certificateSha256) || certificateSha256 !== String(expectedSigner.certificateSha256).toLowerCase()) throw new Error("CMS signer certificate fingerprint mismatch");
55
+ return { verified: true, manifestPath: manifest, signaturePath: signature, signer: { ...expectedSigner, certificateSha256 } };
56
+ }
57
+
58
+ export function signReleaseManifestWithAzure({
59
+ manifestPath,
60
+ signaturePath,
61
+ commandRunner = spawnSync,
62
+ environment = process.env,
63
+ signtoolPath,
64
+ dlibPath,
65
+ metadataPath,
66
+ }) {
67
+ if (process.platform !== "win32" && commandRunner === spawnSync) {
68
+ throw new Error("RightRelease manifest signing must run on protected Windows release host");
69
+ }
70
+ const userEnv = (name) => readUserEnvironment(name, commandRunner);
71
+ const env = (name) => environment[name] || userEnv(name);
72
+ const signtool = signtoolPath || firstExisting([
73
+ env("AZURE_SIGNTOOL_PATH"),
74
+ env("SIGNTOOL_PATH"),
75
+ ...signtoolCandidates(env),
76
+ ]);
77
+ if (!signtool) throw new Error("RightRelease manifest signer could not find signtool.exe");
78
+ const dlib = dlibPath || firstExisting([
79
+ env("AZURE_CODESIGN_DLIB_PATH"),
80
+ env("AZURE_ARTIFACT_SIGNING_DLIB_PATH"),
81
+ join(env("LOCALAPPDATA") || "", "AzureArtifactSigningTools", "Microsoft.ArtifactSigning.Client", "bin", "x64", "Azure.CodeSigning.Dlib.dll"),
82
+ "C:\\Program Files\\Microsoft Azure Artifact Signing Client Tools\\x64\\Azure.CodeSigning.Dlib.dll",
83
+ "C:\\Program Files (x86)\\Microsoft\\ArtifactSigningClientTools\\bin\\x64\\Azure.CodeSigning.Dlib.dll",
84
+ ]);
85
+ if (!dlib) throw new Error("RightRelease manifest signer could not find Azure Artifact Signing dlib");
86
+
87
+ const temp = mkdtempSync(join(tmpdir(), "rightrelease-manifest-sign-"));
88
+ const metadata = metadataPath || materializeMetadata(temp, env);
89
+ const outputDir = join(temp, "signature");
90
+ mkdirSync(outputDir, { recursive: true });
91
+ try {
92
+ const result = commandRunner(signtool, [
93
+ "sign", "/v", "/fd", "SHA256", "/tr", "http://timestamp.acs.microsoft.com", "/td", "SHA256",
94
+ "/dlib", dlib, "/dmdf", metadata,
95
+ "/p7", outputDir, "/p7ce", "DetachedSignedData", "/p7co", "1.2.840.113549.1.7.1",
96
+ resolve(manifestPath),
97
+ ], { encoding: "utf8", windowsHide: true, env: environment });
98
+ if (result?.status !== 0) throw new Error(`RightRelease manifest signing failed: ${String(result?.stderr ?? "").trim()}`);
99
+ const generated = join(outputDir, `${basename(manifestPath)}.p7`);
100
+ if (!existsSync(generated)) throw new Error("RightRelease manifest signer did not produce detached CMS signature");
101
+ const inspector = join(temp, "inspect-signer.ps1");
102
+ writeFileSync(inspector, [
103
+ "param([string]$ManifestPath, [string]$SignaturePath)",
104
+ "Add-Type -AssemblyName System.Security",
105
+ "$Content = New-Object Security.Cryptography.Pkcs.ContentInfo(,[IO.File]::ReadAllBytes($ManifestPath))",
106
+ "$Cms = New-Object Security.Cryptography.Pkcs.SignedCms($Content, $true)",
107
+ "$Cms.Decode([IO.File]::ReadAllBytes($SignaturePath))",
108
+ "$Cms.CheckSignature($true)",
109
+ "if ($Cms.SignerInfos.Count -ne 1) { throw 'Expected exactly one manifest signer' }",
110
+ "$Certificate = $Cms.SignerInfos[0].Certificate",
111
+ "$Sha256 = [Security.Cryptography.SHA256]::Create()",
112
+ "try { $Fingerprint = ([BitConverter]::ToString($Sha256.ComputeHash($Certificate.RawData))).Replace('-','').ToLowerInvariant() } finally { $Sha256.Dispose() }",
113
+ "@{ subject=$Certificate.Subject; certificateSha256=$Fingerprint } | ConvertTo-Json -Compress",
114
+ ].join("\r\n") + "\r\n");
115
+ const inspection = commandRunner("powershell", ["-NoProfile", "-File", inspector, resolve(manifestPath), generated], { encoding: "utf8", windowsHide: true, env: environment });
116
+ if (inspection?.status !== 0) throw new Error(`RightRelease could not inspect manifest signer: ${String(inspection?.stderr ?? "").trim()}`);
117
+ let certificate;
118
+ try { certificate = JSON.parse(String(inspection.stdout ?? "").trim()); } catch { throw new Error("RightRelease manifest signer inspection returned invalid JSON"); }
119
+ if (certificate.subject !== RIGHTRELEASE_MANIFEST_SIGNER.subject || !/^[a-f0-9]{64}$/.test(certificate.certificateSha256 ?? "")) {
120
+ throw new Error("RightRelease manifest signer identity does not match protected Azure profile");
121
+ }
122
+ mkdirSync(dirname(signaturePath), { recursive: true });
123
+ writeFileSync(signaturePath, readFileSync(generated));
124
+ return { signaturePath: resolve(signaturePath), signer: { ...RIGHTRELEASE_MANIFEST_SIGNER, certificateSha256: certificate.certificateSha256 } };
125
+ } finally {
126
+ rmSync(temp, { recursive: true, force: true });
127
+ }
128
+ }
129
+
130
+ function verifyCmsWithPowerShell({ manifestPath, signaturePath, commandRunner, powershellPath }) {
131
+ const temp = mkdtempSync(join(tmpdir(), "rightrelease-cms-verify-"));
132
+ const verifier = join(temp, "verify-cms.ps1");
133
+ writeFileSync(verifier, [
134
+ "param([string]$ManifestPath, [string]$SignaturePath)",
135
+ "Add-Type -AssemblyName System.Security",
136
+ "$Content = New-Object Security.Cryptography.Pkcs.ContentInfo(,[IO.File]::ReadAllBytes($ManifestPath))",
137
+ "$Cms = New-Object Security.Cryptography.Pkcs.SignedCms($Content, $true)",
138
+ "$Cms.Decode([IO.File]::ReadAllBytes($SignaturePath))",
139
+ "$Cms.CheckSignature($true)",
140
+ "if ($Cms.SignerInfos.Count -ne 1) { throw 'Expected exactly one manifest signer' }",
141
+ "$Certificate = $Cms.SignerInfos[0].Certificate",
142
+ "$Sha256 = [Security.Cryptography.SHA256]::Create()",
143
+ "try { $Fingerprint = ([BitConverter]::ToString($Sha256.ComputeHash($Certificate.RawData))).Replace('-','').ToLowerInvariant() } finally { $Sha256.Dispose() }",
144
+ "@{ verified=$true; subject=$Certificate.Subject; certificateSha256=$Fingerprint } | ConvertTo-Json -Compress",
145
+ ].join("\r\n") + "\r\n");
146
+ try {
147
+ const result = commandRunner(powershellPath, ["-NoProfile", "-File", verifier, manifestPath, signaturePath], { encoding: "utf8", windowsHide: true });
148
+ if (result?.status !== 0) throw new Error(`CMS signature verification failed: ${String(result?.stderr ?? "").trim()}`);
149
+ return parseCmsInspection(result?.stdout);
150
+ } finally {
151
+ rmSync(temp, { recursive: true, force: true });
152
+ }
153
+ }
154
+
155
+ function verifyCmsWithOpenSsl({ manifestPath, signaturePath, commandRunner, opensslPath }) {
156
+ const temp = mkdtempSync(join(tmpdir(), "rightrelease-cms-verify-"));
157
+ const verified = join(temp, "verified-manifest");
158
+ const certificate = join(temp, "signer.pem");
159
+ try {
160
+ const result = commandRunner(opensslPath, ["cms", "-verify", "-binary", "-inform", "DER", "-in", signaturePath, "-content", manifestPath, "-noverify", "-certsout", certificate, "-out", verified], { encoding: "utf8", windowsHide: true });
161
+ if (result?.status !== 0) throw new Error(`CMS signature verification failed: ${String(result?.stderr ?? "").trim()}`);
162
+ if (!existsSync(verified) || !readFileSync(verified).equals(readFileSync(manifestPath))) throw new Error("CMS signature content does not match manifest bytes");
163
+ if (!existsSync(certificate)) throw new Error("CMS signer certificate is missing");
164
+ const inspection = commandRunner(opensslPath, ["x509", "-in", certificate, "-noout", "-subject", "-fingerprint", "-sha256"], { encoding: "utf8", windowsHide: true });
165
+ if (inspection?.status !== 0) throw new Error(`CMS signer inspection failed: ${String(inspection?.stderr ?? "").trim()}`);
166
+ const output = String(inspection?.stdout ?? "");
167
+ const subject = output.match(/^\s*subject\s*=\s*(.+)$/im)?.[1]?.trim();
168
+ const certificateSha256 = output.match(/^\s*sha256\s+Fingerprint\s*=\s*([0-9a-f:]+)\s*$/im)?.[1];
169
+ return parseCmsInspection(JSON.stringify({ verified: true, subject, certificateSha256 }));
170
+ } finally {
171
+ rmSync(temp, { recursive: true, force: true });
172
+ }
173
+ }
174
+
175
+ function parseCmsInspection(output) {
176
+ let inspection;
177
+ try { inspection = JSON.parse(String(output ?? "").trim()); } catch { throw new Error("CMS verifier returned invalid JSON"); }
178
+ if (inspection.verified !== true || typeof inspection.subject !== "string" || typeof inspection.certificateSha256 !== "string") throw new Error("CMS verifier returned incomplete identity");
179
+ return inspection;
180
+ }
181
+
182
+ function normalizeSubject(subject) {
183
+ return String(subject).trim().replace(/\s*=\s*/g, "=").replace(/\s*,\s*/g, ",");
184
+ }
185
+
186
+ function materializeMetadata(dir, env) {
187
+ const existing = env("AZURE_ARTIFACT_SIGNING_METADATA") || env("AZURE_SIGNING_METADATA");
188
+ if (existing) {
189
+ if (!existsSync(existing)) throw new Error(`Azure signing metadata file not found: ${existing}`);
190
+ return existing;
191
+ }
192
+ const Endpoint = env("AZURE_ARTIFACT_SIGNING_ENDPOINT") || env("AZURE_SIGNING_ENDPOINT") || env("AZURE_ENDPOINT");
193
+ const CodeSigningAccountName = env("AZURE_ARTIFACT_SIGNING_ACCOUNT") || env("AZURE_SIGNING_ACCOUNT_NAME") || env("AZURE_ACCOUNT");
194
+ const CertificateProfileName = env("AZURE_ARTIFACT_SIGNING_PROFILE") || env("AZURE_CERTIFICATE_PROFILE_NAME") || env("AZURE_PROFILE");
195
+ if (!Endpoint || !CodeSigningAccountName || !CertificateProfileName) {
196
+ throw new Error("RightRelease manifest signer requires existing Azure Artifact Signing configuration");
197
+ }
198
+ const path = join(dir, "metadata.json");
199
+ writeFileSync(path, `${JSON.stringify({ Endpoint, CodeSigningAccountName, CertificateProfileName, ExcludeCredentials: EXCLUDE_CREDENTIALS }, null, 2)}\n`);
200
+ return path;
201
+ }
202
+
203
+ function signtoolCandidates(env) {
204
+ const roots = [
205
+ join(env("LOCALAPPDATA") || "", "AzureArtifactSigningTools", "Microsoft.Windows.SDK.BuildTools", "bin"),
206
+ join(env("ProgramFiles(x86)") || "C:\\Program Files (x86)", "Windows Kits", "10", "bin"),
207
+ ];
208
+ return roots.flatMap((root) => existsSync(root)
209
+ ? readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(root, entry.name, "x64", "signtool.exe")).sort().reverse()
210
+ : []);
211
+ }
212
+
213
+ function firstExisting(candidates) {
214
+ return candidates.find((candidate) => candidate && existsSync(candidate));
215
+ }
216
+
217
+ function readUserEnvironment(name, commandRunner) {
218
+ const result = commandRunner("powershell", ["-NoProfile", "-Command", `[Environment]::GetEnvironmentVariable('${name.replaceAll("'", "''")}','User')`], {
219
+ encoding: "utf8",
220
+ windowsHide: true,
221
+ });
222
+ return result?.status === 0 ? String(result.stdout ?? "").trim() : "";
223
+ }
@@ -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
+ }