generate-sbom.mjs
212 lines 7.8 KB
Raw
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ breaking 24 days ago
1 #!/usr/bin/env node
2 import crypto from 'node:crypto';
3 import fs from 'node:fs';
4 import path from 'node:path';
5
6 function arg(name) {
7 const index = process.argv.indexOf(`--${name}`);
8 if (index < 0 || !process.argv[index + 1]) throw new Error(`missing --${name}`);
9 return path.resolve(process.argv[index + 1]);
10 }
11
12 function digest(value) {
13 return crypto.createHash('sha256').update(value).digest('hex');
14 }
15
16 function packageRoots(root) {
17 const roots = [];
18 function visit(directory) {
19 for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
20 if (!entry.isDirectory() || entry.name === '.bin') continue;
21 const candidate = path.join(directory, entry.name);
22 if (entry.name.startsWith('@')) {
23 for (const scoped of fs.readdirSync(candidate, { withFileTypes: true })) {
24 if (scoped.isDirectory()) visitPackage(path.join(candidate, scoped.name));
25 }
26 } else {
27 visitPackage(candidate);
28 }
29 }
30 }
31 function visitPackage(directory) {
32 const manifest = path.join(directory, 'package.json');
33 if (fs.existsSync(manifest)) roots.push(directory);
34 const nested = path.join(directory, 'node_modules');
35 if (fs.existsSync(nested)) visit(nested);
36 }
37 visit(root);
38 return roots.sort();
39 }
40
41 function includedLicenseText(directory) {
42 const candidate = fs.readdirSync(directory)
43 .filter((name) => /^(license|licence|copying|notice)(\.|$)/i.test(name))
44 .sort()[0];
45 if (!candidate) return null;
46 const file = path.join(directory, candidate);
47 if (!fs.statSync(file).isFile()) return null;
48 return fs.readFileSync(file, 'utf8');
49 }
50
51 function authorName(author) {
52 if (typeof author === 'string' && author.trim()) return author.trim();
53 if (author && typeof author.name === 'string' && author.name.trim()) return author.name.trim();
54 return null;
55 }
56
57 function normalizeLicense(value) {
58 if (value === 'MIT/X11') return 'MIT';
59 if (value === 'MIT OR Apache') return 'MIT OR Apache-2.0';
60 return value;
61 }
62
63 function fallbackLicense({ expression, copyright, source, templates }) {
64 if (!copyright) throw new Error(`license fallback lacks copyright attribution for ${source}`);
65 const identifiers = expression.split(/\s+OR\s+/);
66 const texts = identifiers.map((identifier) => {
67 if (!['MIT', 'ISC', 'Apache-2.0'].includes(identifier)) {
68 throw new Error(`unsupported license fallback ${expression} for ${source}`);
69 }
70 const text = fs.readFileSync(path.join(templates, `${identifier}.txt`), 'utf8').trim();
71 const attribution = identifier === 'Apache-2.0' ? '' : `Copyright (c) ${copyright}\n\n`;
72 return `--- ${identifier} ---\n${attribution}${text}`;
73 });
74 return [
75 'The published package omitted its repository license file.',
76 `Source metadata: ${source}`,
77 '',
78 ...texts,
79 ].join('\n');
80 }
81
82 function integrityChecksum(integrity) {
83 const match = /^(sha512|sha256)-([A-Za-z0-9+/=]+)$/.exec(integrity ?? '');
84 if (!match) return [];
85 return [{
86 algorithm: match[1].toUpperCase(),
87 checksumValue: Buffer.from(match[2], 'base64').toString('hex'),
88 }];
89 }
90
91 function purl(name, version) {
92 const packageName = name.startsWith('@') ? `%40${name.slice(1)}` : name;
93 return `pkg:npm/${packageName}@${version}`;
94 }
95
96 const modules = arg('node-modules');
97 const sbomOutput = arg('sbom');
98 const noticesOutput = arg('notices');
99 const packageLockFile = arg('package-lock');
100 const releaseLockFile = arg('release-lock');
101 const nodeLicenseFile = arg('node-license');
102 const licenseTemplates = arg('license-templates');
103 const overridesFile = arg('license-overrides');
104 const packageLock = JSON.parse(fs.readFileSync(packageLockFile, 'utf8'));
105 const releaseLock = JSON.parse(fs.readFileSync(releaseLockFile, 'utf8'));
106 const overrides = JSON.parse(fs.readFileSync(overridesFile, 'utf8')).packages;
107 const lockDigest = digest(fs.readFileSync(releaseLockFile));
108
109 const packages = packageRoots(modules).map((directory, index) => {
110 const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'));
111 const key = `${manifest.name}@${manifest.version}`;
112 const override = overrides[key] ?? {};
113 const declared = normalizeLicense(override.license ?? manifest.license);
114 if (!declared) throw new Error(`dependency has no declared or reviewed license: ${key}`);
115 const relative = path.relative(modules, directory).split(path.sep).join('/');
116 const lockEntry = packageLock.packages[`node_modules/${relative}`];
117 if (!lockEntry || lockEntry.version !== manifest.version) {
118 throw new Error(`installed dependency is absent from exact package lock: ${key} at ${relative}`);
119 }
120 const repository = typeof manifest.repository === 'string'
121 ? manifest.repository
122 : manifest.repository?.url;
123 const source = override.source ?? repository ?? manifest.homepage;
124 const included = includedLicenseText(directory);
125 const notice = included ?? fallbackLicense({
126 expression: declared,
127 copyright: override.copyright ?? authorName(manifest.author),
128 source: source ?? key,
129 templates: licenseTemplates,
130 });
131 return {
132 SPDXID: `SPDXRef-Package-${index + 1}`,
133 name: manifest.name,
134 versionInfo: manifest.version,
135 downloadLocation: lockEntry.resolved ?? 'NOASSERTION',
136 filesAnalyzed: false,
137 checksums: integrityChecksum(lockEntry.integrity),
138 licenseConcluded: declared,
139 licenseDeclared: declared,
140 copyrightText: override.copyright ?? authorName(manifest.author) ?? 'NOASSERTION',
141 externalRefs: [{
142 referenceCategory: 'PACKAGE-MANAGER',
143 referenceType: 'purl',
144 referenceLocator: purl(manifest.name, manifest.version),
145 }],
146 notice,
147 };
148 });
149
150 const spdxPackages = packages.map(({ notice, ...entry }) => entry);
151 spdxPackages.push(
152 {
153 SPDXID: 'SPDXRef-NodeRuntime',
154 name: 'Node.js',
155 versionInfo: releaseLock.node.version,
156 downloadLocation: releaseLock.node.url,
157 filesAnalyzed: false,
158 checksums: [{ algorithm: 'SHA256', checksumValue: releaseLock.node.sha256 }],
159 licenseConcluded: 'MIT',
160 licenseDeclared: 'MIT',
161 copyrightText: 'Node.js contributors',
162 },
163 {
164 SPDXID: 'SPDXRef-EmbeddingModel',
165 name: releaseLock.model.repository,
166 versionInfo: releaseLock.model.revision,
167 downloadLocation: `https://huggingface.co/${releaseLock.model.repository}/tree/${releaseLock.model.revision}`,
168 filesAnalyzed: false,
169 licenseConcluded: 'Apache-2.0',
170 licenseDeclared: 'Apache-2.0',
171 copyrightText: 'NOASSERTION',
172 }
173 );
174
175 const sbom = {
176 spdxVersion: 'SPDX-2.3',
177 dataLicense: 'CC0-1.0',
178 SPDXID: 'SPDXRef-DOCUMENT',
179 name: 'Knowtation-0.1.0-100-macos-arm64',
180 documentNamespace: `https://releases.knowtation.store/spdx/${lockDigest}`,
181 creationInfo: {
182 created: new Date(Number(process.env.SOURCE_DATE_EPOCH ?? 0) * 1000).toISOString(),
183 creators: ['Tool: Knowtation Phase 7 release builder'],
184 },
185 packages: spdxPackages,
186 relationships: spdxPackages.map((entry) => ({
187 spdxElementId: 'SPDXRef-DOCUMENT',
188 relationshipType: 'DESCRIBES',
189 relatedSpdxElement: entry.SPDXID,
190 })),
191 };
192
193 fs.mkdirSync(path.dirname(sbomOutput), { recursive: true });
194 fs.writeFileSync(sbomOutput, `${JSON.stringify(sbom, null, 2)}\n`);
195
196 const noticeParts = [
197 'Knowtation third-party notices',
198 'Generated from the exact standalone runtime dependency tree.',
199 '',
200 ];
201 for (const pkg of packages) {
202 noticeParts.push(`===== ${pkg.name} ${pkg.versionInfo} (${pkg.licenseDeclared}) =====`);
203 noticeParts.push(pkg.notice.trim());
204 noticeParts.push('');
205 }
206 noticeParts.push(`===== Node.js ${releaseLock.node.version} (MIT and bundled third-party licenses) =====`);
207 noticeParts.push(fs.readFileSync(nodeLicenseFile, 'utf8').trim());
208 noticeParts.push('');
209 noticeParts.push(`===== ${releaseLock.model.repository} ${releaseLock.model.revision} (Apache-2.0) =====`);
210 noticeParts.push(fs.readFileSync(path.join(licenseTemplates, 'Apache-2.0.txt'), 'utf8').trim());
211 noticeParts.push('');
212 fs.writeFileSync(noticesOutput, `${noticeParts.join('\n')}\n`);
File History 1 commit
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ 24 days ago