@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,9 +1,11 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import { fileURLToPath } from "node:url";
|
|
3
5
|
|
|
4
6
|
import { assert, describe, it } from "poku";
|
|
5
7
|
|
|
6
|
-
import { githubActionsParser } from "./githubActions.js";
|
|
8
|
+
import { githubActionsParser, parseWorkflowFile } from "./githubActions.js";
|
|
7
9
|
|
|
8
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
11
|
const repoRoot = path.resolve(__dirname, "../../..");
|
|
@@ -143,6 +145,30 @@ describe("githubActionsParser", () => {
|
|
|
143
145
|
assert.deepStrictEqual(result.workflows, []);
|
|
144
146
|
});
|
|
145
147
|
|
|
148
|
+
it("derives unnamed workflow names from the file stem without leaking Windows-style path segments", () => {
|
|
149
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-gha-"));
|
|
150
|
+
const workflowFile = path.join(tmpDir, "nested\\workflow-file.yml");
|
|
151
|
+
mkdirSync(path.dirname(workflowFile), { recursive: true });
|
|
152
|
+
writeFileSync(
|
|
153
|
+
workflowFile,
|
|
154
|
+
[
|
|
155
|
+
"on: push",
|
|
156
|
+
"jobs:",
|
|
157
|
+
" build:",
|
|
158
|
+
" runs-on: ubuntu-latest",
|
|
159
|
+
' steps:\n - run: echo "ok"',
|
|
160
|
+
].join("\n"),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const result = parseWorkflowFile(workflowFile, { specVersion: 1.7 });
|
|
165
|
+
assert.strictEqual(result.workflows.length, 1);
|
|
166
|
+
assert.strictEqual(result.workflows[0].name, "workflow-file");
|
|
167
|
+
} finally {
|
|
168
|
+
rmSync(tmpDir, { force: true, recursive: true });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
146
172
|
it("disambiguates identical steps (uniqueItems compliance)", () => {
|
|
147
173
|
const wfFile = path.join(
|
|
148
174
|
repoRoot,
|
|
@@ -324,6 +350,30 @@ describe("githubActionsParser", () => {
|
|
|
324
350
|
);
|
|
325
351
|
});
|
|
326
352
|
|
|
353
|
+
it("emits pull_request_target trigger metadata for cache poisoning analysis", () => {
|
|
354
|
+
const result = parseWorkflow("cache-pull-request-target.yml");
|
|
355
|
+
|
|
356
|
+
const workflow = result.workflows[0];
|
|
357
|
+
assert.strictEqual(
|
|
358
|
+
getProp(workflow, "cdx:github:workflow:hasPullRequestTargetTrigger"),
|
|
359
|
+
"true",
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const cacheComp = findComponentByPurlSubstring(
|
|
363
|
+
result.components,
|
|
364
|
+
"actions/cache",
|
|
365
|
+
);
|
|
366
|
+
assert.ok(cacheComp, "expected actions/cache component");
|
|
367
|
+
assert.strictEqual(
|
|
368
|
+
getProp(cacheComp, "cdx:github:workflow:hasPullRequestTargetTrigger"),
|
|
369
|
+
"true",
|
|
370
|
+
);
|
|
371
|
+
assert.strictEqual(
|
|
372
|
+
getProp(cacheComp, "cdx:github:workflow:hasWritePermissions"),
|
|
373
|
+
"true",
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
327
377
|
it("handles cache action without optional fields gracefully", () => {
|
|
328
378
|
const result = parseWorkflow("cache-minimal.yml");
|
|
329
379
|
|
|
@@ -386,6 +436,24 @@ describe("githubActionsParser", () => {
|
|
|
386
436
|
);
|
|
387
437
|
});
|
|
388
438
|
|
|
439
|
+
it("detects github.event.comment.body interpolation in issue_comment workflows", () => {
|
|
440
|
+
const result = parseWorkflow("injection-issue-comment-body.yml");
|
|
441
|
+
|
|
442
|
+
const workflow = result.workflows[0];
|
|
443
|
+
assert.strictEqual(
|
|
444
|
+
getProp(workflow, "cdx:github:workflow:hasIssueCommentTrigger"),
|
|
445
|
+
"true",
|
|
446
|
+
);
|
|
447
|
+
const runStepComp = result.components.find((c) =>
|
|
448
|
+
hasProp(c, "cdx:github:step:hasUntrustedInterpolation", "true"),
|
|
449
|
+
);
|
|
450
|
+
assert.ok(runStepComp, "expected issue_comment injection component");
|
|
451
|
+
assert.match(
|
|
452
|
+
getProp(runStepComp, "cdx:github:step:interpolatedVars"),
|
|
453
|
+
/github\.event\.comment\.body/,
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
|
|
389
457
|
it("detects inputs.* interpolation in workflow_dispatch", () => {
|
|
390
458
|
const result = parseWorkflow("injection-workflow-inputs.yml");
|
|
391
459
|
|
|
@@ -417,6 +485,19 @@ describe("githubActionsParser", () => {
|
|
|
417
485
|
}
|
|
418
486
|
});
|
|
419
487
|
|
|
488
|
+
it("does not flag structured SHA interpolation as untrusted input", () => {
|
|
489
|
+
const result = parseWorkflow("safe-sha-interpolation.yml");
|
|
490
|
+
|
|
491
|
+
const runStepComp = result.components.find((c) =>
|
|
492
|
+
c.properties?.some((p) => p.name === "cdx:github:step:type"),
|
|
493
|
+
);
|
|
494
|
+
assert.ok(runStepComp, "expected a run step component");
|
|
495
|
+
assert.strictEqual(
|
|
496
|
+
getProp(runStepComp, "cdx:github:step:hasUntrustedInterpolation"),
|
|
497
|
+
undefined,
|
|
498
|
+
);
|
|
499
|
+
});
|
|
500
|
+
|
|
420
501
|
it("handles multiple interpolations in single run block", () => {
|
|
421
502
|
const result = parseWorkflow("injection-multiple-vars.yml");
|
|
422
503
|
|
|
@@ -524,6 +605,221 @@ describe("githubActionsParser", () => {
|
|
|
524
605
|
});
|
|
525
606
|
});
|
|
526
607
|
|
|
608
|
+
describe("explicit permissions metadata and sensitive-operation heuristics", () => {
|
|
609
|
+
it("emits false explicit-permissions metadata and sensitive-operation flags for implicit high-risk workflows", () => {
|
|
610
|
+
const result = parseWorkflow(
|
|
611
|
+
"heuristic-implicit-permissions-sensitive.yml",
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
const workflow = result.workflows[0];
|
|
615
|
+
assert.strictEqual(
|
|
616
|
+
getProp(workflow, "cdx:github:workflow:hasHighRiskTrigger"),
|
|
617
|
+
"true",
|
|
618
|
+
);
|
|
619
|
+
assert.strictEqual(
|
|
620
|
+
getProp(workflow, "cdx:github:workflow:hasExplicitPermissionsBlock"),
|
|
621
|
+
"false",
|
|
622
|
+
);
|
|
623
|
+
assert.strictEqual(
|
|
624
|
+
getProp(workflow, "cdx:github:workflow:hasAnyExplicitPermissionsBlock"),
|
|
625
|
+
"false",
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
const runStepComp = result.components.find(
|
|
629
|
+
(component) => component.name === "Trigger downstream release",
|
|
630
|
+
);
|
|
631
|
+
assert.ok(runStepComp, "expected sensitive run-step component");
|
|
632
|
+
assert.strictEqual(
|
|
633
|
+
getProp(
|
|
634
|
+
runStepComp,
|
|
635
|
+
"cdx:github:workflow:hasAnyExplicitPermissionsBlock",
|
|
636
|
+
),
|
|
637
|
+
"false",
|
|
638
|
+
);
|
|
639
|
+
assert.strictEqual(
|
|
640
|
+
getProp(runStepComp, "cdx:github:step:hasSensitiveOperations"),
|
|
641
|
+
"true",
|
|
642
|
+
);
|
|
643
|
+
assert.match(
|
|
644
|
+
getProp(runStepComp, "cdx:github:step:sensitiveOperations"),
|
|
645
|
+
/dispatches-workflow/,
|
|
646
|
+
);
|
|
647
|
+
assert.match(
|
|
648
|
+
getProp(runStepComp, "cdx:github:step:sensitiveOperations"),
|
|
649
|
+
/references-sensitive-context/,
|
|
650
|
+
);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("emits true explicit-permissions metadata when a permissions block is present", () => {
|
|
654
|
+
const result = parseWorkflow(
|
|
655
|
+
"heuristic-explicit-permissions-sensitive.yml",
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
const workflow = result.workflows[0];
|
|
659
|
+
assert.strictEqual(
|
|
660
|
+
getProp(workflow, "cdx:github:workflow:hasExplicitPermissionsBlock"),
|
|
661
|
+
"true",
|
|
662
|
+
);
|
|
663
|
+
assert.strictEqual(
|
|
664
|
+
getProp(workflow, "cdx:github:workflow:hasAnyExplicitPermissionsBlock"),
|
|
665
|
+
"true",
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
const runStepComp = result.components.find(
|
|
669
|
+
(component) => component.name === "Trigger downstream release",
|
|
670
|
+
);
|
|
671
|
+
assert.ok(runStepComp, "expected sensitive run-step component");
|
|
672
|
+
assert.strictEqual(
|
|
673
|
+
getProp(
|
|
674
|
+
runStepComp,
|
|
675
|
+
"cdx:github:workflow:hasAnyExplicitPermissionsBlock",
|
|
676
|
+
),
|
|
677
|
+
"true",
|
|
678
|
+
);
|
|
679
|
+
assert.strictEqual(
|
|
680
|
+
getProp(runStepComp, "cdx:github:step:hasSensitiveOperations"),
|
|
681
|
+
"true",
|
|
682
|
+
);
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
describe("job-scoped privilege and trust metadata", () => {
|
|
687
|
+
it("propagates job-scoped id-token write to components and workflows", () => {
|
|
688
|
+
const result = parseWorkflow("job-id-token-write.yml");
|
|
689
|
+
|
|
690
|
+
const workflow = result.workflows[0];
|
|
691
|
+
assert.strictEqual(
|
|
692
|
+
getProp(workflow, "cdx:github:workflow:hasIdTokenWrite"),
|
|
693
|
+
"true",
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
const actionComp = findComponentByPurlSubstring(
|
|
697
|
+
result.components,
|
|
698
|
+
"vendor/deploy-action",
|
|
699
|
+
);
|
|
700
|
+
assert.ok(actionComp, "expected third-party deploy action");
|
|
701
|
+
assert.strictEqual(
|
|
702
|
+
getProp(actionComp, "cdx:github:workflow:hasIdTokenWrite"),
|
|
703
|
+
"true",
|
|
704
|
+
);
|
|
705
|
+
assert.strictEqual(
|
|
706
|
+
getProp(actionComp, "cdx:github:job:hasIdTokenWrite"),
|
|
707
|
+
"true",
|
|
708
|
+
);
|
|
709
|
+
assert.strictEqual(
|
|
710
|
+
getProp(actionComp, "cdx:actions:isOfficial"),
|
|
711
|
+
"false",
|
|
712
|
+
);
|
|
713
|
+
assert.strictEqual(
|
|
714
|
+
getProp(actionComp, "cdx:actions:isVerified"),
|
|
715
|
+
"false",
|
|
716
|
+
);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("emits workflow dispatch input metadata", () => {
|
|
720
|
+
const result = parseWorkflow("injection-workflow-inputs.yml");
|
|
721
|
+
|
|
722
|
+
const workflow = result.workflows[0];
|
|
723
|
+
assert.strictEqual(
|
|
724
|
+
getProp(workflow, "cdx:github:workflow:hasWorkflowDispatchInputs"),
|
|
725
|
+
"true",
|
|
726
|
+
);
|
|
727
|
+
assert.strictEqual(
|
|
728
|
+
getProp(workflow, "cdx:github:workflow:hasWorkflowDispatchTrigger"),
|
|
729
|
+
"true",
|
|
730
|
+
);
|
|
731
|
+
assert.ok(
|
|
732
|
+
getProp(workflow, "cdx:github:workflow:workflowDispatchInputs")?.split(
|
|
733
|
+
",",
|
|
734
|
+
).length >= 1,
|
|
735
|
+
);
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
describe("reusable workflow parsing", () => {
|
|
740
|
+
it("models external reusable workflows with secrets inheritance", () => {
|
|
741
|
+
const result = parseWorkflow("reusable-workflow-secrets-inherit.yml");
|
|
742
|
+
|
|
743
|
+
const reusableComp = result.components.find((c) =>
|
|
744
|
+
hasProp(c, "cdx:github:reusableWorkflow:secretsInherit", "true"),
|
|
745
|
+
);
|
|
746
|
+
assert.ok(reusableComp, "expected reusable workflow component");
|
|
747
|
+
assert.strictEqual(
|
|
748
|
+
getProp(reusableComp, "cdx:github:reusableWorkflow:isExternal"),
|
|
749
|
+
"true",
|
|
750
|
+
);
|
|
751
|
+
assert.strictEqual(
|
|
752
|
+
getProp(reusableComp, "cdx:github:reusableWorkflow:isShaPinned"),
|
|
753
|
+
"false",
|
|
754
|
+
);
|
|
755
|
+
assert.strictEqual(
|
|
756
|
+
getProp(reusableComp, "cdx:github:reusableWorkflow:versionPinningType"),
|
|
757
|
+
"branch",
|
|
758
|
+
);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("models external reusable workflows pinned to mutable refs", () => {
|
|
762
|
+
const result = parseWorkflow("reusable-workflow-external-unpinned.yml");
|
|
763
|
+
|
|
764
|
+
const reusableComp = result.components.find((c) =>
|
|
765
|
+
hasProp(c, "cdx:github:reusableWorkflow:isExternal", "true"),
|
|
766
|
+
);
|
|
767
|
+
assert.ok(reusableComp, "expected external reusable workflow component");
|
|
768
|
+
assert.strictEqual(
|
|
769
|
+
getProp(reusableComp, "cdx:github:reusableWorkflow:isShaPinned"),
|
|
770
|
+
"false",
|
|
771
|
+
);
|
|
772
|
+
assert.strictEqual(
|
|
773
|
+
getProp(reusableComp, "cdx:github:reusableWorkflow:withKeys"),
|
|
774
|
+
"run-tests",
|
|
775
|
+
);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("emits workflow_call producer metadata for reusable workflow definitions", () => {
|
|
779
|
+
const result = parseWorkflow("workflow-call-producer-risky.yml");
|
|
780
|
+
|
|
781
|
+
const workflow = result.workflows[0];
|
|
782
|
+
assert.strictEqual(
|
|
783
|
+
getProp(workflow, "cdx:github:workflow:hasWorkflowCallTrigger"),
|
|
784
|
+
"true",
|
|
785
|
+
);
|
|
786
|
+
assert.strictEqual(
|
|
787
|
+
getProp(workflow, "cdx:github:workflow:isWorkflowCallProducer"),
|
|
788
|
+
"true",
|
|
789
|
+
);
|
|
790
|
+
assert.strictEqual(
|
|
791
|
+
getProp(workflow, "cdx:github:workflow:workflowCallInputs"),
|
|
792
|
+
"release_tag",
|
|
793
|
+
);
|
|
794
|
+
assert.strictEqual(
|
|
795
|
+
getProp(workflow, "cdx:github:workflow:workflowCallSecrets"),
|
|
796
|
+
"release_token",
|
|
797
|
+
);
|
|
798
|
+
assert.strictEqual(
|
|
799
|
+
getProp(workflow, "cdx:github:workflow:workflowCallOutputs"),
|
|
800
|
+
"image_tag",
|
|
801
|
+
);
|
|
802
|
+
assert.strictEqual(
|
|
803
|
+
getProp(workflow, "cdx:github:workflow:hasWritePermissions"),
|
|
804
|
+
"true",
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
const actionComp = findComponentByPurlSubstring(
|
|
808
|
+
result.components,
|
|
809
|
+
"vendor/publish-action",
|
|
810
|
+
);
|
|
811
|
+
assert.ok(actionComp, "expected publish action component");
|
|
812
|
+
assert.strictEqual(
|
|
813
|
+
getProp(actionComp, "cdx:github:workflow:hasWorkflowCallTrigger"),
|
|
814
|
+
"true",
|
|
815
|
+
);
|
|
816
|
+
assert.strictEqual(
|
|
817
|
+
getProp(actionComp, "cdx:github:workflow:workflowCallSecrets"),
|
|
818
|
+
"release_token",
|
|
819
|
+
);
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
|
|
527
823
|
describe("combined security risk scenarios", () => {
|
|
528
824
|
it("detects cache poisoning risk: cache + pull_request + write perms", () => {
|
|
529
825
|
const result = parseWorkflow("risk-cache-poisoning.yml");
|
|
@@ -548,6 +844,14 @@ describe("githubActionsParser", () => {
|
|
|
548
844
|
"true",
|
|
549
845
|
"write permissions should be duplicated",
|
|
550
846
|
);
|
|
847
|
+
assert.strictEqual(
|
|
848
|
+
getProp(cacheComp, "cdx:github:workflow:hasPullRequestTrigger"),
|
|
849
|
+
"true",
|
|
850
|
+
);
|
|
851
|
+
assert.strictEqual(
|
|
852
|
+
getProp(cacheComp, "cdx:github:cache:keyUsesHashFiles"),
|
|
853
|
+
undefined,
|
|
854
|
+
);
|
|
551
855
|
});
|
|
552
856
|
|
|
553
857
|
it("detects credential exposure: checkout persist + privileged workflow", () => {
|
|
@@ -569,6 +873,39 @@ describe("githubActionsParser", () => {
|
|
|
569
873
|
);
|
|
570
874
|
});
|
|
571
875
|
|
|
876
|
+
it("detects checkout of pull_request head context inside pull_request_target", () => {
|
|
877
|
+
const result = parseWorkflow("checkout-untrusted-pr-head.yml");
|
|
878
|
+
|
|
879
|
+
const checkoutComp = findComponentByPurlSubstring(
|
|
880
|
+
result.components,
|
|
881
|
+
"actions/checkout",
|
|
882
|
+
);
|
|
883
|
+
assert.ok(checkoutComp, "expected actions/checkout component");
|
|
884
|
+
assert.strictEqual(
|
|
885
|
+
getProp(
|
|
886
|
+
checkoutComp,
|
|
887
|
+
"cdx:github:workflow:hasPullRequestTargetTrigger",
|
|
888
|
+
),
|
|
889
|
+
"true",
|
|
890
|
+
);
|
|
891
|
+
assert.strictEqual(
|
|
892
|
+
getProp(checkoutComp, "cdx:github:checkout:checksOutUntrustedRef"),
|
|
893
|
+
"true",
|
|
894
|
+
);
|
|
895
|
+
assert.match(
|
|
896
|
+
getProp(checkoutComp, "cdx:github:checkout:untrustedRefContexts"),
|
|
897
|
+
/github\.event\.pull_request\.head\.sha/,
|
|
898
|
+
);
|
|
899
|
+
assert.strictEqual(
|
|
900
|
+
getProp(checkoutComp, "cdx:github:checkout:referencesForkContext"),
|
|
901
|
+
"true",
|
|
902
|
+
);
|
|
903
|
+
assert.match(
|
|
904
|
+
getProp(checkoutComp, "cdx:github:checkout:forkContextRefs"),
|
|
905
|
+
/github\.event\.pull_request\.head\.repo\.full_name/,
|
|
906
|
+
);
|
|
907
|
+
});
|
|
908
|
+
|
|
572
909
|
it("detects script injection in privileged context", () => {
|
|
573
910
|
const result = parseWorkflow("risk-injection-privileged.yml");
|
|
574
911
|
|
|
@@ -607,6 +944,328 @@ describe("githubActionsParser", () => {
|
|
|
607
944
|
"true",
|
|
608
945
|
);
|
|
609
946
|
});
|
|
947
|
+
|
|
948
|
+
it("detects self-hosted runners in high-risk workflows", () => {
|
|
949
|
+
const result = parseWorkflow("self-hosted-high-risk.yml");
|
|
950
|
+
|
|
951
|
+
const actionComp = findComponentByPurlSubstring(
|
|
952
|
+
result.components,
|
|
953
|
+
"actions/checkout",
|
|
954
|
+
);
|
|
955
|
+
assert.ok(actionComp, "expected actions/checkout component");
|
|
956
|
+
assert.strictEqual(
|
|
957
|
+
getProp(actionComp, "cdx:github:job:isSelfHosted"),
|
|
958
|
+
"true",
|
|
959
|
+
);
|
|
960
|
+
assert.strictEqual(
|
|
961
|
+
getProp(actionComp, "cdx:github:workflow:hasHighRiskTrigger"),
|
|
962
|
+
"true",
|
|
963
|
+
);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
it("detects runner-state mutation in privileged run steps", () => {
|
|
967
|
+
const result = parseWorkflow("runner-state-mutation.yml");
|
|
968
|
+
|
|
969
|
+
const runStepComp = result.components.find((c) =>
|
|
970
|
+
hasProp(c, "cdx:github:step:mutatesRunnerState", "true"),
|
|
971
|
+
);
|
|
972
|
+
assert.ok(runStepComp, "expected runner-state mutation component");
|
|
973
|
+
assert.strictEqual(
|
|
974
|
+
getProp(runStepComp, "cdx:github:step:runnerStateTargets"),
|
|
975
|
+
"GITHUB_ENV",
|
|
976
|
+
);
|
|
977
|
+
assert.strictEqual(
|
|
978
|
+
getProp(runStepComp, "cdx:github:workflow:hasWritePermissions"),
|
|
979
|
+
"true",
|
|
980
|
+
);
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it("detects outbound commands that reference sensitive context", () => {
|
|
984
|
+
const result = parseWorkflow("outbound-sensitive-context.yml");
|
|
985
|
+
|
|
986
|
+
const runStepComp = result.components.find((c) =>
|
|
987
|
+
hasProp(c, "cdx:github:step:hasOutboundNetworkCommand", "true"),
|
|
988
|
+
);
|
|
989
|
+
assert.ok(runStepComp, "expected outbound network component");
|
|
990
|
+
assert.strictEqual(
|
|
991
|
+
getProp(runStepComp, "cdx:github:step:referencesSensitiveContext"),
|
|
992
|
+
"true",
|
|
993
|
+
);
|
|
994
|
+
assert.match(
|
|
995
|
+
getProp(runStepComp, "cdx:github:step:sensitiveContextRefs"),
|
|
996
|
+
/env:UPLOAD_AUTH/,
|
|
997
|
+
);
|
|
998
|
+
assert.strictEqual(
|
|
999
|
+
getProp(runStepComp, "cdx:github:step:likelyExfiltration"),
|
|
1000
|
+
"true",
|
|
1001
|
+
);
|
|
1002
|
+
assert.match(
|
|
1003
|
+
getProp(runStepComp, "cdx:github:step:exfiltrationIndicators"),
|
|
1004
|
+
/auth-header/,
|
|
1005
|
+
);
|
|
1006
|
+
assert.match(
|
|
1007
|
+
getProp(runStepComp, "cdx:github:step:exfiltrationIndicators"),
|
|
1008
|
+
/state-changing-method/,
|
|
1009
|
+
);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it("does not mark low-signal outbound steps as likely exfiltration", () => {
|
|
1013
|
+
const result = parseWorkflow("outbound-sensitive-context-low-signal.yml");
|
|
1014
|
+
|
|
1015
|
+
const runStepComp = result.components.find((c) =>
|
|
1016
|
+
hasProp(c, "cdx:github:step:hasOutboundNetworkCommand", "true"),
|
|
1017
|
+
);
|
|
1018
|
+
assert.ok(runStepComp, "expected outbound network component");
|
|
1019
|
+
assert.strictEqual(
|
|
1020
|
+
getProp(runStepComp, "cdx:github:step:referencesSensitiveContext"),
|
|
1021
|
+
"true",
|
|
1022
|
+
);
|
|
1023
|
+
assert.strictEqual(
|
|
1024
|
+
getProp(runStepComp, "cdx:github:step:likelyExfiltration"),
|
|
1025
|
+
undefined,
|
|
1026
|
+
);
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
it("detects fork-aware workflow dispatch chains in run steps", () => {
|
|
1030
|
+
const result = parseWorkflow("dispatch-chain-fork-sensitive.yml");
|
|
1031
|
+
|
|
1032
|
+
const dispatchStepComp = result.components.find((c) =>
|
|
1033
|
+
hasProp(c, "cdx:github:step:dispatchesWorkflow", "true"),
|
|
1034
|
+
);
|
|
1035
|
+
assert.ok(
|
|
1036
|
+
dispatchStepComp,
|
|
1037
|
+
"expected dispatching workflow step component",
|
|
1038
|
+
);
|
|
1039
|
+
assert.strictEqual(
|
|
1040
|
+
getProp(dispatchStepComp, "cdx:github:step:dispatchKinds"),
|
|
1041
|
+
"workflow_dispatch",
|
|
1042
|
+
);
|
|
1043
|
+
assert.match(
|
|
1044
|
+
getProp(dispatchStepComp, "cdx:github:step:dispatchMechanisms"),
|
|
1045
|
+
/gh-workflow-run/,
|
|
1046
|
+
);
|
|
1047
|
+
assert.match(
|
|
1048
|
+
getProp(dispatchStepComp, "cdx:github:step:dispatchTargets"),
|
|
1049
|
+
/workflow:release.yml/,
|
|
1050
|
+
);
|
|
1051
|
+
assert.strictEqual(
|
|
1052
|
+
getProp(dispatchStepComp, "cdx:github:step:referencesForkContext"),
|
|
1053
|
+
"true",
|
|
1054
|
+
);
|
|
1055
|
+
assert.match(
|
|
1056
|
+
getProp(dispatchStepComp, "cdx:github:step:sensitiveContextRefs"),
|
|
1057
|
+
/env:GH_TOKEN/,
|
|
1058
|
+
);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it("detects workflow dispatches from actions/github-script", () => {
|
|
1062
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-gha-"));
|
|
1063
|
+
const workflowFile = path.join(tmpDir, "github-script-dispatch.yml");
|
|
1064
|
+
writeFileSync(
|
|
1065
|
+
workflowFile,
|
|
1066
|
+
[
|
|
1067
|
+
"name: Script dispatch",
|
|
1068
|
+
"on: workflow_run",
|
|
1069
|
+
"permissions:",
|
|
1070
|
+
" actions: write",
|
|
1071
|
+
"jobs:",
|
|
1072
|
+
" relay:",
|
|
1073
|
+
" runs-on: ubuntu-latest",
|
|
1074
|
+
" steps:",
|
|
1075
|
+
" - uses: actions/github-script@v7",
|
|
1076
|
+
" with:",
|
|
1077
|
+
" github-token: $" + "{{ secrets.GITHUB_TOKEN }}",
|
|
1078
|
+
" script: |",
|
|
1079
|
+
" await github.rest.actions.createWorkflowDispatch({",
|
|
1080
|
+
" owner: 'octo-org',",
|
|
1081
|
+
" repo: 'release-repo',",
|
|
1082
|
+
" workflow_id: 'release.yml',",
|
|
1083
|
+
" ref: 'main',",
|
|
1084
|
+
" });",
|
|
1085
|
+
].join("\n"),
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
try {
|
|
1089
|
+
const result = parseWorkflowFile(workflowFile, { specVersion: 1.7 });
|
|
1090
|
+
const githubScriptComp = findComponentByPurlSubstring(
|
|
1091
|
+
result.components,
|
|
1092
|
+
"actions/github-script",
|
|
1093
|
+
);
|
|
1094
|
+
assert.ok(githubScriptComp, "expected actions/github-script component");
|
|
1095
|
+
assert.strictEqual(
|
|
1096
|
+
getProp(githubScriptComp, "cdx:github:step:dispatchesWorkflow"),
|
|
1097
|
+
"true",
|
|
1098
|
+
);
|
|
1099
|
+
assert.match(
|
|
1100
|
+
getProp(githubScriptComp, "cdx:github:step:dispatchTargets"),
|
|
1101
|
+
/repo:octo-org\/release-repo/,
|
|
1102
|
+
);
|
|
1103
|
+
assert.match(
|
|
1104
|
+
getProp(githubScriptComp, "cdx:github:step:sensitiveContextRefs"),
|
|
1105
|
+
/input:github-token/,
|
|
1106
|
+
);
|
|
1107
|
+
} finally {
|
|
1108
|
+
rmSync(tmpDir, { force: true, recursive: true });
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
it("correlates dispatch senders with local receiver workflow definitions", () => {
|
|
1113
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-gha-"));
|
|
1114
|
+
const senderWorkflow = path.join(tmpDir, "sender.yml");
|
|
1115
|
+
const dispatchReceiverWorkflow = path.join(tmpDir, "release.yml");
|
|
1116
|
+
const repoDispatchReceiverWorkflow = path.join(
|
|
1117
|
+
tmpDir,
|
|
1118
|
+
"repo-dispatch.yml",
|
|
1119
|
+
);
|
|
1120
|
+
writeFileSync(
|
|
1121
|
+
senderWorkflow,
|
|
1122
|
+
[
|
|
1123
|
+
"name: Sender workflow",
|
|
1124
|
+
"on: push",
|
|
1125
|
+
"jobs:",
|
|
1126
|
+
" relay:",
|
|
1127
|
+
" runs-on: ubuntu-latest",
|
|
1128
|
+
" steps:",
|
|
1129
|
+
" - name: Trigger release receiver",
|
|
1130
|
+
" env:",
|
|
1131
|
+
" GH_TOKEN: $" + "{{ github.token }}",
|
|
1132
|
+
" run: gh workflow run release.yml --ref main",
|
|
1133
|
+
" - name: Trigger promote event",
|
|
1134
|
+
" uses: peter-evans/repository-dispatch@v3",
|
|
1135
|
+
" with:",
|
|
1136
|
+
" event-type: promote",
|
|
1137
|
+
].join("\n"),
|
|
1138
|
+
);
|
|
1139
|
+
writeFileSync(
|
|
1140
|
+
dispatchReceiverWorkflow,
|
|
1141
|
+
[
|
|
1142
|
+
"name: Release workflow",
|
|
1143
|
+
"on:",
|
|
1144
|
+
" workflow_dispatch:",
|
|
1145
|
+
" inputs:",
|
|
1146
|
+
" version:",
|
|
1147
|
+
" required: true",
|
|
1148
|
+
"jobs:",
|
|
1149
|
+
" release:",
|
|
1150
|
+
" runs-on: ubuntu-latest",
|
|
1151
|
+
" steps:",
|
|
1152
|
+
" - run: echo release",
|
|
1153
|
+
].join("\n"),
|
|
1154
|
+
);
|
|
1155
|
+
writeFileSync(
|
|
1156
|
+
repoDispatchReceiverWorkflow,
|
|
1157
|
+
[
|
|
1158
|
+
"name: Promote workflow",
|
|
1159
|
+
"on:",
|
|
1160
|
+
" repository_dispatch:",
|
|
1161
|
+
" types: [promote]",
|
|
1162
|
+
"jobs:",
|
|
1163
|
+
" promote:",
|
|
1164
|
+
" runs-on: ubuntu-latest",
|
|
1165
|
+
" steps:",
|
|
1166
|
+
" - run: echo promote",
|
|
1167
|
+
].join("\n"),
|
|
1168
|
+
);
|
|
1169
|
+
|
|
1170
|
+
try {
|
|
1171
|
+
const result = githubActionsParser.parse(
|
|
1172
|
+
[
|
|
1173
|
+
senderWorkflow,
|
|
1174
|
+
dispatchReceiverWorkflow,
|
|
1175
|
+
repoDispatchReceiverWorkflow,
|
|
1176
|
+
],
|
|
1177
|
+
{ specVersion: 1.7 },
|
|
1178
|
+
);
|
|
1179
|
+
const runDispatchStep = result.components.find(
|
|
1180
|
+
(component) => component.name === "Trigger release receiver",
|
|
1181
|
+
);
|
|
1182
|
+
assert.ok(
|
|
1183
|
+
runDispatchStep,
|
|
1184
|
+
"expected local workflow_dispatch sender step",
|
|
1185
|
+
);
|
|
1186
|
+
assert.strictEqual(
|
|
1187
|
+
getProp(runDispatchStep, "cdx:github:step:hasLocalDispatchReceiver"),
|
|
1188
|
+
"true",
|
|
1189
|
+
);
|
|
1190
|
+
assert.match(
|
|
1191
|
+
getProp(
|
|
1192
|
+
runDispatchStep,
|
|
1193
|
+
"cdx:github:step:dispatchReceiverWorkflowFiles",
|
|
1194
|
+
),
|
|
1195
|
+
/release\.yml/,
|
|
1196
|
+
);
|
|
1197
|
+
assert.match(
|
|
1198
|
+
getProp(
|
|
1199
|
+
runDispatchStep,
|
|
1200
|
+
"cdx:github:step:dispatchReceiverWorkflowNames",
|
|
1201
|
+
),
|
|
1202
|
+
/Release workflow/,
|
|
1203
|
+
);
|
|
1204
|
+
|
|
1205
|
+
const actionDispatchStep = result.components.find(
|
|
1206
|
+
(component) =>
|
|
1207
|
+
getProp(component, "cdx:github:action:uses") ===
|
|
1208
|
+
"peter-evans/repository-dispatch@v3",
|
|
1209
|
+
);
|
|
1210
|
+
assert.ok(
|
|
1211
|
+
actionDispatchStep,
|
|
1212
|
+
"expected local repository_dispatch sender step",
|
|
1213
|
+
);
|
|
1214
|
+
assert.strictEqual(
|
|
1215
|
+
getProp(
|
|
1216
|
+
actionDispatchStep,
|
|
1217
|
+
"cdx:github:step:hasLocalDispatchReceiver",
|
|
1218
|
+
),
|
|
1219
|
+
"true",
|
|
1220
|
+
);
|
|
1221
|
+
assert.match(
|
|
1222
|
+
getProp(
|
|
1223
|
+
actionDispatchStep,
|
|
1224
|
+
"cdx:github:step:dispatchReceiverMatchBasis",
|
|
1225
|
+
),
|
|
1226
|
+
/repository_dispatch:promote/,
|
|
1227
|
+
);
|
|
1228
|
+
|
|
1229
|
+
const releaseWorkflow = result.workflows.find(
|
|
1230
|
+
(workflow) => workflow.name === "Release workflow",
|
|
1231
|
+
);
|
|
1232
|
+
assert.ok(
|
|
1233
|
+
releaseWorkflow,
|
|
1234
|
+
"expected workflow_dispatch receiver workflow",
|
|
1235
|
+
);
|
|
1236
|
+
assert.strictEqual(
|
|
1237
|
+
getProp(
|
|
1238
|
+
releaseWorkflow,
|
|
1239
|
+
"cdx:github:workflow:hasLocalDispatchSender",
|
|
1240
|
+
),
|
|
1241
|
+
"true",
|
|
1242
|
+
);
|
|
1243
|
+
assert.match(
|
|
1244
|
+
getProp(
|
|
1245
|
+
releaseWorkflow,
|
|
1246
|
+
"cdx:github:workflow:dispatchSenderWorkflowNames",
|
|
1247
|
+
),
|
|
1248
|
+
/Sender workflow/,
|
|
1249
|
+
);
|
|
1250
|
+
|
|
1251
|
+
const repoDispatchWorkflow = result.workflows.find(
|
|
1252
|
+
(workflow) => workflow.name === "Promote workflow",
|
|
1253
|
+
);
|
|
1254
|
+
assert.ok(
|
|
1255
|
+
repoDispatchWorkflow,
|
|
1256
|
+
"expected repository_dispatch receiver workflow",
|
|
1257
|
+
);
|
|
1258
|
+
assert.match(
|
|
1259
|
+
getProp(
|
|
1260
|
+
repoDispatchWorkflow,
|
|
1261
|
+
"cdx:github:workflow:repositoryDispatchTypes",
|
|
1262
|
+
),
|
|
1263
|
+
/promote/,
|
|
1264
|
+
);
|
|
1265
|
+
} finally {
|
|
1266
|
+
rmSync(tmpDir, { force: true, recursive: true });
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
610
1269
|
});
|
|
611
1270
|
|
|
612
1271
|
describe("edge cases and robustness", () => {
|
|
@@ -662,6 +1321,37 @@ describe("githubActionsParser", () => {
|
|
|
662
1321
|
);
|
|
663
1322
|
});
|
|
664
1323
|
|
|
1324
|
+
it("treats short hexadecimal refs as mutable action versions, not immutable SHAs", () => {
|
|
1325
|
+
const result = parseWorkflow("short-sha-pinning.yml");
|
|
1326
|
+
|
|
1327
|
+
const shortRefComp = result.components.find(
|
|
1328
|
+
(component) => component.version === "deadbee",
|
|
1329
|
+
);
|
|
1330
|
+
assert.ok(shortRefComp, "expected short-ref action component");
|
|
1331
|
+
assert.strictEqual(
|
|
1332
|
+
getProp(shortRefComp, "cdx:github:action:isShaPinned"),
|
|
1333
|
+
"false",
|
|
1334
|
+
);
|
|
1335
|
+
assert.strictEqual(
|
|
1336
|
+
getProp(shortRefComp, "cdx:github:action:versionPinningType"),
|
|
1337
|
+
"tag",
|
|
1338
|
+
);
|
|
1339
|
+
|
|
1340
|
+
const fullShaComp = result.components.find(
|
|
1341
|
+
(component) =>
|
|
1342
|
+
component.version === "0123456789abcdef0123456789abcdef01234567",
|
|
1343
|
+
);
|
|
1344
|
+
assert.ok(fullShaComp, "expected full-SHA action component");
|
|
1345
|
+
assert.strictEqual(
|
|
1346
|
+
getProp(fullShaComp, "cdx:github:action:isShaPinned"),
|
|
1347
|
+
"true",
|
|
1348
|
+
);
|
|
1349
|
+
assert.strictEqual(
|
|
1350
|
+
getProp(fullShaComp, "cdx:github:action:versionPinningType"),
|
|
1351
|
+
"sha",
|
|
1352
|
+
);
|
|
1353
|
+
});
|
|
1354
|
+
|
|
665
1355
|
it("preserves existing properties when adding new ones", () => {
|
|
666
1356
|
const result = parseWorkflow("checkout-default.yml");
|
|
667
1357
|
|
|
@@ -799,4 +1489,166 @@ describe("githubActionsParser", () => {
|
|
|
799
1489
|
);
|
|
800
1490
|
});
|
|
801
1491
|
});
|
|
1492
|
+
|
|
1493
|
+
describe("safe vs risky workflow corpus", () => {
|
|
1494
|
+
it("distinguishes safe cache keys from risky PR cache usage", () => {
|
|
1495
|
+
const safeResult = parseWorkflow("cache-pull-request.yml");
|
|
1496
|
+
const riskyResult = parseWorkflow("risk-cache-poisoning.yml");
|
|
1497
|
+
|
|
1498
|
+
const safeCache = findComponentByPurlSubstring(
|
|
1499
|
+
safeResult.components,
|
|
1500
|
+
"actions/cache",
|
|
1501
|
+
);
|
|
1502
|
+
const riskyCache = findComponentByPurlSubstring(
|
|
1503
|
+
riskyResult.components,
|
|
1504
|
+
"actions/cache",
|
|
1505
|
+
);
|
|
1506
|
+
assert.ok(safeCache, "expected safe cache component");
|
|
1507
|
+
assert.ok(riskyCache, "expected risky cache component");
|
|
1508
|
+
assert.strictEqual(
|
|
1509
|
+
getProp(safeCache, "cdx:github:cache:keyUsesHashFiles"),
|
|
1510
|
+
"true",
|
|
1511
|
+
);
|
|
1512
|
+
assert.strictEqual(
|
|
1513
|
+
getProp(safeCache, "cdx:github:cache:hasRestoreKeys"),
|
|
1514
|
+
undefined,
|
|
1515
|
+
);
|
|
1516
|
+
assert.strictEqual(
|
|
1517
|
+
getProp(riskyCache, "cdx:github:cache:keyUsesHashFiles"),
|
|
1518
|
+
undefined,
|
|
1519
|
+
);
|
|
1520
|
+
assert.strictEqual(
|
|
1521
|
+
getProp(riskyCache, "cdx:github:workflow:hasWritePermissions"),
|
|
1522
|
+
"true",
|
|
1523
|
+
);
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
it("distinguishes safe and risky workflow_call producers", () => {
|
|
1527
|
+
const safeResult = parseWorkflow("workflow-call-producer-safe.yml");
|
|
1528
|
+
const riskyResult = parseWorkflow("workflow-call-producer-risky.yml");
|
|
1529
|
+
|
|
1530
|
+
const safeWorkflow = safeResult.workflows[0];
|
|
1531
|
+
const riskyWorkflow = riskyResult.workflows[0];
|
|
1532
|
+
assert.strictEqual(
|
|
1533
|
+
getProp(safeWorkflow, "cdx:github:workflow:hasWorkflowCallTrigger"),
|
|
1534
|
+
"true",
|
|
1535
|
+
);
|
|
1536
|
+
assert.strictEqual(
|
|
1537
|
+
getProp(riskyWorkflow, "cdx:github:workflow:hasWorkflowCallTrigger"),
|
|
1538
|
+
"true",
|
|
1539
|
+
);
|
|
1540
|
+
assert.strictEqual(
|
|
1541
|
+
getProp(safeWorkflow, "cdx:github:workflow:workflowCallSecrets"),
|
|
1542
|
+
undefined,
|
|
1543
|
+
);
|
|
1544
|
+
assert.strictEqual(
|
|
1545
|
+
getProp(riskyWorkflow, "cdx:github:workflow:workflowCallSecrets"),
|
|
1546
|
+
"release_token",
|
|
1547
|
+
);
|
|
1548
|
+
assert.strictEqual(
|
|
1549
|
+
getProp(riskyWorkflow, "cdx:github:workflow:workflowCallOutputs"),
|
|
1550
|
+
"image_tag",
|
|
1551
|
+
);
|
|
1552
|
+
});
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
describe("workflow security metadata", () => {
|
|
1556
|
+
it("emits hidden Unicode workflow properties when dangerous characters appear in comments", () => {
|
|
1557
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-gha-"));
|
|
1558
|
+
const workflowFile = path.join(tmpDir, "unicode-workflow.yml");
|
|
1559
|
+
writeFileSync(
|
|
1560
|
+
workflowFile,
|
|
1561
|
+
[
|
|
1562
|
+
"name: Unicode workflow",
|
|
1563
|
+
"on: push",
|
|
1564
|
+
"# suspicious comment \u202E marker",
|
|
1565
|
+
"jobs:",
|
|
1566
|
+
" test:",
|
|
1567
|
+
" runs-on: ubuntu-latest",
|
|
1568
|
+
' steps:\n - run: echo "ok"',
|
|
1569
|
+
].join("\n"),
|
|
1570
|
+
);
|
|
1571
|
+
|
|
1572
|
+
try {
|
|
1573
|
+
const result = parseWorkflowFile(workflowFile, { specVersion: 1.7 });
|
|
1574
|
+
assert.strictEqual(result.workflows.length, 1);
|
|
1575
|
+
const workflow = result.workflows[0];
|
|
1576
|
+
assert.strictEqual(
|
|
1577
|
+
getProp(workflow, "cdx:github:workflow:hasHiddenUnicode"),
|
|
1578
|
+
"true",
|
|
1579
|
+
);
|
|
1580
|
+
assert.strictEqual(
|
|
1581
|
+
getProp(workflow, "cdx:github:workflow:hiddenUnicodeInComments"),
|
|
1582
|
+
"true",
|
|
1583
|
+
);
|
|
1584
|
+
assert.match(
|
|
1585
|
+
getProp(workflow, "cdx:github:workflow:hiddenUnicodeCodePoints"),
|
|
1586
|
+
/U\+202E/,
|
|
1587
|
+
);
|
|
1588
|
+
} finally {
|
|
1589
|
+
rmSync(tmpDir, { force: true, recursive: true });
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
it("flags legacy npm and PyPI token-based publish commands in run steps", () => {
|
|
1594
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-gha-"));
|
|
1595
|
+
const workflowFile = path.join(tmpDir, "publish-workflow.yml");
|
|
1596
|
+
writeFileSync(
|
|
1597
|
+
workflowFile,
|
|
1598
|
+
[
|
|
1599
|
+
"name: Publish packages",
|
|
1600
|
+
"on: push",
|
|
1601
|
+
"env:",
|
|
1602
|
+
" NPM_TOKEN: $" + "{{ secrets.NPM_TOKEN }}",
|
|
1603
|
+
"jobs:",
|
|
1604
|
+
" release:",
|
|
1605
|
+
" runs-on: ubuntu-latest",
|
|
1606
|
+
" env:",
|
|
1607
|
+
" TWINE_PASSWORD: $" + "{{ secrets.PYPI_TOKEN }}",
|
|
1608
|
+
" steps:",
|
|
1609
|
+
" - name: Publish npm",
|
|
1610
|
+
" run: npm publish --token=$" + "{NPM_TOKEN}",
|
|
1611
|
+
" - name: Publish pypi",
|
|
1612
|
+
" run: twine upload dist/*",
|
|
1613
|
+
].join("\n"),
|
|
1614
|
+
);
|
|
1615
|
+
|
|
1616
|
+
try {
|
|
1617
|
+
const result = parseWorkflowFile(workflowFile, { specVersion: 1.7 });
|
|
1618
|
+
const npmStep = result.components.find(
|
|
1619
|
+
(component) =>
|
|
1620
|
+
getProp(component, "cdx:github:step:publishEcosystem") === "npm",
|
|
1621
|
+
);
|
|
1622
|
+
const pypiStep = result.components.find(
|
|
1623
|
+
(component) =>
|
|
1624
|
+
getProp(component, "cdx:github:step:publishEcosystem") === "pypi",
|
|
1625
|
+
);
|
|
1626
|
+
|
|
1627
|
+
assert.ok(npmStep, "expected npm publish run-step component");
|
|
1628
|
+
assert.ok(pypiStep, "expected PyPI publish run-step component");
|
|
1629
|
+
assert.strictEqual(
|
|
1630
|
+
getProp(npmStep, "cdx:github:step:usesLegacyPublishToken"),
|
|
1631
|
+
"true",
|
|
1632
|
+
);
|
|
1633
|
+
assert.match(
|
|
1634
|
+
getProp(npmStep, "cdx:github:step:legacyPublishTokenSources"),
|
|
1635
|
+
/cli-flag/,
|
|
1636
|
+
);
|
|
1637
|
+
assert.match(
|
|
1638
|
+
getProp(npmStep, "cdx:github:step:legacyPublishTokenSources"),
|
|
1639
|
+
/env:NPM_TOKEN/,
|
|
1640
|
+
);
|
|
1641
|
+
assert.strictEqual(
|
|
1642
|
+
getProp(pypiStep, "cdx:github:step:usesLegacyPublishToken"),
|
|
1643
|
+
"true",
|
|
1644
|
+
);
|
|
1645
|
+
assert.match(
|
|
1646
|
+
getProp(pypiStep, "cdx:github:step:legacyPublishTokenSources"),
|
|
1647
|
+
/env:TWINE_PASSWORD/,
|
|
1648
|
+
);
|
|
1649
|
+
} finally {
|
|
1650
|
+
rmSync(tmpDir, { force: true, recursive: true });
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
});
|
|
802
1654
|
});
|