@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
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Post-generation BOM audit orchestrator
3
+ * Evaluates security rules against CI/CD and dependency data in the BOM
4
+ */
5
+ import { join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import { table } from "table";
9
+
10
+ import {
11
+ DEBUG_MODE,
12
+ getTimestamp,
13
+ safeExistsSync,
14
+ } from "../../helpers/utils.js";
15
+ import { evaluateRules, loadRules } from "./ruleEngine.js";
16
+
17
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
18
+ const BUILTIN_RULES_DIR = join(__dirname, "..", "..", "..", "data", "rules");
19
+ const CODE_BLOCK = "```";
20
+
21
+ /**
22
+ * Audit BOM formulation section using JSONata-powered rule engine
23
+ * @param {Object} bomJson - Generated CycloneDX BOM
24
+ * @param {Object} options - CLI options
25
+ * @returns {Promise<Array>} Array of audit findings
26
+ */
27
+ export async function auditBom(bomJson, options) {
28
+ if (!bomJson) {
29
+ return [];
30
+ }
31
+ const findings = [];
32
+ const rules = await loadRules(BUILTIN_RULES_DIR);
33
+ if (options.bomAuditRulesDir && safeExistsSync(options.bomAuditRulesDir)) {
34
+ const userRulesDir = resolve(options.bomAuditRulesDir);
35
+ const userRules = await loadRules(userRulesDir);
36
+ if (DEBUG_MODE) {
37
+ console.log(`Loaded ${userRules.length} user rules from ${userRulesDir}`);
38
+ }
39
+ rules.push(...userRules);
40
+ }
41
+ if (rules.length === 0) {
42
+ if (DEBUG_MODE) {
43
+ console.log("No audit rules loaded; formulation audit skipped");
44
+ }
45
+ return findings;
46
+ }
47
+ let activeRules = rules;
48
+ if (options.bomAuditCategories) {
49
+ const categories = options.bomAuditCategories
50
+ .split(",")
51
+ .map((c) => c.trim())
52
+ .filter(Boolean);
53
+ if (categories.length > 0) {
54
+ activeRules = rules.filter((r) => categories.includes(r.category));
55
+ if (DEBUG_MODE) {
56
+ console.log(
57
+ `Filtering rules by categories: ${categories.join(", ")} (${activeRules.length} active)`,
58
+ );
59
+ }
60
+ }
61
+ }
62
+ const allFindings = await evaluateRules(activeRules, bomJson);
63
+ if (options.bomAuditMinSeverity) {
64
+ const minSeverity = options.bomAuditMinSeverity.toLowerCase();
65
+ const severityThreshold = { low: 0, medium: 1, high: 2, critical: 3 };
66
+ const threshold = severityThreshold[minSeverity] ?? 0;
67
+ findings.push(
68
+ ...allFindings.filter((f) => severityThreshold[f.severity] >= threshold),
69
+ );
70
+ } else {
71
+ findings.push(...allFindings);
72
+ }
73
+ if (DEBUG_MODE) {
74
+ console.log(
75
+ `Formulation audit complete: ${findings.length} finding(s) from ${activeRules.length} rule(s)`,
76
+ );
77
+ }
78
+
79
+ return findings;
80
+ }
81
+
82
+ /**
83
+ * Format findings for console output with color-coded severity
84
+ */
85
+ export function formatConsoleOutput(findings) {
86
+ if (!findings?.length) {
87
+ return "";
88
+ }
89
+ const config = {
90
+ columnDefault: { wrapWord: true, width: 100 },
91
+ columns: [
92
+ { width: 10 },
93
+ { width: 35 },
94
+ { width: 50 },
95
+ { width: 50 },
96
+ { width: 60 },
97
+ ],
98
+ header: {
99
+ alignment: "center",
100
+ content: "BOM Audit Findings\nGenerated with \u2665 by cdxgen",
101
+ },
102
+ };
103
+ const data = [["Rule", "Message", "Description", "Ref", "File"]];
104
+ for (const f of findings) {
105
+ const line = [];
106
+ line.push(f.ruleId);
107
+ line.push(f.message);
108
+ line.push(f.description || "");
109
+ line.push(f.location?.purl || f.location?.bomRef || "");
110
+ line.push(f.location?.file || "");
111
+ data.push(line);
112
+ }
113
+ console.log(table(data, config));
114
+ }
115
+
116
+ /**
117
+ * Convert findings to CycloneDX annotations
118
+ */
119
+ export function formatAnnotations(findings, bomJson) {
120
+ if (!findings?.length) {
121
+ return [];
122
+ }
123
+ const cdxgenAnnotator =
124
+ bomJson?.metadata?.tools?.components?.filter((c) => c.name === "cdxgen") ||
125
+ [];
126
+ if (!cdxgenAnnotator.length) {
127
+ if (DEBUG_MODE) {
128
+ console.warn(
129
+ "Cannot create audit annotations: cdxgen tool component not found in metadata",
130
+ );
131
+ }
132
+ return [];
133
+ }
134
+ return findings.map((f) => {
135
+ const subjects = [bomJson.serialNumber];
136
+ const properties = [
137
+ { name: "cdx:audit:ruleId", value: f.ruleId },
138
+ { name: "cdx:audit:severity", value: f.severity },
139
+ { name: "cdx:audit:category", value: f.category },
140
+ ];
141
+ if (f.name) {
142
+ properties.push({ name: "cdx:audit:name", value: f.name });
143
+ }
144
+ if (f.mitigation) {
145
+ properties.push({ name: "cdx:audit:mitigation", value: f.mitigation });
146
+ }
147
+ if (f?.location?.purl) {
148
+ properties.push({
149
+ name: "cdx:audit:location:purl",
150
+ value: f.location.purl,
151
+ });
152
+ }
153
+ if (f.location?.file) {
154
+ properties.push({
155
+ name: "cdx:audit:location:file",
156
+ value: f.location.file,
157
+ });
158
+ }
159
+ if (f.location?.bomRef) {
160
+ properties.push({
161
+ name: "cdx:audit:location:bomRef",
162
+ value: f.location.bomRef,
163
+ });
164
+ }
165
+ if (f.evidence && typeof f.evidence === "object") {
166
+ for (const [key, value] of Object.entries(f.evidence)) {
167
+ const propValue =
168
+ typeof value === "object" ? JSON.stringify(value) : String(value);
169
+ properties.push({
170
+ name: `cdx:audit:evidence:${key}`,
171
+ value: propValue,
172
+ });
173
+ }
174
+ }
175
+ return {
176
+ subjects,
177
+ annotator: {
178
+ component: cdxgenAnnotator[0],
179
+ },
180
+ timestamp: getTimestamp(),
181
+ text: `${f.message}\n${CODE_BLOCK}\n${JSON.stringify(properties)}\n${CODE_BLOCK}`,
182
+ };
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Check if any findings meet the severity threshold for secure mode failure
188
+ */
189
+ export function hasCriticalFindings(findings, options) {
190
+ if (!findings?.length) {
191
+ return false;
192
+ }
193
+ const failSeverity = options.bomAuditFailSeverity || "high";
194
+ const severityOrder = { low: 0, medium: 1, high: 2, critical: 3 };
195
+ const threshold = severityOrder[failSeverity] ?? severityOrder.high;
196
+ return findings.some((f) => (severityOrder[f.severity] ?? 0) >= threshold);
197
+ }
@@ -0,0 +1,378 @@
1
+ import { join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { assert, describe, it } from "poku";
5
+
6
+ import {
7
+ auditBom,
8
+ formatAnnotations,
9
+ hasCriticalFindings,
10
+ } from "./auditBom.js";
11
+ import { evaluateRule, evaluateRules, loadRules } from "./ruleEngine.js";
12
+
13
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
14
+ const RULES_DIR = join(__dirname, "..", "..", "..", "data", "rules");
15
+
16
+ function makeBom(components = [], workflows = []) {
17
+ return {
18
+ bomFormat: "CycloneDX",
19
+ specVersion: "1.6",
20
+ serialNumber: "urn:uuid:test-bom",
21
+ metadata: {
22
+ tools: {
23
+ components: [
24
+ {
25
+ type: "application",
26
+ name: "cdxgen",
27
+ version: "11.0.0",
28
+ "bom-ref": "pkg:npm/%40cyclonedx/cdxgen@11.0.0",
29
+ },
30
+ ],
31
+ },
32
+ component: {
33
+ name: "test-project",
34
+ type: "application",
35
+ "bom-ref": "pkg:npm/test-project@1.0.0",
36
+ },
37
+ },
38
+ components,
39
+ formulation: workflows.length ? [{ workflows }] : undefined,
40
+ };
41
+ }
42
+
43
+ function makeComponent(name, version, properties) {
44
+ return {
45
+ type: "library",
46
+ name,
47
+ version,
48
+ purl: `pkg:npm/${name}@${version}`,
49
+ "bom-ref": `pkg:npm/${name}@${version}`,
50
+ properties: properties.map(([k, v]) => ({ name: k, value: v })),
51
+ };
52
+ }
53
+
54
+ describe("loadRules", () => {
55
+ it("should load built-in rules from the data/rules directory", async () => {
56
+ const rules = await loadRules(RULES_DIR);
57
+ assert.ok(rules.length > 0, "Should load at least one rule");
58
+ for (const rule of rules) {
59
+ assert.ok(rule.id, "Each rule must have an id");
60
+ assert.ok(rule.condition, "Each rule must have a condition");
61
+ assert.ok(rule.message, "Each rule must have a message");
62
+ assert.ok(
63
+ ["critical", "high", "medium", "low"].includes(rule.severity),
64
+ `Rule ${rule.id} severity must be valid`,
65
+ );
66
+ }
67
+ });
68
+
69
+ it("should return empty array for non-existent directory", async () => {
70
+ const rules = await loadRules("/tmp/non-existent-rules-dir-12345");
71
+ assert.deepStrictEqual(rules, []);
72
+ });
73
+
74
+ it("should load rules with all required fields", async () => {
75
+ const rules = await loadRules(RULES_DIR);
76
+ const ciRules = rules.filter((r) => r.category === "ci-permission");
77
+ assert.ok(ciRules.length > 0, "Should have CI permission rules");
78
+ const depRules = rules.filter((r) => r.category === "dependency-source");
79
+ assert.ok(depRules.length > 0, "Should have dependency source rules");
80
+ const intRules = rules.filter((r) => r.category === "package-integrity");
81
+ assert.ok(intRules.length > 0, "Should have package integrity rules");
82
+ });
83
+ });
84
+
85
+ describe("evaluateRule", () => {
86
+ it("should detect unpinned action with write permissions (CI-001)", async () => {
87
+ const rules = await loadRules(RULES_DIR);
88
+ const rule = rules.find((r) => r.id === "CI-001");
89
+ assert.ok(rule, "CI-001 rule should exist");
90
+
91
+ const bom = makeBom([
92
+ makeComponent("actions/setup-node", "v3", [
93
+ ["cdx:github:action:isShaPinned", "false"],
94
+ ["cdx:github:workflow:hasWritePermissions", "true"],
95
+ ["cdx:github:action:uses", "actions/setup-node@v3"],
96
+ ["cdx:github:action:versionPinningType", "tag"],
97
+ ]),
98
+ ]);
99
+
100
+ const findings = await evaluateRule(rule, bom);
101
+ assert.ok(findings.length > 0, "Should find unpinned action");
102
+ assert.strictEqual(findings[0].ruleId, "CI-001");
103
+ assert.strictEqual(findings[0].severity, "high");
104
+ });
105
+
106
+ it("should not flag SHA-pinned actions for CI-001", async () => {
107
+ const rules = await loadRules(RULES_DIR);
108
+ const rule = rules.find((r) => r.id === "CI-001");
109
+
110
+ const bom = makeBom([
111
+ makeComponent("actions/setup-node", "v3", [
112
+ ["cdx:github:action:isShaPinned", "true"],
113
+ ["cdx:github:workflow:hasWritePermissions", "true"],
114
+ ["cdx:github:action:uses", "actions/setup-node@abc123"],
115
+ ]),
116
+ ]);
117
+
118
+ const findings = await evaluateRule(rule, bom);
119
+ assert.strictEqual(
120
+ findings.length,
121
+ 0,
122
+ "SHA-pinned action should not trigger",
123
+ );
124
+ });
125
+
126
+ it("should detect npm install script from non-registry source (PKG-001)", async () => {
127
+ const rules = await loadRules(RULES_DIR);
128
+ const rule = rules.find((r) => r.id === "PKG-001");
129
+ assert.ok(rule, "PKG-001 rule should exist");
130
+
131
+ const bom = makeBom([
132
+ makeComponent("sketchy-pkg", "1.0.0", [
133
+ ["cdx:npm:hasInstallScript", "true"],
134
+ ["cdx:npm:isRegistryDependency", "false"],
135
+ ]),
136
+ ]);
137
+
138
+ const findings = await evaluateRule(rule, bom);
139
+ assert.ok(findings.length > 0, "Should detect install script risk");
140
+ assert.strictEqual(findings[0].severity, "high");
141
+ });
142
+
143
+ it("should detect npm name mismatch (INT-002)", async () => {
144
+ const rules = await loadRules(RULES_DIR);
145
+ const rule = rules.find((r) => r.id === "INT-002");
146
+ assert.ok(rule, "INT-002 rule should exist");
147
+
148
+ const bom = makeBom([
149
+ makeComponent("suspicious-pkg", "1.0.0", [
150
+ [
151
+ "cdx:npm:nameMismatchError",
152
+ "Expected 'real-pkg', found 'suspicious-pkg'",
153
+ ],
154
+ ]),
155
+ ]);
156
+
157
+ const findings = await evaluateRule(rule, bom);
158
+ assert.ok(findings.length > 0, "Should detect name mismatch");
159
+ assert.strictEqual(findings[0].severity, "high");
160
+ });
161
+
162
+ it("should detect yanked Ruby gem (INT-004)", async () => {
163
+ const rules = await loadRules(RULES_DIR);
164
+ const rule = rules.find((r) => r.id === "INT-004");
165
+ assert.ok(rule, "INT-004 rule should exist");
166
+
167
+ const bom = makeBom([
168
+ {
169
+ type: "library",
170
+ name: "bad-gem",
171
+ version: "0.5.0",
172
+ purl: "pkg:gem/bad-gem@0.5.0",
173
+ "bom-ref": "pkg:gem/bad-gem@0.5.0",
174
+ properties: [{ name: "cdx:gem:yanked", value: "true" }],
175
+ },
176
+ ]);
177
+
178
+ const findings = await evaluateRule(rule, bom);
179
+ assert.ok(findings.length > 0, "Should detect yanked gem");
180
+ assert.strictEqual(findings[0].severity, "high");
181
+ });
182
+
183
+ it("should return empty findings when no components match", async () => {
184
+ const rules = await loadRules(RULES_DIR);
185
+ const rule = rules.find((r) => r.id === "CI-001");
186
+
187
+ const bom = makeBom([]);
188
+ const findings = await evaluateRule(rule, bom);
189
+ assert.strictEqual(findings.length, 0, "No components means no findings");
190
+ });
191
+ });
192
+
193
+ describe("evaluateRules", () => {
194
+ it("should sort findings by severity (high before medium before low)", async () => {
195
+ const rules = await loadRules(RULES_DIR);
196
+ const bom = makeBom([
197
+ makeComponent("actions/checkout", "v3", [
198
+ ["cdx:github:action:isShaPinned", "false"],
199
+ ["cdx:github:workflow:hasWritePermissions", "true"],
200
+ ["cdx:github:action:uses", "actions/checkout@v3"],
201
+ ["cdx:github:action:versionPinningType", "tag"],
202
+ ]),
203
+ makeComponent("deprecated-go-mod", "1.0.0", [
204
+ ["cdx:go:deprecated", "use other-module instead"],
205
+ ]),
206
+ ]);
207
+
208
+ const findings = await evaluateRules(rules, bom);
209
+ if (findings.length >= 2) {
210
+ const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
211
+ for (let i = 1; i < findings.length; i++) {
212
+ const prev = severityOrder[findings[i - 1].severity] ?? 4;
213
+ const curr = severityOrder[findings[i].severity] ?? 4;
214
+ assert.ok(
215
+ prev <= curr,
216
+ `Finding ${i - 1} severity (${findings[i - 1].severity}) should be >= severity of finding ${i} (${findings[i].severity})`,
217
+ );
218
+ }
219
+ }
220
+ });
221
+ });
222
+
223
+ describe("auditBom", () => {
224
+ it("should run audit and return findings", async () => {
225
+ const bom = makeBom([
226
+ makeComponent("actions/setup-node", "v3", [
227
+ ["cdx:github:action:isShaPinned", "false"],
228
+ ["cdx:github:workflow:hasWritePermissions", "true"],
229
+ ["cdx:github:action:uses", "actions/setup-node@v3"],
230
+ ["cdx:github:action:versionPinningType", "tag"],
231
+ ]),
232
+ ]);
233
+
234
+ const findings = await auditBom(bom, {});
235
+ assert.ok(findings.length > 0, "Should find at least one issue");
236
+ });
237
+
238
+ it("should return empty array for null bom", async () => {
239
+ const findings = await auditBom(null, {});
240
+ assert.deepStrictEqual(findings, []);
241
+ });
242
+
243
+ it("should filter by category", async () => {
244
+ const bom = makeBom([
245
+ makeComponent("actions/setup-node", "v3", [
246
+ ["cdx:github:action:isShaPinned", "false"],
247
+ ["cdx:github:workflow:hasWritePermissions", "true"],
248
+ ["cdx:github:action:uses", "actions/setup-node@v3"],
249
+ ["cdx:github:action:versionPinningType", "tag"],
250
+ ]),
251
+ makeComponent("sketchy-pkg", "1.0.0", [
252
+ ["cdx:npm:hasInstallScript", "true"],
253
+ ["cdx:npm:isRegistryDependency", "false"],
254
+ ]),
255
+ ]);
256
+
257
+ const ciOnly = await auditBom(bom, {
258
+ bomAuditCategories: "ci-permission",
259
+ });
260
+ for (const f of ciOnly) {
261
+ assert.strictEqual(f.category, "ci-permission");
262
+ }
263
+ });
264
+
265
+ it("should filter by minimum severity", async () => {
266
+ const bom = makeBom([
267
+ makeComponent("actions/setup-node", "v3", [
268
+ ["cdx:github:action:isShaPinned", "false"],
269
+ ["cdx:github:workflow:hasWritePermissions", "true"],
270
+ ["cdx:github:action:uses", "actions/setup-node@v3"],
271
+ ["cdx:github:action:versionPinningType", "tag"],
272
+ ]),
273
+ ]);
274
+
275
+ const highOnly = await auditBom(bom, {
276
+ bomAuditMinSeverity: "high",
277
+ });
278
+ for (const f of highOnly) {
279
+ assert.strictEqual(f.severity, "high");
280
+ }
281
+ });
282
+ });
283
+
284
+ describe("formatAnnotations", () => {
285
+ it("should create CycloneDX annotations from findings", () => {
286
+ const bom = makeBom([]);
287
+ const findings = [
288
+ {
289
+ ruleId: "CI-001",
290
+ name: "Unpinned action",
291
+ severity: "high",
292
+ category: "ci-permission",
293
+ message: "Unpinned GitHub Action detected",
294
+ mitigation: "Pin to SHA",
295
+ },
296
+ ];
297
+ const annotations = formatAnnotations(findings, bom);
298
+ assert.strictEqual(annotations.length, 1);
299
+ assert.ok(
300
+ annotations[0].text.startsWith("Unpinned GitHub Action detected"),
301
+ );
302
+ assert.ok(
303
+ annotations[0].annotator.component,
304
+ "Annotation should have annotator component",
305
+ );
306
+ assert.ok(annotations[0].subjects.includes(bom.serialNumber));
307
+ });
308
+
309
+ it("should return empty array when cdxgen tool component is missing", () => {
310
+ const bom = {
311
+ serialNumber: "urn:uuid:test",
312
+ metadata: { tools: { components: [] } },
313
+ components: [],
314
+ };
315
+ const findings = [
316
+ {
317
+ ruleId: "CI-001",
318
+ severity: "high",
319
+ category: "ci-permission",
320
+ message: "test",
321
+ },
322
+ ];
323
+ const annotations = formatAnnotations(findings, bom);
324
+ assert.deepStrictEqual(annotations, []);
325
+ });
326
+
327
+ it("should return empty array when metadata.tools is undefined", () => {
328
+ const bom = {
329
+ serialNumber: "urn:uuid:test",
330
+ metadata: {},
331
+ components: [],
332
+ };
333
+ const annotations = formatAnnotations(
334
+ [{ ruleId: "X", severity: "low", category: "test", message: "test" }],
335
+ bom,
336
+ );
337
+ assert.deepStrictEqual(annotations, []);
338
+ });
339
+ });
340
+
341
+ describe("hasCriticalFindings", () => {
342
+ it("should return true when high severity findings exist", () => {
343
+ const findings = [{ severity: "high" }];
344
+ assert.ok(hasCriticalFindings(findings, {}));
345
+ });
346
+
347
+ it("should return false when only low severity findings exist", () => {
348
+ const findings = [{ severity: "low" }];
349
+ assert.ok(!hasCriticalFindings(findings, {}));
350
+ });
351
+
352
+ it("should use threshold semantics (at or above)", () => {
353
+ const findings = [{ severity: "high" }];
354
+ // medium threshold should catch high findings
355
+ assert.ok(
356
+ hasCriticalFindings(findings, { bomAuditFailSeverity: "medium" }),
357
+ );
358
+ // high threshold should catch high findings
359
+ assert.ok(hasCriticalFindings(findings, { bomAuditFailSeverity: "high" }));
360
+ // critical threshold should NOT catch high findings
361
+ assert.ok(
362
+ !hasCriticalFindings(findings, { bomAuditFailSeverity: "critical" }),
363
+ );
364
+ });
365
+
366
+ it("should respect custom fail severity for medium", () => {
367
+ const findings = [{ severity: "medium" }];
368
+ assert.ok(
369
+ hasCriticalFindings(findings, { bomAuditFailSeverity: "medium" }),
370
+ );
371
+ assert.ok(!hasCriticalFindings(findings, { bomAuditFailSeverity: "high" }));
372
+ });
373
+
374
+ it("should return false for empty findings", () => {
375
+ assert.ok(!hasCriticalFindings([], {}));
376
+ assert.ok(!hasCriticalFindings(null, {}));
377
+ });
378
+ });
@@ -4,6 +4,8 @@ import process from "node:process";
4
4
 
5
5
  import { PackageURL } from "packageurl-js";
6
6
 
7
+ import { mergeDependencies } from "../../helpers/depsUtils.js";
8
+ import { addFormulationSection } from "../../helpers/formulationParsers.js";
7
9
  import { thoughtLog } from "../../helpers/logger.js";
8
10
  import {
9
11
  DEBUG_MODE,
@@ -41,6 +43,44 @@ function relativeDir(d, options) {
41
43
  return d;
42
44
  }
43
45
 
46
+ /**
47
+ * Attach the CycloneDX formulation section to an already-built BOM JSON object.
48
+ *
49
+ * This is intentionally called once, from {@link postProcess}, so that the
50
+ * formulation section is added exactly once regardless of how many per-language
51
+ * `buildBomNSData` calls were made during BOM generation.
52
+ *
53
+ * @param {Object} bomJson The assembled BOM JSON object (mutated in place).
54
+ * @param {Object} options CLI options.
55
+ * @param {string} filePath File path.
56
+ * @param {Array} [formulationList] Optional language-specific formulation
57
+ * data (e.g. from Pixi) carried on `bomNSData`.
58
+ * @returns {Object} The same `bomJson` with `formulation` populated.
59
+ */
60
+ function applyFormulation(bomJson, options, filePath, formulationList) {
61
+ if (
62
+ !options.includeFormulation ||
63
+ options.specVersion < 1.5 ||
64
+ !bomJson ||
65
+ bomJson.formulation !== undefined
66
+ ) {
67
+ return bomJson;
68
+ }
69
+ const context = formulationList?.length ? { formulationList } : {};
70
+ const formulationData = addFormulationSection(filePath, options, context);
71
+ if (!formulationData) {
72
+ return bomJson;
73
+ }
74
+ bomJson.formulation = formulationData.formulation;
75
+ if (formulationData.dependencies?.length) {
76
+ bomJson.dependencies = mergeDependencies(
77
+ bomJson.dependencies || [],
78
+ formulationData.dependencies,
79
+ );
80
+ }
81
+ return bomJson;
82
+ }
83
+
44
84
  /**
45
85
  * Filter and enhance BOM post generation.
46
86
  *
@@ -49,7 +89,7 @@ function relativeDir(d, options) {
49
89
  *
50
90
  * @returns {Object} Modified bomNSData
51
91
  */
52
- export function postProcess(bomNSData, options) {
92
+ export function postProcess(bomNSData, options, filePath) {
53
93
  let jsonPayload = bomNSData.bomJson;
54
94
  if (
55
95
  typeof bomNSData.bomJson === "string" ||
@@ -61,6 +101,12 @@ export function postProcess(bomNSData, options) {
61
101
  bomNSData.bomJson = filterBom(jsonPayload, options);
62
102
  bomNSData.bomJson = applyStandards(bomNSData.bomJson, options);
63
103
  bomNSData.bomJson = applyMetadata(bomNSData.bomJson, options);
104
+ bomNSData.bomJson = applyFormulation(
105
+ bomNSData.bomJson,
106
+ options,
107
+ filePath,
108
+ bomNSData.formulationList,
109
+ );
64
110
  // Support for automatic annotations
65
111
  if (options.specVersion >= 1.6) {
66
112
  bomNSData.bomJson = annotate(bomNSData.bomJson, options);
@@ -494,6 +540,13 @@ export function cleanupEnv(_options) {
494
540
  }
495
541
  }
496
542
 
543
+ /**
544
+ * Removes the cdxgen temporary directory if it was created inside the system
545
+ * temp directory (as indicated by `CDXGEN_TMP_DIR`). No-ops when the variable
546
+ * is unset or points outside the system temp directory.
547
+ *
548
+ * @returns {void}
549
+ */
497
550
  export function cleanupTmpDir() {
498
551
  if (process.env?.CDXGEN_TMP_DIR?.startsWith(getTmpDir())) {
499
552
  rmSync(process.env.CDXGEN_TMP_DIR, { recursive: true, force: true });