@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.
Files changed (181) hide show
  1. package/README.md +242 -90
  2. package/bin/audit.js +191 -0
  3. package/bin/cdxgen.js +532 -168
  4. package/bin/convert.js +99 -0
  5. package/bin/evinse.js +23 -0
  6. package/bin/repl.js +339 -8
  7. package/bin/sign.js +8 -0
  8. package/bin/validate.js +8 -0
  9. package/bin/verify.js +8 -0
  10. package/data/container-knowledge-index.json +125 -0
  11. package/data/gtfobins-index.json +6296 -0
  12. package/data/lolbas-index.json +150 -0
  13. package/data/queries-darwin.json +63 -3
  14. package/data/queries-win.json +45 -3
  15. package/data/queries.json +74 -2
  16. package/data/rules/chrome-extensions.yaml +240 -0
  17. package/data/rules/ci-permissions.yaml +478 -18
  18. package/data/rules/container-risk.yaml +270 -0
  19. package/data/rules/obom-runtime.yaml +891 -0
  20. package/data/rules/package-integrity.yaml +49 -0
  21. package/data/spdx-export.schema.json +6794 -0
  22. package/data/spdx-model-v3.0.1.jsonld +15999 -0
  23. package/lib/audit/index.js +1924 -0
  24. package/lib/audit/index.poku.js +1488 -0
  25. package/lib/audit/progress.js +137 -0
  26. package/lib/audit/progress.poku.js +188 -0
  27. package/lib/audit/reporters.js +618 -0
  28. package/lib/audit/scoring.js +310 -0
  29. package/lib/audit/scoring.poku.js +341 -0
  30. package/lib/audit/targets.js +260 -0
  31. package/lib/audit/targets.poku.js +331 -0
  32. package/lib/cli/index.js +276 -68
  33. package/lib/cli/index.poku.js +368 -0
  34. package/lib/helpers/analyzer.js +1052 -5
  35. package/lib/helpers/analyzer.poku.js +301 -0
  36. package/lib/helpers/annotationFormatter.js +49 -0
  37. package/lib/helpers/annotationFormatter.poku.js +44 -0
  38. package/lib/helpers/bomUtils.js +36 -0
  39. package/lib/helpers/bomUtils.poku.js +51 -0
  40. package/lib/helpers/caxa.js +2 -2
  41. package/lib/helpers/chromextutils.js +1153 -0
  42. package/lib/helpers/chromextutils.poku.js +493 -0
  43. package/lib/helpers/ciParsers/githubActions.js +1632 -45
  44. package/lib/helpers/ciParsers/githubActions.poku.js +853 -1
  45. package/lib/helpers/containerRisk.js +186 -0
  46. package/lib/helpers/containerRisk.poku.js +52 -0
  47. package/lib/helpers/depsUtils.js +16 -0
  48. package/lib/helpers/depsUtils.poku.js +58 -1
  49. package/lib/helpers/display.js +245 -61
  50. package/lib/helpers/display.poku.js +162 -2
  51. package/lib/helpers/exportUtils.js +123 -0
  52. package/lib/helpers/exportUtils.poku.js +60 -0
  53. package/lib/helpers/formulationParsers.js +69 -0
  54. package/lib/helpers/formulationParsers.poku.js +44 -0
  55. package/lib/helpers/gtfobins.js +189 -0
  56. package/lib/helpers/gtfobins.poku.js +49 -0
  57. package/lib/helpers/lolbas.js +267 -0
  58. package/lib/helpers/lolbas.poku.js +39 -0
  59. package/lib/helpers/osqueryTransform.js +84 -0
  60. package/lib/helpers/osqueryTransform.poku.js +49 -0
  61. package/lib/helpers/provenanceUtils.js +193 -0
  62. package/lib/helpers/provenanceUtils.poku.js +145 -0
  63. package/lib/helpers/pylockutils.js +281 -0
  64. package/lib/helpers/pylockutils.poku.js +48 -0
  65. package/lib/helpers/registryProvenance.js +793 -0
  66. package/lib/helpers/registryProvenance.poku.js +452 -0
  67. package/lib/helpers/remote/dependency-track.js +84 -0
  68. package/lib/helpers/remote/dependency-track.poku.js +119 -0
  69. package/lib/helpers/source.js +1267 -0
  70. package/lib/helpers/source.poku.js +771 -0
  71. package/lib/helpers/spdxUtils.js +97 -0
  72. package/lib/helpers/spdxUtils.poku.js +70 -0
  73. package/lib/helpers/table.js +384 -0
  74. package/lib/helpers/table.poku.js +186 -0
  75. package/lib/helpers/unicodeScan.js +147 -0
  76. package/lib/helpers/unicodeScan.poku.js +45 -0
  77. package/lib/helpers/utils.js +882 -136
  78. package/lib/helpers/utils.poku.js +995 -91
  79. package/lib/managers/binary.js +29 -5
  80. package/lib/managers/docker.js +179 -52
  81. package/lib/managers/docker.poku.js +327 -28
  82. package/lib/managers/oci.js +107 -23
  83. package/lib/managers/oci.poku.js +132 -0
  84. package/lib/server/openapi.yaml +50 -0
  85. package/lib/server/server.js +228 -331
  86. package/lib/server/server.poku.js +220 -5
  87. package/lib/stages/postgen/annotator.js +7 -0
  88. package/lib/stages/postgen/annotator.poku.js +40 -0
  89. package/lib/stages/postgen/auditBom.js +20 -5
  90. package/lib/stages/postgen/auditBom.poku.js +1729 -67
  91. package/lib/stages/postgen/postgen.js +40 -0
  92. package/lib/stages/postgen/postgen.poku.js +47 -0
  93. package/lib/stages/postgen/ruleEngine.js +80 -2
  94. package/lib/stages/postgen/spdxConverter.js +796 -0
  95. package/lib/stages/postgen/spdxConverter.poku.js +341 -0
  96. package/lib/validator/bomValidator.js +232 -0
  97. package/lib/validator/bomValidator.poku.js +70 -0
  98. package/lib/validator/complianceRules.js +70 -7
  99. package/lib/validator/complianceRules.poku.js +30 -0
  100. package/lib/validator/reporters/annotations.js +2 -2
  101. package/lib/validator/reporters/console.js +13 -2
  102. package/lib/validator/reporters.poku.js +13 -0
  103. package/package.json +10 -8
  104. package/types/bin/audit.d.ts +3 -0
  105. package/types/bin/audit.d.ts.map +1 -0
  106. package/types/bin/convert.d.ts +3 -0
  107. package/types/bin/convert.d.ts.map +1 -0
  108. package/types/bin/repl.d.ts.map +1 -1
  109. package/types/lib/audit/index.d.ts +115 -0
  110. package/types/lib/audit/index.d.ts.map +1 -0
  111. package/types/lib/audit/progress.d.ts +27 -0
  112. package/types/lib/audit/progress.d.ts.map +1 -0
  113. package/types/lib/audit/reporters.d.ts +35 -0
  114. package/types/lib/audit/reporters.d.ts.map +1 -0
  115. package/types/lib/audit/scoring.d.ts +35 -0
  116. package/types/lib/audit/scoring.d.ts.map +1 -0
  117. package/types/lib/audit/targets.d.ts +63 -0
  118. package/types/lib/audit/targets.d.ts.map +1 -0
  119. package/types/lib/cli/index.d.ts +8 -0
  120. package/types/lib/cli/index.d.ts.map +1 -1
  121. package/types/lib/helpers/analyzer.d.ts +13 -0
  122. package/types/lib/helpers/analyzer.d.ts.map +1 -1
  123. package/types/lib/helpers/annotationFormatter.d.ts +23 -0
  124. package/types/lib/helpers/annotationFormatter.d.ts.map +1 -0
  125. package/types/lib/helpers/bomUtils.d.ts +5 -0
  126. package/types/lib/helpers/bomUtils.d.ts.map +1 -0
  127. package/types/lib/helpers/chromextutils.d.ts +97 -0
  128. package/types/lib/helpers/chromextutils.d.ts.map +1 -0
  129. package/types/lib/helpers/ciParsers/githubActions.d.ts +3 -8
  130. package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -1
  131. package/types/lib/helpers/containerRisk.d.ts +17 -0
  132. package/types/lib/helpers/containerRisk.d.ts.map +1 -0
  133. package/types/lib/helpers/depsUtils.d.ts.map +1 -1
  134. package/types/lib/helpers/display.d.ts +4 -1
  135. package/types/lib/helpers/display.d.ts.map +1 -1
  136. package/types/lib/helpers/exportUtils.d.ts +40 -0
  137. package/types/lib/helpers/exportUtils.d.ts.map +1 -0
  138. package/types/lib/helpers/formulationParsers.d.ts.map +1 -1
  139. package/types/lib/helpers/gtfobins.d.ts +17 -0
  140. package/types/lib/helpers/gtfobins.d.ts.map +1 -0
  141. package/types/lib/helpers/lolbas.d.ts +16 -0
  142. package/types/lib/helpers/lolbas.d.ts.map +1 -0
  143. package/types/lib/helpers/osqueryTransform.d.ts +7 -0
  144. package/types/lib/helpers/osqueryTransform.d.ts.map +1 -0
  145. package/types/lib/helpers/provenanceUtils.d.ts +90 -0
  146. package/types/lib/helpers/provenanceUtils.d.ts.map +1 -0
  147. package/types/lib/helpers/pylockutils.d.ts +51 -0
  148. package/types/lib/helpers/pylockutils.d.ts.map +1 -0
  149. package/types/lib/helpers/registryProvenance.d.ts +17 -0
  150. package/types/lib/helpers/registryProvenance.d.ts.map +1 -0
  151. package/types/lib/helpers/remote/dependency-track.d.ts +16 -0
  152. package/types/lib/helpers/remote/dependency-track.d.ts.map +1 -0
  153. package/types/lib/helpers/source.d.ts +141 -0
  154. package/types/lib/helpers/source.d.ts.map +1 -0
  155. package/types/lib/helpers/spdxUtils.d.ts +2 -0
  156. package/types/lib/helpers/spdxUtils.d.ts.map +1 -0
  157. package/types/lib/helpers/table.d.ts +6 -0
  158. package/types/lib/helpers/table.d.ts.map +1 -0
  159. package/types/lib/helpers/unicodeScan.d.ts +46 -0
  160. package/types/lib/helpers/unicodeScan.d.ts.map +1 -0
  161. package/types/lib/helpers/utils.d.ts +30 -11
  162. package/types/lib/helpers/utils.d.ts.map +1 -1
  163. package/types/lib/managers/binary.d.ts.map +1 -1
  164. package/types/lib/managers/docker.d.ts.map +1 -1
  165. package/types/lib/managers/oci.d.ts.map +1 -1
  166. package/types/lib/server/server.d.ts +0 -35
  167. package/types/lib/server/server.d.ts.map +1 -1
  168. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  169. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  170. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  171. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  172. package/types/lib/stages/postgen/spdxConverter.d.ts +11 -0
  173. package/types/lib/stages/postgen/spdxConverter.d.ts.map +1 -0
  174. package/types/lib/validator/bomValidator.d.ts +1 -0
  175. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  176. package/types/lib/validator/complianceRules.d.ts.map +1 -1
  177. package/types/lib/validator/reporters/console.d.ts.map +1 -1
  178. package/types/bin/dependencies.d.ts +0 -3
  179. package/types/bin/dependencies.d.ts.map +0 -1
  180. package/types/bin/licenses.d.ts +0 -3
  181. 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
  });