generate-sbom.mjs
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