@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.
- package/README.md +47 -39
- package/bin/cdxgen.js +175 -96
- package/bin/evinse.js +4 -4
- package/bin/repl.js +1 -1
- package/bin/sign.js +102 -0
- package/bin/validate.js +233 -0
- package/bin/verify.js +69 -28
- package/data/queries.json +1 -1
- package/data/rules/ci-permissions.yaml +186 -0
- package/data/rules/dependency-sources.yaml +123 -0
- package/data/rules/package-integrity.yaml +135 -0
- package/data/rules/vscode-extensions.yaml +228 -0
- package/lib/cli/index.js +327 -372
- package/lib/evinser/db.js +137 -0
- package/lib/{helpers → evinser}/db.poku.js +2 -6
- package/lib/evinser/evinser.js +2 -14
- package/lib/helpers/bomSigner.js +312 -0
- package/lib/helpers/bomSigner.poku.js +156 -0
- package/lib/helpers/ciParsers/azurePipelines.js +295 -0
- package/lib/helpers/ciParsers/azurePipelines.poku.js +253 -0
- package/lib/helpers/ciParsers/circleCi.js +286 -0
- package/lib/helpers/ciParsers/circleCi.poku.js +230 -0
- package/lib/helpers/ciParsers/common.js +24 -0
- package/lib/helpers/ciParsers/githubActions.js +636 -0
- package/lib/helpers/ciParsers/githubActions.poku.js +802 -0
- package/lib/helpers/ciParsers/gitlabCi.js +213 -0
- package/lib/helpers/ciParsers/gitlabCi.poku.js +247 -0
- package/lib/helpers/ciParsers/jenkins.js +181 -0
- package/lib/helpers/ciParsers/jenkins.poku.js +197 -0
- package/lib/helpers/depsUtils.js +203 -0
- package/lib/helpers/depsUtils.poku.js +150 -0
- package/lib/helpers/display.js +423 -4
- package/lib/helpers/envcontext.js +18 -3
- package/lib/helpers/formulationParsers.js +351 -0
- package/lib/helpers/logger.js +14 -0
- package/lib/helpers/protobom.js +9 -9
- package/lib/helpers/pythonutils.js +9 -0
- package/lib/helpers/utils.js +681 -406
- package/lib/helpers/utils.poku.js +55 -255
- package/lib/helpers/versutils.js +202 -0
- package/lib/helpers/versutils.poku.js +315 -0
- package/lib/helpers/vsixutils.js +1061 -0
- package/lib/helpers/vsixutils.poku.js +2247 -0
- package/lib/managers/binary.js +19 -19
- package/lib/managers/docker.js +108 -1
- package/lib/managers/oci.js +10 -0
- package/lib/managers/piptree.js +3 -9
- package/lib/parsers/npmrc.js +17 -13
- package/lib/parsers/npmrc.poku.js +41 -5
- package/lib/server/openapi.yaml +1 -1
- package/lib/server/server.js +40 -11
- package/lib/server/server.poku.js +123 -144
- package/lib/stages/postgen/annotator.js +1 -1
- package/lib/stages/postgen/auditBom.js +197 -0
- package/lib/stages/postgen/auditBom.poku.js +378 -0
- package/lib/stages/postgen/postgen.js +54 -1
- package/lib/stages/postgen/postgen.poku.js +90 -1
- package/lib/stages/postgen/ruleEngine.js +369 -0
- package/lib/stages/pregen/envAudit.js +299 -0
- package/lib/stages/pregen/envAudit.poku.js +572 -0
- package/lib/stages/pregen/pregen.js +12 -8
- package/lib/{helpers/validator.js → validator/bomValidator.js} +107 -47
- package/lib/validator/complianceEngine.js +241 -0
- package/lib/validator/complianceEngine.poku.js +168 -0
- package/lib/validator/complianceRules.js +1610 -0
- package/lib/validator/complianceRules.poku.js +328 -0
- package/lib/validator/index.js +222 -0
- package/lib/validator/index.poku.js +144 -0
- package/lib/validator/reporters/annotations.js +121 -0
- package/lib/validator/reporters/console.js +149 -0
- package/lib/validator/reporters/index.js +41 -0
- package/lib/validator/reporters/json.js +37 -0
- package/lib/validator/reporters/sarif.js +184 -0
- package/lib/validator/reporters.poku.js +150 -0
- package/package.json +8 -8
- package/types/bin/sign.d.ts +3 -0
- package/types/bin/sign.d.ts.map +1 -0
- package/types/bin/validate.d.ts +3 -0
- package/types/bin/validate.d.ts.map +1 -0
- package/types/helpers/utils.d.ts +0 -1
- package/types/lib/cli/index.d.ts +49 -52
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/evinser/db.d.ts +34 -0
- package/types/lib/evinser/db.d.ts.map +1 -0
- package/types/lib/evinser/evinser.d.ts +63 -16
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/helpers/bomSigner.d.ts +27 -0
- package/types/lib/helpers/bomSigner.d.ts.map +1 -0
- package/types/lib/helpers/ciParsers/azurePipelines.d.ts +17 -0
- package/types/lib/helpers/ciParsers/azurePipelines.d.ts.map +1 -0
- package/types/lib/helpers/ciParsers/circleCi.d.ts +17 -0
- package/types/lib/helpers/ciParsers/circleCi.d.ts.map +1 -0
- package/types/lib/helpers/ciParsers/common.d.ts +11 -0
- package/types/lib/helpers/ciParsers/common.d.ts.map +1 -0
- package/types/lib/helpers/ciParsers/githubActions.d.ts +34 -0
- package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -0
- package/types/lib/helpers/ciParsers/gitlabCi.d.ts +17 -0
- package/types/lib/helpers/ciParsers/gitlabCi.d.ts.map +1 -0
- package/types/lib/helpers/ciParsers/jenkins.d.ts +17 -0
- package/types/lib/helpers/ciParsers/jenkins.d.ts.map +1 -0
- package/types/lib/helpers/depsUtils.d.ts +21 -0
- package/types/lib/helpers/depsUtils.d.ts.map +1 -0
- package/types/lib/helpers/display.d.ts +111 -11
- package/types/lib/helpers/display.d.ts.map +1 -1
- package/types/lib/helpers/envcontext.d.ts +19 -7
- package/types/lib/helpers/envcontext.d.ts.map +1 -1
- package/types/lib/helpers/formulationParsers.d.ts +50 -0
- package/types/lib/helpers/formulationParsers.d.ts.map +1 -0
- package/types/lib/helpers/logger.d.ts +15 -1
- package/types/lib/helpers/logger.d.ts.map +1 -1
- package/types/lib/helpers/protobom.d.ts +2 -2
- package/types/lib/helpers/pythonutils.d.ts +10 -1
- package/types/lib/helpers/pythonutils.d.ts.map +1 -1
- package/types/lib/helpers/utils.d.ts +532 -128
- package/types/lib/helpers/utils.d.ts.map +1 -1
- package/types/lib/helpers/versutils.d.ts +8 -0
- package/types/lib/helpers/versutils.d.ts.map +1 -0
- package/types/lib/helpers/vsixutils.d.ts +130 -0
- package/types/lib/helpers/vsixutils.d.ts.map +1 -0
- package/types/lib/managers/docker.d.ts +12 -31
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/managers/oci.d.ts +11 -1
- package/types/lib/managers/oci.d.ts.map +1 -1
- package/types/lib/managers/piptree.d.ts.map +1 -1
- package/types/lib/parsers/npmrc.d.ts +4 -1
- package/types/lib/parsers/npmrc.d.ts.map +1 -1
- package/types/lib/server/server.d.ts +21 -2
- package/types/lib/server/server.d.ts.map +1 -1
- package/types/lib/stages/postgen/auditBom.d.ts +20 -0
- package/types/lib/stages/postgen/auditBom.d.ts.map +1 -0
- package/types/lib/stages/postgen/postgen.d.ts +8 -1
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/stages/postgen/ruleEngine.d.ts +18 -0
- package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -0
- package/types/lib/stages/pregen/envAudit.d.ts +8 -0
- package/types/lib/stages/pregen/envAudit.d.ts.map +1 -0
- package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
- package/types/lib/{helpers/validator.d.ts → validator/bomValidator.d.ts} +1 -1
- package/types/lib/validator/bomValidator.d.ts.map +1 -0
- package/types/lib/validator/complianceEngine.d.ts +66 -0
- package/types/lib/validator/complianceEngine.d.ts.map +1 -0
- package/types/lib/validator/complianceRules.d.ts +70 -0
- package/types/lib/validator/complianceRules.d.ts.map +1 -0
- package/types/lib/validator/index.d.ts +70 -0
- package/types/lib/validator/index.d.ts.map +1 -0
- package/types/lib/validator/reporters/annotations.d.ts +31 -0
- package/types/lib/validator/reporters/annotations.d.ts.map +1 -0
- package/types/lib/validator/reporters/console.d.ts +30 -0
- package/types/lib/validator/reporters/console.d.ts.map +1 -0
- package/types/lib/validator/reporters/index.d.ts +21 -0
- package/types/lib/validator/reporters/index.d.ts.map +1 -0
- package/types/lib/validator/reporters/json.d.ts +11 -0
- package/types/lib/validator/reporters/json.d.ts.map +1 -0
- package/types/lib/validator/reporters/sarif.d.ts +16 -0
- package/types/lib/validator/reporters/sarif.d.ts.map +1 -0
- package/lib/helpers/db.js +0 -162
- package/lib/stages/pregen/env-audit.js +0 -34
- package/lib/stages/pregen/env-audit.poku.js +0 -290
- package/types/helpers/db.d.ts +0 -35
- package/types/helpers/db.d.ts.map +0 -1
- package/types/lib/helpers/db.d.ts +0 -35
- package/types/lib/helpers/db.d.ts.map +0 -1
- package/types/lib/helpers/validator.d.ts.map +0 -1
- package/types/lib/stages/pregen/env-audit.d.ts +0 -2
- package/types/lib/stages/pregen/env-audit.d.ts.map +0 -1
- package/types/managers/binary.d.ts +0 -37
- package/types/managers/binary.d.ts.map +0 -1
- package/types/managers/docker.d.ts +0 -56
- package/types/managers/docker.d.ts.map +0 -1
- package/types/managers/oci.d.ts +0 -2
- package/types/managers/oci.d.ts.map +0 -1
- package/types/managers/piptree.d.ts +0 -2
- package/types/managers/piptree.d.ts.map +0 -1
- package/types/server/server.d.ts +0 -34
- package/types/server/server.d.ts.map +0 -1
- package/types/stages/postgen/annotator.d.ts +0 -27
- package/types/stages/postgen/annotator.d.ts.map +0 -1
- package/types/stages/postgen/postgen.d.ts +0 -51
- package/types/stages/postgen/postgen.d.ts.map +0 -1
- package/types/stages/pregen/pregen.d.ts +0 -59
- 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 });
|