@cyclonedx/cdxgen 12.1.4 → 12.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/README.md +47 -39
  2. package/bin/cdxgen.js +181 -90
  3. package/bin/evinse.js +4 -4
  4. package/bin/repl.js +3 -3
  5. package/bin/sign.js +102 -0
  6. package/bin/validate.js +233 -0
  7. package/bin/verify.js +69 -28
  8. package/data/queries.json +1 -1
  9. package/data/rules/ci-permissions.yaml +186 -0
  10. package/data/rules/dependency-sources.yaml +123 -0
  11. package/data/rules/package-integrity.yaml +135 -0
  12. package/data/rules/vscode-extensions.yaml +228 -0
  13. package/lib/cli/index.js +484 -440
  14. package/lib/evinser/db.js +137 -0
  15. package/lib/{helpers → evinser}/db.poku.js +2 -6
  16. package/lib/evinser/evinser.js +5 -18
  17. package/lib/evinser/swiftsem.js +1 -1
  18. package/lib/helpers/bomSigner.js +312 -0
  19. package/lib/helpers/bomSigner.poku.js +156 -0
  20. package/lib/helpers/caxa.js +1 -1
  21. package/lib/helpers/ciParsers/azurePipelines.js +295 -0
  22. package/lib/helpers/ciParsers/azurePipelines.poku.js +253 -0
  23. package/lib/helpers/ciParsers/circleCi.js +286 -0
  24. package/lib/helpers/ciParsers/circleCi.poku.js +230 -0
  25. package/lib/helpers/ciParsers/common.js +24 -0
  26. package/lib/helpers/ciParsers/githubActions.js +636 -0
  27. package/lib/helpers/ciParsers/githubActions.poku.js +802 -0
  28. package/lib/helpers/ciParsers/gitlabCi.js +213 -0
  29. package/lib/helpers/ciParsers/gitlabCi.poku.js +247 -0
  30. package/lib/helpers/ciParsers/jenkins.js +181 -0
  31. package/lib/helpers/ciParsers/jenkins.poku.js +197 -0
  32. package/lib/helpers/depsUtils.js +203 -0
  33. package/lib/helpers/depsUtils.poku.js +150 -0
  34. package/lib/helpers/display.js +429 -14
  35. package/lib/helpers/envcontext.js +23 -8
  36. package/lib/helpers/formulationParsers.js +351 -0
  37. package/lib/helpers/logger.js +14 -0
  38. package/lib/helpers/protobom.js +9 -9
  39. package/lib/helpers/pythonutils.js +305 -0
  40. package/lib/helpers/pythonutils.poku.js +469 -0
  41. package/lib/helpers/utils.js +970 -528
  42. package/lib/helpers/utils.poku.js +139 -256
  43. package/lib/helpers/versutils.js +202 -0
  44. package/lib/helpers/versutils.poku.js +315 -0
  45. package/lib/helpers/vsixutils.js +1061 -0
  46. package/lib/helpers/vsixutils.poku.js +2247 -0
  47. package/lib/managers/binary.js +19 -19
  48. package/lib/managers/docker.js +108 -1
  49. package/lib/managers/oci.js +10 -0
  50. package/lib/managers/piptree.js +4 -10
  51. package/lib/parsers/npmrc.js +92 -0
  52. package/lib/parsers/npmrc.poku.js +528 -0
  53. package/lib/server/openapi.yaml +1 -10
  54. package/lib/server/server.js +58 -16
  55. package/lib/server/server.poku.js +123 -144
  56. package/lib/stages/postgen/annotator.js +1 -1
  57. package/lib/stages/postgen/auditBom.js +197 -0
  58. package/lib/stages/postgen/auditBom.poku.js +378 -0
  59. package/lib/stages/postgen/postgen.js +54 -1
  60. package/lib/stages/postgen/postgen.poku.js +90 -1
  61. package/lib/stages/postgen/ruleEngine.js +369 -0
  62. package/lib/stages/pregen/envAudit.js +299 -0
  63. package/lib/stages/pregen/envAudit.poku.js +572 -0
  64. package/lib/stages/pregen/pregen.js +12 -8
  65. package/lib/third-party/arborist/lib/deepest-nesting-target.js +1 -1
  66. package/lib/third-party/arborist/lib/node.js +3 -3
  67. package/lib/third-party/arborist/lib/shrinkwrap.js +1 -1
  68. package/lib/third-party/arborist/lib/tree-check.js +1 -1
  69. package/lib/{helpers/validator.js → validator/bomValidator.js} +107 -47
  70. package/lib/validator/complianceEngine.js +241 -0
  71. package/lib/validator/complianceEngine.poku.js +168 -0
  72. package/lib/validator/complianceRules.js +1610 -0
  73. package/lib/validator/complianceRules.poku.js +328 -0
  74. package/lib/validator/index.js +222 -0
  75. package/lib/validator/index.poku.js +144 -0
  76. package/lib/validator/reporters/annotations.js +121 -0
  77. package/lib/validator/reporters/console.js +149 -0
  78. package/lib/validator/reporters/index.js +41 -0
  79. package/lib/validator/reporters/json.js +37 -0
  80. package/lib/validator/reporters/sarif.js +184 -0
  81. package/lib/validator/reporters.poku.js +150 -0
  82. package/package.json +8 -8
  83. package/types/bin/sign.d.ts +3 -0
  84. package/types/bin/sign.d.ts.map +1 -0
  85. package/types/bin/validate.d.ts +3 -0
  86. package/types/bin/validate.d.ts.map +1 -0
  87. package/types/helpers/utils.d.ts +0 -1
  88. package/types/lib/cli/index.d.ts +49 -52
  89. package/types/lib/cli/index.d.ts.map +1 -1
  90. package/types/lib/evinser/db.d.ts +34 -0
  91. package/types/lib/evinser/db.d.ts.map +1 -0
  92. package/types/lib/evinser/evinser.d.ts +63 -16
  93. package/types/lib/evinser/evinser.d.ts.map +1 -1
  94. package/types/lib/helpers/bomSigner.d.ts +27 -0
  95. package/types/lib/helpers/bomSigner.d.ts.map +1 -0
  96. package/types/lib/helpers/ciParsers/azurePipelines.d.ts +17 -0
  97. package/types/lib/helpers/ciParsers/azurePipelines.d.ts.map +1 -0
  98. package/types/lib/helpers/ciParsers/circleCi.d.ts +17 -0
  99. package/types/lib/helpers/ciParsers/circleCi.d.ts.map +1 -0
  100. package/types/lib/helpers/ciParsers/common.d.ts +11 -0
  101. package/types/lib/helpers/ciParsers/common.d.ts.map +1 -0
  102. package/types/lib/helpers/ciParsers/githubActions.d.ts +34 -0
  103. package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -0
  104. package/types/lib/helpers/ciParsers/gitlabCi.d.ts +17 -0
  105. package/types/lib/helpers/ciParsers/gitlabCi.d.ts.map +1 -0
  106. package/types/lib/helpers/ciParsers/jenkins.d.ts +17 -0
  107. package/types/lib/helpers/ciParsers/jenkins.d.ts.map +1 -0
  108. package/types/lib/helpers/depsUtils.d.ts +21 -0
  109. package/types/lib/helpers/depsUtils.d.ts.map +1 -0
  110. package/types/lib/helpers/display.d.ts +111 -11
  111. package/types/lib/helpers/display.d.ts.map +1 -1
  112. package/types/lib/helpers/envcontext.d.ts +19 -7
  113. package/types/lib/helpers/envcontext.d.ts.map +1 -1
  114. package/types/lib/helpers/formulationParsers.d.ts +50 -0
  115. package/types/lib/helpers/formulationParsers.d.ts.map +1 -0
  116. package/types/lib/helpers/logger.d.ts +15 -1
  117. package/types/lib/helpers/logger.d.ts.map +1 -1
  118. package/types/lib/helpers/protobom.d.ts +2 -2
  119. package/types/lib/helpers/pythonutils.d.ts +18 -0
  120. package/types/lib/helpers/pythonutils.d.ts.map +1 -0
  121. package/types/lib/helpers/utils.d.ts +532 -128
  122. package/types/lib/helpers/utils.d.ts.map +1 -1
  123. package/types/lib/helpers/versutils.d.ts +8 -0
  124. package/types/lib/helpers/versutils.d.ts.map +1 -0
  125. package/types/lib/helpers/vsixutils.d.ts +130 -0
  126. package/types/lib/helpers/vsixutils.d.ts.map +1 -0
  127. package/types/lib/managers/docker.d.ts +12 -31
  128. package/types/lib/managers/docker.d.ts.map +1 -1
  129. package/types/lib/managers/oci.d.ts +11 -1
  130. package/types/lib/managers/oci.d.ts.map +1 -1
  131. package/types/lib/managers/piptree.d.ts.map +1 -1
  132. package/types/lib/parsers/npmrc.d.ts +26 -0
  133. package/types/lib/parsers/npmrc.d.ts.map +1 -0
  134. package/types/lib/server/server.d.ts +21 -2
  135. package/types/lib/server/server.d.ts.map +1 -1
  136. package/types/lib/stages/postgen/auditBom.d.ts +20 -0
  137. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -0
  138. package/types/lib/stages/postgen/postgen.d.ts +8 -1
  139. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  140. package/types/lib/stages/postgen/ruleEngine.d.ts +18 -0
  141. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -0
  142. package/types/lib/stages/pregen/envAudit.d.ts +8 -0
  143. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -0
  144. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  145. package/types/lib/{helpers/validator.d.ts → validator/bomValidator.d.ts} +1 -1
  146. package/types/lib/validator/bomValidator.d.ts.map +1 -0
  147. package/types/lib/validator/complianceEngine.d.ts +66 -0
  148. package/types/lib/validator/complianceEngine.d.ts.map +1 -0
  149. package/types/lib/validator/complianceRules.d.ts +70 -0
  150. package/types/lib/validator/complianceRules.d.ts.map +1 -0
  151. package/types/lib/validator/index.d.ts +70 -0
  152. package/types/lib/validator/index.d.ts.map +1 -0
  153. package/types/lib/validator/reporters/annotations.d.ts +31 -0
  154. package/types/lib/validator/reporters/annotations.d.ts.map +1 -0
  155. package/types/lib/validator/reporters/console.d.ts +30 -0
  156. package/types/lib/validator/reporters/console.d.ts.map +1 -0
  157. package/types/lib/validator/reporters/index.d.ts +21 -0
  158. package/types/lib/validator/reporters/index.d.ts.map +1 -0
  159. package/types/lib/validator/reporters/json.d.ts +11 -0
  160. package/types/lib/validator/reporters/json.d.ts.map +1 -0
  161. package/types/lib/validator/reporters/sarif.d.ts +16 -0
  162. package/types/lib/validator/reporters/sarif.d.ts.map +1 -0
  163. package/lib/helpers/db.js +0 -162
  164. package/types/helpers/db.d.ts +0 -35
  165. package/types/helpers/db.d.ts.map +0 -1
  166. package/types/lib/helpers/db.d.ts +0 -35
  167. package/types/lib/helpers/db.d.ts.map +0 -1
  168. package/types/lib/helpers/validator.d.ts.map +0 -1
  169. package/types/managers/binary.d.ts +0 -37
  170. package/types/managers/binary.d.ts.map +0 -1
  171. package/types/managers/docker.d.ts +0 -56
  172. package/types/managers/docker.d.ts.map +0 -1
  173. package/types/managers/oci.d.ts +0 -2
  174. package/types/managers/oci.d.ts.map +0 -1
  175. package/types/managers/piptree.d.ts +0 -2
  176. package/types/managers/piptree.d.ts.map +0 -1
  177. package/types/server/server.d.ts +0 -34
  178. package/types/server/server.d.ts.map +0 -1
  179. package/types/stages/postgen/annotator.d.ts +0 -27
  180. package/types/stages/postgen/annotator.d.ts.map +0 -1
  181. package/types/stages/postgen/postgen.d.ts +0 -51
  182. package/types/stages/postgen/postgen.d.ts.map +0 -1
  183. package/types/stages/pregen/pregen.d.ts +0 -59
  184. package/types/stages/pregen/pregen.d.ts.map +0 -1
@@ -0,0 +1,802 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { assert, describe, it } from "poku";
5
+
6
+ import { githubActionsParser } from "./githubActions.js";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const repoRoot = path.resolve(__dirname, "../../..");
10
+ const workflowsDir = path.join(repoRoot, "test", "data", "workflows");
11
+
12
+ /**
13
+ * Helper: Find a component by purl substring
14
+ */
15
+ function findComponentByPurlSubstring(components, substring) {
16
+ return components.find((c) => c.purl?.includes(substring));
17
+ }
18
+
19
+ /**
20
+ * Helper: Extract property value from a component/workflow/task
21
+ */
22
+ function getProp(obj, propName) {
23
+ if (!obj?.properties) return undefined;
24
+ const prop = obj.properties.find((p) => p.name === propName);
25
+ return prop?.value;
26
+ }
27
+
28
+ /**
29
+ * Helper: Check if a property exists with expected value
30
+ */
31
+ function hasProp(obj, propName, expectedValue) {
32
+ const val = getProp(obj, propName);
33
+ return expectedValue !== undefined
34
+ ? val === expectedValue
35
+ : val !== undefined;
36
+ }
37
+
38
+ /**
39
+ * Helper: Parse workflow and return flattened results for assertions
40
+ */
41
+ function parseWorkflow(filename, options = {}) {
42
+ const wfFile = path.join(workflowsDir, filename);
43
+ return githubActionsParser.parse([wfFile], { specVersion: 1.6, ...options });
44
+ }
45
+
46
+ describe("githubActionsParser", () => {
47
+ it("has correct metadata", () => {
48
+ assert.strictEqual(githubActionsParser.id, "github-actions");
49
+ assert.ok(Array.isArray(githubActionsParser.patterns));
50
+ assert.ok(githubActionsParser.patterns.length > 0);
51
+ assert.strictEqual(typeof githubActionsParser.parse, "function");
52
+ });
53
+
54
+ it("returns empty arrays for no files", () => {
55
+ const result = githubActionsParser.parse([], {});
56
+ assert.deepStrictEqual(result.workflows, []);
57
+ assert.deepStrictEqual(result.components, []);
58
+ assert.deepStrictEqual(result.services, []);
59
+ assert.deepStrictEqual(result.properties, []);
60
+ assert.deepStrictEqual(result.dependencies, []);
61
+ });
62
+
63
+ it("parses a real GitHub Actions workflow file", () => {
64
+ const wfFile = path.join(repoRoot, ".github", "workflows", "nodejs.yml");
65
+ const result = githubActionsParser.parse([wfFile], { specVersion: 1.6 });
66
+
67
+ assert.ok(Array.isArray(result.workflows));
68
+ assert.ok(result.workflows.length > 0, "expected at least one workflow");
69
+
70
+ const wf = result.workflows[0];
71
+ assert.ok(wf["bom-ref"], "workflow must have bom-ref");
72
+ assert.ok(wf.uid, "workflow must have uid");
73
+ assert.ok(wf.name, "workflow must have a name");
74
+ assert.ok(Array.isArray(wf.tasks), "workflow must have tasks array");
75
+ assert.ok(wf.tasks.length > 0, "workflow must have at least one task");
76
+
77
+ const firstTask = wf.tasks[0];
78
+ assert.ok(firstTask["bom-ref"], "task must have bom-ref");
79
+ assert.ok(firstTask.name, "task must have a name");
80
+
81
+ // Components include referenced actions
82
+ assert.ok(Array.isArray(result.components));
83
+ assert.ok(result.components.length > 0, "expected action components");
84
+ const actionComp = result.components.find((c) =>
85
+ c.purl?.startsWith("pkg:github/"),
86
+ );
87
+ assert.ok(actionComp, "expected at least one pkg:github component");
88
+ });
89
+
90
+ it("parses the test fixture with vulnerable actions", () => {
91
+ const wfFile = path.join(
92
+ repoRoot,
93
+ "test",
94
+ "data",
95
+ "github-actions-tj.yaml",
96
+ );
97
+ const result = githubActionsParser.parse([wfFile], { specVersion: 1.5 });
98
+
99
+ assert.ok(result.workflows.length > 0);
100
+ assert.ok(result.components.length > 0);
101
+
102
+ const purls = result.components.map((c) => c.purl).filter(Boolean);
103
+ assert.ok(
104
+ purls.some((p) => p.includes("pixel/steamcmd")),
105
+ "expected pixel/steamcmd purl",
106
+ );
107
+ assert.ok(
108
+ purls.some((p) => p.includes("tj/branch")),
109
+ "expected tj/branch purl",
110
+ );
111
+ });
112
+
113
+ it("produces workflow→task dependency links", () => {
114
+ const wfFile = path.join(repoRoot, ".github", "workflows", "nodejs.yml");
115
+ const result = githubActionsParser.parse([wfFile], {});
116
+
117
+ assert.ok(Array.isArray(result.dependencies));
118
+ assert.ok(result.dependencies.length > 0);
119
+
120
+ const workflowDep = result.dependencies.find(
121
+ (d) => d.ref === result.workflows[0]["bom-ref"],
122
+ );
123
+ assert.ok(
124
+ workflowDep,
125
+ "expected a dependency entry for the workflow bom-ref",
126
+ );
127
+ assert.ok(Array.isArray(workflowDep.dependsOn));
128
+ assert.ok(workflowDep.dependsOn.length > 0);
129
+ });
130
+
131
+ it("gracefully handles missing file", () => {
132
+ const result = githubActionsParser.parse(
133
+ ["/this/file/does/not/exist.yml"],
134
+ {},
135
+ );
136
+ assert.deepStrictEqual(result.workflows, []);
137
+ assert.deepStrictEqual(result.components, []);
138
+ });
139
+
140
+ it("gracefully handles malformed YAML", () => {
141
+ const jf = path.join(repoRoot, "test", "data", "Jenkinsfile");
142
+ const result = githubActionsParser.parse([jf], {});
143
+ assert.deepStrictEqual(result.workflows, []);
144
+ });
145
+
146
+ it("disambiguates identical steps (uniqueItems compliance)", () => {
147
+ const wfFile = path.join(
148
+ repoRoot,
149
+ "test",
150
+ "data",
151
+ "github-actions-qwiet.yaml",
152
+ );
153
+ const result = githubActionsParser.parse([wfFile], {});
154
+
155
+ assert.ok(result.workflows.length > 0);
156
+ const wf = result.workflows[0];
157
+ const uploadTask = wf.tasks?.find((t) => t.name === "uploadArtifacts");
158
+ assert.ok(uploadTask, "expected uploadArtifacts task");
159
+
160
+ const steps = uploadTask.steps ?? [];
161
+ const stepKeys = steps.map((s) => JSON.stringify(s));
162
+ const uniqueKeys = new Set(stepKeys);
163
+ assert.strictEqual(
164
+ uniqueKeys.size,
165
+ stepKeys.length,
166
+ "steps array contains duplicate items",
167
+ );
168
+
169
+ const uploadSteps = steps.filter((s) =>
170
+ s.name.startsWith("actions/upload-artifact@v1.0.0"),
171
+ );
172
+ assert.strictEqual(
173
+ uploadSteps.length,
174
+ 2,
175
+ "both upload-artifact steps must be kept",
176
+ );
177
+ assert.ok(
178
+ uploadSteps.some((s) => s.name === "actions/upload-artifact@v1.0.0"),
179
+ "first upload-artifact step must keep original name",
180
+ );
181
+ assert.ok(
182
+ uploadSteps.some((s) => s.name === "actions/upload-artifact@v1.0.0 (2)"),
183
+ "second upload-artifact step must be renamed with counter",
184
+ );
185
+
186
+ const preZeroTask = wf.tasks?.find((t) => t.name === "preZero");
187
+ assert.ok(preZeroTask, "expected preZero task");
188
+ const preZeroSteps = preZeroTask.steps ?? [];
189
+ const preZeroKeys = preZeroSteps.map((s) => JSON.stringify(s));
190
+ assert.strictEqual(
191
+ new Set(preZeroKeys).size,
192
+ preZeroKeys.length,
193
+ "preZero steps must also have no duplicates",
194
+ );
195
+ });
196
+
197
+ describe("checkout persist-credentials property emission", () => {
198
+ it("emits persistCredentials=true when not specified (default)", () => {
199
+ const result = parseWorkflow("checkout-default.yml");
200
+ assert.ok(result.components.length > 0, "expected action components");
201
+ const checkoutComp = findComponentByPurlSubstring(
202
+ result.components,
203
+ "actions/checkout",
204
+ );
205
+ assert.ok(checkoutComp, "expected actions/checkout component");
206
+ assert.strictEqual(
207
+ getProp(checkoutComp, "cdx:github:checkout:persistCredentials"),
208
+ "true",
209
+ "persistCredentials should default to 'true' when not specified",
210
+ );
211
+ });
212
+
213
+ it("emits persistCredentials=false when explicitly disabled", () => {
214
+ const result = parseWorkflow("checkout-no-persist.yml");
215
+
216
+ const checkoutComp = findComponentByPurlSubstring(
217
+ result.components,
218
+ "actions/checkout",
219
+ );
220
+ assert.ok(checkoutComp, "expected actions/checkout component");
221
+
222
+ assert.strictEqual(
223
+ getProp(checkoutComp, "cdx:github:checkout:persistCredentials"),
224
+ "false",
225
+ "persistCredentials should be 'false' when explicitly set",
226
+ );
227
+ });
228
+
229
+ it("emits persistCredentials for checkout in privileged workflow", () => {
230
+ const result = parseWorkflow("checkout-privileged.yml");
231
+
232
+ const checkoutComp = findComponentByPurlSubstring(
233
+ result.components,
234
+ "actions/checkout",
235
+ );
236
+ assert.ok(checkoutComp, "expected actions/checkout component");
237
+
238
+ assert.strictEqual(
239
+ getProp(checkoutComp, "cdx:github:checkout:persistCredentials"),
240
+ "true",
241
+ );
242
+ assert.strictEqual(
243
+ getProp(checkoutComp, "cdx:github:workflow:hasWritePermissions"),
244
+ "true",
245
+ "workflow should have write permissions flag",
246
+ );
247
+ });
248
+
249
+ it("does not emit checkout properties for non-checkout actions", () => {
250
+ const result = parseWorkflow("simple-build.yml");
251
+
252
+ const nonCheckoutComp = result.components.find((c) =>
253
+ c.purl?.includes("actions/setup-node"),
254
+ );
255
+ assert.ok(nonCheckoutComp, "expected setup-node component");
256
+
257
+ assert.strictEqual(
258
+ getProp(nonCheckoutComp, "cdx:github:checkout:persistCredentials"),
259
+ undefined,
260
+ "non-checkout actions should not have persistCredentials property",
261
+ );
262
+ });
263
+ });
264
+
265
+ describe("cache action property emission", () => {
266
+ it("emits cache key and path properties", () => {
267
+ const result = parseWorkflow("cache-basic.yml");
268
+
269
+ const cacheComp = findComponentByPurlSubstring(
270
+ result.components,
271
+ "actions/cache",
272
+ );
273
+ assert.ok(cacheComp, "expected actions/cache component");
274
+ // biome-ignore-start lint/suspicious/noTemplateCurlyInString: Test
275
+ assert.strictEqual(
276
+ getProp(cacheComp, "cdx:github:cache:key"),
277
+ "npm-${{ hashFiles('**/package-lock.json') }}",
278
+ "cache key should be extracted",
279
+ );
280
+ // biome-ignore-end lint/suspicious/noTemplateCurlyInString: Test
281
+ assert.strictEqual(
282
+ getProp(cacheComp, "cdx:github:cache:path"),
283
+ "~/.npm",
284
+ "cache path should be extracted",
285
+ );
286
+ });
287
+
288
+ it("emits restore-keys as comma-separated list", () => {
289
+ const result = parseWorkflow("cache-restore-keys.yml");
290
+
291
+ const cacheComp = findComponentByPurlSubstring(
292
+ result.components,
293
+ "actions/cache",
294
+ );
295
+ assert.ok(cacheComp);
296
+
297
+ const restoreKeys = getProp(cacheComp, "cdx:github:cache:restoreKeys");
298
+ assert.ok(restoreKeys, "restore-keys should be emitted");
299
+ assert.ok(
300
+ restoreKeys.includes("npm-") && restoreKeys.includes("node-modules-"),
301
+ "restore-keys should contain both fallback patterns",
302
+ );
303
+ });
304
+
305
+ it("emits workflow triggers for cache context analysis", () => {
306
+ const result = parseWorkflow("cache-pull-request.yml");
307
+
308
+ const workflow = result.workflows[0];
309
+ const triggers = getProp(workflow, "cdx:github:workflow:triggers");
310
+ assert.ok(triggers, "workflow triggers should be emitted");
311
+ assert.ok(
312
+ triggers.split(",").includes("pull_request"),
313
+ "pull_request trigger should be detected",
314
+ );
315
+
316
+ const cacheComp = findComponentByPurlSubstring(
317
+ result.components,
318
+ "actions/cache",
319
+ );
320
+ assert.strictEqual(
321
+ getProp(cacheComp, "cdx:github:workflow:triggers"),
322
+ "pull_request",
323
+ "triggers should be duplicated to component level",
324
+ );
325
+ });
326
+
327
+ it("handles cache action without optional fields gracefully", () => {
328
+ const result = parseWorkflow("cache-minimal.yml");
329
+
330
+ const cacheComp = findComponentByPurlSubstring(
331
+ result.components,
332
+ "actions/cache",
333
+ );
334
+ assert.ok(cacheComp);
335
+
336
+ assert.ok(
337
+ getProp(cacheComp, "cdx:github:cache:key"),
338
+ "cache key should always be present",
339
+ );
340
+ assert.ok(
341
+ getProp(cacheComp, "cdx:github:cache:path") === undefined ||
342
+ typeof getProp(cacheComp, "cdx:github:cache:path") === "string",
343
+ "cache path should be string or undefined",
344
+ );
345
+ });
346
+ });
347
+
348
+ describe("script injection interpolation detection", () => {
349
+ it("detects github.event.pull_request interpolation", () => {
350
+ const result = parseWorkflow("injection-pull-request-title.yml");
351
+
352
+ const runStepComp = result.components.find((c) =>
353
+ c.properties?.some(
354
+ (p) => p.name === "cdx:github:step:hasUntrustedInterpolation",
355
+ ),
356
+ );
357
+ assert.ok(
358
+ runStepComp,
359
+ "should detect untrusted interpolation in run step",
360
+ );
361
+
362
+ assert.strictEqual(
363
+ getProp(runStepComp, "cdx:github:step:hasUntrustedInterpolation"),
364
+ "true",
365
+ );
366
+ const vars = getProp(runStepComp, "cdx:github:step:interpolatedVars");
367
+ assert.ok(vars, "interpolated variables should be listed");
368
+ assert.ok(
369
+ vars.includes("github.event.pull_request.title"),
370
+ "should detect pull_request.title interpolation",
371
+ );
372
+ });
373
+
374
+ it("detects github.head_ref interpolation", () => {
375
+ const result = parseWorkflow("injection-head-ref.yml");
376
+
377
+ const runStepComp = result.components.find((c) =>
378
+ hasProp(c, "cdx:github:step:hasUntrustedInterpolation", "true"),
379
+ );
380
+ assert.ok(runStepComp);
381
+
382
+ const vars = getProp(runStepComp, "cdx:github:step:interpolatedVars");
383
+ assert.ok(
384
+ vars.includes("github.head_ref"),
385
+ "should detect github.head_ref interpolation",
386
+ );
387
+ });
388
+
389
+ it("detects inputs.* interpolation in workflow_dispatch", () => {
390
+ const result = parseWorkflow("injection-workflow-inputs.yml");
391
+
392
+ const runStepComp = result.components.find((c) =>
393
+ hasProp(c, "cdx:github:step:hasUntrustedInterpolation", "true"),
394
+ );
395
+ assert.ok(runStepComp);
396
+
397
+ const vars = getProp(runStepComp, "cdx:github:step:interpolatedVars");
398
+ assert.ok(
399
+ vars.split(",").some((v) => v.trim().startsWith("inputs.")),
400
+ "should detect inputs.* interpolation",
401
+ );
402
+ });
403
+
404
+ it("does not flag safe interpolations", () => {
405
+ const result = parseWorkflow("safe-interpolation.yml");
406
+
407
+ const runStepComp = result.components.find(
408
+ (c) => c.purl?.includes("run") || c.name?.includes("echo"),
409
+ );
410
+
411
+ if (runStepComp) {
412
+ assert.strictEqual(
413
+ getProp(runStepComp, "cdx:github:step:hasUntrustedInterpolation"),
414
+ undefined,
415
+ "safe env-var indirection should not trigger injection detection",
416
+ );
417
+ }
418
+ });
419
+
420
+ it("handles multiple interpolations in single run block", () => {
421
+ const result = parseWorkflow("injection-multiple-vars.yml");
422
+
423
+ const runStepComp = result.components.find((c) =>
424
+ hasProp(c, "cdx:github:step:hasUntrustedInterpolation", "true"),
425
+ );
426
+ assert.ok(runStepComp);
427
+
428
+ const vars = getProp(runStepComp, "cdx:github:step:interpolatedVars");
429
+ const varList = vars.split(",");
430
+ assert.ok(
431
+ varList.length >= 2,
432
+ "should detect multiple untrusted variables",
433
+ );
434
+ assert.ok(
435
+ varList.some((v) => v.includes("pull_request.title")),
436
+ "should include pull_request.title",
437
+ );
438
+ assert.ok(
439
+ varList.some((v) => v.includes("pull_request.body")),
440
+ "should include pull_request.body",
441
+ );
442
+ });
443
+ });
444
+
445
+ describe("high-risk trigger detection", () => {
446
+ it("flags pull_request_target trigger", () => {
447
+ const result = parseWorkflow("trigger-pull-request-target.yml");
448
+
449
+ const workflow = result.workflows[0];
450
+ assert.strictEqual(
451
+ getProp(workflow, "cdx:github:workflow:hasHighRiskTrigger"),
452
+ "true",
453
+ "pull_request_target should be flagged as high-risk",
454
+ );
455
+
456
+ const triggers = getProp(workflow, "cdx:github:workflow:triggers");
457
+ assert.ok(
458
+ triggers.split(",").includes("pull_request_target"),
459
+ "trigger list should include pull_request_target",
460
+ );
461
+ });
462
+
463
+ it("flags issue_comment trigger", () => {
464
+ const result = parseWorkflow("trigger-issue-comment.yml");
465
+
466
+ const workflow = result.workflows[0];
467
+ assert.strictEqual(
468
+ getProp(workflow, "cdx:github:workflow:hasHighRiskTrigger"),
469
+ "true",
470
+ "issue_comment should be flagged as high-risk",
471
+ );
472
+ });
473
+
474
+ it("flags workflow_run trigger", () => {
475
+ const result = parseWorkflow("trigger-workflow-run.yml");
476
+
477
+ const workflow = result.workflows[0];
478
+ assert.strictEqual(
479
+ getProp(workflow, "cdx:github:workflow:hasHighRiskTrigger"),
480
+ "true",
481
+ "workflow_run should be flagged as high-risk",
482
+ );
483
+ });
484
+
485
+ it("does not flag safe triggers", () => {
486
+ const result = parseWorkflow("trigger-safe-push.yml");
487
+
488
+ const workflow = result.workflows[0];
489
+ assert.strictEqual(
490
+ getProp(workflow, "cdx:github:workflow:hasHighRiskTrigger"),
491
+ undefined,
492
+ "push trigger should not be flagged as high-risk",
493
+ );
494
+ });
495
+
496
+ it("combines high-risk trigger with write permissions in components", () => {
497
+ const result = parseWorkflow("trigger-privileged.yml");
498
+
499
+ const workflow = result.workflows[0];
500
+ assert.strictEqual(
501
+ getProp(workflow, "cdx:github:workflow:hasHighRiskTrigger"),
502
+ "true",
503
+ );
504
+ assert.strictEqual(
505
+ getProp(workflow, "cdx:github:workflow:hasWritePermissions"),
506
+ "true",
507
+ );
508
+
509
+ const actionComp = result.components.find((c) =>
510
+ c.purl?.includes("actions/checkout"),
511
+ );
512
+ if (actionComp) {
513
+ assert.strictEqual(
514
+ getProp(actionComp, "cdx:github:workflow:hasHighRiskTrigger"),
515
+ "true",
516
+ "high-risk trigger should be duplicated to component",
517
+ );
518
+ assert.strictEqual(
519
+ getProp(actionComp, "cdx:github:workflow:hasWritePermissions"),
520
+ "true",
521
+ "write permissions should be duplicated to component",
522
+ );
523
+ }
524
+ });
525
+ });
526
+
527
+ describe("combined security risk scenarios", () => {
528
+ it("detects cache poisoning risk: cache + pull_request + write perms", () => {
529
+ const result = parseWorkflow("risk-cache-poisoning.yml");
530
+
531
+ const cacheComp = findComponentByPurlSubstring(
532
+ result.components,
533
+ "actions/cache",
534
+ );
535
+ assert.ok(cacheComp, "expected cache component");
536
+
537
+ assert.ok(
538
+ getProp(cacheComp, "cdx:github:cache:key"),
539
+ "cache key should be present",
540
+ );
541
+ assert.strictEqual(
542
+ getProp(cacheComp, "cdx:github:workflow:triggers"),
543
+ "pull_request",
544
+ "pull_request trigger should be duplicated",
545
+ );
546
+ assert.strictEqual(
547
+ getProp(cacheComp, "cdx:github:workflow:hasWritePermissions"),
548
+ "true",
549
+ "write permissions should be duplicated",
550
+ );
551
+ });
552
+
553
+ it("detects credential exposure: checkout persist + privileged workflow", () => {
554
+ const result = parseWorkflow("risk-credential-exposure.yml");
555
+
556
+ const checkoutComp = findComponentByPurlSubstring(
557
+ result.components,
558
+ "actions/checkout",
559
+ );
560
+ assert.ok(checkoutComp);
561
+
562
+ assert.strictEqual(
563
+ getProp(checkoutComp, "cdx:github:checkout:persistCredentials"),
564
+ "true",
565
+ );
566
+ assert.strictEqual(
567
+ getProp(checkoutComp, "cdx:github:workflow:hasWritePermissions"),
568
+ "true",
569
+ );
570
+ });
571
+
572
+ it("detects script injection in privileged context", () => {
573
+ const result = parseWorkflow("risk-injection-privileged.yml");
574
+
575
+ const injectionComp = result.components.find((c) =>
576
+ hasProp(c, "cdx:github:step:hasUntrustedInterpolation", "true"),
577
+ );
578
+ assert.ok(injectionComp, "should detect injection attempt");
579
+
580
+ assert.strictEqual(
581
+ getProp(injectionComp, "cdx:github:workflow:hasWritePermissions"),
582
+ "true",
583
+ "injection in privileged workflow should have permission flag",
584
+ );
585
+ });
586
+
587
+ it("detects unpinned action in high-risk trigger workflow", () => {
588
+ const result = parseWorkflow("risk-unpinned-high-risk.yml");
589
+
590
+ const actionComp = result.components.find((c) =>
591
+ c.purl?.includes("third-party/action"),
592
+ );
593
+ assert.ok(actionComp);
594
+
595
+ assert.strictEqual(
596
+ getProp(actionComp, "cdx:github:action:isShaPinned"),
597
+ "false",
598
+ "action should be detected as unpinned",
599
+ );
600
+ assert.strictEqual(
601
+ getProp(actionComp, "cdx:github:action:versionPinningType"),
602
+ "tag",
603
+ "pinning type should be 'tag'",
604
+ );
605
+ assert.strictEqual(
606
+ getProp(actionComp, "cdx:github:workflow:hasHighRiskTrigger"),
607
+ "true",
608
+ );
609
+ });
610
+ });
611
+
612
+ describe("edge cases and robustness", () => {
613
+ it("handles checkout step with complex with: block", () => {
614
+ const result = parseWorkflow("checkout-complex.yml");
615
+
616
+ const checkoutComp = findComponentByPurlSubstring(
617
+ result.components,
618
+ "actions/checkout",
619
+ );
620
+ assert.ok(checkoutComp);
621
+
622
+ const persistVal = getProp(
623
+ checkoutComp,
624
+ "cdx:github:checkout:persistCredentials",
625
+ );
626
+ assert.ok(
627
+ persistVal === "true" || persistVal === "false",
628
+ "persistCredentials should be boolean string",
629
+ );
630
+ });
631
+
632
+ it("handles cache with array-style restore-keys", () => {
633
+ const result = parseWorkflow("cache-array-restore.yml");
634
+
635
+ const cacheComp = findComponentByPurlSubstring(
636
+ result.components,
637
+ "actions/cache",
638
+ );
639
+ assert.ok(cacheComp);
640
+
641
+ const restoreKeys = getProp(cacheComp, "cdx:github:cache:restoreKeys");
642
+ assert.ok(restoreKeys, "restore-keys should be emitted");
643
+ assert.ok(
644
+ restoreKeys.split(",").length >= 2,
645
+ "should handle array-style restore-keys",
646
+ );
647
+ });
648
+
649
+ it("handles interpolation with nested expressions", () => {
650
+ const result = parseWorkflow("injection-nested.yml");
651
+
652
+ const runStepComp = result.components.find((c) =>
653
+ hasProp(c, "cdx:github:step:hasUntrustedInterpolation", "true"),
654
+ );
655
+ assert.ok(runStepComp);
656
+
657
+ const vars = getProp(runStepComp, "cdx:github:step:interpolatedVars");
658
+ assert.ok(
659
+ vars.includes("github.event.pull_request.title") ||
660
+ vars.includes("github.event.issue.title"),
661
+ "should detect untrusted variable in nested expression",
662
+ );
663
+ });
664
+
665
+ it("preserves existing properties when adding new ones", () => {
666
+ const result = parseWorkflow("checkout-default.yml");
667
+
668
+ const checkoutComp = findComponentByPurlSubstring(
669
+ result.components,
670
+ "actions/checkout",
671
+ );
672
+ assert.ok(checkoutComp);
673
+
674
+ assert.ok(
675
+ hasProp(checkoutComp, "cdx:github:action:uses"),
676
+ "existing uses property should be preserved",
677
+ );
678
+ assert.ok(
679
+ hasProp(checkoutComp, "cdx:github:action:isShaPinned"),
680
+ "existing pinning property should be preserved",
681
+ );
682
+ assert.ok(
683
+ hasProp(checkoutComp, "cdx:github:action:versionPinningType"),
684
+ "existing versionPinningType should be preserved",
685
+ );
686
+ assert.ok(
687
+ hasProp(checkoutComp, "cdx:github:checkout:persistCredentials"),
688
+ "new persistCredentials property should be added",
689
+ );
690
+ });
691
+
692
+ it("handles workflow with no jobs gracefully", () => {
693
+ const result = parseWorkflow("empty-workflow.yml");
694
+
695
+ assert.ok(Array.isArray(result.workflows));
696
+ if (result.workflows.length > 0) {
697
+ const wf = result.workflows[0];
698
+ assert.ok(wf["bom-ref"], "workflow should have bom-ref even if empty");
699
+ }
700
+ });
701
+ });
702
+
703
+ describe("policy rule compatibility", () => {
704
+ it("emits properties in JSONata-accessible format", () => {
705
+ const result = parseWorkflow("checkout-privileged.yml");
706
+
707
+ const checkoutComp = findComponentByPurlSubstring(
708
+ result.components,
709
+ "actions/checkout",
710
+ );
711
+ assert.ok(checkoutComp);
712
+
713
+ assert.ok(Array.isArray(checkoutComp.properties));
714
+ const propNames = checkoutComp.properties.map((p) => p.name);
715
+
716
+ assert.ok(
717
+ propNames.includes("cdx:github:checkout:persistCredentials"),
718
+ "persistCredentials property should be JSONata-accessible",
719
+ );
720
+ assert.ok(
721
+ propNames.includes("cdx:github:workflow:hasWritePermissions"),
722
+ "hasWritePermissions should be JSONata-accessible on component",
723
+ );
724
+ assert.ok(
725
+ propNames.includes("cdx:github:action:isShaPinned"),
726
+ "isShaPinned should be JSONata-accessible",
727
+ );
728
+ });
729
+
730
+ it("emits boolean properties as string 'true'/'false' for JSONata", () => {
731
+ const result = parseWorkflow("checkout-default.yml");
732
+
733
+ const checkoutComp = findComponentByPurlSubstring(
734
+ result.components,
735
+ "actions/checkout",
736
+ );
737
+ const persistVal = getProp(
738
+ checkoutComp,
739
+ "cdx:github:checkout:persistCredentials",
740
+ );
741
+
742
+ assert.strictEqual(
743
+ typeof persistVal,
744
+ "string",
745
+ "boolean-like properties should be emitted as strings",
746
+ );
747
+ assert.ok(
748
+ persistVal === "true" || persistVal === "false",
749
+ "boolean properties should be 'true' or 'false' strings",
750
+ );
751
+ });
752
+
753
+ it("emits list properties as comma-separated strings", () => {
754
+ const result = parseWorkflow("cache-restore-keys.yml");
755
+
756
+ const cacheComp = findComponentByPurlSubstring(
757
+ result.components,
758
+ "actions/cache",
759
+ );
760
+ const restoreKeys = getProp(cacheComp, "cdx:github:cache:restoreKeys");
761
+
762
+ assert.strictEqual(
763
+ typeof restoreKeys,
764
+ "string",
765
+ "list properties should be strings",
766
+ );
767
+ assert.ok(
768
+ restoreKeys.includes(","),
769
+ "multi-value lists should be comma-separated",
770
+ );
771
+ });
772
+
773
+ it("duplicates workflow-level properties to components for policy scanning", () => {
774
+ const result = parseWorkflow("risk-cache-poisoning.yml");
775
+
776
+ const workflow = result.workflows[0];
777
+ const workflowTriggers = getProp(
778
+ workflow,
779
+ "cdx:github:workflow:triggers",
780
+ );
781
+ const workflowPerms = getProp(
782
+ workflow,
783
+ "cdx:github:workflow:hasWritePermissions",
784
+ );
785
+
786
+ const cacheComp = findComponentByPurlSubstring(
787
+ result.components,
788
+ "actions/cache",
789
+ );
790
+ assert.strictEqual(
791
+ getProp(cacheComp, "cdx:github:workflow:triggers"),
792
+ workflowTriggers,
793
+ "triggers should be duplicated to component",
794
+ );
795
+ assert.strictEqual(
796
+ getProp(cacheComp, "cdx:github:workflow:hasWritePermissions"),
797
+ workflowPerms,
798
+ "permissions should be duplicated to component",
799
+ );
800
+ });
801
+ });
802
+ });