harness-everything 0.19.1 → 0.20.0

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,239 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Generate a CycloneDX SBOM from package.json and package-lock.json.
6
+ *
7
+ * The file is intentionally generated during semantic-release rather than
8
+ * committed on every change. npm packs the generated root-level file, so the
9
+ * published package carries the exact release version and dependency graph.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const crypto = require('crypto');
14
+ const path = require('path');
15
+
16
+ const ROOT = path.resolve(__dirname, '..');
17
+ const DEFAULT_OUTPUT = path.join(ROOT, 'sbom.cdx.json');
18
+ const PACKAGE_NAME = 'harness-everything';
19
+ const CYCLONEDX_VERSION = '1.7';
20
+ const CYCLONEDX_SCHEMA_URL = `https://cyclonedx.org/schema/bom-${CYCLONEDX_VERSION}.schema.json`;
21
+
22
+ function readJson(file) {
23
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
24
+ }
25
+
26
+ function npmPurl(name, version) {
27
+ const purlName = String(name).startsWith('@') ? `%40${String(name).slice(1)}` : String(name);
28
+ return `pkg:npm/${purlName}@${encodeURIComponent(version)}`;
29
+ }
30
+
31
+ function serialNumberFor(name, version) {
32
+ // UUID v5-shaped, deterministic serial number: release SBOMs remain
33
+ // comparable without introducing a random value into the generated file.
34
+ const namespace = Buffer.from('6ba7b8109dad11d180b400c04fd430c8', 'hex');
35
+ const digest = crypto.createHash('sha1').update(namespace).update(`${name}@${version}`).digest();
36
+ digest[6] = (digest[6] & 0x0f) | 0x50;
37
+ digest[8] = (digest[8] & 0x3f) | 0x80;
38
+ const hex = digest.toString('hex').slice(0, 32);
39
+ return `urn:uuid:${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
40
+ }
41
+
42
+ function integrityHash(integrity) {
43
+ if (typeof integrity !== 'string') return null;
44
+ const match = integrity.match(/^(sha\d+)-(.+)$/i);
45
+ if (!match) return null;
46
+ const algorithm = match[1].toUpperCase().replace(/^SHA(\d+)$/, 'SHA-$1');
47
+ return { alg: algorithm, content: match[2] };
48
+ }
49
+
50
+ function packageNameFromKey(key, record) {
51
+ if (record && record.name) return record.name;
52
+ const marker = '/node_modules/';
53
+ const last = key.lastIndexOf(marker);
54
+ const name = last === -1 ? key.replace(/^node_modules\//, '') : key.slice(last + marker.length);
55
+ if (name.startsWith('@')) return name;
56
+ return name.split('/node_modules/').pop();
57
+ }
58
+
59
+ function componentFor(key, record) {
60
+ const name = packageNameFromKey(key, record);
61
+ if (!record || !record.version || !name) return null;
62
+ const purl = npmPurl(name, record.version);
63
+ const component = {
64
+ type: 'library',
65
+ 'bom-ref': purl,
66
+ name,
67
+ version: record.version,
68
+ scope: record.dev ? 'excluded' : (record.optional ? 'optional' : 'required'),
69
+ purl
70
+ };
71
+ const hash = integrityHash(record.integrity);
72
+ if (hash) component.hashes = [hash];
73
+ return component;
74
+ }
75
+
76
+ function resolveDependencyKey(packages, parentKey, dependencyName) {
77
+ let current = parentKey;
78
+ while (true) {
79
+ const candidate = current
80
+ ? `${current}/node_modules/${dependencyName}`
81
+ : `node_modules/${dependencyName}`;
82
+ if (packages[candidate] && packages[candidate].version) return candidate;
83
+ const marker = '/node_modules/';
84
+ const markerIndex = current.lastIndexOf(marker);
85
+ if (markerIndex !== -1) current = current.slice(0, markerIndex);
86
+ else if (current.startsWith('node_modules/')) current = '';
87
+ else break;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function directDependencyNames(packageJson) {
93
+ return [...new Set([
94
+ ...Object.keys(packageJson.dependencies || {}),
95
+ ...Object.keys(packageJson.optionalDependencies || {}),
96
+ ...Object.keys(packageJson.devDependencies || {})
97
+ ])].sort();
98
+ }
99
+
100
+ function buildBom(root = ROOT, options = {}) {
101
+ const packageJson = readJson(path.join(root, 'package.json'));
102
+ const lock = readJson(path.join(root, 'package-lock.json'));
103
+ const version = options.version || packageJson.version;
104
+ const name = packageJson.name || PACKAGE_NAME;
105
+ const rootPurl = npmPurl(name, version);
106
+ const packages = lock.packages || {};
107
+ const components = [];
108
+ const componentByKey = new Map();
109
+
110
+ for (const [key, record] of Object.entries(packages)) {
111
+ if (!key) continue;
112
+ const component = componentFor(key, record);
113
+ if (!component) continue;
114
+ components.push(component);
115
+ componentByKey.set(key, component);
116
+ }
117
+
118
+ components.sort((left, right) => left.purl.localeCompare(right.purl));
119
+ const dependencyRecords = [];
120
+ const dependencySources = [['', packages[''] || {}]];
121
+ for (const [key, record] of Object.entries(packages)) {
122
+ if (key) dependencySources.push([key, record]);
123
+ }
124
+ for (const [key, record] of dependencySources) {
125
+ const sourceRef = key ? componentByKey.get(key)?.['bom-ref'] : rootPurl;
126
+ if (!sourceRef) continue;
127
+ const dependencyNames = key ? Object.keys(record.dependencies || {}) : directDependencyNames(packageJson);
128
+ const dependsOn = dependencyNames
129
+ .map(dependencyName => resolveDependencyKey(packages, key, dependencyName))
130
+ .map(dependencyKey => dependencyKey && componentByKey.get(dependencyKey)?.['bom-ref'])
131
+ .filter(Boolean)
132
+ .sort();
133
+ if (dependsOn.length) dependencyRecords.push({ ref: sourceRef, dependsOn: [...new Set(dependsOn)] });
134
+ }
135
+ dependencyRecords.sort((left, right) => left.ref.localeCompare(right.ref));
136
+
137
+ const bom = {
138
+ $schema: CYCLONEDX_SCHEMA_URL,
139
+ bomFormat: 'CycloneDX',
140
+ specVersion: CYCLONEDX_VERSION,
141
+ serialNumber: options.serialNumber || serialNumberFor(name, version),
142
+ version: 1,
143
+ metadata: {
144
+ timestamp: options.timestamp || new Date().toISOString(),
145
+ tools: [{ vendor: 'Harness Everything', name: 'generate-sbom.js', version }],
146
+ component: {
147
+ type: 'application',
148
+ 'bom-ref': rootPurl,
149
+ name,
150
+ version,
151
+ purl: rootPurl
152
+ }
153
+ },
154
+ components,
155
+ dependencies: dependencyRecords
156
+ };
157
+ return bom;
158
+ }
159
+
160
+ function validateBom(bom, expectedVersion) {
161
+ if (!bom || bom.bomFormat !== 'CycloneDX') throw new Error('SBOM must use the CycloneDX format');
162
+ if (bom.$schema !== CYCLONEDX_SCHEMA_URL) throw new Error(`SBOM schema must be ${CYCLONEDX_SCHEMA_URL}`);
163
+ if (bom.specVersion !== CYCLONEDX_VERSION) throw new Error(`SBOM specVersion must be ${CYCLONEDX_VERSION}, got ${bom.specVersion}`);
164
+ if (!Number.isInteger(bom.version) || bom.version < 1) throw new Error('SBOM document version is missing');
165
+ if (!/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(bom.serialNumber || ''))) {
166
+ throw new Error('SBOM serialNumber must be a UUID URN');
167
+ }
168
+ const component = bom.metadata && bom.metadata.component;
169
+ if (!component || component.name !== PACKAGE_NAME) throw new Error('SBOM metadata component must be harness-everything');
170
+ if (!component.version || (expectedVersion && component.version !== expectedVersion)) {
171
+ throw new Error(`SBOM metadata version drift: expected ${expectedVersion || 'a version'}, got ${component.version || 'missing'}`);
172
+ }
173
+ if (!Array.isArray(bom.components)) throw new Error('SBOM components array is missing');
174
+ for (const entry of bom.components) {
175
+ if (!entry.name || !entry.version || !entry.purl || !entry['bom-ref']) throw new Error('SBOM component is incomplete');
176
+ }
177
+ if (!Array.isArray(bom.dependencies)) throw new Error('SBOM dependencies array is missing');
178
+ return true;
179
+ }
180
+
181
+ function writeBom(output, root = ROOT, options = {}) {
182
+ const bom = buildBom(root, options);
183
+ validateBom(bom, options.version);
184
+ fs.writeFileSync(output, `${JSON.stringify(bom, null, 2)}\n`, 'utf8');
185
+ return bom;
186
+ }
187
+
188
+ function parseArgs(argv) {
189
+ const options = { output: DEFAULT_OUTPUT, validate: null, version: null };
190
+ for (let index = 0; index < argv.length; index++) {
191
+ const arg = argv[index];
192
+ if (arg === '--output') options.output = path.resolve(argv[++index] || '');
193
+ else if (arg === '--validate') options.validate = path.resolve(argv[++index] || '');
194
+ else if (arg === '--version') options.version = argv[++index] || null;
195
+ else if (arg === '--help' || arg === '-h') options.help = true;
196
+ else throw new Error(`Unknown option: ${arg}`);
197
+ }
198
+ return options;
199
+ }
200
+
201
+ function main(argv = process.argv.slice(2)) {
202
+ const options = parseArgs(argv);
203
+ if (options.help) {
204
+ console.log('Usage: node scripts/generate-sbom.js [--output <file>] [--version <x.y.z>]');
205
+ console.log(' node scripts/generate-sbom.js --validate <file> [--version <x.y.z>]');
206
+ return 0;
207
+ }
208
+ if (options.validate) {
209
+ const bom = readJson(options.validate);
210
+ validateBom(bom, options.version);
211
+ console.log(`Validated CycloneDX SBOM ${options.validate} (${bom.components.length} components).`);
212
+ return 0;
213
+ }
214
+ const bom = writeBom(options.output, ROOT, { version: options.version });
215
+ console.log(`Generated CycloneDX SBOM ${options.output} (${bom.components.length} components).`);
216
+ return 0;
217
+ }
218
+
219
+ if (require.main === module) {
220
+ try {
221
+ process.exitCode = main();
222
+ } catch (error) {
223
+ console.error(`[Error] ${error.message}`);
224
+ process.exitCode = 1;
225
+ }
226
+ }
227
+
228
+ module.exports = {
229
+ buildBom,
230
+ componentFor,
231
+ CYCLONEDX_SCHEMA_URL,
232
+ CYCLONEDX_VERSION,
233
+ directDependencyNames,
234
+ integrityHash,
235
+ main,
236
+ npmPurl,
237
+ validateBom,
238
+ writeBom
239
+ };