cra-audit 0.1.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.
- package/LICENSE +21 -0
- package/README.md +248 -0
- package/bin/cra-audit.js +17 -0
- package/package.json +45 -0
- package/src/cli.js +217 -0
- package/src/commands/audit.js +62 -0
- package/src/commands/sbom.js +100 -0
- package/src/commands/visualize.js +188 -0
- package/src/core/auditor.js +128 -0
- package/src/core/enrich.js +220 -0
- package/src/core/license-checker.js +102 -0
- package/src/core/lockfile-parser.js +198 -0
- package/src/core/policy.js +76 -0
- package/src/core/sbom-generator.js +146 -0
- package/src/core/sbom-validator.js +124 -0
- package/src/core/vuln-scanner.js +148 -0
- package/src/index.js +33 -0
- package/src/reporters/console.js +137 -0
- package/src/reporters/html.js +267 -0
- package/src/reporters/json.js +20 -0
- package/src/utils/exec.js +57 -0
- package/src/utils/fs.js +44 -0
- package/src/utils/http.js +63 -0
- package/src/utils/logger.js +85 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { readJson } = require('../utils/fs');
|
|
6
|
+
const { parseLockfile } = require('./lockfile-parser');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolves and evaluates the license of every third-party component.
|
|
10
|
+
* CRA / TR-03183 require every component's license to be documented.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} projectRoot
|
|
13
|
+
* @param {{ allow?: string[], deny?: string[] }} [policy]
|
|
14
|
+
*/
|
|
15
|
+
function checkLicenses(projectRoot, policy = {}) {
|
|
16
|
+
const parsed = parseLockfile(projectRoot);
|
|
17
|
+
if (!parsed.ok) {
|
|
18
|
+
return { ok: false, error: parsed.error, components: [], summary: emptySummary() };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const allow = normalizeList(policy.allow);
|
|
22
|
+
const deny = normalizeList(policy.deny);
|
|
23
|
+
|
|
24
|
+
const components = parsed.components.map((c) => {
|
|
25
|
+
const license = c.license || readLicenseFromDisk(projectRoot, c.path, c.name);
|
|
26
|
+
const normalized = license ? String(license) : null;
|
|
27
|
+
const status = classify(normalized, allow, deny);
|
|
28
|
+
return {
|
|
29
|
+
name: c.name,
|
|
30
|
+
version: c.version,
|
|
31
|
+
license: normalized,
|
|
32
|
+
status, // 'ok' | 'missing' | 'denied' | 'not-allowed'
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const summary = {
|
|
37
|
+
total: components.length,
|
|
38
|
+
documented: components.filter((c) => c.license).length,
|
|
39
|
+
missing: components.filter((c) => c.status === 'missing'),
|
|
40
|
+
denied: components.filter((c) => c.status === 'denied'),
|
|
41
|
+
notAllowed: components.filter((c) => c.status === 'not-allowed'),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return { ok: true, components, summary };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Reads the `license` field from an installed package's package.json. */
|
|
48
|
+
function readLicenseFromDisk(projectRoot, pkgPath, name) {
|
|
49
|
+
const candidates = [];
|
|
50
|
+
if (pkgPath) candidates.push(path.join(projectRoot, pkgPath, 'package.json'));
|
|
51
|
+
candidates.push(path.join(projectRoot, 'node_modules', name, 'package.json'));
|
|
52
|
+
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
try {
|
|
55
|
+
if (!fs.existsSync(candidate)) continue;
|
|
56
|
+
const pkg = readJson(candidate);
|
|
57
|
+
if (!pkg) continue;
|
|
58
|
+
if (typeof pkg.license === 'string') return pkg.license;
|
|
59
|
+
if (pkg.license && pkg.license.type) return pkg.license.type;
|
|
60
|
+
if (Array.isArray(pkg.licenses) && pkg.licenses.length) {
|
|
61
|
+
return pkg.licenses.map((l) => (typeof l === 'string' ? l : l.type)).filter(Boolean).join(' OR ');
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Ignore unreadable package and continue.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function classify(license, allow, deny) {
|
|
71
|
+
if (!license || /^(unknown|unlicensed|see license)/i.test(license)) {
|
|
72
|
+
return 'missing';
|
|
73
|
+
}
|
|
74
|
+
const ids = extractLicenseIds(license);
|
|
75
|
+
if (deny.length && ids.some((id) => deny.includes(id))) {
|
|
76
|
+
return 'denied';
|
|
77
|
+
}
|
|
78
|
+
if (allow.length && !ids.every((id) => allow.includes(id))) {
|
|
79
|
+
return 'not-allowed';
|
|
80
|
+
}
|
|
81
|
+
return 'ok';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Splits an SPDX expression into individual license identifiers (lowercased). */
|
|
85
|
+
function extractLicenseIds(expression) {
|
|
86
|
+
return expression
|
|
87
|
+
.replace(/[()]/g, ' ')
|
|
88
|
+
.split(/\s+(?:AND|OR|WITH)\s+/i)
|
|
89
|
+
.map((s) => s.trim().toLowerCase())
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeList(list) {
|
|
94
|
+
if (!Array.isArray(list)) return [];
|
|
95
|
+
return list.map((s) => String(s).trim().toLowerCase()).filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function emptySummary() {
|
|
99
|
+
return { total: 0, documented: 0, missing: [], denied: [], notAllowed: [] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { checkLicenses };
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { readJson, exists } = require('../utils/fs');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parses an npm lockfile (package-lock.json v2/v3 or npm-shrinkwrap.json)
|
|
8
|
+
* into a normalized list of components. CRA / TR-03183 require traceability
|
|
9
|
+
* of every direct and transitive dependency, including integrity hashes.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} projectRoot
|
|
12
|
+
* @returns {{
|
|
13
|
+
* ok: boolean,
|
|
14
|
+
* lockfileName: string|null,
|
|
15
|
+
* lockfileVersion: number|null,
|
|
16
|
+
* root: { name: string, version: string },
|
|
17
|
+
* components: Array<object>,
|
|
18
|
+
* error?: string
|
|
19
|
+
* }}
|
|
20
|
+
*/
|
|
21
|
+
function parseLockfile(projectRoot) {
|
|
22
|
+
const candidates = ['package-lock.json', 'npm-shrinkwrap.json'];
|
|
23
|
+
let lockPath = null;
|
|
24
|
+
let lockfileName = null;
|
|
25
|
+
|
|
26
|
+
for (const candidate of candidates) {
|
|
27
|
+
const full = path.join(projectRoot, candidate);
|
|
28
|
+
if (exists(full)) {
|
|
29
|
+
lockPath = full;
|
|
30
|
+
lockfileName = candidate;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
36
|
+
const rootPkg = readJson(pkgPath) || {};
|
|
37
|
+
const root = {
|
|
38
|
+
name: rootPkg.name || path.basename(projectRoot),
|
|
39
|
+
version: rootPkg.version || '0.0.0',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (!lockPath) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
lockfileName: null,
|
|
46
|
+
lockfileVersion: null,
|
|
47
|
+
root,
|
|
48
|
+
components: [],
|
|
49
|
+
error:
|
|
50
|
+
'No se encontró package-lock.json ni npm-shrinkwrap.json. ' +
|
|
51
|
+
'El CRA exige trazabilidad determinista de dependencias: ejecuta `npm install` para generar el lockfile.',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const lock = readJson(lockPath);
|
|
56
|
+
const lockfileVersion = lock.lockfileVersion || 1;
|
|
57
|
+
|
|
58
|
+
let components;
|
|
59
|
+
if (lock.packages) {
|
|
60
|
+
components = parsePackagesField(lock.packages);
|
|
61
|
+
} else if (lock.dependencies) {
|
|
62
|
+
components = parseDependenciesField(lock.dependencies);
|
|
63
|
+
} else {
|
|
64
|
+
components = [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
lockfileName,
|
|
70
|
+
lockfileVersion,
|
|
71
|
+
root,
|
|
72
|
+
components,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Lockfile v2/v3 store everything under the `packages` map keyed by path. */
|
|
77
|
+
function parsePackagesField(packages) {
|
|
78
|
+
const components = [];
|
|
79
|
+
|
|
80
|
+
for (const [pkgPath, info] of Object.entries(packages)) {
|
|
81
|
+
// The root project is stored under the empty key.
|
|
82
|
+
if (pkgPath === '') continue;
|
|
83
|
+
if (info.link) continue; // local symlinked workspace, not a third-party artifact
|
|
84
|
+
|
|
85
|
+
const name = info.name || nameFromPath(pkgPath);
|
|
86
|
+
if (!name) continue;
|
|
87
|
+
|
|
88
|
+
components.push(normalizeComponent({
|
|
89
|
+
name,
|
|
90
|
+
version: info.version,
|
|
91
|
+
resolved: info.resolved,
|
|
92
|
+
integrity: info.integrity,
|
|
93
|
+
license: info.license,
|
|
94
|
+
dev: Boolean(info.dev),
|
|
95
|
+
optional: Boolean(info.optional),
|
|
96
|
+
path: pkgPath,
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return dedupe(components);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Lockfile v1 nests dependencies recursively under `dependencies`. */
|
|
104
|
+
function parseDependenciesField(dependencies, acc = []) {
|
|
105
|
+
for (const [name, info] of Object.entries(dependencies)) {
|
|
106
|
+
acc.push(normalizeComponent({
|
|
107
|
+
name,
|
|
108
|
+
version: info.version,
|
|
109
|
+
resolved: info.resolved,
|
|
110
|
+
integrity: info.integrity,
|
|
111
|
+
license: info.license,
|
|
112
|
+
dev: Boolean(info.dev),
|
|
113
|
+
optional: Boolean(info.optional),
|
|
114
|
+
path: `node_modules/${name}`,
|
|
115
|
+
}));
|
|
116
|
+
if (info.dependencies) {
|
|
117
|
+
parseDependenciesField(info.dependencies, acc);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return dedupe(acc);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function nameFromPath(pkgPath) {
|
|
124
|
+
const idx = pkgPath.lastIndexOf('node_modules/');
|
|
125
|
+
if (idx === -1) return null;
|
|
126
|
+
return pkgPath.slice(idx + 'node_modules/'.length);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeComponent(raw) {
|
|
130
|
+
const { algorithm, hash } = parseIntegrity(raw.integrity);
|
|
131
|
+
return {
|
|
132
|
+
name: raw.name,
|
|
133
|
+
version: raw.version || null,
|
|
134
|
+
purl: buildPurl(raw.name, raw.version),
|
|
135
|
+
resolved: raw.resolved || null,
|
|
136
|
+
integrity: raw.integrity || null,
|
|
137
|
+
hashAlgorithm: algorithm,
|
|
138
|
+
hashValue: hash,
|
|
139
|
+
license: raw.license || null,
|
|
140
|
+
scope: raw.dev ? 'optional' : 'required',
|
|
141
|
+
dev: raw.dev,
|
|
142
|
+
optional: raw.optional,
|
|
143
|
+
path: raw.path,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Parses an npm Subresource Integrity string ("sha512-<base64>")
|
|
149
|
+
* into a CycloneDX-friendly { algorithm, hash } pair.
|
|
150
|
+
*/
|
|
151
|
+
function parseIntegrity(integrity) {
|
|
152
|
+
if (!integrity || typeof integrity !== 'string') {
|
|
153
|
+
return { algorithm: null, hash: null };
|
|
154
|
+
}
|
|
155
|
+
const [algo, b64] = integrity.split('-');
|
|
156
|
+
if (!algo || !b64) return { algorithm: null, hash: null };
|
|
157
|
+
|
|
158
|
+
const map = { sha512: 'SHA-512', sha384: 'SHA-384', sha256: 'SHA-256', sha1: 'SHA-1' };
|
|
159
|
+
const algorithm = map[algo.toLowerCase()] || null;
|
|
160
|
+
if (!algorithm) return { algorithm: null, hash: null };
|
|
161
|
+
|
|
162
|
+
let hex;
|
|
163
|
+
try {
|
|
164
|
+
hex = Buffer.from(b64, 'base64').toString('hex');
|
|
165
|
+
} catch {
|
|
166
|
+
hex = null;
|
|
167
|
+
}
|
|
168
|
+
return { algorithm, hash: hex };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Builds a Package URL (purl) per the npm purl-spec. */
|
|
172
|
+
function buildPurl(name, version) {
|
|
173
|
+
if (!name) return null;
|
|
174
|
+
let purl = 'pkg:npm/';
|
|
175
|
+
if (name.startsWith('@')) {
|
|
176
|
+
const [scope, pkg] = name.split('/');
|
|
177
|
+
purl += `${encodeURIComponent(scope)}/${encodeURIComponent(pkg)}`;
|
|
178
|
+
} else {
|
|
179
|
+
purl += encodeURIComponent(name);
|
|
180
|
+
}
|
|
181
|
+
if (version) purl += `@${version}`;
|
|
182
|
+
return purl;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function dedupe(components) {
|
|
186
|
+
const seen = new Map();
|
|
187
|
+
for (const comp of components) {
|
|
188
|
+
const key = `${comp.name}@${comp.version}`;
|
|
189
|
+
if (!seen.has(key)) {
|
|
190
|
+
seen.set(key, comp);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return Array.from(seen.values()).sort((a, b) =>
|
|
194
|
+
a.name.localeCompare(b.name) || String(a.version).localeCompare(String(b.version))
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { parseLockfile, buildPurl, parseIntegrity };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { readJson, exists } = require('../utils/fs');
|
|
5
|
+
|
|
6
|
+
const CONFIG_FILENAMES = ['.cra-audit.json', 'cra-audit.config.json'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default security policy aligned with CRA Article 13 ("no known exploitable
|
|
10
|
+
* vulnerabilities") and TR-03183 transparency requirements.
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_POLICY = {
|
|
13
|
+
// Fail the audit when a vulnerability of this severity (or higher) is found.
|
|
14
|
+
failOn: 'high', // info | low | moderate | high | critical
|
|
15
|
+
// Require an SBOM that satisfies the minimum TR-03183 elements.
|
|
16
|
+
requireSbom: true,
|
|
17
|
+
sbomFormat: 'cyclonedx', // cyclonedx | spdx
|
|
18
|
+
// Audit only production dependencies (recommended for shipped products).
|
|
19
|
+
productionOnly: false,
|
|
20
|
+
vulnerabilities: {
|
|
21
|
+
// CVE/advisory ids explicitly accepted with documented justification.
|
|
22
|
+
allowlist: [],
|
|
23
|
+
},
|
|
24
|
+
licenses: {
|
|
25
|
+
// Empty allow = accept any license that is not denied.
|
|
26
|
+
allow: [],
|
|
27
|
+
deny: ['GPL-3.0', 'AGPL-3.0'],
|
|
28
|
+
// Treat undocumented licenses as a compliance failure.
|
|
29
|
+
failOnMissing: true,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Loads and merges the project security policy from disk over the defaults.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} projectRoot
|
|
37
|
+
* @param {string} [explicitPath] Path provided via --config.
|
|
38
|
+
*/
|
|
39
|
+
function loadPolicy(projectRoot, explicitPath) {
|
|
40
|
+
let configPath = null;
|
|
41
|
+
|
|
42
|
+
if (explicitPath) {
|
|
43
|
+
configPath = path.isAbsolute(explicitPath)
|
|
44
|
+
? explicitPath
|
|
45
|
+
: path.join(projectRoot, explicitPath);
|
|
46
|
+
if (!exists(configPath)) {
|
|
47
|
+
throw new Error(`No se encontró el archivo de configuración: ${explicitPath}`);
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
for (const name of CONFIG_FILENAMES) {
|
|
51
|
+
const candidate = path.join(projectRoot, name);
|
|
52
|
+
if (exists(candidate)) {
|
|
53
|
+
configPath = candidate;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let userPolicy = {};
|
|
60
|
+
if (configPath) {
|
|
61
|
+
userPolicy = readJson(configPath) || {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { policy: mergePolicy(DEFAULT_POLICY, userPolicy), source: configPath };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function mergePolicy(base, override) {
|
|
68
|
+
return {
|
|
69
|
+
...base,
|
|
70
|
+
...override,
|
|
71
|
+
vulnerabilities: { ...base.vulnerabilities, ...(override.vulnerabilities || {}) },
|
|
72
|
+
licenses: { ...base.licenses, ...(override.licenses || {}) },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { loadPolicy, DEFAULT_POLICY };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const { parseLockfile } = require('./lockfile-parser');
|
|
5
|
+
|
|
6
|
+
const TOOL_NAME = 'cra-audit';
|
|
7
|
+
const TOOL_VERSION = require('../../package.json').version;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generates a Software Bill of Materials from the project lockfile.
|
|
11
|
+
* Supported formats: CycloneDX 1.5 (JSON) and SPDX 2.3 (JSON).
|
|
12
|
+
* Required by CRA Annex I and BSI TR-03183 Part 2.
|
|
13
|
+
*/
|
|
14
|
+
function generateSbom(projectRoot, { format = 'cyclonedx' } = {}) {
|
|
15
|
+
const parsed = parseLockfile(projectRoot);
|
|
16
|
+
if (!parsed.ok) {
|
|
17
|
+
return { ok: false, error: parsed.error };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const normalizedFormat = String(format).toLowerCase();
|
|
21
|
+
let document;
|
|
22
|
+
if (normalizedFormat === 'spdx') {
|
|
23
|
+
document = buildSpdx(parsed);
|
|
24
|
+
} else if (normalizedFormat === 'cyclonedx' || normalizedFormat === 'cdx') {
|
|
25
|
+
document = buildCycloneDx(parsed);
|
|
26
|
+
} else {
|
|
27
|
+
return { ok: false, error: `Formato de SBOM no soportado: "${format}". Usa "cyclonedx" o "spdx".` };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
format: normalizedFormat === 'spdx' ? 'spdx' : 'cyclonedx',
|
|
33
|
+
componentCount: parsed.components.length,
|
|
34
|
+
document,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildCycloneDx(parsed) {
|
|
39
|
+
const timestamp = new Date().toISOString();
|
|
40
|
+
const serialNumber = `urn:uuid:${crypto.randomUUID()}`;
|
|
41
|
+
|
|
42
|
+
const components = parsed.components.map((c) => {
|
|
43
|
+
const component = {
|
|
44
|
+
type: 'library',
|
|
45
|
+
'bom-ref': c.purl || `${c.name}@${c.version}`,
|
|
46
|
+
name: c.name,
|
|
47
|
+
version: c.version || undefined,
|
|
48
|
+
scope: c.scope,
|
|
49
|
+
purl: c.purl || undefined,
|
|
50
|
+
};
|
|
51
|
+
if (c.hashAlgorithm && c.hashValue) {
|
|
52
|
+
component.hashes = [{ alg: c.hashAlgorithm, content: c.hashValue }];
|
|
53
|
+
}
|
|
54
|
+
if (c.license) {
|
|
55
|
+
component.licenses = [normalizeCdxLicense(c.license)];
|
|
56
|
+
}
|
|
57
|
+
return component;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
bomFormat: 'CycloneDX',
|
|
62
|
+
specVersion: '1.5',
|
|
63
|
+
serialNumber,
|
|
64
|
+
version: 1,
|
|
65
|
+
metadata: {
|
|
66
|
+
timestamp,
|
|
67
|
+
tools: [{ vendor: 'cra-audit', name: TOOL_NAME, version: TOOL_VERSION }],
|
|
68
|
+
component: {
|
|
69
|
+
type: 'application',
|
|
70
|
+
'bom-ref': `pkg:npm/${parsed.root.name}@${parsed.root.version}`,
|
|
71
|
+
name: parsed.root.name,
|
|
72
|
+
version: parsed.root.version,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
components,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeCdxLicense(license) {
|
|
80
|
+
if (typeof license === 'string') {
|
|
81
|
+
// Use SPDX expression when it contains operators, else a license id.
|
|
82
|
+
if (/\s(AND|OR|WITH)\s/i.test(license)) {
|
|
83
|
+
return { expression: license };
|
|
84
|
+
}
|
|
85
|
+
return { license: { id: license } };
|
|
86
|
+
}
|
|
87
|
+
return { license: { name: String(license) } };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildSpdx(parsed) {
|
|
91
|
+
const created = new Date().toISOString();
|
|
92
|
+
const docNamespace = `https://spdx.org/spdxdocs/${parsed.root.name}-${crypto.randomUUID()}`;
|
|
93
|
+
|
|
94
|
+
const packages = parsed.components.map((c, index) => {
|
|
95
|
+
const spdxId = `SPDXRef-Package-${index}`;
|
|
96
|
+
const pkg = {
|
|
97
|
+
name: c.name,
|
|
98
|
+
SPDXID: spdxId,
|
|
99
|
+
versionInfo: c.version || 'NOASSERTION',
|
|
100
|
+
downloadLocation: c.resolved || 'NOASSERTION',
|
|
101
|
+
filesAnalyzed: false,
|
|
102
|
+
licenseConcluded: c.license || 'NOASSERTION',
|
|
103
|
+
licenseDeclared: c.license || 'NOASSERTION',
|
|
104
|
+
copyrightText: 'NOASSERTION',
|
|
105
|
+
externalRefs: c.purl
|
|
106
|
+
? [{
|
|
107
|
+
referenceCategory: 'PACKAGE-MANAGER',
|
|
108
|
+
referenceType: 'purl',
|
|
109
|
+
referenceLocator: c.purl,
|
|
110
|
+
}]
|
|
111
|
+
: undefined,
|
|
112
|
+
};
|
|
113
|
+
if (c.hashAlgorithm && c.hashValue) {
|
|
114
|
+
pkg.checksums = [{ algorithm: c.hashAlgorithm.replace('-', ''), checksumValue: c.hashValue }];
|
|
115
|
+
}
|
|
116
|
+
return pkg;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const rootSpdxId = 'SPDXRef-Package-root';
|
|
120
|
+
packages.unshift({
|
|
121
|
+
name: parsed.root.name,
|
|
122
|
+
SPDXID: rootSpdxId,
|
|
123
|
+
versionInfo: parsed.root.version,
|
|
124
|
+
downloadLocation: 'NOASSERTION',
|
|
125
|
+
filesAnalyzed: false,
|
|
126
|
+
licenseConcluded: 'NOASSERTION',
|
|
127
|
+
licenseDeclared: 'NOASSERTION',
|
|
128
|
+
copyrightText: 'NOASSERTION',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
spdxVersion: 'SPDX-2.3',
|
|
133
|
+
dataLicense: 'CC0-1.0',
|
|
134
|
+
SPDXID: 'SPDXRef-DOCUMENT',
|
|
135
|
+
name: `${parsed.root.name}@${parsed.root.version}`,
|
|
136
|
+
documentNamespace: docNamespace,
|
|
137
|
+
creationInfo: {
|
|
138
|
+
created,
|
|
139
|
+
creators: [`Tool: ${TOOL_NAME}-${TOOL_VERSION}`],
|
|
140
|
+
},
|
|
141
|
+
documentDescribes: [rootSpdxId],
|
|
142
|
+
packages,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { generateSbom };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validates an SBOM document against the minimum elements required by
|
|
5
|
+
* CRA Annex I (2)(1) and BSI TR-03183 Part 2. It auto-detects whether the
|
|
6
|
+
* document is CycloneDX or SPDX.
|
|
7
|
+
*
|
|
8
|
+
* Minimum required elements (TR-03183 §6 "minimum elements"):
|
|
9
|
+
* - SBOM format declared (CycloneDX or SPDX)
|
|
10
|
+
* - Author / creator of the SBOM
|
|
11
|
+
* - Timestamp of creation
|
|
12
|
+
* - For each component: name, version, supplier/author, unique identifier,
|
|
13
|
+
* cryptographic hash, license and dependency relationship.
|
|
14
|
+
*/
|
|
15
|
+
function validateSbom(document) {
|
|
16
|
+
if (!document || typeof document !== 'object') {
|
|
17
|
+
return invalid('El SBOM no es un objeto JSON válido.');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (document.bomFormat === 'CycloneDX' || document.specVersion) {
|
|
21
|
+
return validateCycloneDx(document);
|
|
22
|
+
}
|
|
23
|
+
if (document.spdxVersion || document.SPDXID === 'SPDXRef-DOCUMENT') {
|
|
24
|
+
return validateSpdx(document);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return invalid('Formato de SBOM no reconocido (no es CycloneDX ni SPDX).');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validateCycloneDx(doc) {
|
|
31
|
+
const issues = [];
|
|
32
|
+
const checks = [];
|
|
33
|
+
|
|
34
|
+
pushCheck(checks, 'format', 'Formato declarado (CycloneDX)', doc.bomFormat === 'CycloneDX');
|
|
35
|
+
pushCheck(checks, 'specVersion', 'Versión de especificación presente', Boolean(doc.specVersion));
|
|
36
|
+
pushCheck(checks, 'serialNumber', 'Identificador único del documento (serialNumber)', Boolean(doc.serialNumber));
|
|
37
|
+
pushCheck(checks, 'timestamp', 'Marca temporal de creación', Boolean(doc.metadata && doc.metadata.timestamp));
|
|
38
|
+
pushCheck(checks, 'author', 'Autor / herramienta generadora declarada',
|
|
39
|
+
Boolean(doc.metadata && (doc.metadata.tools || doc.metadata.authors)));
|
|
40
|
+
pushCheck(checks, 'rootComponent', 'Componente raíz (producto) identificado',
|
|
41
|
+
Boolean(doc.metadata && doc.metadata.component && doc.metadata.component.name));
|
|
42
|
+
|
|
43
|
+
const components = Array.isArray(doc.components) ? doc.components : [];
|
|
44
|
+
pushCheck(checks, 'components', 'Contiene al menos un componente', components.length > 0);
|
|
45
|
+
|
|
46
|
+
const stats = { total: components.length, missingVersion: 0, missingHash: 0, missingLicense: 0, missingPurl: 0 };
|
|
47
|
+
for (const comp of components) {
|
|
48
|
+
const label = comp.name || comp['bom-ref'] || 'desconocido';
|
|
49
|
+
if (!comp.version) { stats.missingVersion++; issues.push(`Componente sin versión: ${label}`); }
|
|
50
|
+
if (!hasCdxHash(comp)) { stats.missingHash++; issues.push(`Componente sin hash criptográfico: ${label}`); }
|
|
51
|
+
if (!hasCdxLicense(comp)) { stats.missingLicense++; issues.push(`Componente sin licencia: ${label}`); }
|
|
52
|
+
if (!comp.purl && !comp['bom-ref']) { stats.missingPurl++; issues.push(`Componente sin identificador único (purl): ${label}`); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
pushCheck(checks, 'componentVersions', 'Todos los componentes tienen versión', stats.missingVersion === 0);
|
|
56
|
+
pushCheck(checks, 'componentHashes', 'Todos los componentes tienen hash criptográfico', stats.missingHash === 0);
|
|
57
|
+
pushCheck(checks, 'componentLicenses', 'Todos los componentes tienen licencia', stats.missingLicense === 0);
|
|
58
|
+
pushCheck(checks, 'componentIdentifiers', 'Todos los componentes tienen identificador único', stats.missingPurl === 0);
|
|
59
|
+
|
|
60
|
+
return summarize('cyclonedx', checks, issues, stats);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateSpdx(doc) {
|
|
64
|
+
const issues = [];
|
|
65
|
+
const checks = [];
|
|
66
|
+
|
|
67
|
+
pushCheck(checks, 'format', 'Formato declarado (SPDX)', Boolean(doc.spdxVersion));
|
|
68
|
+
pushCheck(checks, 'dataLicense', 'Licencia de los datos declarada', Boolean(doc.dataLicense));
|
|
69
|
+
pushCheck(checks, 'serialNumber', 'Namespace único del documento', Boolean(doc.documentNamespace));
|
|
70
|
+
pushCheck(checks, 'timestamp', 'Marca temporal de creación', Boolean(doc.creationInfo && doc.creationInfo.created));
|
|
71
|
+
pushCheck(checks, 'author', 'Autor / creador declarado',
|
|
72
|
+
Boolean(doc.creationInfo && Array.isArray(doc.creationInfo.creators) && doc.creationInfo.creators.length));
|
|
73
|
+
|
|
74
|
+
const packages = Array.isArray(doc.packages) ? doc.packages : [];
|
|
75
|
+
pushCheck(checks, 'components', 'Contiene al menos un paquete', packages.length > 0);
|
|
76
|
+
|
|
77
|
+
const stats = { total: packages.length, missingVersion: 0, missingHash: 0, missingLicense: 0, missingPurl: 0 };
|
|
78
|
+
for (const pkg of packages) {
|
|
79
|
+
const label = pkg.name || pkg.SPDXID || 'desconocido';
|
|
80
|
+
if (pkg.SPDXID === 'SPDXRef-Package-root') continue;
|
|
81
|
+
if (!pkg.versionInfo || pkg.versionInfo === 'NOASSERTION') { stats.missingVersion++; issues.push(`Paquete sin versión: ${label}`); }
|
|
82
|
+
if (!Array.isArray(pkg.checksums) || pkg.checksums.length === 0) { stats.missingHash++; issues.push(`Paquete sin checksum: ${label}`); }
|
|
83
|
+
if (!pkg.licenseConcluded || pkg.licenseConcluded === 'NOASSERTION') { stats.missingLicense++; issues.push(`Paquete sin licencia: ${label}`); }
|
|
84
|
+
if (!Array.isArray(pkg.externalRefs) || !pkg.externalRefs.some((r) => r.referenceType === 'purl')) { stats.missingPurl++; issues.push(`Paquete sin purl: ${label}`); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
pushCheck(checks, 'componentVersions', 'Todos los paquetes tienen versión', stats.missingVersion === 0);
|
|
88
|
+
pushCheck(checks, 'componentHashes', 'Todos los paquetes tienen checksum', stats.missingHash === 0);
|
|
89
|
+
pushCheck(checks, 'componentLicenses', 'Todos los paquetes tienen licencia', stats.missingLicense === 0);
|
|
90
|
+
pushCheck(checks, 'componentIdentifiers', 'Todos los paquetes tienen purl', stats.missingPurl === 0);
|
|
91
|
+
|
|
92
|
+
return summarize('spdx', checks, issues, stats);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hasCdxHash(comp) {
|
|
96
|
+
return Array.isArray(comp.hashes) && comp.hashes.length > 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hasCdxLicense(comp) {
|
|
100
|
+
return Array.isArray(comp.licenses) && comp.licenses.length > 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pushCheck(checks, id, label, passed) {
|
|
104
|
+
checks.push({ id, label, passed: Boolean(passed) });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function summarize(format, checks, issues, stats) {
|
|
108
|
+
const failed = checks.filter((c) => !c.passed);
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
format,
|
|
112
|
+
valid: failed.length === 0,
|
|
113
|
+
checks,
|
|
114
|
+
failedChecks: failed,
|
|
115
|
+
issues,
|
|
116
|
+
stats,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function invalid(message) {
|
|
121
|
+
return { ok: false, valid: false, error: message, checks: [], issues: [message] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { validateSbom };
|