@cyclonedx/cdxgen 12.1.5 → 12.2.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.
Files changed (181) hide show
  1. package/README.md +47 -39
  2. package/bin/cdxgen.js +175 -96
  3. package/bin/evinse.js +4 -4
  4. package/bin/repl.js +1 -1
  5. package/bin/sign.js +102 -0
  6. package/bin/validate.js +233 -0
  7. package/bin/verify.js +69 -28
  8. package/data/queries.json +1 -1
  9. package/data/rules/ci-permissions.yaml +186 -0
  10. package/data/rules/dependency-sources.yaml +123 -0
  11. package/data/rules/package-integrity.yaml +135 -0
  12. package/data/rules/vscode-extensions.yaml +228 -0
  13. package/lib/cli/index.js +327 -372
  14. package/lib/evinser/db.js +137 -0
  15. package/lib/{helpers → evinser}/db.poku.js +2 -6
  16. package/lib/evinser/evinser.js +2 -14
  17. package/lib/helpers/bomSigner.js +312 -0
  18. package/lib/helpers/bomSigner.poku.js +156 -0
  19. package/lib/helpers/ciParsers/azurePipelines.js +295 -0
  20. package/lib/helpers/ciParsers/azurePipelines.poku.js +253 -0
  21. package/lib/helpers/ciParsers/circleCi.js +286 -0
  22. package/lib/helpers/ciParsers/circleCi.poku.js +230 -0
  23. package/lib/helpers/ciParsers/common.js +24 -0
  24. package/lib/helpers/ciParsers/githubActions.js +636 -0
  25. package/lib/helpers/ciParsers/githubActions.poku.js +802 -0
  26. package/lib/helpers/ciParsers/gitlabCi.js +213 -0
  27. package/lib/helpers/ciParsers/gitlabCi.poku.js +247 -0
  28. package/lib/helpers/ciParsers/jenkins.js +181 -0
  29. package/lib/helpers/ciParsers/jenkins.poku.js +197 -0
  30. package/lib/helpers/depsUtils.js +203 -0
  31. package/lib/helpers/depsUtils.poku.js +150 -0
  32. package/lib/helpers/display.js +423 -4
  33. package/lib/helpers/envcontext.js +18 -3
  34. package/lib/helpers/formulationParsers.js +351 -0
  35. package/lib/helpers/logger.js +14 -0
  36. package/lib/helpers/protobom.js +9 -9
  37. package/lib/helpers/pythonutils.js +9 -0
  38. package/lib/helpers/utils.js +681 -406
  39. package/lib/helpers/utils.poku.js +55 -255
  40. package/lib/helpers/versutils.js +202 -0
  41. package/lib/helpers/versutils.poku.js +315 -0
  42. package/lib/helpers/vsixutils.js +1061 -0
  43. package/lib/helpers/vsixutils.poku.js +2247 -0
  44. package/lib/managers/binary.js +19 -19
  45. package/lib/managers/docker.js +108 -1
  46. package/lib/managers/oci.js +10 -0
  47. package/lib/managers/piptree.js +3 -9
  48. package/lib/parsers/npmrc.js +17 -13
  49. package/lib/parsers/npmrc.poku.js +41 -5
  50. package/lib/server/openapi.yaml +1 -1
  51. package/lib/server/server.js +40 -11
  52. package/lib/server/server.poku.js +123 -144
  53. package/lib/stages/postgen/annotator.js +1 -1
  54. package/lib/stages/postgen/auditBom.js +197 -0
  55. package/lib/stages/postgen/auditBom.poku.js +378 -0
  56. package/lib/stages/postgen/postgen.js +54 -1
  57. package/lib/stages/postgen/postgen.poku.js +90 -1
  58. package/lib/stages/postgen/ruleEngine.js +369 -0
  59. package/lib/stages/pregen/envAudit.js +299 -0
  60. package/lib/stages/pregen/envAudit.poku.js +572 -0
  61. package/lib/stages/pregen/pregen.js +12 -8
  62. package/lib/{helpers/validator.js → validator/bomValidator.js} +107 -47
  63. package/lib/validator/complianceEngine.js +241 -0
  64. package/lib/validator/complianceEngine.poku.js +168 -0
  65. package/lib/validator/complianceRules.js +1610 -0
  66. package/lib/validator/complianceRules.poku.js +328 -0
  67. package/lib/validator/index.js +222 -0
  68. package/lib/validator/index.poku.js +144 -0
  69. package/lib/validator/reporters/annotations.js +121 -0
  70. package/lib/validator/reporters/console.js +149 -0
  71. package/lib/validator/reporters/index.js +41 -0
  72. package/lib/validator/reporters/json.js +37 -0
  73. package/lib/validator/reporters/sarif.js +184 -0
  74. package/lib/validator/reporters.poku.js +150 -0
  75. package/package.json +8 -8
  76. package/types/bin/sign.d.ts +3 -0
  77. package/types/bin/sign.d.ts.map +1 -0
  78. package/types/bin/validate.d.ts +3 -0
  79. package/types/bin/validate.d.ts.map +1 -0
  80. package/types/helpers/utils.d.ts +0 -1
  81. package/types/lib/cli/index.d.ts +49 -52
  82. package/types/lib/cli/index.d.ts.map +1 -1
  83. package/types/lib/evinser/db.d.ts +34 -0
  84. package/types/lib/evinser/db.d.ts.map +1 -0
  85. package/types/lib/evinser/evinser.d.ts +63 -16
  86. package/types/lib/evinser/evinser.d.ts.map +1 -1
  87. package/types/lib/helpers/bomSigner.d.ts +27 -0
  88. package/types/lib/helpers/bomSigner.d.ts.map +1 -0
  89. package/types/lib/helpers/ciParsers/azurePipelines.d.ts +17 -0
  90. package/types/lib/helpers/ciParsers/azurePipelines.d.ts.map +1 -0
  91. package/types/lib/helpers/ciParsers/circleCi.d.ts +17 -0
  92. package/types/lib/helpers/ciParsers/circleCi.d.ts.map +1 -0
  93. package/types/lib/helpers/ciParsers/common.d.ts +11 -0
  94. package/types/lib/helpers/ciParsers/common.d.ts.map +1 -0
  95. package/types/lib/helpers/ciParsers/githubActions.d.ts +34 -0
  96. package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -0
  97. package/types/lib/helpers/ciParsers/gitlabCi.d.ts +17 -0
  98. package/types/lib/helpers/ciParsers/gitlabCi.d.ts.map +1 -0
  99. package/types/lib/helpers/ciParsers/jenkins.d.ts +17 -0
  100. package/types/lib/helpers/ciParsers/jenkins.d.ts.map +1 -0
  101. package/types/lib/helpers/depsUtils.d.ts +21 -0
  102. package/types/lib/helpers/depsUtils.d.ts.map +1 -0
  103. package/types/lib/helpers/display.d.ts +111 -11
  104. package/types/lib/helpers/display.d.ts.map +1 -1
  105. package/types/lib/helpers/envcontext.d.ts +19 -7
  106. package/types/lib/helpers/envcontext.d.ts.map +1 -1
  107. package/types/lib/helpers/formulationParsers.d.ts +50 -0
  108. package/types/lib/helpers/formulationParsers.d.ts.map +1 -0
  109. package/types/lib/helpers/logger.d.ts +15 -1
  110. package/types/lib/helpers/logger.d.ts.map +1 -1
  111. package/types/lib/helpers/protobom.d.ts +2 -2
  112. package/types/lib/helpers/pythonutils.d.ts +10 -1
  113. package/types/lib/helpers/pythonutils.d.ts.map +1 -1
  114. package/types/lib/helpers/utils.d.ts +532 -128
  115. package/types/lib/helpers/utils.d.ts.map +1 -1
  116. package/types/lib/helpers/versutils.d.ts +8 -0
  117. package/types/lib/helpers/versutils.d.ts.map +1 -0
  118. package/types/lib/helpers/vsixutils.d.ts +130 -0
  119. package/types/lib/helpers/vsixutils.d.ts.map +1 -0
  120. package/types/lib/managers/docker.d.ts +12 -31
  121. package/types/lib/managers/docker.d.ts.map +1 -1
  122. package/types/lib/managers/oci.d.ts +11 -1
  123. package/types/lib/managers/oci.d.ts.map +1 -1
  124. package/types/lib/managers/piptree.d.ts.map +1 -1
  125. package/types/lib/parsers/npmrc.d.ts +4 -1
  126. package/types/lib/parsers/npmrc.d.ts.map +1 -1
  127. package/types/lib/server/server.d.ts +21 -2
  128. package/types/lib/server/server.d.ts.map +1 -1
  129. package/types/lib/stages/postgen/auditBom.d.ts +20 -0
  130. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -0
  131. package/types/lib/stages/postgen/postgen.d.ts +8 -1
  132. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  133. package/types/lib/stages/postgen/ruleEngine.d.ts +18 -0
  134. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -0
  135. package/types/lib/stages/pregen/envAudit.d.ts +8 -0
  136. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -0
  137. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  138. package/types/lib/{helpers/validator.d.ts → validator/bomValidator.d.ts} +1 -1
  139. package/types/lib/validator/bomValidator.d.ts.map +1 -0
  140. package/types/lib/validator/complianceEngine.d.ts +66 -0
  141. package/types/lib/validator/complianceEngine.d.ts.map +1 -0
  142. package/types/lib/validator/complianceRules.d.ts +70 -0
  143. package/types/lib/validator/complianceRules.d.ts.map +1 -0
  144. package/types/lib/validator/index.d.ts +70 -0
  145. package/types/lib/validator/index.d.ts.map +1 -0
  146. package/types/lib/validator/reporters/annotations.d.ts +31 -0
  147. package/types/lib/validator/reporters/annotations.d.ts.map +1 -0
  148. package/types/lib/validator/reporters/console.d.ts +30 -0
  149. package/types/lib/validator/reporters/console.d.ts.map +1 -0
  150. package/types/lib/validator/reporters/index.d.ts +21 -0
  151. package/types/lib/validator/reporters/index.d.ts.map +1 -0
  152. package/types/lib/validator/reporters/json.d.ts +11 -0
  153. package/types/lib/validator/reporters/json.d.ts.map +1 -0
  154. package/types/lib/validator/reporters/sarif.d.ts +16 -0
  155. package/types/lib/validator/reporters/sarif.d.ts.map +1 -0
  156. package/lib/helpers/db.js +0 -162
  157. package/lib/stages/pregen/env-audit.js +0 -34
  158. package/lib/stages/pregen/env-audit.poku.js +0 -290
  159. package/types/helpers/db.d.ts +0 -35
  160. package/types/helpers/db.d.ts.map +0 -1
  161. package/types/lib/helpers/db.d.ts +0 -35
  162. package/types/lib/helpers/db.d.ts.map +0 -1
  163. package/types/lib/helpers/validator.d.ts.map +0 -1
  164. package/types/lib/stages/pregen/env-audit.d.ts +0 -2
  165. package/types/lib/stages/pregen/env-audit.d.ts.map +0 -1
  166. package/types/managers/binary.d.ts +0 -37
  167. package/types/managers/binary.d.ts.map +0 -1
  168. package/types/managers/docker.d.ts +0 -56
  169. package/types/managers/docker.d.ts.map +0 -1
  170. package/types/managers/oci.d.ts +0 -2
  171. package/types/managers/oci.d.ts.map +0 -1
  172. package/types/managers/piptree.d.ts +0 -2
  173. package/types/managers/piptree.d.ts.map +0 -1
  174. package/types/server/server.d.ts +0 -34
  175. package/types/server/server.d.ts.map +0 -1
  176. package/types/stages/postgen/annotator.d.ts +0 -27
  177. package/types/stages/postgen/annotator.d.ts.map +0 -1
  178. package/types/stages/postgen/postgen.d.ts +0 -51
  179. package/types/stages/postgen/postgen.d.ts.map +0 -1
  180. package/types/stages/pregen/pregen.d.ts +0 -59
  181. package/types/stages/pregen/pregen.d.ts.map +0 -1
@@ -5,8 +5,8 @@ import Ajv from "ajv";
5
5
  import addFormats from "ajv-formats";
6
6
  import { PackageURL } from "packageurl-js";
7
7
 
8
- import { thoughtLog } from "./logger.js";
9
- import { DEBUG_MODE, dirNameStr, isPartialTree } from "./utils.js";
8
+ import { thoughtLog } from "../helpers/logger.js";
9
+ import { DEBUG_MODE, dirNameStr, isPartialTree } from "../helpers/utils.js";
10
10
 
11
11
  const dirName = dirNameStr;
12
12
  const PLACEHOLDER_COMPONENT_NAMES = new Set(["app", "application", "project"]);
@@ -194,51 +194,53 @@ export const validatePurls = (bomJson) => {
194
194
  }
195
195
  } else {
196
196
  try {
197
- const purlObj = PackageURL.fromString(comp.purl);
198
- if (purlObj.type && purlObj.type !== purlObj.type.toLowerCase()) {
199
- warningsList.push(
200
- `purl type is not normalized to lower case ${comp.purl}`,
201
- );
202
- }
203
- if (
204
- ["npm", "golang"].includes(purlObj.type) &&
205
- purlObj.name.includes("%2F") &&
206
- !purlObj.namespace
207
- ) {
208
- errorList.push(
209
- `purl does not include namespace but includes encoded slash in name for npm type. ${comp.purl}`,
210
- );
211
- }
212
- // Catch the trivy version hack that removes the epoch from version
213
- const qualifiers = purlObj.qualifiers || {};
214
- if (
215
- Object.keys(qualifiers).length &&
216
- [
217
- "cargo",
218
- "cocoapods",
219
- "composer",
220
- "cran",
221
- "github",
222
- "golang",
223
- "hackage",
224
- "nuget",
225
- "opam",
226
- "pub",
227
- "qpkg",
228
- "swift",
229
- ].includes(purlObj.type)
230
- ) {
231
- warningsList.push(
232
- `Qualifiers are usually not expected for the PURL type: ${purlObj.type}. Purl: ${comp.purl}, Qualifiers: ${Object.keys(qualifiers).join(", ")}.`,
233
- );
234
- }
235
- if (
236
- qualifiers.epoch &&
237
- !comp.version.startsWith(`${qualifiers.epoch}:`)
238
- ) {
239
- errorList.push(
240
- `'${comp.name}' version '${comp.version}' doesn't include epoch '${qualifiers.epoch}'.`,
241
- );
197
+ if (comp.purl) {
198
+ const purlObj = PackageURL.fromString(comp.purl);
199
+ if (purlObj.type && purlObj.type !== purlObj.type.toLowerCase()) {
200
+ warningsList.push(
201
+ `purl type is not normalized to lower case ${comp.purl}`,
202
+ );
203
+ }
204
+ if (
205
+ ["npm", "golang"].includes(purlObj.type) &&
206
+ purlObj.name.includes("%2F") &&
207
+ !purlObj.namespace
208
+ ) {
209
+ errorList.push(
210
+ `purl does not include namespace but includes encoded slash in name for npm type. ${comp.purl}`,
211
+ );
212
+ }
213
+ // Catch the trivy version hack that removes the epoch from version
214
+ const qualifiers = purlObj.qualifiers || {};
215
+ if (
216
+ Object.keys(qualifiers).length &&
217
+ [
218
+ "cargo",
219
+ "cocoapods",
220
+ "composer",
221
+ "cran",
222
+ "github",
223
+ "golang",
224
+ "hackage",
225
+ "nuget",
226
+ "opam",
227
+ "pub",
228
+ "qpkg",
229
+ "swift",
230
+ ].includes(purlObj.type)
231
+ ) {
232
+ warningsList.push(
233
+ `SPEC VIOLATION: Qualifiers are not expected for ${purlObj.type} type. Purl: ${comp.purl}, Qualifier(s): ${Object.keys(qualifiers).join(", ")}.`,
234
+ );
235
+ }
236
+ if (
237
+ qualifiers.epoch &&
238
+ !comp.version.startsWith(`${qualifiers.epoch}:`)
239
+ ) {
240
+ errorList.push(
241
+ `'${comp.name}' version '${comp.version}' doesn't include epoch '${qualifiers.epoch}'.`,
242
+ );
243
+ }
242
244
  }
243
245
  } catch (_ex) {
244
246
  errorList.push(`Invalid purl ${comp.purl}`);
@@ -283,6 +285,25 @@ const buildRefs = (bomJson) => {
283
285
  refMap[comp["bom-ref"]] = true;
284
286
  }
285
287
  }
288
+ if (bomJson?.formulation) {
289
+ for (const aformulation of bomJson.formulation) {
290
+ if (aformulation?.components?.length) {
291
+ for (const formComp of aformulation.components) {
292
+ refMap[formComp["bom-ref"]] = true;
293
+ }
294
+ }
295
+ if (aformulation?.workflows?.length) {
296
+ for (const formWf of aformulation.workflows) {
297
+ refMap[formWf["bom-ref"]] = true;
298
+ if (formWf?.tasks?.length) {
299
+ for (const atask of formWf.tasks) {
300
+ refMap[atask["bom-ref"]] = true;
301
+ }
302
+ }
303
+ }
304
+ }
305
+ }
306
+ }
286
307
  }
287
308
  return refMap;
288
309
  };
@@ -392,6 +413,8 @@ export function validateProps(bomJson) {
392
413
  let lacksProperties = false;
393
414
  let lacksEvidence = false;
394
415
  let lacksRelativePath = false;
416
+ let npmComponentsWithoutTarball = 0;
417
+ let npmComponentsWithTarball = 0;
395
418
  if (
396
419
  !["application", "framework", "library"].includes(
397
420
  bomJson?.metadata?.component?.type,
@@ -400,6 +423,28 @@ export function validateProps(bomJson) {
400
423
  return true;
401
424
  }
402
425
  if (bomJson?.components) {
426
+ const npmPkgs =
427
+ bomJson.components?.filter((c) => c.purl?.startsWith("pkg:npm")) || [];
428
+ const nativeByName = new Set(
429
+ npmPkgs
430
+ .filter((c) =>
431
+ c.properties?.some(
432
+ (p) => p.name === "cdx:npm:native_addon" && p.value === "true",
433
+ ),
434
+ )
435
+ .map((c) => c.name),
436
+ );
437
+ const suspicious = npmPkgs.filter(
438
+ (c) =>
439
+ (c.name.includes("native") || c.name.includes("bindings")) &&
440
+ !nativeByName.has(c.name) &&
441
+ !c.properties?.some((p) => p.name === "cdx:npm:native_addon"),
442
+ );
443
+ if (suspicious.length > 0 && DEBUG_MODE) {
444
+ warningsList.push(
445
+ `Found ${suspicious.length} packages with native-sounding names but no native_addon flag: ${suspicious.map((c) => c.name).join(", ")}. May need deeper inspection.`,
446
+ );
447
+ }
403
448
  for (const comp of bomJson.components) {
404
449
  if (!["library", "framework"].includes(comp.type)) {
405
450
  continue;
@@ -411,6 +456,16 @@ export function validateProps(bomJson) {
411
456
  ) {
412
457
  continue;
413
458
  }
459
+ if (comp.purl?.startsWith("pkg:npm")) {
460
+ const hasDistributionRef = comp.externalReferences?.some(
461
+ (ref) => ref.type === "distribution" && ref.url,
462
+ );
463
+ if (hasDistributionRef) {
464
+ npmComponentsWithTarball++;
465
+ } else {
466
+ npmComponentsWithoutTarball++;
467
+ }
468
+ }
414
469
  if (!comp.properties) {
415
470
  if (!lacksProperties) {
416
471
  warningsList.push(`${comp["bom-ref"]} lacks properties.`);
@@ -453,6 +508,11 @@ export function validateProps(bomJson) {
453
508
  }
454
509
  }
455
510
  }
511
+ if (npmComponentsWithoutTarball > 0 && npmComponentsWithTarball === 0) {
512
+ warningsList.push(
513
+ `Found ${npmComponentsWithoutTarball} pkg:npm components without externalReferences.distribution. Please file a bug, if your package-lock.json or pnpm-lock.yaml includes the tarball url.`,
514
+ );
515
+ }
456
516
  if (lacksRelativePath) {
457
517
  warningsList.push(
458
518
  "BOM includes absolute paths for properties like SrcFile.",
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Orchestrates evaluation of internal compliance rule packs (SCVS + CRA) and
3
+ * aggregates results into per-benchmark scorecards.
4
+ *
5
+ * This module is deliberately independent of the CycloneDX BOM audit engine
6
+ * (lib/stages/postgen/auditBom.js). The two engines share similar *Finding*
7
+ * output shape so reporters can consume either source uniformly.
8
+ */
9
+
10
+ import {
11
+ getAllComplianceRules,
12
+ getCraRules,
13
+ getScvsRules,
14
+ } from "./complianceRules.js";
15
+
16
+ /**
17
+ * Benchmark alias → rule filter.
18
+ * Aliases are resolved case-insensitively on the CLI.
19
+ */
20
+ const BENCHMARKS = {
21
+ scvs: {
22
+ id: "scvs",
23
+ name: "OWASP SCVS (all levels)",
24
+ standard: "SCVS",
25
+ filter: (rules) => rules.filter((r) => r.standard === "SCVS"),
26
+ // All SCVS rules are considered for overall score regardless of level.
27
+ levelPredicate: () => true,
28
+ },
29
+ "scvs-l1": {
30
+ id: "scvs-l1",
31
+ name: "OWASP SCVS Level 1",
32
+ standard: "SCVS",
33
+ filter: (rules) =>
34
+ rules.filter(
35
+ (r) => r.standard === "SCVS" && r.scvsLevels?.includes("L1"),
36
+ ),
37
+ levelPredicate: (rule) => rule.scvsLevels?.includes("L1"),
38
+ },
39
+ "scvs-l2": {
40
+ id: "scvs-l2",
41
+ name: "OWASP SCVS Level 2",
42
+ standard: "SCVS",
43
+ filter: (rules) =>
44
+ rules.filter(
45
+ (r) => r.standard === "SCVS" && r.scvsLevels?.includes("L2"),
46
+ ),
47
+ levelPredicate: (rule) => rule.scvsLevels?.includes("L2"),
48
+ },
49
+ "scvs-l3": {
50
+ id: "scvs-l3",
51
+ name: "OWASP SCVS Level 3",
52
+ standard: "SCVS",
53
+ filter: (rules) =>
54
+ rules.filter(
55
+ (r) => r.standard === "SCVS" && r.scvsLevels?.includes("L3"),
56
+ ),
57
+ levelPredicate: (rule) => rule.scvsLevels?.includes("L3"),
58
+ },
59
+ cra: {
60
+ id: "cra",
61
+ name: "EU Cyber Resilience Act (SBOM expectations)",
62
+ standard: "CRA",
63
+ filter: (rules) => rules.filter((r) => r.standard === "CRA"),
64
+ levelPredicate: () => true,
65
+ },
66
+ };
67
+
68
+ /**
69
+ * Resolve a benchmark alias (case-insensitive). Returns null when unknown.
70
+ *
71
+ * @param {string} alias
72
+ * @returns {object | null}
73
+ */
74
+ export function resolveBenchmark(alias) {
75
+ if (typeof alias !== "string") return null;
76
+ return BENCHMARKS[alias.trim().toLowerCase()] || null;
77
+ }
78
+
79
+ /**
80
+ * List all known benchmark aliases in a stable display order.
81
+ *
82
+ * @returns {Array<object>}
83
+ */
84
+ export function listBenchmarks() {
85
+ return Object.values(BENCHMARKS);
86
+ }
87
+
88
+ /**
89
+ * Evaluate one rule against the BOM and return a Finding-shaped object.
90
+ *
91
+ * Rules are pure synchronous functions, but we wrap them in try/catch so one
92
+ * bad rule cannot fail the entire run.
93
+ *
94
+ * @param {object} rule
95
+ * @param {object} bomJson
96
+ * @returns {object} Finding
97
+ */
98
+ export function evaluateRule(rule, bomJson) {
99
+ let result;
100
+ try {
101
+ result = rule.evaluate(bomJson);
102
+ } catch (err) {
103
+ result = {
104
+ status: "fail",
105
+ message: `Rule evaluation threw: ${err?.message || err}`,
106
+ };
107
+ }
108
+ const {
109
+ status = "fail",
110
+ message,
111
+ mitigation,
112
+ locations,
113
+ evidence,
114
+ } = result || {};
115
+ // Non-automatable rules always emit `info` severity regardless of status.
116
+ const severity =
117
+ rule.automatable === false
118
+ ? "info"
119
+ : status === "fail"
120
+ ? rule.severity || "medium"
121
+ : status === "manual"
122
+ ? "info"
123
+ : "info";
124
+ return {
125
+ engine: "compliance",
126
+ ruleId: rule.id,
127
+ name: rule.name,
128
+ description: rule.description,
129
+ category: rule.category,
130
+ standard: rule.standard,
131
+ standardRefs: rule.standardRefs || [rule.id],
132
+ scvsLevels: rule.scvsLevels || [],
133
+ automatable: rule.automatable !== false,
134
+ status,
135
+ severity,
136
+ message: message || rule.name,
137
+ mitigation: mitigation || rule.mitigation,
138
+ locations: Array.isArray(locations) ? locations : [],
139
+ evidence: evidence && typeof evidence === "object" ? evidence : null,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Evaluate every rule in the catalog, or a filtered subset.
145
+ *
146
+ * @param {object} bomJson CycloneDX BOM
147
+ * @param {object} [opts]
148
+ * @param {Array<string>} [opts.categories] Filter to these category values.
149
+ * @param {Array<string>} [opts.benchmarks] Only run rules from these benchmark aliases.
150
+ * @returns {Array<object>} Findings (one per rule)
151
+ */
152
+ export function evaluateAll(bomJson, opts = {}) {
153
+ let rules = getAllComplianceRules();
154
+ if (Array.isArray(opts.categories) && opts.categories.length > 0) {
155
+ const wanted = new Set(opts.categories.map((c) => c.toLowerCase()));
156
+ rules = rules.filter((r) => wanted.has((r.category || "").toLowerCase()));
157
+ }
158
+ if (Array.isArray(opts.benchmarks) && opts.benchmarks.length > 0) {
159
+ const selected = new Set();
160
+ for (const alias of opts.benchmarks) {
161
+ const bench = resolveBenchmark(alias);
162
+ if (bench) {
163
+ for (const r of bench.filter(rules)) {
164
+ selected.add(r.id);
165
+ }
166
+ }
167
+ }
168
+ rules = rules.filter((r) => selected.has(r.id));
169
+ }
170
+ return rules.map((r) => evaluateRule(r, bomJson));
171
+ }
172
+
173
+ /**
174
+ * Produce a scorecard for a single benchmark against a set of already-evaluated
175
+ * findings. Scoring rules:
176
+ * - pass counts as 1 / 1.
177
+ * - fail counts as 0 / 1.
178
+ * - manual is excluded from the percentage but counted separately.
179
+ *
180
+ * This mirrors how OWASP SCVS publishes results: automatable controls score a
181
+ * percentage, manual controls are reported so reviewers can address them.
182
+ *
183
+ * @param {object} benchmark Result of resolveBenchmark
184
+ * @param {Array<object>} findings Full set of findings from evaluateAll
185
+ * @returns {object}
186
+ */
187
+ export function scoreBenchmark(benchmark, findings) {
188
+ const catalog = benchmark.filter(getAllComplianceRules());
189
+ const byId = new Map(findings.map((f) => [f.ruleId, f]));
190
+ const controls = [];
191
+ let pass = 0;
192
+ let failed = 0;
193
+ let manual = 0;
194
+ for (const rule of catalog) {
195
+ const f = byId.get(rule.id) || evaluateRule(rule, {});
196
+ controls.push({
197
+ id: rule.id,
198
+ name: rule.name,
199
+ standardRefs: rule.standardRefs,
200
+ status: f.status,
201
+ severity: f.severity,
202
+ automatable: rule.automatable !== false,
203
+ message: f.message,
204
+ });
205
+ if (f.status === "pass") pass += 1;
206
+ else if (f.status === "fail") failed += 1;
207
+ else manual += 1;
208
+ }
209
+ const automatable = pass + failed;
210
+ const scorePct =
211
+ automatable === 0 ? 0 : Math.round((pass / automatable) * 100);
212
+ return {
213
+ id: benchmark.id,
214
+ name: benchmark.name,
215
+ standard: benchmark.standard,
216
+ totalControls: catalog.length,
217
+ pass,
218
+ fail: failed,
219
+ manual,
220
+ automatable,
221
+ scorePct,
222
+ controls,
223
+ };
224
+ }
225
+
226
+ /**
227
+ * Build scorecards for each requested benchmark. When no benchmarks are
228
+ * specified, returns scorecards for every built-in benchmark alias.
229
+ *
230
+ * @param {Array<object>} findings
231
+ * @param {Array<string>} [requestedAliases]
232
+ * @returns {Array<object>}
233
+ */
234
+ export function buildBenchmarkReports(findings, requestedAliases) {
235
+ const aliases = requestedAliases?.length
236
+ ? requestedAliases.map((a) => resolveBenchmark(a)).filter(Boolean)
237
+ : Object.values(BENCHMARKS);
238
+ return aliases.map((b) => scoreBenchmark(b, findings));
239
+ }
240
+
241
+ export { getAllComplianceRules, getCraRules, getScvsRules };
@@ -0,0 +1,168 @@
1
+ import { assert, describe, it } from "poku";
2
+
3
+ import {
4
+ buildBenchmarkReports,
5
+ evaluateAll,
6
+ evaluateRule,
7
+ listBenchmarks,
8
+ resolveBenchmark,
9
+ scoreBenchmark,
10
+ } from "./complianceEngine.js";
11
+ import { getAllComplianceRules } from "./complianceRules.js";
12
+
13
+ function richBom() {
14
+ return {
15
+ bomFormat: "CycloneDX",
16
+ specVersion: "1.6",
17
+ serialNumber: "urn:uuid:1b671687-395b-41f5-a30f-a58921a69b79",
18
+ metadata: {
19
+ timestamp: "2024-02-02T00:00:00Z",
20
+ tools: {
21
+ components: [
22
+ { type: "application", name: "cdxgen", version: "12.0.0" },
23
+ ],
24
+ },
25
+ component: {
26
+ name: "demo",
27
+ version: "1.0.0",
28
+ type: "application",
29
+ "bom-ref": "pkg:generic/demo@1.0.0",
30
+ },
31
+ supplier: {
32
+ name: "Acme",
33
+ contact: [{ email: "psirt@example.com" }],
34
+ },
35
+ },
36
+ components: [
37
+ {
38
+ type: "library",
39
+ name: "lodash",
40
+ version: "4.17.21",
41
+ purl: "pkg:npm/lodash@4.17.21",
42
+ "bom-ref": "pkg:npm/lodash@4.17.21",
43
+ licenses: [{ license: { id: "MIT" } }],
44
+ hashes: [{ alg: "SHA-256", content: "x" }],
45
+ copyright: "© OpenJS",
46
+ },
47
+ ],
48
+ dependencies: [
49
+ { ref: "pkg:generic/demo@1.0.0", dependsOn: ["pkg:npm/lodash@4.17.21"] },
50
+ ],
51
+ };
52
+ }
53
+
54
+ describe("complianceEngine.resolveBenchmark/listBenchmarks", () => {
55
+ it("resolves known aliases case-insensitively", () => {
56
+ for (const alias of ["scvs", "scvs-l1", "SCVS-L2", "cra"]) {
57
+ const b = resolveBenchmark(alias);
58
+ assert.ok(b, `expected resolution for ${alias}`);
59
+ assert.ok(typeof b.filter === "function");
60
+ }
61
+ });
62
+ it("returns null for unknown aliases", () => {
63
+ assert.strictEqual(resolveBenchmark("ntia"), null);
64
+ assert.strictEqual(resolveBenchmark(null), null);
65
+ assert.strictEqual(resolveBenchmark(42), null);
66
+ });
67
+ it("listBenchmarks exposes all aliases", () => {
68
+ const ids = listBenchmarks().map((b) => b.id);
69
+ assert.ok(ids.includes("scvs"));
70
+ assert.ok(ids.includes("scvs-l1"));
71
+ assert.ok(ids.includes("scvs-l2"));
72
+ assert.ok(ids.includes("scvs-l3"));
73
+ assert.ok(ids.includes("cra"));
74
+ });
75
+ });
76
+
77
+ describe("complianceEngine.evaluateRule", () => {
78
+ it("marks non-automatable rules as info severity regardless of status", () => {
79
+ const rule = getAllComplianceRules().find((r) => r.automatable === false);
80
+ const f = evaluateRule(rule, richBom());
81
+ assert.strictEqual(f.severity, "info");
82
+ assert.strictEqual(f.status, "manual");
83
+ assert.strictEqual(f.automatable, false);
84
+ });
85
+
86
+ it("converts a throwing rule into a fail finding", () => {
87
+ const broken = {
88
+ id: "TEST-001",
89
+ name: "broken",
90
+ description: "",
91
+ standard: "SCVS",
92
+ standardRefs: ["TEST-001"],
93
+ category: "x",
94
+ severity: "high",
95
+ scvsLevels: [],
96
+ automatable: true,
97
+ evaluate: () => {
98
+ throw new Error("boom");
99
+ },
100
+ };
101
+ const f = evaluateRule(broken, {});
102
+ assert.strictEqual(f.status, "fail");
103
+ assert.match(f.message, /boom/);
104
+ });
105
+ });
106
+
107
+ describe("complianceEngine.evaluateAll", () => {
108
+ it("returns one finding per rule with no filters", () => {
109
+ const findings = evaluateAll(richBom());
110
+ assert.strictEqual(findings.length, getAllComplianceRules().length);
111
+ });
112
+
113
+ it("filters by category", () => {
114
+ const cra = evaluateAll(richBom(), { categories: ["compliance-cra"] });
115
+ assert.ok(cra.length > 0);
116
+ for (const f of cra) {
117
+ assert.strictEqual(f.category, "compliance-cra");
118
+ }
119
+ });
120
+
121
+ it("filters by benchmark alias", () => {
122
+ const l1 = evaluateAll(richBom(), { benchmarks: ["scvs-l1"] });
123
+ for (const f of l1) {
124
+ assert.ok(f.scvsLevels.includes("L1"));
125
+ }
126
+ assert.ok(l1.length < getAllComplianceRules().length);
127
+ });
128
+ });
129
+
130
+ describe("complianceEngine.scoreBenchmark", () => {
131
+ it("computes per-benchmark pass/fail/manual totals", () => {
132
+ const findings = evaluateAll(richBom());
133
+ const cra = resolveBenchmark("cra");
134
+ const report = scoreBenchmark(cra, findings);
135
+ assert.strictEqual(report.id, "cra");
136
+ assert.strictEqual(
137
+ report.totalControls,
138
+ report.pass + report.fail + report.manual,
139
+ );
140
+ assert.ok(report.scorePct >= 0 && report.scorePct <= 100);
141
+ // On the rich BOM every CRA rule should pass.
142
+ assert.strictEqual(report.fail, 0);
143
+ assert.strictEqual(report.scorePct, 100);
144
+ });
145
+
146
+ it("scores 0% when all automatable rules fail", () => {
147
+ const scvs = resolveBenchmark("scvs-l1");
148
+ const empty = { bomFormat: "CycloneDX", specVersion: "1.6" };
149
+ const findings = evaluateAll(empty);
150
+ const report = scoreBenchmark(scvs, findings);
151
+ assert.ok(report.fail > 0);
152
+ assert.ok(report.scorePct < 100);
153
+ });
154
+ });
155
+
156
+ describe("complianceEngine.buildBenchmarkReports", () => {
157
+ it("returns every benchmark when none requested", () => {
158
+ const findings = evaluateAll(richBom());
159
+ const reports = buildBenchmarkReports(findings);
160
+ assert.strictEqual(reports.length, listBenchmarks().length);
161
+ });
162
+ it("returns only requested benchmarks and skips unknown aliases", () => {
163
+ const findings = evaluateAll(richBom());
164
+ const reports = buildBenchmarkReports(findings, ["scvs-l1", "unknown"]);
165
+ assert.strictEqual(reports.length, 1);
166
+ assert.strictEqual(reports[0].id, "scvs-l1");
167
+ });
168
+ });