cra-audit 1.0.0 → 1.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.
@@ -1,124 +1,228 @@
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('The SBOM is not a valid JSON object.');
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('Unrecognized SBOM format (neither CycloneDX nor SPDX).');
28
- }
29
-
30
- function validateCycloneDx(doc) {
31
- const issues = [];
32
- const checks = [];
33
-
34
- pushCheck(checks, 'format', 'Format declared (CycloneDX)', doc.bomFormat === 'CycloneDX');
35
- pushCheck(checks, 'specVersion', 'Specification version present', Boolean(doc.specVersion));
36
- pushCheck(checks, 'serialNumber', 'Unique document identifier (serialNumber)', Boolean(doc.serialNumber));
37
- pushCheck(checks, 'timestamp', 'Creation timestamp', Boolean(doc.metadata && doc.metadata.timestamp));
38
- pushCheck(checks, 'author', 'Author / generating tool declared',
39
- Boolean(doc.metadata && (doc.metadata.tools || doc.metadata.authors)));
40
- pushCheck(checks, 'rootComponent', 'Root component (product) identified',
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', 'Contains at least one component', 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'] || 'unknown';
49
- if (!comp.version) { stats.missingVersion++; issues.push(`Component without a version: ${label}`); }
50
- if (!hasCdxHash(comp)) { stats.missingHash++; issues.push(`Component without a cryptographic hash: ${label}`); }
51
- if (!hasCdxLicense(comp)) { stats.missingLicense++; issues.push(`Component without a license: ${label}`); }
52
- if (!comp.purl && !comp['bom-ref']) { stats.missingPurl++; issues.push(`Component without a unique identifier (purl): ${label}`); }
53
- }
54
-
55
- pushCheck(checks, 'componentVersions', 'All components have a version', stats.missingVersion === 0);
56
- pushCheck(checks, 'componentHashes', 'All components have a cryptographic hash', stats.missingHash === 0);
57
- pushCheck(checks, 'componentLicenses', 'All components have a license', stats.missingLicense === 0);
58
- pushCheck(checks, 'componentIdentifiers', 'All components have a unique identifier', 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', 'Format declared (SPDX)', Boolean(doc.spdxVersion));
68
- pushCheck(checks, 'dataLicense', 'Data license declared', Boolean(doc.dataLicense));
69
- pushCheck(checks, 'serialNumber', 'Unique document namespace', Boolean(doc.documentNamespace));
70
- pushCheck(checks, 'timestamp', 'Creation timestamp', Boolean(doc.creationInfo && doc.creationInfo.created));
71
- pushCheck(checks, 'author', 'Author / creator declared',
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', 'Contains at least one package', 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 || 'unknown';
80
- if (pkg.SPDXID === 'SPDXRef-Package-root') continue;
81
- if (!pkg.versionInfo || pkg.versionInfo === 'NOASSERTION') { stats.missingVersion++; issues.push(`Package without a version: ${label}`); }
82
- if (!Array.isArray(pkg.checksums) || pkg.checksums.length === 0) { stats.missingHash++; issues.push(`Package without a checksum: ${label}`); }
83
- if (!pkg.licenseConcluded || pkg.licenseConcluded === 'NOASSERTION') { stats.missingLicense++; issues.push(`Package without a license: ${label}`); }
84
- if (!Array.isArray(pkg.externalRefs) || !pkg.externalRefs.some((r) => r.referenceType === 'purl')) { stats.missingPurl++; issues.push(`Package without a purl: ${label}`); }
85
- }
86
-
87
- pushCheck(checks, 'componentVersions', 'All packages have a version', stats.missingVersion === 0);
88
- pushCheck(checks, 'componentHashes', 'All packages have a checksum', stats.missingHash === 0);
89
- pushCheck(checks, 'componentLicenses', 'All packages have a license', stats.missingLicense === 0);
90
- pushCheck(checks, 'componentIdentifiers', 'All packages have a 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 };
1
+ 'use strict';
2
+
3
+ /**
4
+ * Validates an SBOM document against the requirements of CRA Annex I and
5
+ * BSI TR-03183-2 v2.1.0. It auto-detects whether the document is CycloneDX
6
+ * or SPDX.
7
+ *
8
+ * TR-03183-2 v2.1.0 requirements checked:
9
+ * - §4: format CycloneDX >= 1.6 or SPDX >= 3.0.1
10
+ * - §5.2.1: creator of the SBOM (email or URL) and timestamp
11
+ * - §5.2.2: for each component: creator (email or URL), name, version,
12
+ * filename, dependencies (and completeness of that enumeration),
13
+ * licence, SHA-512 hash of the deployable component and the
14
+ * executable / archive / structured properties
15
+ * - §5.2.4: unique identifier (purl, CPE, SWID)
16
+ */
17
+ function validateSbom(document) {
18
+ if (!document || typeof document !== 'object') {
19
+ return invalid('The SBOM is not a valid JSON object.');
20
+ }
21
+
22
+ if (document.bomFormat === 'CycloneDX' || document.specVersion) {
23
+ return validateCycloneDx(document);
24
+ }
25
+ if (document.spdxVersion || document.SPDXID === 'SPDXRef-DOCUMENT') {
26
+ return validateSpdx(document);
27
+ }
28
+
29
+ return invalid('Unrecognized SBOM format (neither CycloneDX nor SPDX).');
30
+ }
31
+
32
+ const BSI_PROPERTIES = ['bsi:component:executable', 'bsi:component:archive', 'bsi:component:structured'];
33
+
34
+ function validateCycloneDx(doc) {
35
+ const issues = [];
36
+ const checks = [];
37
+ const metadata = doc.metadata || {};
38
+
39
+ pushCheck(checks, 'format', 'Format declared (CycloneDX)', doc.bomFormat === 'CycloneDX');
40
+ pushCheck(checks, 'specVersion', 'Specification version CycloneDX >= 1.6 (TR-03183-2 §4)',
41
+ compareVersions(doc.specVersion, '1.6') >= 0);
42
+ pushCheck(checks, 'serialNumber', 'Unique document identifier (serialNumber)', Boolean(doc.serialNumber));
43
+ pushCheck(checks, 'timestamp', 'Creation timestamp', Boolean(metadata.timestamp));
44
+ pushCheck(checks, 'author', 'Creator of the SBOM (email or URL)',
45
+ hasCdxContact(metadata.manufacturer) || toArray(metadata.authors).some((a) => a && a.email));
46
+ pushCheck(checks, 'rootComponent', 'Root component (product) identified',
47
+ Boolean(metadata.component && metadata.component.name));
48
+
49
+ const components = toArray(doc.components);
50
+ pushCheck(checks, 'components', 'Contains at least one component', components.length > 0);
51
+
52
+ const graphRefs = new Set(toArray(doc.dependencies).map((d) => d && d.ref));
53
+ const stats = {
54
+ total: components.length,
55
+ missingVersion: 0,
56
+ missingCreator: 0,
57
+ missingFilename: 0,
58
+ missingHash: 0,
59
+ missingLicense: 0,
60
+ missingPurl: 0,
61
+ missingProperties: 0,
62
+ missingDependencies: 0,
63
+ };
64
+
65
+ for (const comp of components) {
66
+ const label = comp.name || comp['bom-ref'] || 'unknown';
67
+ const props = new Map(toArray(comp.properties).map((p) => [p && p.name, p && p.value]));
68
+
69
+ if (!comp.version) { stats.missingVersion++; issues.push(`Component without a version: ${label}`); }
70
+ if (!hasCdxContact(comp.manufacturer) && !hasCdxContact(comp.supplier) && !hasEmail(comp.authors)) {
71
+ stats.missingCreator++; issues.push(`Component without a creator (email or URL): ${label}`);
72
+ }
73
+ if (!props.get('bsi:component:filename')) { stats.missingFilename++; issues.push(`Component without a filename: ${label}`); }
74
+ if (!hasCdxSha512(comp)) { stats.missingHash++; issues.push(`Component without a SHA-512 hash: ${label}`); }
75
+ if (!hasCdxLicense(comp)) { stats.missingLicense++; issues.push(`Component without a license: ${label}`); }
76
+ if (!comp.purl && !comp.cpe && !comp.swid && !comp['bom-ref']) {
77
+ stats.missingPurl++; issues.push(`Component without a unique identifier (purl): ${label}`);
78
+ }
79
+ if (BSI_PROPERTIES.some((name) => !props.get(name))) {
80
+ stats.missingProperties++; issues.push(`Component without the executable/archive/structured properties: ${label}`);
81
+ }
82
+ if (!graphRefs.has(comp['bom-ref'])) {
83
+ stats.missingDependencies++; issues.push(`Component missing from the dependency graph: ${label}`);
84
+ }
85
+ }
86
+
87
+ const completeness = toArray(doc.compositions).some((c) => c && c.aggregate);
88
+
89
+ pushCheck(checks, 'componentVersions', 'All components have a version', stats.missingVersion === 0);
90
+ pushCheck(checks, 'componentCreators', 'All components have a creator (email or URL)', stats.missingCreator === 0);
91
+ pushCheck(checks, 'componentFilenames', 'All components have a filename', stats.missingFilename === 0);
92
+ pushCheck(checks, 'componentHashes', 'All components have a SHA-512 hash', stats.missingHash === 0);
93
+ pushCheck(checks, 'componentLicenses', 'All components have a license', stats.missingLicense === 0);
94
+ pushCheck(checks, 'componentIdentifiers', 'All components have a unique identifier', stats.missingPurl === 0);
95
+ pushCheck(checks, 'componentProperties', 'All components declare executable/archive/structured', stats.missingProperties === 0);
96
+ pushCheck(checks, 'dependencyGraph', 'Dependency graph covers every component', stats.missingDependencies === 0);
97
+ pushCheck(checks, 'dependencyCompleteness', 'Completeness of the dependency graph declared (compositions)', completeness);
98
+
99
+ return summarize('cyclonedx', checks, issues, stats);
100
+ }
101
+
102
+ function validateSpdx(doc) {
103
+ const issues = [];
104
+ const checks = [];
105
+ const creators = toArray(doc.creationInfo && doc.creationInfo.creators);
106
+
107
+ pushCheck(checks, 'format', 'Format declared (SPDX)', Boolean(doc.spdxVersion));
108
+ const spdxVersion = String(doc.spdxVersion || '').replace(/^SPDX-/, '');
109
+ pushCheck(checks, 'specVersion', 'Specification version SPDX >= 3.0.1 (TR-03183-2 §4; use CycloneDX)',
110
+ compareVersions(spdxVersion, '3.0.1') >= 0);
111
+ pushCheck(checks, 'dataLicense', 'Data license declared', Boolean(doc.dataLicense));
112
+ pushCheck(checks, 'serialNumber', 'Unique document namespace', Boolean(doc.documentNamespace));
113
+ pushCheck(checks, 'timestamp', 'Creation timestamp', Boolean(doc.creationInfo && doc.creationInfo.created));
114
+ pushCheck(checks, 'author', 'Creator of the SBOM (organization or person)',
115
+ creators.some((c) => /^(Organization|Person):/.test(String(c))));
116
+
117
+ const packages = toArray(doc.packages);
118
+ pushCheck(checks, 'components', 'Contains at least one package', packages.length > 0);
119
+
120
+ const relationships = toArray(doc.relationships);
121
+ const inGraph = new Set();
122
+ for (const r of relationships) {
123
+ if (r && r.relationshipType === 'DEPENDS_ON') {
124
+ inGraph.add(r.spdxElementId);
125
+ inGraph.add(r.relatedSpdxElement);
126
+ }
127
+ }
128
+
129
+ const stats = {
130
+ total: packages.length,
131
+ missingVersion: 0,
132
+ missingCreator: 0,
133
+ missingFilename: 0,
134
+ missingHash: 0,
135
+ missingLicense: 0,
136
+ missingPurl: 0,
137
+ missingDependencies: 0,
138
+ };
139
+ for (const pkg of packages) {
140
+ const label = pkg.name || pkg.SPDXID || 'unknown';
141
+ if (pkg.SPDXID === 'SPDXRef-Package-root') continue;
142
+ if (!pkg.versionInfo || pkg.versionInfo === 'NOASSERTION') { stats.missingVersion++; issues.push(`Package without a version: ${label}`); }
143
+ if (!isAssertion(pkg.originator) && !isAssertion(pkg.supplier)) { stats.missingCreator++; issues.push(`Package without an originator: ${label}`); }
144
+ if (!pkg.packageFileName) { stats.missingFilename++; issues.push(`Package without a filename: ${label}`); }
145
+ if (!toArray(pkg.checksums).some((c) => /^SHA-?512$/i.test(String(c && c.algorithm)))) {
146
+ stats.missingHash++; issues.push(`Package without a SHA-512 checksum: ${label}`);
147
+ }
148
+ if (!isAssertion(pkg.licenseConcluded)) { stats.missingLicense++; issues.push(`Package without a license: ${label}`); }
149
+ if (!toArray(pkg.externalRefs).some((r) => r.referenceType === 'purl')) { stats.missingPurl++; issues.push(`Package without a purl: ${label}`); }
150
+ if (packages.length > 2 && !inGraph.has(pkg.SPDXID)) {
151
+ stats.missingDependencies++; issues.push(`Package missing from the dependency graph: ${label}`);
152
+ }
153
+ }
154
+
155
+ pushCheck(checks, 'componentVersions', 'All packages have a version', stats.missingVersion === 0);
156
+ pushCheck(checks, 'componentCreators', 'All packages have an originator', stats.missingCreator === 0);
157
+ pushCheck(checks, 'componentFilenames', 'All packages have a filename', stats.missingFilename === 0);
158
+ pushCheck(checks, 'componentHashes', 'All packages have a SHA-512 checksum', stats.missingHash === 0);
159
+ pushCheck(checks, 'componentLicenses', 'All packages have a license', stats.missingLicense === 0);
160
+ pushCheck(checks, 'componentIdentifiers', 'All packages have a purl', stats.missingPurl === 0);
161
+ pushCheck(checks, 'dependencyGraph', 'Dependency relationships (DEPENDS_ON) declared', stats.missingDependencies === 0);
162
+
163
+ return summarize('spdx', checks, issues, stats);
164
+ }
165
+
166
+ /** CycloneDX organizational entity with an email contact or a URL. */
167
+ function hasCdxContact(entity) {
168
+ if (!entity || typeof entity !== 'object') return false;
169
+ return toArray(entity.url).length > 0 || hasEmail(entity.contact);
170
+ }
171
+
172
+ function hasEmail(people) {
173
+ return toArray(people).some((p) => p && p.email);
174
+ }
175
+
176
+ /** SHA-512 in `hashes` or on the `distribution` reference (TR-03183 mapping). */
177
+ function hasCdxSha512(comp) {
178
+ const isSha512 = (h) => h && h.alg === 'SHA-512' && h.content;
179
+ if (toArray(comp.hashes).some(isSha512)) return true;
180
+ return toArray(comp.externalReferences).some((ref) => ref && toArray(ref.hashes).some(isSha512));
181
+ }
182
+
183
+ function hasCdxLicense(comp) {
184
+ return toArray(comp.licenses).length > 0;
185
+ }
186
+
187
+ function isAssertion(value) {
188
+ return Boolean(value) && value !== 'NOASSERTION' && value !== 'NONE';
189
+ }
190
+
191
+ /** Compares dotted numeric versions ("1.6" vs "1.5"); missing parts are 0. */
192
+ function compareVersions(a, b) {
193
+ if (!a) return -1;
194
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
195
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
196
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
197
+ const diff = (pa[i] || 0) - (pb[i] || 0);
198
+ if (diff !== 0) return diff;
199
+ }
200
+ return 0;
201
+ }
202
+
203
+ function toArray(value) {
204
+ return Array.isArray(value) ? value : [];
205
+ }
206
+
207
+ function pushCheck(checks, id, label, passed) {
208
+ checks.push({ id, label, passed: Boolean(passed) });
209
+ }
210
+
211
+ function summarize(format, checks, issues, stats) {
212
+ const failed = checks.filter((c) => !c.passed);
213
+ return {
214
+ ok: true,
215
+ format,
216
+ valid: failed.length === 0,
217
+ checks,
218
+ failedChecks: failed,
219
+ issues,
220
+ stats,
221
+ };
222
+ }
223
+
224
+ function invalid(message) {
225
+ return { ok: false, valid: false, error: message, checks: [], issues: [message] };
226
+ }
227
+
228
+ module.exports = { validateSbom };
@@ -83,7 +83,7 @@ function renderSbom(section) {
83
83
  logger.log(` ${mark} ${check.label}`);
84
84
  }
85
85
  if (v.valid) {
86
- logger.success('The SBOM meets the TR-03183 §6 minimum elements.');
86
+ logger.success('The SBOM meets the TR-03183-2 required data fields.');
87
87
  } else {
88
88
  logger.warn(`The SBOM does not meet ${v.failedChecks.length} minimum requirement(s).`);
89
89
  }
@@ -94,7 +94,7 @@ function renderSbom(section) {
94
94
  }
95
95
 
96
96
  function renderLicenses(section) {
97
- logger.heading('3) Third-party component licenses (TR-03183 §6)');
97
+ logger.heading('3) Third-party component licenses (TR-03183-2 §5.2)');
98
98
  if (!section.ok) {
99
99
  logger.error(section.error);
100
100
  return;