@cyclonedx/cdxgen 12.2.1 → 12.3.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 +239 -90
- package/bin/audit.js +191 -0
- package/bin/cdxgen.js +513 -167
- package/bin/convert.js +99 -0
- package/bin/evinse.js +23 -0
- package/bin/repl.js +339 -8
- package/bin/sign.js +8 -0
- package/bin/validate.js +8 -0
- package/bin/verify.js +8 -0
- package/data/container-knowledge-index.json +125 -0
- package/data/gtfobins-index.json +6296 -0
- package/data/lolbas-index.json +150 -0
- package/data/queries-darwin.json +63 -3
- package/data/queries-win.json +45 -3
- package/data/queries.json +74 -2
- package/data/rules/chrome-extensions.yaml +240 -0
- package/data/rules/ci-permissions.yaml +478 -18
- package/data/rules/container-risk.yaml +270 -0
- package/data/rules/obom-runtime.yaml +891 -0
- package/data/rules/package-integrity.yaml +49 -0
- package/data/spdx-export.schema.json +6794 -0
- package/data/spdx-model-v3.0.1.jsonld +15999 -0
- package/lib/audit/index.js +1924 -0
- package/lib/audit/index.poku.js +1488 -0
- package/lib/audit/progress.js +137 -0
- package/lib/audit/progress.poku.js +188 -0
- package/lib/audit/reporters.js +618 -0
- package/lib/audit/scoring.js +310 -0
- package/lib/audit/scoring.poku.js +341 -0
- package/lib/audit/targets.js +260 -0
- package/lib/audit/targets.poku.js +331 -0
- package/lib/cli/index.js +154 -11
- package/lib/cli/index.poku.js +251 -0
- package/lib/helpers/analyzer.js +446 -2
- package/lib/helpers/analyzer.poku.js +72 -1
- package/lib/helpers/annotationFormatter.js +49 -0
- package/lib/helpers/annotationFormatter.poku.js +44 -0
- package/lib/helpers/bomUtils.js +36 -0
- package/lib/helpers/bomUtils.poku.js +51 -0
- package/lib/helpers/caxa.js +2 -2
- package/lib/helpers/chromextutils.js +1153 -0
- package/lib/helpers/chromextutils.poku.js +493 -0
- package/lib/helpers/ciParsers/githubActions.js +1632 -45
- package/lib/helpers/ciParsers/githubActions.poku.js +853 -1
- package/lib/helpers/containerRisk.js +186 -0
- package/lib/helpers/containerRisk.poku.js +52 -0
- package/lib/helpers/display.js +241 -59
- package/lib/helpers/display.poku.js +162 -2
- package/lib/helpers/exportUtils.js +123 -0
- package/lib/helpers/exportUtils.poku.js +60 -0
- package/lib/helpers/formulationParsers.js +69 -0
- package/lib/helpers/formulationParsers.poku.js +44 -0
- package/lib/helpers/gtfobins.js +189 -0
- package/lib/helpers/gtfobins.poku.js +49 -0
- package/lib/helpers/lolbas.js +267 -0
- package/lib/helpers/lolbas.poku.js +39 -0
- package/lib/helpers/osqueryTransform.js +84 -0
- package/lib/helpers/osqueryTransform.poku.js +49 -0
- package/lib/helpers/provenanceUtils.js +193 -0
- package/lib/helpers/provenanceUtils.poku.js +145 -0
- package/lib/helpers/pylockutils.js +281 -0
- package/lib/helpers/pylockutils.poku.js +48 -0
- package/lib/helpers/registryProvenance.js +793 -0
- package/lib/helpers/registryProvenance.poku.js +452 -0
- package/lib/helpers/source.js +1267 -0
- package/lib/helpers/source.poku.js +771 -0
- package/lib/helpers/spdxUtils.js +97 -0
- package/lib/helpers/spdxUtils.poku.js +70 -0
- package/lib/helpers/unicodeScan.js +147 -0
- package/lib/helpers/unicodeScan.poku.js +45 -0
- package/lib/helpers/utils.js +700 -128
- package/lib/helpers/utils.poku.js +877 -80
- package/lib/managers/binary.js +29 -5
- package/lib/managers/docker.js +179 -52
- package/lib/managers/docker.poku.js +327 -28
- package/lib/managers/oci.js +107 -23
- package/lib/managers/oci.poku.js +132 -0
- package/lib/server/openapi.yaml +17 -0
- package/lib/server/server.js +225 -336
- package/lib/server/server.poku.js +16 -10
- package/lib/stages/postgen/annotator.js +7 -0
- package/lib/stages/postgen/annotator.poku.js +40 -0
- package/lib/stages/postgen/auditBom.js +19 -3
- package/lib/stages/postgen/auditBom.poku.js +1729 -67
- package/lib/stages/postgen/postgen.js +40 -0
- package/lib/stages/postgen/postgen.poku.js +47 -0
- package/lib/stages/postgen/ruleEngine.js +80 -2
- package/lib/stages/postgen/spdxConverter.js +796 -0
- package/lib/stages/postgen/spdxConverter.poku.js +341 -0
- package/lib/validator/bomValidator.js +232 -0
- package/lib/validator/bomValidator.poku.js +70 -0
- package/lib/validator/complianceRules.js +70 -7
- package/lib/validator/complianceRules.poku.js +30 -0
- package/lib/validator/reporters/annotations.js +2 -2
- package/lib/validator/reporters/console.js +11 -0
- package/lib/validator/reporters.poku.js +13 -0
- package/package.json +10 -7
- package/types/bin/audit.d.ts +3 -0
- package/types/bin/audit.d.ts.map +1 -0
- package/types/bin/convert.d.ts +3 -0
- package/types/bin/convert.d.ts.map +1 -0
- package/types/bin/repl.d.ts.map +1 -1
- package/types/lib/audit/index.d.ts +115 -0
- package/types/lib/audit/index.d.ts.map +1 -0
- package/types/lib/audit/progress.d.ts +27 -0
- package/types/lib/audit/progress.d.ts.map +1 -0
- package/types/lib/audit/reporters.d.ts +35 -0
- package/types/lib/audit/reporters.d.ts.map +1 -0
- package/types/lib/audit/scoring.d.ts +35 -0
- package/types/lib/audit/scoring.d.ts.map +1 -0
- package/types/lib/audit/targets.d.ts +63 -0
- package/types/lib/audit/targets.d.ts.map +1 -0
- package/types/lib/cli/index.d.ts +8 -0
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/helpers/analyzer.d.ts +13 -0
- package/types/lib/helpers/analyzer.d.ts.map +1 -1
- package/types/lib/helpers/annotationFormatter.d.ts +23 -0
- package/types/lib/helpers/annotationFormatter.d.ts.map +1 -0
- package/types/lib/helpers/bomUtils.d.ts +5 -0
- package/types/lib/helpers/bomUtils.d.ts.map +1 -0
- package/types/lib/helpers/chromextutils.d.ts +97 -0
- package/types/lib/helpers/chromextutils.d.ts.map +1 -0
- package/types/lib/helpers/ciParsers/githubActions.d.ts +3 -8
- package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -1
- package/types/lib/helpers/containerRisk.d.ts +17 -0
- package/types/lib/helpers/containerRisk.d.ts.map +1 -0
- package/types/lib/helpers/display.d.ts +4 -1
- package/types/lib/helpers/display.d.ts.map +1 -1
- package/types/lib/helpers/exportUtils.d.ts +40 -0
- package/types/lib/helpers/exportUtils.d.ts.map +1 -0
- package/types/lib/helpers/formulationParsers.d.ts.map +1 -1
- package/types/lib/helpers/gtfobins.d.ts +17 -0
- package/types/lib/helpers/gtfobins.d.ts.map +1 -0
- package/types/lib/helpers/lolbas.d.ts +16 -0
- package/types/lib/helpers/lolbas.d.ts.map +1 -0
- package/types/lib/helpers/osqueryTransform.d.ts +7 -0
- package/types/lib/helpers/osqueryTransform.d.ts.map +1 -0
- package/types/lib/helpers/provenanceUtils.d.ts +90 -0
- package/types/lib/helpers/provenanceUtils.d.ts.map +1 -0
- package/types/lib/helpers/pylockutils.d.ts +51 -0
- package/types/lib/helpers/pylockutils.d.ts.map +1 -0
- package/types/lib/helpers/registryProvenance.d.ts +17 -0
- package/types/lib/helpers/registryProvenance.d.ts.map +1 -0
- package/types/lib/helpers/source.d.ts +141 -0
- package/types/lib/helpers/source.d.ts.map +1 -0
- package/types/lib/helpers/spdxUtils.d.ts +2 -0
- package/types/lib/helpers/spdxUtils.d.ts.map +1 -0
- package/types/lib/helpers/unicodeScan.d.ts +46 -0
- package/types/lib/helpers/unicodeScan.d.ts.map +1 -0
- package/types/lib/helpers/utils.d.ts +29 -11
- package/types/lib/helpers/utils.d.ts.map +1 -1
- package/types/lib/managers/binary.d.ts.map +1 -1
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/managers/oci.d.ts.map +1 -1
- package/types/lib/server/server.d.ts +0 -36
- package/types/lib/server/server.d.ts.map +1 -1
- package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
- package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
- package/types/lib/stages/postgen/spdxConverter.d.ts +11 -0
- package/types/lib/stages/postgen/spdxConverter.d.ts.map +1 -0
- package/types/lib/validator/bomValidator.d.ts +1 -0
- package/types/lib/validator/bomValidator.d.ts.map +1 -1
- package/types/lib/validator/complianceRules.d.ts.map +1 -1
- package/types/lib/validator/reporters/console.d.ts.map +1 -1
- package/types/bin/dependencies.d.ts +0 -3
- package/types/bin/dependencies.d.ts.map +0 -1
- package/types/bin/licenses.d.ts +0 -3
- package/types/bin/licenses.d.ts.map +0 -1
|
@@ -0,0 +1,1488 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
realpathSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
import esmock from "esmock";
|
|
14
|
+
import { assert, describe, it } from "poku";
|
|
15
|
+
import sinon from "sinon";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
buildPythonSourceHeuristicFindings,
|
|
19
|
+
buildTargetContextFindings,
|
|
20
|
+
finalizeAuditReport,
|
|
21
|
+
groupAuditResults,
|
|
22
|
+
loadInputBoms,
|
|
23
|
+
} from "./index.js";
|
|
24
|
+
import {
|
|
25
|
+
formatPredictiveAnnotations,
|
|
26
|
+
renderAuditReport,
|
|
27
|
+
renderConsoleReport,
|
|
28
|
+
} from "./reporters.js";
|
|
29
|
+
|
|
30
|
+
function writeJson(filePath, payload) {
|
|
31
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
32
|
+
writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function auditTargetSlug(target) {
|
|
36
|
+
const packageName = target.namespace
|
|
37
|
+
? `${target.namespace}-${target.name}`
|
|
38
|
+
: target.name;
|
|
39
|
+
const normalized = packageName
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/[-_.]+/g, "-")
|
|
42
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
43
|
+
.replace(/-+/g, "-")
|
|
44
|
+
.replace(/^-|-$/g, "");
|
|
45
|
+
const version = (target.version || "latest")
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.replace(/[-_.]+/g, "-");
|
|
48
|
+
const digest = createHash("sha256")
|
|
49
|
+
.update(target.purl)
|
|
50
|
+
.digest("hex")
|
|
51
|
+
.slice(0, 12);
|
|
52
|
+
return `${target.type}-${normalized || "package"}-${version || "latest"}-${digest}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("loadInputBoms()", () => {
|
|
56
|
+
it("loads valid BOMs from a directory and skips unrelated JSON files", () => {
|
|
57
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdx-audit-"));
|
|
58
|
+
const bomPath = path.join(tmpDir, "bom.json");
|
|
59
|
+
const otherPath = path.join(tmpDir, "notes.json");
|
|
60
|
+
|
|
61
|
+
writeJson(bomPath, {
|
|
62
|
+
bomFormat: "CycloneDX",
|
|
63
|
+
specVersion: "1.6",
|
|
64
|
+
version: 1,
|
|
65
|
+
components: [],
|
|
66
|
+
});
|
|
67
|
+
writeJson(otherPath, {
|
|
68
|
+
hello: "world",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const inputBoms = loadInputBoms({ bomDir: tmpDir });
|
|
73
|
+
assert.strictEqual(inputBoms.length, 1);
|
|
74
|
+
assert.strictEqual(inputBoms[0].source, bomPath);
|
|
75
|
+
} finally {
|
|
76
|
+
rmSync(tmpDir, { force: true, recursive: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("runAuditFromBoms()", () => {
|
|
82
|
+
it("passes scope/max target options to the selector and emits a preflight notice", async () => {
|
|
83
|
+
const collectAuditTargetsStub = sinon.stub().returns({
|
|
84
|
+
skipped: [],
|
|
85
|
+
stats: {
|
|
86
|
+
availableTargets: 60,
|
|
87
|
+
nonRequiredTargets: 59,
|
|
88
|
+
requiredTargets: 1,
|
|
89
|
+
trustedTargets: 12,
|
|
90
|
+
trustedTargetsExcluded: 12,
|
|
91
|
+
truncatedTargets: 59,
|
|
92
|
+
},
|
|
93
|
+
targets: [
|
|
94
|
+
{
|
|
95
|
+
name: "core",
|
|
96
|
+
namespace: "acme",
|
|
97
|
+
purl: "pkg:npm/acme/core@1.0.0",
|
|
98
|
+
required: true,
|
|
99
|
+
type: "npm",
|
|
100
|
+
version: "1.0.0",
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
const progressEvents = [];
|
|
105
|
+
const { runAuditFromBoms: mockedRunAuditFromBoms } = await esmock(
|
|
106
|
+
"./index.js",
|
|
107
|
+
{
|
|
108
|
+
"../cli/index.js": {
|
|
109
|
+
createBom: sinon.stub().resolves({
|
|
110
|
+
bomJson: {
|
|
111
|
+
bomFormat: "CycloneDX",
|
|
112
|
+
components: [],
|
|
113
|
+
specVersion: "1.7",
|
|
114
|
+
version: 1,
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
},
|
|
118
|
+
"../helpers/bomUtils.js": {
|
|
119
|
+
getNonCycloneDxErrorMessage: sinon.stub(),
|
|
120
|
+
isCycloneDxBom: () => true,
|
|
121
|
+
},
|
|
122
|
+
"../helpers/logger.js": { thoughtLog: sinon.stub() },
|
|
123
|
+
"../helpers/provenanceUtils.js": {
|
|
124
|
+
hasRegistryProvenanceEvidenceProperties: () => false,
|
|
125
|
+
hasTrustedPublishingProperties: () => false,
|
|
126
|
+
},
|
|
127
|
+
"../helpers/source.js": {
|
|
128
|
+
cleanupSourceDir: sinon.stub(),
|
|
129
|
+
findGitRefForPurlVersion: sinon.stub().returns(undefined),
|
|
130
|
+
hardenedGitCommand: sinon.stub().returns({ status: 0 }),
|
|
131
|
+
resolveGitUrlFromPurl: sinon.stub().resolves({
|
|
132
|
+
repoUrl: "https://github.com/acme/core.git",
|
|
133
|
+
type: "npm",
|
|
134
|
+
}),
|
|
135
|
+
resolvePurlSourceDirectory: sinon.stub().returnsArg(0),
|
|
136
|
+
sanitizeRemoteUrlForLogs: (value) => value,
|
|
137
|
+
},
|
|
138
|
+
"../helpers/utils.js": {
|
|
139
|
+
dirNameStr: path.resolve("."),
|
|
140
|
+
getTmpDir: () => os.tmpdir(),
|
|
141
|
+
safeExistsSync: (filePath) => existsSync(filePath),
|
|
142
|
+
safeMkdirSync: (filePath, options) => mkdirSync(filePath, options),
|
|
143
|
+
},
|
|
144
|
+
"../stages/postgen/auditBom.js": {
|
|
145
|
+
auditBom: sinon.stub().resolves([]),
|
|
146
|
+
},
|
|
147
|
+
"../stages/postgen/postgen.js": {
|
|
148
|
+
postProcess: sinon.stub().callsFake((bomNSData) => bomNSData),
|
|
149
|
+
},
|
|
150
|
+
"./targets.js": {
|
|
151
|
+
collectAuditTargets: collectAuditTargetsStub,
|
|
152
|
+
normalizePackageName: (value) =>
|
|
153
|
+
(value || "").toLowerCase().replace(/[-_.]+/g, "-"),
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const report = await mockedRunAuditFromBoms(
|
|
159
|
+
[
|
|
160
|
+
{
|
|
161
|
+
bomJson: {
|
|
162
|
+
bomFormat: "CycloneDX",
|
|
163
|
+
components: [],
|
|
164
|
+
specVersion: "1.7",
|
|
165
|
+
version: 1,
|
|
166
|
+
},
|
|
167
|
+
source: "bom.json",
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
{
|
|
171
|
+
maxTargets: 50,
|
|
172
|
+
onProgress: (event) => progressEvents.push(event),
|
|
173
|
+
scope: "required",
|
|
174
|
+
trustedSelectionHelp:
|
|
175
|
+
"Use --include-trusted to include them or --only-trusted to audit just those packages.",
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
assert.strictEqual(report.summary.totalTargets, 1);
|
|
180
|
+
assert.deepStrictEqual(collectAuditTargetsStub.firstCall.args[1], {
|
|
181
|
+
maxTargets: 50,
|
|
182
|
+
scope: "required",
|
|
183
|
+
trusted: undefined,
|
|
184
|
+
});
|
|
185
|
+
assert.strictEqual(progressEvents[0].type, "run:info");
|
|
186
|
+
assert.match(progressEvents[0].message, /scan 1 required package/);
|
|
187
|
+
assert.match(
|
|
188
|
+
progressEvents[0].message,
|
|
189
|
+
/Skipping 12 trusted-publishing-backed package/,
|
|
190
|
+
);
|
|
191
|
+
assert.strictEqual(progressEvents[1].type, "run:start");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("finalizeAuditReport()", () => {
|
|
196
|
+
it("returns exit code 3 when a target meets the fail severity", () => {
|
|
197
|
+
const finalized = finalizeAuditReport(
|
|
198
|
+
{
|
|
199
|
+
results: [
|
|
200
|
+
{
|
|
201
|
+
assessment: {
|
|
202
|
+
severity: "high",
|
|
203
|
+
},
|
|
204
|
+
findings: [],
|
|
205
|
+
target: {
|
|
206
|
+
name: "left-pad",
|
|
207
|
+
type: "npm",
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
summary: {
|
|
212
|
+
erroredTargets: 0,
|
|
213
|
+
inputBomCount: 1,
|
|
214
|
+
scannedTargets: 1,
|
|
215
|
+
skippedTargets: 0,
|
|
216
|
+
totalTargets: 1,
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
failSeverity: "high",
|
|
221
|
+
minSeverity: "low",
|
|
222
|
+
report: "console",
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
assert.strictEqual(finalized.exitCode, 3);
|
|
227
|
+
assert.match(finalized.output, /left-pad/);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("returns exit code 0 when no target crosses the fail threshold", () => {
|
|
231
|
+
const finalized = finalizeAuditReport(
|
|
232
|
+
{
|
|
233
|
+
results: [
|
|
234
|
+
{
|
|
235
|
+
assessment: {
|
|
236
|
+
confidenceLabel: "medium",
|
|
237
|
+
reasons: ["Only one mild signal observed."],
|
|
238
|
+
score: 18,
|
|
239
|
+
severity: "low",
|
|
240
|
+
},
|
|
241
|
+
findings: [
|
|
242
|
+
{
|
|
243
|
+
message: "Deprecated package",
|
|
244
|
+
ruleId: "INT-005",
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
target: {
|
|
248
|
+
name: "requests",
|
|
249
|
+
type: "pypi",
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
summary: {
|
|
254
|
+
erroredTargets: 0,
|
|
255
|
+
inputBomCount: 1,
|
|
256
|
+
scannedTargets: 1,
|
|
257
|
+
skippedTargets: 0,
|
|
258
|
+
totalTargets: 1,
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
failSeverity: "high",
|
|
263
|
+
minSeverity: "low",
|
|
264
|
+
report: "console",
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
assert.strictEqual(finalized.exitCode, 0);
|
|
269
|
+
assert.match(finalized.output, /requests/);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("uses consolidated grouped results for fail-threshold decisions", () => {
|
|
273
|
+
const finalized = finalizeAuditReport(
|
|
274
|
+
{
|
|
275
|
+
groupedResults: [
|
|
276
|
+
{
|
|
277
|
+
assessment: {
|
|
278
|
+
severity: "medium",
|
|
279
|
+
},
|
|
280
|
+
findings: [],
|
|
281
|
+
grouping: {
|
|
282
|
+
label: "npm:@npmcli/*",
|
|
283
|
+
},
|
|
284
|
+
target: {
|
|
285
|
+
name: "*",
|
|
286
|
+
type: "npm",
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
results: [
|
|
291
|
+
{
|
|
292
|
+
assessment: {
|
|
293
|
+
severity: "high",
|
|
294
|
+
},
|
|
295
|
+
findings: [],
|
|
296
|
+
target: {
|
|
297
|
+
name: "fs",
|
|
298
|
+
type: "npm",
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
summary: {
|
|
303
|
+
erroredTargets: 0,
|
|
304
|
+
inputBomCount: 1,
|
|
305
|
+
scannedTargets: 1,
|
|
306
|
+
skippedTargets: 0,
|
|
307
|
+
totalTargets: 1,
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
failSeverity: "high",
|
|
312
|
+
minSeverity: "low",
|
|
313
|
+
report: "console",
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
assert.strictEqual(finalized.exitCode, 0);
|
|
318
|
+
assert.match(finalized.output, /@npmcli/);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("renders grouped predictive findings as SARIF 2.1.0 output", () => {
|
|
322
|
+
const finalized = finalizeAuditReport(
|
|
323
|
+
{
|
|
324
|
+
groupedResults: [
|
|
325
|
+
{
|
|
326
|
+
assessment: {
|
|
327
|
+
confidenceLabel: "high",
|
|
328
|
+
reasons: ["Two corroborating signals were observed."],
|
|
329
|
+
score: 72,
|
|
330
|
+
severity: "high",
|
|
331
|
+
},
|
|
332
|
+
findings: [
|
|
333
|
+
{
|
|
334
|
+
category: "package-integrity",
|
|
335
|
+
description: "Install-time hooks without provenance.",
|
|
336
|
+
message: "Package lacks registry-visible provenance.",
|
|
337
|
+
mitigation: "Prefer provenance-backed releases.",
|
|
338
|
+
ruleId: "PROV-001",
|
|
339
|
+
severity: "medium",
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
grouping: {
|
|
343
|
+
groupedPurls: ["pkg:npm/%40npmcli/fs@5.0.0"],
|
|
344
|
+
label: "npm:@npmcli/*",
|
|
345
|
+
memberCount: 1,
|
|
346
|
+
},
|
|
347
|
+
status: "audited",
|
|
348
|
+
target: {
|
|
349
|
+
bomRefs: ["pkg:npm/@npmcli/fs@5.0.0"],
|
|
350
|
+
name: "*",
|
|
351
|
+
namespace: "@npmcli",
|
|
352
|
+
purl: "pkg:npm/%40npmcli/fs@5.0.0",
|
|
353
|
+
type: "npm",
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
summary: {
|
|
358
|
+
erroredTargets: 0,
|
|
359
|
+
inputBomCount: 1,
|
|
360
|
+
scannedTargets: 1,
|
|
361
|
+
skippedTargets: 0,
|
|
362
|
+
totalTargets: 1,
|
|
363
|
+
},
|
|
364
|
+
tool: {
|
|
365
|
+
name: "cdx-audit",
|
|
366
|
+
version: "12.3.0",
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
failSeverity: "critical",
|
|
371
|
+
minSeverity: "low",
|
|
372
|
+
report: "sarif",
|
|
373
|
+
},
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const parsed = JSON.parse(finalized.output);
|
|
377
|
+
assert.strictEqual(finalized.exitCode, 0);
|
|
378
|
+
assert.strictEqual(parsed.version, "2.1.0");
|
|
379
|
+
assert.strictEqual(parsed.runs[0].tool.driver.name, "cdx-audit");
|
|
380
|
+
assert.strictEqual(parsed.runs[0].tool.driver.version, "12.3.0");
|
|
381
|
+
assert.strictEqual(parsed.runs[0].results.length, 1);
|
|
382
|
+
assert.strictEqual(parsed.runs[0].results[0].ruleId, "PROV-001");
|
|
383
|
+
assert.strictEqual(
|
|
384
|
+
parsed.runs[0].results[0].locations[0].logicalLocations[0]
|
|
385
|
+
.fullyQualifiedName,
|
|
386
|
+
"pkg:npm/@npmcli/fs@5.0.0",
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("includes synthetic SARIF results when a target fails before findings are produced", () => {
|
|
391
|
+
const rendered = renderAuditReport(
|
|
392
|
+
"sarif",
|
|
393
|
+
{
|
|
394
|
+
results: [
|
|
395
|
+
{
|
|
396
|
+
assessment: {
|
|
397
|
+
confidenceLabel: "low",
|
|
398
|
+
reasons: ["Source resolution failed."],
|
|
399
|
+
score: 45,
|
|
400
|
+
severity: "high",
|
|
401
|
+
},
|
|
402
|
+
error: "Unable to clone repository.",
|
|
403
|
+
errorType: "clone",
|
|
404
|
+
findings: [],
|
|
405
|
+
status: "error",
|
|
406
|
+
target: {
|
|
407
|
+
bomRefs: ["pkg:pypi/example@1.0.0"],
|
|
408
|
+
name: "example",
|
|
409
|
+
purl: "pkg:pypi/example@1.0.0",
|
|
410
|
+
type: "pypi",
|
|
411
|
+
version: "1.0.0",
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
summary: {
|
|
416
|
+
erroredTargets: 1,
|
|
417
|
+
inputBomCount: 1,
|
|
418
|
+
scannedTargets: 0,
|
|
419
|
+
skippedTargets: 0,
|
|
420
|
+
totalTargets: 1,
|
|
421
|
+
},
|
|
422
|
+
tool: {
|
|
423
|
+
name: "cdx-audit",
|
|
424
|
+
version: "12.3.0",
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
minSeverity: "low",
|
|
429
|
+
},
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const parsed = JSON.parse(rendered);
|
|
433
|
+
assert.strictEqual(parsed.runs[0].results.length, 1);
|
|
434
|
+
assert.strictEqual(parsed.runs[0].results[0].ruleId, "AUDIT-ERROR");
|
|
435
|
+
assert.strictEqual(parsed.runs[0].results[0].level, "error");
|
|
436
|
+
assert.strictEqual(parsed.runs[0].tool.driver.rules[0].id, "AUDIT-ERROR");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("includes next-action and upstream guidance in SARIF output", () => {
|
|
440
|
+
const rendered = renderAuditReport(
|
|
441
|
+
"sarif",
|
|
442
|
+
{
|
|
443
|
+
results: [
|
|
444
|
+
{
|
|
445
|
+
assessment: {
|
|
446
|
+
confidenceLabel: "high",
|
|
447
|
+
reasons: ["Release workflow exposes legacy credentials."],
|
|
448
|
+
score: 71,
|
|
449
|
+
severity: "high",
|
|
450
|
+
},
|
|
451
|
+
findings: [
|
|
452
|
+
{
|
|
453
|
+
attackTactics: ["TA0006", "TA0010"],
|
|
454
|
+
attackTechniques: ["T1528"],
|
|
455
|
+
location: {
|
|
456
|
+
file: ".github/workflows/release.yml",
|
|
457
|
+
},
|
|
458
|
+
message:
|
|
459
|
+
"Workflow publish step uses legacy npm token-based publishing.",
|
|
460
|
+
mitigation:
|
|
461
|
+
"Prefer trusted publishing or OIDC-backed release flows instead of long-lived tokens.",
|
|
462
|
+
ruleId: "CI-010",
|
|
463
|
+
severity: "medium",
|
|
464
|
+
},
|
|
465
|
+
],
|
|
466
|
+
repoUrl: "https://github.com/example/project",
|
|
467
|
+
status: "audited",
|
|
468
|
+
target: {
|
|
469
|
+
bomRefs: ["pkg:npm/example@1.0.0"],
|
|
470
|
+
name: "example",
|
|
471
|
+
purl: "pkg:npm/example@1.0.0",
|
|
472
|
+
type: "npm",
|
|
473
|
+
version: "1.0.0",
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
],
|
|
477
|
+
summary: {
|
|
478
|
+
erroredTargets: 0,
|
|
479
|
+
inputBomCount: 1,
|
|
480
|
+
scannedTargets: 1,
|
|
481
|
+
skippedTargets: 0,
|
|
482
|
+
totalTargets: 1,
|
|
483
|
+
},
|
|
484
|
+
tool: {
|
|
485
|
+
name: "cdx-audit",
|
|
486
|
+
version: "12.3.0",
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
minSeverity: "low",
|
|
491
|
+
},
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
const parsed = JSON.parse(rendered);
|
|
495
|
+
assert.match(
|
|
496
|
+
parsed.runs[0].tool.driver.rules[0].help.text,
|
|
497
|
+
/open an issue or discussion/i,
|
|
498
|
+
);
|
|
499
|
+
assert.match(
|
|
500
|
+
parsed.runs[0].results[0].properties.nextAction,
|
|
501
|
+
/open an issue or discussion/i,
|
|
502
|
+
);
|
|
503
|
+
assert.match(
|
|
504
|
+
parsed.runs[0].results[0].properties.upstreamEscalation,
|
|
505
|
+
/upstream maintainers/i,
|
|
506
|
+
);
|
|
507
|
+
assert.deepStrictEqual(parsed.runs[0].results[0].properties.attackTactics, [
|
|
508
|
+
"TA0006",
|
|
509
|
+
"TA0010",
|
|
510
|
+
]);
|
|
511
|
+
assert.deepStrictEqual(
|
|
512
|
+
parsed.runs[0].tool.driver.rules[0].properties.attackTechniques,
|
|
513
|
+
["T1528"],
|
|
514
|
+
);
|
|
515
|
+
assert.ok(
|
|
516
|
+
parsed.runs[0].tool.driver.rules[0].properties.tags.includes(
|
|
517
|
+
"ATT&CK:TA0006",
|
|
518
|
+
),
|
|
519
|
+
);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("renders an action-oriented console report for actionable results", () => {
|
|
523
|
+
const rendered = renderConsoleReport(
|
|
524
|
+
{
|
|
525
|
+
results: [
|
|
526
|
+
{
|
|
527
|
+
assessment: {
|
|
528
|
+
confidenceLabel: "high",
|
|
529
|
+
reasons: ["Release workflow exposes legacy credentials."],
|
|
530
|
+
score: 71,
|
|
531
|
+
severity: "high",
|
|
532
|
+
},
|
|
533
|
+
findings: [
|
|
534
|
+
{
|
|
535
|
+
location: {
|
|
536
|
+
file: ".github/workflows/release.yml",
|
|
537
|
+
},
|
|
538
|
+
message:
|
|
539
|
+
"Workflow publish step uses legacy npm token-based publishing.",
|
|
540
|
+
mitigation:
|
|
541
|
+
"Prefer trusted publishing or OIDC-backed release flows instead of long-lived tokens.",
|
|
542
|
+
ruleId: "CI-010",
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
repoUrl: "https://github.com/example/project",
|
|
546
|
+
status: "audited",
|
|
547
|
+
target: {
|
|
548
|
+
bomRefs: ["pkg:npm/example@1.0.0"],
|
|
549
|
+
name: "example",
|
|
550
|
+
purl: "pkg:npm/example@1.0.0",
|
|
551
|
+
type: "npm",
|
|
552
|
+
version: "1.0.0",
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
],
|
|
556
|
+
summary: {
|
|
557
|
+
erroredTargets: 0,
|
|
558
|
+
inputBomCount: 1,
|
|
559
|
+
scannedTargets: 1,
|
|
560
|
+
skippedTargets: 0,
|
|
561
|
+
totalTargets: 1,
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
minSeverity: "low",
|
|
566
|
+
},
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
assert.match(rendered, /Dependencies requiring your attention:/);
|
|
570
|
+
assert.match(rendered, /What to do next/);
|
|
571
|
+
assert.match(rendered, /\.github\/workflows\/release\.yml/);
|
|
572
|
+
assert.match(rendered, /https:\/\/github.com\/example\/project/);
|
|
573
|
+
assert.match(rendered, /OIDC-backed release flows/);
|
|
574
|
+
assert.match(rendered, /open an issue or discussion/i);
|
|
575
|
+
assert.match(rendered, /upstream maintainers/i);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it("suggests upstream reporting for externally maintained package findings", () => {
|
|
579
|
+
const rendered = renderConsoleReport(
|
|
580
|
+
{
|
|
581
|
+
results: [
|
|
582
|
+
{
|
|
583
|
+
assessment: {
|
|
584
|
+
confidenceLabel: "medium",
|
|
585
|
+
reasons: ["Publisher drift was detected on a mature package."],
|
|
586
|
+
score: 46,
|
|
587
|
+
severity: "medium",
|
|
588
|
+
},
|
|
589
|
+
findings: [
|
|
590
|
+
{
|
|
591
|
+
location: {
|
|
592
|
+
purl: "pkg:npm/example@2.0.0",
|
|
593
|
+
},
|
|
594
|
+
message:
|
|
595
|
+
"npm package 'example@2.0.0' was published by a different identity than the prior release and lacks registry-visible provenance.",
|
|
596
|
+
mitigation:
|
|
597
|
+
"Review maintainer changes, compare the prior release publisher, and validate provenance before upgrading execution-capable packages.",
|
|
598
|
+
ruleId: "PROV-004",
|
|
599
|
+
},
|
|
600
|
+
],
|
|
601
|
+
repoUrl: "https://github.com/example/project",
|
|
602
|
+
status: "audited",
|
|
603
|
+
target: {
|
|
604
|
+
bomRefs: ["pkg:npm/example@2.0.0"],
|
|
605
|
+
name: "example",
|
|
606
|
+
purl: "pkg:npm/example@2.0.0",
|
|
607
|
+
type: "npm",
|
|
608
|
+
version: "2.0.0",
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
],
|
|
612
|
+
summary: {
|
|
613
|
+
erroredTargets: 0,
|
|
614
|
+
inputBomCount: 1,
|
|
615
|
+
scannedTargets: 1,
|
|
616
|
+
skippedTargets: 0,
|
|
617
|
+
totalTargets: 1,
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
minSeverity: "low",
|
|
622
|
+
},
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
assert.match(rendered, /pkg:npm\/example@2.0.0/);
|
|
626
|
+
assert.match(rendered, /maintained externally/i);
|
|
627
|
+
assert.match(rendered, /open an issue or discussion/i);
|
|
628
|
+
assert.match(rendered, /upstream maintainers/i);
|
|
629
|
+
assert.match(rendered, /Review maintainer/i);
|
|
630
|
+
assert.match(rendered, /prior release publisher/i);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("renders a clearer no-action-needed console report when nothing crosses the threshold", () => {
|
|
634
|
+
const rendered = renderConsoleReport(
|
|
635
|
+
{
|
|
636
|
+
results: [],
|
|
637
|
+
summary: {
|
|
638
|
+
erroredTargets: 0,
|
|
639
|
+
inputBomCount: 1,
|
|
640
|
+
scannedTargets: 0,
|
|
641
|
+
skippedTargets: 0,
|
|
642
|
+
totalTargets: 0,
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
minSeverity: "low",
|
|
647
|
+
},
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
assert.match(rendered, /No dependencies require your attention right now/);
|
|
651
|
+
assert.match(rendered, /configured severity threshold \('low'\)/);
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
describe("groupAuditResults()", () => {
|
|
656
|
+
it("consolidates npm namespace findings with the same rule pattern", () => {
|
|
657
|
+
const groupedResults = groupAuditResults([
|
|
658
|
+
{
|
|
659
|
+
assessment: {
|
|
660
|
+
categoryCounts: {
|
|
661
|
+
"ci-permission": 1,
|
|
662
|
+
},
|
|
663
|
+
confidenceLabel: "high",
|
|
664
|
+
reasons: [
|
|
665
|
+
"1 strong finding(s) were observed across the generated source SBOM.",
|
|
666
|
+
],
|
|
667
|
+
score: 58,
|
|
668
|
+
severity: "medium",
|
|
669
|
+
},
|
|
670
|
+
findings: [
|
|
671
|
+
{
|
|
672
|
+
category: "ci-permission",
|
|
673
|
+
message: "Interpolated github.event.pull_request.title",
|
|
674
|
+
ruleId: "CI-007",
|
|
675
|
+
},
|
|
676
|
+
],
|
|
677
|
+
repoUrl: "https://github.com/npm/fs.git",
|
|
678
|
+
status: "audited",
|
|
679
|
+
target: {
|
|
680
|
+
bomRefs: ["pkg:npm/@npmcli/fs@5.0.0"],
|
|
681
|
+
name: "fs",
|
|
682
|
+
namespace: "@npmcli",
|
|
683
|
+
purl: "pkg:npm/%40npmcli/fs@5.0.0",
|
|
684
|
+
type: "npm",
|
|
685
|
+
version: "5.0.0",
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
assessment: {
|
|
690
|
+
categoryCounts: {
|
|
691
|
+
"ci-permission": 1,
|
|
692
|
+
},
|
|
693
|
+
confidenceLabel: "high",
|
|
694
|
+
reasons: [
|
|
695
|
+
"1 strong finding(s) were observed across the generated source SBOM.",
|
|
696
|
+
],
|
|
697
|
+
score: 58,
|
|
698
|
+
severity: "medium",
|
|
699
|
+
},
|
|
700
|
+
findings: [
|
|
701
|
+
{
|
|
702
|
+
category: "ci-permission",
|
|
703
|
+
message: "Interpolated github.event.pull_request.title",
|
|
704
|
+
ruleId: "CI-007",
|
|
705
|
+
},
|
|
706
|
+
],
|
|
707
|
+
repoUrl: "https://github.com/npm/git.git",
|
|
708
|
+
status: "audited",
|
|
709
|
+
target: {
|
|
710
|
+
bomRefs: ["pkg:npm/@npmcli/git@7.0.2"],
|
|
711
|
+
name: "git",
|
|
712
|
+
namespace: "@npmcli",
|
|
713
|
+
purl: "pkg:npm/%40npmcli/git@7.0.2",
|
|
714
|
+
type: "npm",
|
|
715
|
+
version: "7.0.2",
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
assessment: {
|
|
720
|
+
categoryCounts: {
|
|
721
|
+
"package-integrity": 1,
|
|
722
|
+
},
|
|
723
|
+
confidenceLabel: "high",
|
|
724
|
+
reasons: ["Findings remained isolated."],
|
|
725
|
+
score: 16,
|
|
726
|
+
severity: "low",
|
|
727
|
+
},
|
|
728
|
+
findings: [
|
|
729
|
+
{
|
|
730
|
+
category: "package-integrity",
|
|
731
|
+
message: "Install hook present",
|
|
732
|
+
ruleId: "INT-001",
|
|
733
|
+
},
|
|
734
|
+
],
|
|
735
|
+
repoUrl: "https://github.com/isaacs/string-locale-compare.git",
|
|
736
|
+
status: "audited",
|
|
737
|
+
target: {
|
|
738
|
+
bomRefs: ["pkg:npm/@isaacs/string-locale-compare@1.1.0"],
|
|
739
|
+
name: "string-locale-compare",
|
|
740
|
+
namespace: "@isaacs",
|
|
741
|
+
purl: "pkg:npm/%40isaacs/string-locale-compare@1.1.0",
|
|
742
|
+
type: "npm",
|
|
743
|
+
version: "1.1.0",
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
]);
|
|
747
|
+
|
|
748
|
+
assert.strictEqual(groupedResults.length, 2);
|
|
749
|
+
assert.strictEqual(groupedResults[0].grouping?.label, "npm:@npmcli/*");
|
|
750
|
+
assert.strictEqual(groupedResults[0].grouping?.memberCount, 2);
|
|
751
|
+
assert.strictEqual(groupedResults[1].target.name, "string-locale-compare");
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
describe("buildTargetContextFindings()", () => {
|
|
756
|
+
it("creates a medium provenance detector for npm install-script packages without provenance", () => {
|
|
757
|
+
const findings = buildTargetContextFindings({
|
|
758
|
+
bomRefs: ["pkg:npm/example@1.2.3"],
|
|
759
|
+
name: "example",
|
|
760
|
+
purl: "pkg:npm/example@1.2.3",
|
|
761
|
+
properties: [
|
|
762
|
+
{
|
|
763
|
+
name: "cdx:npm:hasInstallScript",
|
|
764
|
+
value: "true",
|
|
765
|
+
},
|
|
766
|
+
],
|
|
767
|
+
type: "npm",
|
|
768
|
+
version: "1.2.3",
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
assert.strictEqual(findings.length, 1);
|
|
772
|
+
assert.strictEqual(findings[0].ruleId, "PROV-001");
|
|
773
|
+
assert.strictEqual(findings[0].severity, "medium");
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it("creates a low provenance detector for default-registry PyPI packages without provenance", () => {
|
|
777
|
+
const findings = buildTargetContextFindings({
|
|
778
|
+
bomRefs: ["pkg:pypi/example@2.0.0"],
|
|
779
|
+
name: "example",
|
|
780
|
+
purl: "pkg:pypi/example@2.0.0",
|
|
781
|
+
properties: [],
|
|
782
|
+
type: "pypi",
|
|
783
|
+
version: "2.0.0",
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
assert.strictEqual(findings.length, 1);
|
|
787
|
+
assert.strictEqual(findings[0].ruleId, "PROV-002");
|
|
788
|
+
assert.strictEqual(findings[0].severity, "low");
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it("does not create provenance detector findings when trusted publishing is present", () => {
|
|
792
|
+
const findings = buildTargetContextFindings({
|
|
793
|
+
bomRefs: ["pkg:npm/example@1.2.3"],
|
|
794
|
+
name: "example",
|
|
795
|
+
purl: "pkg:npm/example@1.2.3",
|
|
796
|
+
properties: [
|
|
797
|
+
{
|
|
798
|
+
name: "cdx:npm:hasInstallScript",
|
|
799
|
+
value: "true",
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
name: "cdx:npm:trustedPublishing",
|
|
803
|
+
value: "true",
|
|
804
|
+
},
|
|
805
|
+
],
|
|
806
|
+
type: "npm",
|
|
807
|
+
version: "1.2.3",
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
assert.strictEqual(findings.length, 0);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it("does not create provenance detector findings when direct provenance evidence is present", () => {
|
|
814
|
+
const findings = buildTargetContextFindings({
|
|
815
|
+
bomRefs: ["pkg:npm/example@1.2.3"],
|
|
816
|
+
name: "example",
|
|
817
|
+
purl: "pkg:npm/example@1.2.3",
|
|
818
|
+
properties: [
|
|
819
|
+
{
|
|
820
|
+
name: "cdx:npm:hasInstallScript",
|
|
821
|
+
value: "true",
|
|
822
|
+
},
|
|
823
|
+
{
|
|
824
|
+
name: "cdx:npm:provenanceKeyId",
|
|
825
|
+
value: "sigstore-key",
|
|
826
|
+
},
|
|
827
|
+
],
|
|
828
|
+
type: "npm",
|
|
829
|
+
version: "1.2.3",
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
assert.strictEqual(findings.length, 0);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it("creates recent-release and publisher-drift detectors for risky npm packages", () => {
|
|
836
|
+
const recentTimestamp = new Date(
|
|
837
|
+
Date.now() - 1000 * 60 * 60 * 12,
|
|
838
|
+
).toISOString();
|
|
839
|
+
const oldTimestamp = new Date(
|
|
840
|
+
Date.now() - 1000 * 60 * 60 * 24 * 120,
|
|
841
|
+
).toISOString();
|
|
842
|
+
const findings = buildTargetContextFindings({
|
|
843
|
+
bomRefs: ["pkg:npm/example@2.0.0"],
|
|
844
|
+
name: "example",
|
|
845
|
+
purl: "pkg:npm/example@2.0.0",
|
|
846
|
+
properties: [
|
|
847
|
+
{
|
|
848
|
+
name: "cdx:npm:hasInstallScript",
|
|
849
|
+
value: "true",
|
|
850
|
+
},
|
|
851
|
+
{
|
|
852
|
+
name: "cdx:npm:publishTime",
|
|
853
|
+
value: recentTimestamp,
|
|
854
|
+
},
|
|
855
|
+
{
|
|
856
|
+
name: "cdx:npm:packageCreatedTime",
|
|
857
|
+
value: oldTimestamp,
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
name: "cdx:npm:versionCount",
|
|
861
|
+
value: "10",
|
|
862
|
+
},
|
|
863
|
+
{
|
|
864
|
+
name: "cdx:npm:publisherDrift",
|
|
865
|
+
value: "true",
|
|
866
|
+
},
|
|
867
|
+
],
|
|
868
|
+
type: "npm",
|
|
869
|
+
version: "2.0.0",
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
assert.ok(findings.some((finding) => finding.ruleId === "PROV-003"));
|
|
873
|
+
assert.ok(findings.some((finding) => finding.ruleId === "PROV-004"));
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it("creates maintainer-set drift and dormant-gap detectors for risky npm packages", () => {
|
|
877
|
+
const findings = buildTargetContextFindings({
|
|
878
|
+
bomRefs: ["pkg:npm/example@3.0.0"],
|
|
879
|
+
name: "example",
|
|
880
|
+
purl: "pkg:npm/example@3.0.0",
|
|
881
|
+
properties: [
|
|
882
|
+
{
|
|
883
|
+
name: "cdx:npm:hasInstallScript",
|
|
884
|
+
value: "true",
|
|
885
|
+
},
|
|
886
|
+
{
|
|
887
|
+
name: "cdx:npm:packageCreatedTime",
|
|
888
|
+
value: "2024-01-01T00:00:00.000Z",
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
name: "cdx:npm:versionCount",
|
|
892
|
+
value: "12",
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
name: "cdx:npm:maintainerSetDrift",
|
|
896
|
+
value: "true",
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
name: "cdx:npm:releaseGapDays",
|
|
900
|
+
value: "240",
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
name: "cdx:npm:releaseGapBaselineDays",
|
|
904
|
+
value: "12",
|
|
905
|
+
},
|
|
906
|
+
{
|
|
907
|
+
name: "cdx:npm:releaseGapSampleSize",
|
|
908
|
+
value: "4",
|
|
909
|
+
},
|
|
910
|
+
],
|
|
911
|
+
type: "npm",
|
|
912
|
+
version: "3.0.0",
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
assert.ok(findings.some((finding) => finding.ruleId === "PROV-007"));
|
|
916
|
+
assert.ok(findings.some((finding) => finding.ruleId === "PROV-008"));
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
it("creates partial-overlap drift and compressed-cadence detectors for risky npm packages", () => {
|
|
920
|
+
const findings = buildTargetContextFindings({
|
|
921
|
+
bomRefs: ["pkg:npm/example@3.1.0"],
|
|
922
|
+
name: "example",
|
|
923
|
+
purl: "pkg:npm/example@3.1.0",
|
|
924
|
+
properties: [
|
|
925
|
+
{
|
|
926
|
+
name: "cdx:npm:hasInstallScript",
|
|
927
|
+
value: "true",
|
|
928
|
+
},
|
|
929
|
+
{
|
|
930
|
+
name: "cdx:npm:packageCreatedTime",
|
|
931
|
+
value: "2024-01-01T00:00:00.000Z",
|
|
932
|
+
},
|
|
933
|
+
{
|
|
934
|
+
name: "cdx:npm:versionCount",
|
|
935
|
+
value: "12",
|
|
936
|
+
},
|
|
937
|
+
{
|
|
938
|
+
name: "cdx:npm:maintainerSet",
|
|
939
|
+
value: "alice, bob",
|
|
940
|
+
},
|
|
941
|
+
{
|
|
942
|
+
name: "cdx:npm:priorMaintainerSet",
|
|
943
|
+
value: "bob, charlie",
|
|
944
|
+
},
|
|
945
|
+
{
|
|
946
|
+
name: "cdx:npm:releaseGapDays",
|
|
947
|
+
value: "9",
|
|
948
|
+
},
|
|
949
|
+
{
|
|
950
|
+
name: "cdx:npm:releaseGapBaselineDays",
|
|
951
|
+
value: "60",
|
|
952
|
+
},
|
|
953
|
+
{
|
|
954
|
+
name: "cdx:npm:releaseGapSampleSize",
|
|
955
|
+
value: "3",
|
|
956
|
+
},
|
|
957
|
+
],
|
|
958
|
+
type: "npm",
|
|
959
|
+
version: "3.1.0",
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
assert.ok(findings.some((finding) => finding.ruleId === "PROV-011"));
|
|
963
|
+
assert.ok(findings.some((finding) => finding.ruleId === "PROV-012"));
|
|
964
|
+
assert.ok(!findings.some((finding) => finding.ruleId === "PROV-007"));
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
it("creates recent-release and publisher-drift detectors for default-registry PyPI packages", () => {
|
|
968
|
+
const recentTimestamp = new Date(
|
|
969
|
+
Date.now() - 1000 * 60 * 60 * 12,
|
|
970
|
+
).toISOString();
|
|
971
|
+
const oldTimestamp = new Date(
|
|
972
|
+
Date.now() - 1000 * 60 * 60 * 24 * 120,
|
|
973
|
+
).toISOString();
|
|
974
|
+
const findings = buildTargetContextFindings({
|
|
975
|
+
bomRefs: ["pkg:pypi/example@2.0.0"],
|
|
976
|
+
name: "example",
|
|
977
|
+
purl: "pkg:pypi/example@2.0.0",
|
|
978
|
+
properties: [
|
|
979
|
+
{
|
|
980
|
+
name: "cdx:pypi:publishTime",
|
|
981
|
+
value: recentTimestamp,
|
|
982
|
+
},
|
|
983
|
+
{
|
|
984
|
+
name: "cdx:pypi:packageCreatedTime",
|
|
985
|
+
value: oldTimestamp,
|
|
986
|
+
},
|
|
987
|
+
{
|
|
988
|
+
name: "cdx:pypi:versionCount",
|
|
989
|
+
value: "8",
|
|
990
|
+
},
|
|
991
|
+
{
|
|
992
|
+
name: "cdx:pypi:publisherDrift",
|
|
993
|
+
value: "true",
|
|
994
|
+
},
|
|
995
|
+
],
|
|
996
|
+
type: "pypi",
|
|
997
|
+
version: "2.0.0",
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
assert.ok(findings.some((finding) => finding.ruleId === "PROV-005"));
|
|
1001
|
+
assert.ok(findings.some((finding) => finding.ruleId === "PROV-006"));
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("creates uploader-set drift and dormant-gap detectors for PyPI packages with weak trust posture", () => {
|
|
1005
|
+
const findings = buildTargetContextFindings({
|
|
1006
|
+
bomRefs: ["pkg:pypi/example@3.0.0"],
|
|
1007
|
+
name: "example",
|
|
1008
|
+
purl: "pkg:pypi/example@3.0.0",
|
|
1009
|
+
properties: [
|
|
1010
|
+
{
|
|
1011
|
+
name: "cdx:pypi:packageCreatedTime",
|
|
1012
|
+
value: "2024-01-01T00:00:00.000Z",
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
name: "cdx:pypi:versionCount",
|
|
1016
|
+
value: "12",
|
|
1017
|
+
},
|
|
1018
|
+
{
|
|
1019
|
+
name: "cdx:pypi:uploaderSetDrift",
|
|
1020
|
+
value: "true",
|
|
1021
|
+
},
|
|
1022
|
+
{
|
|
1023
|
+
name: "cdx:pypi:releaseGapDays",
|
|
1024
|
+
value: "240",
|
|
1025
|
+
},
|
|
1026
|
+
{
|
|
1027
|
+
name: "cdx:pypi:releaseGapBaselineDays",
|
|
1028
|
+
value: "12",
|
|
1029
|
+
},
|
|
1030
|
+
{
|
|
1031
|
+
name: "cdx:pypi:releaseGapSampleSize",
|
|
1032
|
+
value: "4",
|
|
1033
|
+
},
|
|
1034
|
+
],
|
|
1035
|
+
type: "pypi",
|
|
1036
|
+
version: "3.0.0",
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
assert.ok(findings.some((finding) => finding.ruleId === "PROV-009"));
|
|
1040
|
+
assert.ok(findings.some((finding) => finding.ruleId === "PROV-010"));
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
it("creates partial-overlap drift and compressed-cadence detectors for PyPI packages with weak trust posture", () => {
|
|
1044
|
+
const findings = buildTargetContextFindings({
|
|
1045
|
+
bomRefs: ["pkg:pypi/example@3.1.0"],
|
|
1046
|
+
name: "example",
|
|
1047
|
+
purl: "pkg:pypi/example@3.1.0",
|
|
1048
|
+
properties: [
|
|
1049
|
+
{
|
|
1050
|
+
name: "cdx:pypi:packageCreatedTime",
|
|
1051
|
+
value: "2024-01-01T00:00:00.000Z",
|
|
1052
|
+
},
|
|
1053
|
+
{
|
|
1054
|
+
name: "cdx:pypi:versionCount",
|
|
1055
|
+
value: "12",
|
|
1056
|
+
},
|
|
1057
|
+
{
|
|
1058
|
+
name: "cdx:pypi:uploaderSet",
|
|
1059
|
+
value: "alice, bob",
|
|
1060
|
+
},
|
|
1061
|
+
{
|
|
1062
|
+
name: "cdx:pypi:priorUploaderSet",
|
|
1063
|
+
value: "bob, charlie",
|
|
1064
|
+
},
|
|
1065
|
+
{
|
|
1066
|
+
name: "cdx:pypi:releaseGapDays",
|
|
1067
|
+
value: "9",
|
|
1068
|
+
},
|
|
1069
|
+
{
|
|
1070
|
+
name: "cdx:pypi:releaseGapBaselineDays",
|
|
1071
|
+
value: "60",
|
|
1072
|
+
},
|
|
1073
|
+
{
|
|
1074
|
+
name: "cdx:pypi:releaseGapSampleSize",
|
|
1075
|
+
value: "3",
|
|
1076
|
+
},
|
|
1077
|
+
],
|
|
1078
|
+
type: "pypi",
|
|
1079
|
+
version: "3.1.0",
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
assert.ok(findings.some((finding) => finding.ruleId === "PROV-013"));
|
|
1083
|
+
assert.ok(findings.some((finding) => finding.ruleId === "PROV-014"));
|
|
1084
|
+
assert.ok(!findings.some((finding) => finding.ruleId === "PROV-009"));
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
describe("buildPythonSourceHeuristicFindings()", () => {
|
|
1089
|
+
it("detects suspicious encoded execution inside setup.py", () => {
|
|
1090
|
+
const tempDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-audit-py-"));
|
|
1091
|
+
writeFileSync(
|
|
1092
|
+
path.join(tempDir, "setup.py"),
|
|
1093
|
+
[
|
|
1094
|
+
"from setuptools import setup",
|
|
1095
|
+
"import base64",
|
|
1096
|
+
"import os",
|
|
1097
|
+
"payload = base64.b64decode('bHM=')",
|
|
1098
|
+
"os.system(payload.decode())",
|
|
1099
|
+
"setup(name='demo')",
|
|
1100
|
+
].join("\n"),
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1103
|
+
try {
|
|
1104
|
+
const findings = buildPythonSourceHeuristicFindings(tempDir, {
|
|
1105
|
+
bomRefs: ["pkg:pypi/demo@1.0.0"],
|
|
1106
|
+
name: "demo",
|
|
1107
|
+
purl: "pkg:pypi/demo@1.0.0",
|
|
1108
|
+
type: "pypi",
|
|
1109
|
+
version: "1.0.0",
|
|
1110
|
+
});
|
|
1111
|
+
assert.ok(findings.some((finding) => finding.ruleId === "PYSRC-001"));
|
|
1112
|
+
} finally {
|
|
1113
|
+
rmSync(tempDir, { force: true, recursive: true });
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
it("detects suspicious import-time behavior in __init__.py", () => {
|
|
1118
|
+
const tempDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-audit-py-"));
|
|
1119
|
+
mkdirSync(path.join(tempDir, "demo"), { recursive: true });
|
|
1120
|
+
writeFileSync(
|
|
1121
|
+
path.join(tempDir, "demo", "__init__.py"),
|
|
1122
|
+
[
|
|
1123
|
+
"import requests",
|
|
1124
|
+
"import subprocess",
|
|
1125
|
+
"requests.get('https://example.invalid/payload')",
|
|
1126
|
+
"subprocess.run(['echo', 'demo'])",
|
|
1127
|
+
].join("\n"),
|
|
1128
|
+
);
|
|
1129
|
+
|
|
1130
|
+
try {
|
|
1131
|
+
const findings = buildPythonSourceHeuristicFindings(tempDir, {
|
|
1132
|
+
bomRefs: ["pkg:pypi/demo@1.0.0"],
|
|
1133
|
+
name: "demo",
|
|
1134
|
+
purl: "pkg:pypi/demo@1.0.0",
|
|
1135
|
+
type: "pypi",
|
|
1136
|
+
version: "1.0.0",
|
|
1137
|
+
});
|
|
1138
|
+
assert.ok(findings.some((finding) => finding.ruleId === "PYSRC-002"));
|
|
1139
|
+
} finally {
|
|
1140
|
+
rmSync(tempDir, { force: true, recursive: true });
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it("detects dynamic execution helpers such as exec in setup.py", () => {
|
|
1145
|
+
const tempDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-audit-py-"));
|
|
1146
|
+
writeFileSync(
|
|
1147
|
+
path.join(tempDir, "setup.py"),
|
|
1148
|
+
[
|
|
1149
|
+
"from setuptools import setup",
|
|
1150
|
+
"import base64",
|
|
1151
|
+
"payload = base64.b64decode('cHJpbnQoJ2RlbW8nKQ==')",
|
|
1152
|
+
"exec(payload.decode())",
|
|
1153
|
+
"setup(name='demo')",
|
|
1154
|
+
].join("\n"),
|
|
1155
|
+
);
|
|
1156
|
+
|
|
1157
|
+
try {
|
|
1158
|
+
const findings = buildPythonSourceHeuristicFindings(tempDir, {
|
|
1159
|
+
bomRefs: ["pkg:pypi/demo@1.0.0"],
|
|
1160
|
+
name: "demo",
|
|
1161
|
+
purl: "pkg:pypi/demo@1.0.0",
|
|
1162
|
+
type: "pypi",
|
|
1163
|
+
version: "1.0.0",
|
|
1164
|
+
});
|
|
1165
|
+
assert.ok(findings.some((finding) => finding.ruleId === "PYSRC-001"));
|
|
1166
|
+
} finally {
|
|
1167
|
+
rmSync(tempDir, { force: true, recursive: true });
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
it("skips oversized heuristic files before reading them", async () => {
|
|
1172
|
+
const readFileSyncStub = sinon
|
|
1173
|
+
.stub()
|
|
1174
|
+
.throws(new Error("should not be read"));
|
|
1175
|
+
const { buildPythonSourceHeuristicFindings: mockedBuildFindings } =
|
|
1176
|
+
await esmock("./index.js", {
|
|
1177
|
+
"node:fs": {
|
|
1178
|
+
mkdtempSync,
|
|
1179
|
+
readdirSync: sinon.stub().callsFake((_dirPath, options) => {
|
|
1180
|
+
if (options?.withFileTypes) {
|
|
1181
|
+
return [
|
|
1182
|
+
{
|
|
1183
|
+
name: "setup.py",
|
|
1184
|
+
isDirectory: () => false,
|
|
1185
|
+
isFile: () => true,
|
|
1186
|
+
},
|
|
1187
|
+
];
|
|
1188
|
+
}
|
|
1189
|
+
return [];
|
|
1190
|
+
}),
|
|
1191
|
+
readFileSync: readFileSyncStub,
|
|
1192
|
+
realpathSync,
|
|
1193
|
+
rmSync,
|
|
1194
|
+
statSync: sinon.stub().returns({ size: 256 * 1024 + 1 }),
|
|
1195
|
+
writeFileSync,
|
|
1196
|
+
},
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
const findings = mockedBuildFindings("/virtual/project", {
|
|
1200
|
+
bomRefs: ["pkg:pypi/demo@1.0.0"],
|
|
1201
|
+
name: "demo",
|
|
1202
|
+
purl: "pkg:pypi/demo@1.0.0",
|
|
1203
|
+
type: "pypi",
|
|
1204
|
+
version: "1.0.0",
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
assert.deepStrictEqual(findings, []);
|
|
1208
|
+
sinon.assert.notCalled(readFileSyncStub);
|
|
1209
|
+
});
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
describe("formatPredictiveAnnotations()", () => {
|
|
1213
|
+
it("creates component-scoped annotations for predictive audit results", () => {
|
|
1214
|
+
const annotations = formatPredictiveAnnotations(
|
|
1215
|
+
{
|
|
1216
|
+
results: [
|
|
1217
|
+
{
|
|
1218
|
+
assessment: {
|
|
1219
|
+
confidenceLabel: "medium",
|
|
1220
|
+
reasons: ["Two signals corroborated the risk posture."],
|
|
1221
|
+
score: 58,
|
|
1222
|
+
severity: "high",
|
|
1223
|
+
},
|
|
1224
|
+
findings: [
|
|
1225
|
+
{
|
|
1226
|
+
message: "Install script from non-registry source",
|
|
1227
|
+
ruleId: "PKG-001",
|
|
1228
|
+
},
|
|
1229
|
+
],
|
|
1230
|
+
repoUrl: "https://github.com/example/left-pad",
|
|
1231
|
+
target: {
|
|
1232
|
+
bomRefs: ["pkg:npm/left-pad@1.3.0"],
|
|
1233
|
+
purl: "pkg:npm/left-pad@1.3.0",
|
|
1234
|
+
},
|
|
1235
|
+
},
|
|
1236
|
+
],
|
|
1237
|
+
},
|
|
1238
|
+
{
|
|
1239
|
+
metadata: {
|
|
1240
|
+
tools: {
|
|
1241
|
+
components: [
|
|
1242
|
+
{
|
|
1243
|
+
name: "cdxgen",
|
|
1244
|
+
type: "application",
|
|
1245
|
+
version: "12.3.0",
|
|
1246
|
+
},
|
|
1247
|
+
],
|
|
1248
|
+
},
|
|
1249
|
+
},
|
|
1250
|
+
serialNumber: "urn:uuid:test-bom",
|
|
1251
|
+
},
|
|
1252
|
+
{
|
|
1253
|
+
minSeverity: "medium",
|
|
1254
|
+
},
|
|
1255
|
+
);
|
|
1256
|
+
|
|
1257
|
+
assert.strictEqual(annotations.length, 1);
|
|
1258
|
+
assert.deepStrictEqual(annotations[0].subjects, ["pkg:npm/left-pad@1.3.0"]);
|
|
1259
|
+
assert.match(annotations[0].text, /Predictive audit score 58/);
|
|
1260
|
+
assert.match(annotations[0].text, /Next action:/);
|
|
1261
|
+
assert.match(annotations[0].text, /open an issue or discussion/i);
|
|
1262
|
+
assert.match(annotations[0].text, /\| Property \| Value \|/);
|
|
1263
|
+
assert.match(annotations[0].text, /cdx:audit:nextAction/);
|
|
1264
|
+
assert.match(annotations[0].text, /cdx:audit:upstreamGuidance/);
|
|
1265
|
+
assert.match(annotations[0].text, /cdx:audit:engine/);
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
it("includes local dispatch sender-to-receiver edges in predictive annotations", () => {
|
|
1269
|
+
const annotations = formatPredictiveAnnotations(
|
|
1270
|
+
{
|
|
1271
|
+
results: [
|
|
1272
|
+
{
|
|
1273
|
+
assessment: {
|
|
1274
|
+
confidenceLabel: "high",
|
|
1275
|
+
reasons: ["Correlated local workflow dispatch chain."],
|
|
1276
|
+
score: 88,
|
|
1277
|
+
severity: "critical",
|
|
1278
|
+
},
|
|
1279
|
+
findings: [
|
|
1280
|
+
{
|
|
1281
|
+
category: "ci-permission",
|
|
1282
|
+
evidence: {
|
|
1283
|
+
hasLocalDispatchReceiver: "true",
|
|
1284
|
+
localReceiverWorkflowFiles: ".github/workflows/release.yml",
|
|
1285
|
+
localReceiverWorkflowNames: "Release workflow",
|
|
1286
|
+
},
|
|
1287
|
+
location: {
|
|
1288
|
+
file: ".github/workflows/sender.yml",
|
|
1289
|
+
},
|
|
1290
|
+
message: "Dispatch chain reaches local receiver",
|
|
1291
|
+
ruleId: "CI-019",
|
|
1292
|
+
severity: "critical",
|
|
1293
|
+
},
|
|
1294
|
+
],
|
|
1295
|
+
target: {
|
|
1296
|
+
bomRefs: ["pkg:npm/left-pad@1.3.0"],
|
|
1297
|
+
purl: "pkg:npm/left-pad@1.3.0",
|
|
1298
|
+
},
|
|
1299
|
+
},
|
|
1300
|
+
],
|
|
1301
|
+
},
|
|
1302
|
+
{
|
|
1303
|
+
metadata: {
|
|
1304
|
+
tools: {
|
|
1305
|
+
components: [
|
|
1306
|
+
{
|
|
1307
|
+
name: "cdxgen",
|
|
1308
|
+
type: "application",
|
|
1309
|
+
version: "12.3.0",
|
|
1310
|
+
},
|
|
1311
|
+
],
|
|
1312
|
+
},
|
|
1313
|
+
},
|
|
1314
|
+
serialNumber: "urn:uuid:test-bom",
|
|
1315
|
+
},
|
|
1316
|
+
{
|
|
1317
|
+
minSeverity: "medium",
|
|
1318
|
+
},
|
|
1319
|
+
);
|
|
1320
|
+
|
|
1321
|
+
assert.strictEqual(annotations.length, 1);
|
|
1322
|
+
assert.match(annotations[0].text, /cdx:audit:dispatch:edge/);
|
|
1323
|
+
assert.match(annotations[0].text, /sender\.yml.*Release workflow/);
|
|
1324
|
+
});
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
describe("audit reporters", () => {
|
|
1328
|
+
it("renders local sender-to-receiver workflow edges in console and SARIF reports", () => {
|
|
1329
|
+
const report = {
|
|
1330
|
+
generatedAt: new Date().toISOString(),
|
|
1331
|
+
groupedResults: [],
|
|
1332
|
+
inputs: ["bom.json"],
|
|
1333
|
+
results: [
|
|
1334
|
+
{
|
|
1335
|
+
assessment: {
|
|
1336
|
+
confidenceLabel: "high",
|
|
1337
|
+
erroredTargets: 0,
|
|
1338
|
+
reasons: ["Correlated local workflow dispatch chain."],
|
|
1339
|
+
score: 88,
|
|
1340
|
+
severity: "critical",
|
|
1341
|
+
},
|
|
1342
|
+
findings: [
|
|
1343
|
+
{
|
|
1344
|
+
attackTactics: ["TA0004"],
|
|
1345
|
+
attackTechniques: ["T1528"],
|
|
1346
|
+
category: "ci-permission",
|
|
1347
|
+
evidence: {
|
|
1348
|
+
hasLocalDispatchReceiver: "true",
|
|
1349
|
+
localReceiverMatchBasis: "workflow:release.yml",
|
|
1350
|
+
localReceiverWorkflowFiles: ".github/workflows/release.yml",
|
|
1351
|
+
localReceiverWorkflowNames: "Release workflow",
|
|
1352
|
+
},
|
|
1353
|
+
location: {
|
|
1354
|
+
file: ".github/workflows/sender.yml",
|
|
1355
|
+
},
|
|
1356
|
+
message: "Dispatch chain reaches local receiver",
|
|
1357
|
+
mitigation: "Split dispatchers from fork-reachable jobs.",
|
|
1358
|
+
ruleId: "CI-019",
|
|
1359
|
+
severity: "critical",
|
|
1360
|
+
},
|
|
1361
|
+
],
|
|
1362
|
+
repoUrl: "https://github.com/example/repo",
|
|
1363
|
+
sourceDirectoryConfidence: "high",
|
|
1364
|
+
status: "audited",
|
|
1365
|
+
target: {
|
|
1366
|
+
bomRefs: ["pkg:npm/left-pad@1.3.0"],
|
|
1367
|
+
name: "left-pad",
|
|
1368
|
+
purl: "pkg:npm/left-pad@1.3.0",
|
|
1369
|
+
type: "npm",
|
|
1370
|
+
version: "1.3.0",
|
|
1371
|
+
},
|
|
1372
|
+
},
|
|
1373
|
+
],
|
|
1374
|
+
summary: {
|
|
1375
|
+
erroredTargets: 0,
|
|
1376
|
+
inputBomCount: 1,
|
|
1377
|
+
scannedTargets: 1,
|
|
1378
|
+
skippedTargets: 0,
|
|
1379
|
+
totalTargets: 1,
|
|
1380
|
+
},
|
|
1381
|
+
tool: {
|
|
1382
|
+
name: "cdx-audit",
|
|
1383
|
+
version: "12.3.0",
|
|
1384
|
+
},
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
const consoleOutput = renderConsoleReport(report, { minSeverity: "low" });
|
|
1388
|
+
assert.match(consoleOutput, /sender\.yml -> Release workflow/);
|
|
1389
|
+
|
|
1390
|
+
const sarifText = renderAuditReport("sarif", report, {
|
|
1391
|
+
minSeverity: "low",
|
|
1392
|
+
});
|
|
1393
|
+
const sarif = JSON.parse(sarifText);
|
|
1394
|
+
const sarifResult = sarif.runs[0].results[0];
|
|
1395
|
+
assert.match(
|
|
1396
|
+
sarifResult.properties.localDispatchEdge,
|
|
1397
|
+
/sender\.yml -> Release workflow/,
|
|
1398
|
+
);
|
|
1399
|
+
assert.strictEqual(sarifResult.relatedLocations.length, 1);
|
|
1400
|
+
assert.strictEqual(
|
|
1401
|
+
sarifResult.relatedLocations[0].physicalLocation.artifactLocation.uri,
|
|
1402
|
+
".github/workflows/release.yml",
|
|
1403
|
+
);
|
|
1404
|
+
});
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
describe("auditTarget() cache resume", () => {
|
|
1408
|
+
it("reuses a cached child SBOM from the workspace without resolving or regenerating source", async () => {
|
|
1409
|
+
const workspaceDir = mkdtempSync(
|
|
1410
|
+
path.join(os.tmpdir(), "cdx-audit-workspace-"),
|
|
1411
|
+
);
|
|
1412
|
+
const target = {
|
|
1413
|
+
bomRefs: ["pkg:npm/@scope/pkg@1.0.0"],
|
|
1414
|
+
name: "pkg",
|
|
1415
|
+
namespace: "@scope",
|
|
1416
|
+
purl: "pkg:npm/%40scope/pkg@1.0.0",
|
|
1417
|
+
properties: [],
|
|
1418
|
+
type: "npm",
|
|
1419
|
+
version: "1.0.0",
|
|
1420
|
+
};
|
|
1421
|
+
const targetDir = path.join(workspaceDir, auditTargetSlug(target));
|
|
1422
|
+
const cacheDir = path.join(targetDir, ".cdx-audit");
|
|
1423
|
+
const cachedBom = {
|
|
1424
|
+
bomFormat: "CycloneDX",
|
|
1425
|
+
specVersion: "1.7",
|
|
1426
|
+
version: 1,
|
|
1427
|
+
components: [],
|
|
1428
|
+
};
|
|
1429
|
+
writeJson(path.join(cacheDir, "source-bom.json"), cachedBom);
|
|
1430
|
+
writeJson(path.join(cacheDir, "source-bom.meta.json"), {
|
|
1431
|
+
repoUrl: "https://github.com/scope/pkg.git",
|
|
1432
|
+
resolution: {
|
|
1433
|
+
name: "pkg",
|
|
1434
|
+
namespace: "@scope",
|
|
1435
|
+
repoUrl: "https://github.com/scope/pkg.git",
|
|
1436
|
+
type: "npm",
|
|
1437
|
+
version: "1.0.0",
|
|
1438
|
+
},
|
|
1439
|
+
scanDirRelative: ".",
|
|
1440
|
+
sourceDirectoryConfidence: "high",
|
|
1441
|
+
versionMatched: true,
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
const createBomStub = sinon.stub().resolves({ bomJson: cachedBom });
|
|
1445
|
+
const resolveGitUrlFromPurlStub = sinon.stub().resolves({
|
|
1446
|
+
repoUrl: "https://github.com/scope/pkg.git",
|
|
1447
|
+
});
|
|
1448
|
+
const auditBomStub = sinon.stub().resolves([]);
|
|
1449
|
+
const { auditTarget } = await esmock("./index.js", {
|
|
1450
|
+
"../cli/index.js": { createBom: createBomStub },
|
|
1451
|
+
"../helpers/logger.js": { thoughtLog: sinon.stub() },
|
|
1452
|
+
"../helpers/source.js": {
|
|
1453
|
+
cleanupSourceDir: sinon.stub(),
|
|
1454
|
+
findGitRefForPurlVersion: sinon.stub().returns(undefined),
|
|
1455
|
+
hardenedGitCommand: sinon.stub(),
|
|
1456
|
+
resolveGitUrlFromPurl: resolveGitUrlFromPurlStub,
|
|
1457
|
+
resolvePurlSourceDirectory: sinon.stub().returns(targetDir),
|
|
1458
|
+
sanitizeRemoteUrlForLogs: (value) => value,
|
|
1459
|
+
},
|
|
1460
|
+
"../helpers/utils.js": {
|
|
1461
|
+
dirNameStr: path.resolve("."),
|
|
1462
|
+
getTmpDir: () => os.tmpdir(),
|
|
1463
|
+
safeExistsSync: (filePath) => existsSync(filePath),
|
|
1464
|
+
safeMkdirSync: (filePath, options) => mkdirSync(filePath, options),
|
|
1465
|
+
},
|
|
1466
|
+
"../stages/postgen/auditBom.js": { auditBom: auditBomStub },
|
|
1467
|
+
"../stages/postgen/postgen.js": {
|
|
1468
|
+
postProcess: sinon.stub().callsFake((bomNSData) => bomNSData),
|
|
1469
|
+
},
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
try {
|
|
1473
|
+
const result = await auditTarget(target, {
|
|
1474
|
+
maxTargets: 1,
|
|
1475
|
+
minSeverity: "low",
|
|
1476
|
+
workspaceDir,
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
assert.strictEqual(result.status, "audited");
|
|
1480
|
+
assert.strictEqual(result.cacheHit, true);
|
|
1481
|
+
assert.strictEqual(createBomStub.callCount, 0);
|
|
1482
|
+
assert.strictEqual(resolveGitUrlFromPurlStub.callCount, 0);
|
|
1483
|
+
assert.strictEqual(auditBomStub.callCount, 1);
|
|
1484
|
+
} finally {
|
|
1485
|
+
rmSync(workspaceDir, { force: true, recursive: true });
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
});
|