@cyclonedx/cdxgen 12.2.1 → 12.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/README.md +239 -90
  2. package/bin/audit.js +191 -0
  3. package/bin/cdxgen.js +513 -167
  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 +154 -11
  33. package/lib/cli/index.poku.js +251 -0
  34. package/lib/helpers/analyzer.js +446 -2
  35. package/lib/helpers/analyzer.poku.js +72 -1
  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/display.js +241 -59
  48. package/lib/helpers/display.poku.js +162 -2
  49. package/lib/helpers/exportUtils.js +123 -0
  50. package/lib/helpers/exportUtils.poku.js +60 -0
  51. package/lib/helpers/formulationParsers.js +69 -0
  52. package/lib/helpers/formulationParsers.poku.js +44 -0
  53. package/lib/helpers/gtfobins.js +189 -0
  54. package/lib/helpers/gtfobins.poku.js +49 -0
  55. package/lib/helpers/lolbas.js +267 -0
  56. package/lib/helpers/lolbas.poku.js +39 -0
  57. package/lib/helpers/osqueryTransform.js +84 -0
  58. package/lib/helpers/osqueryTransform.poku.js +49 -0
  59. package/lib/helpers/provenanceUtils.js +193 -0
  60. package/lib/helpers/provenanceUtils.poku.js +145 -0
  61. package/lib/helpers/pylockutils.js +281 -0
  62. package/lib/helpers/pylockutils.poku.js +48 -0
  63. package/lib/helpers/registryProvenance.js +793 -0
  64. package/lib/helpers/registryProvenance.poku.js +452 -0
  65. package/lib/helpers/source.js +1267 -0
  66. package/lib/helpers/source.poku.js +771 -0
  67. package/lib/helpers/spdxUtils.js +97 -0
  68. package/lib/helpers/spdxUtils.poku.js +70 -0
  69. package/lib/helpers/unicodeScan.js +147 -0
  70. package/lib/helpers/unicodeScan.poku.js +45 -0
  71. package/lib/helpers/utils.js +700 -128
  72. package/lib/helpers/utils.poku.js +877 -80
  73. package/lib/managers/binary.js +29 -5
  74. package/lib/managers/docker.js +179 -52
  75. package/lib/managers/docker.poku.js +327 -28
  76. package/lib/managers/oci.js +107 -23
  77. package/lib/managers/oci.poku.js +132 -0
  78. package/lib/server/openapi.yaml +17 -0
  79. package/lib/server/server.js +225 -336
  80. package/lib/server/server.poku.js +16 -10
  81. package/lib/stages/postgen/annotator.js +7 -0
  82. package/lib/stages/postgen/annotator.poku.js +40 -0
  83. package/lib/stages/postgen/auditBom.js +19 -3
  84. package/lib/stages/postgen/auditBom.poku.js +1729 -67
  85. package/lib/stages/postgen/postgen.js +40 -0
  86. package/lib/stages/postgen/postgen.poku.js +47 -0
  87. package/lib/stages/postgen/ruleEngine.js +80 -2
  88. package/lib/stages/postgen/spdxConverter.js +796 -0
  89. package/lib/stages/postgen/spdxConverter.poku.js +341 -0
  90. package/lib/validator/bomValidator.js +232 -0
  91. package/lib/validator/bomValidator.poku.js +70 -0
  92. package/lib/validator/complianceRules.js +70 -7
  93. package/lib/validator/complianceRules.poku.js +30 -0
  94. package/lib/validator/reporters/annotations.js +2 -2
  95. package/lib/validator/reporters/console.js +11 -0
  96. package/lib/validator/reporters.poku.js +13 -0
  97. package/package.json +10 -7
  98. package/types/bin/audit.d.ts +3 -0
  99. package/types/bin/audit.d.ts.map +1 -0
  100. package/types/bin/convert.d.ts +3 -0
  101. package/types/bin/convert.d.ts.map +1 -0
  102. package/types/bin/repl.d.ts.map +1 -1
  103. package/types/lib/audit/index.d.ts +115 -0
  104. package/types/lib/audit/index.d.ts.map +1 -0
  105. package/types/lib/audit/progress.d.ts +27 -0
  106. package/types/lib/audit/progress.d.ts.map +1 -0
  107. package/types/lib/audit/reporters.d.ts +35 -0
  108. package/types/lib/audit/reporters.d.ts.map +1 -0
  109. package/types/lib/audit/scoring.d.ts +35 -0
  110. package/types/lib/audit/scoring.d.ts.map +1 -0
  111. package/types/lib/audit/targets.d.ts +63 -0
  112. package/types/lib/audit/targets.d.ts.map +1 -0
  113. package/types/lib/cli/index.d.ts +8 -0
  114. package/types/lib/cli/index.d.ts.map +1 -1
  115. package/types/lib/helpers/analyzer.d.ts +13 -0
  116. package/types/lib/helpers/analyzer.d.ts.map +1 -1
  117. package/types/lib/helpers/annotationFormatter.d.ts +23 -0
  118. package/types/lib/helpers/annotationFormatter.d.ts.map +1 -0
  119. package/types/lib/helpers/bomUtils.d.ts +5 -0
  120. package/types/lib/helpers/bomUtils.d.ts.map +1 -0
  121. package/types/lib/helpers/chromextutils.d.ts +97 -0
  122. package/types/lib/helpers/chromextutils.d.ts.map +1 -0
  123. package/types/lib/helpers/ciParsers/githubActions.d.ts +3 -8
  124. package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -1
  125. package/types/lib/helpers/containerRisk.d.ts +17 -0
  126. package/types/lib/helpers/containerRisk.d.ts.map +1 -0
  127. package/types/lib/helpers/display.d.ts +4 -1
  128. package/types/lib/helpers/display.d.ts.map +1 -1
  129. package/types/lib/helpers/exportUtils.d.ts +40 -0
  130. package/types/lib/helpers/exportUtils.d.ts.map +1 -0
  131. package/types/lib/helpers/formulationParsers.d.ts.map +1 -1
  132. package/types/lib/helpers/gtfobins.d.ts +17 -0
  133. package/types/lib/helpers/gtfobins.d.ts.map +1 -0
  134. package/types/lib/helpers/lolbas.d.ts +16 -0
  135. package/types/lib/helpers/lolbas.d.ts.map +1 -0
  136. package/types/lib/helpers/osqueryTransform.d.ts +7 -0
  137. package/types/lib/helpers/osqueryTransform.d.ts.map +1 -0
  138. package/types/lib/helpers/provenanceUtils.d.ts +90 -0
  139. package/types/lib/helpers/provenanceUtils.d.ts.map +1 -0
  140. package/types/lib/helpers/pylockutils.d.ts +51 -0
  141. package/types/lib/helpers/pylockutils.d.ts.map +1 -0
  142. package/types/lib/helpers/registryProvenance.d.ts +17 -0
  143. package/types/lib/helpers/registryProvenance.d.ts.map +1 -0
  144. package/types/lib/helpers/source.d.ts +141 -0
  145. package/types/lib/helpers/source.d.ts.map +1 -0
  146. package/types/lib/helpers/spdxUtils.d.ts +2 -0
  147. package/types/lib/helpers/spdxUtils.d.ts.map +1 -0
  148. package/types/lib/helpers/unicodeScan.d.ts +46 -0
  149. package/types/lib/helpers/unicodeScan.d.ts.map +1 -0
  150. package/types/lib/helpers/utils.d.ts +29 -11
  151. package/types/lib/helpers/utils.d.ts.map +1 -1
  152. package/types/lib/managers/binary.d.ts.map +1 -1
  153. package/types/lib/managers/docker.d.ts.map +1 -1
  154. package/types/lib/managers/oci.d.ts.map +1 -1
  155. package/types/lib/server/server.d.ts +0 -36
  156. package/types/lib/server/server.d.ts.map +1 -1
  157. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  158. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  159. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  160. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  161. package/types/lib/stages/postgen/spdxConverter.d.ts +11 -0
  162. package/types/lib/stages/postgen/spdxConverter.d.ts.map +1 -0
  163. package/types/lib/validator/bomValidator.d.ts +1 -0
  164. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  165. package/types/lib/validator/complianceRules.d.ts.map +1 -1
  166. package/types/lib/validator/reporters/console.d.ts.map +1 -1
  167. package/types/bin/dependencies.d.ts +0 -3
  168. package/types/bin/dependencies.d.ts.map +0 -1
  169. package/types/bin/licenses.d.ts +0 -3
  170. package/types/bin/licenses.d.ts.map +0 -1
@@ -1,9 +1,11 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import path from "node:path";
2
3
 
3
4
  import { PackageURL } from "packageurl-js";
4
5
  import { v4 as uuidv4 } from "uuid";
5
6
  import { parse as _load } from "yaml";
6
7
 
8
+ import { scanTextForHiddenUnicode } from "../unicodeScan.js";
7
9
  import { disambiguateSteps } from "./common.js";
8
10
 
9
11
  /**
@@ -39,6 +41,119 @@ const HIGH_RISK_TRIGGERS = [
39
41
  "workflow_run",
40
42
  ];
41
43
 
44
+ const LOW_RISK_INTERPOLATION_PATTERNS = [
45
+ /^github\.sha$/,
46
+ /^github\.event\.pull_request\.(?:head|base)\.sha$/,
47
+ /^github\.event\.workflow_run\.head_sha$/,
48
+ /^github\.event\.pull_request\.number$/,
49
+ /^github\.event\.issue\.number$/,
50
+ /^github\.run_attempt$/,
51
+ /^github\.run_id$/,
52
+ /^github\.run_number$/,
53
+ ];
54
+
55
+ const LEGACY_PUBLISH_TOKEN_ENV_NAMES = new Set([
56
+ "NPM_CONFIG_TOKEN",
57
+ "TWINE_PASSWORD",
58
+ ]);
59
+
60
+ const SECRET_LIKE_ENV_NAME_PATTERN =
61
+ /token|secret|password|credential|auth|api[_-]?key|access[_-]?key|client[_-]?secret/i;
62
+
63
+ const SENSITIVE_ENV_VALUE_PATTERN =
64
+ /secrets\.[A-Za-z0-9_]+|github\.token|ACTIONS_ID_TOKEN_REQUEST_(?:TOKEN|URL)/i;
65
+
66
+ const SHELL_VARIABLE_REFERENCE_PATTERN =
67
+ /\$[A-Za-z_][A-Za-z0-9_]*\b|\$\{[A-Za-z_][A-Za-z0-9_]*}|%[A-Za-z_][A-Za-z0-9_]*%|\$env:[A-Za-z_][A-Za-z0-9_]*\b/i;
68
+
69
+ const IMPLICIT_SENSITIVE_ENV_NAMES = [
70
+ "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
71
+ "ACTIONS_ID_TOKEN_REQUEST_URL",
72
+ "ACTIONS_RUNTIME_TOKEN",
73
+ "GITHUB_TOKEN",
74
+ ];
75
+
76
+ const OUTBOUND_NETWORK_TOOLS = [
77
+ ["curl", /\bcurl\b/i],
78
+ ["wget", /\bwget\b/i],
79
+ ["invoke-webrequest", /\b(?:invoke-webrequest|iwr)\b/i],
80
+ ["invoke-restmethod", /\b(?:invoke-restmethod|irm)\b/i],
81
+ ["nc", /\b(?:nc|ncat|netcat)\b/i],
82
+ ["scp", /\bscp\b/i],
83
+ ["rsync", /\brsync\b/i],
84
+ ["ftp", /\b(?:ftp|sftp)\b/i],
85
+ ];
86
+
87
+ const KNOWN_DISPATCH_ACTIONS = [
88
+ {
89
+ kind: "repository_dispatch",
90
+ mechanism: "repository-dispatch-action",
91
+ pattern: /^peter-evans\/repository-dispatch(?:@|$)/i,
92
+ repoKeys: ["repository"],
93
+ targetKeys: ["event-type"],
94
+ },
95
+ {
96
+ kind: "workflow_dispatch",
97
+ mechanism: "workflow-dispatch-action",
98
+ pattern:
99
+ /^(?:benc-uk\/workflow-dispatch|lasith-kg\/dispatch-workflow|convictional\/trigger-workflow-and-wait-for-workflow)(?:@|$)/i,
100
+ repoKeys: ["repo", "repository"],
101
+ targetKeys: ["workflow", "workflow_id", "event-type", "ref"],
102
+ },
103
+ ];
104
+
105
+ const FORK_CONTEXT_PATTERNS = [
106
+ [
107
+ "github.event.pull_request.head.repo.fork",
108
+ /github\.event\.pull_request\.head\.repo\.fork/i,
109
+ ],
110
+ [
111
+ "github.event.pull_request.head.repo.full_name",
112
+ /github\.event\.pull_request\.head\.repo\.full_name/i,
113
+ ],
114
+ [
115
+ "github.event.pull_request.head.repo.clone_url",
116
+ /github\.event\.pull_request\.head\.repo\.clone_url/i,
117
+ ],
118
+ [
119
+ "github.event.workflow_run.head_repository.fork",
120
+ /github\.event\.workflow_run\.head_repository\.fork/i,
121
+ ],
122
+ [
123
+ "github.event.workflow_run.head_repository.full_name",
124
+ /github\.event\.workflow_run\.head_repository\.full_name/i,
125
+ ],
126
+ [
127
+ "github.event.pull_request.head.ref",
128
+ /github\.event\.pull_request\.head\.ref/i,
129
+ ],
130
+ ["github.head_ref", /github\.head_ref/i],
131
+ ];
132
+
133
+ const UNTRUSTED_CHECKOUT_CONTEXT_PATTERNS = [
134
+ [
135
+ "github.event.pull_request.head.sha",
136
+ /github\.event\.pull_request\.head\.sha/i,
137
+ ],
138
+ [
139
+ "github.event.pull_request.head.ref",
140
+ /github\.event\.pull_request\.head\.ref/i,
141
+ ],
142
+ [
143
+ "github.event.pull_request.head.label",
144
+ /github\.event\.pull_request\.head\.label/i,
145
+ ],
146
+ ["github.head_ref", /github\.head_ref/i],
147
+ [
148
+ "github.event.workflow_run.head_sha",
149
+ /github\.event\.workflow_run\.head_sha/i,
150
+ ],
151
+ [
152
+ "github.event.workflow_run.head_branch",
153
+ /github\.event\.workflow_run\.head_branch/i,
154
+ ],
155
+ ];
156
+
42
157
  /**
43
158
  * Analyse a workflow-level or job-level permissions map for any write grants.
44
159
  *
@@ -67,6 +182,137 @@ function analyzePermissions(permissions) {
67
182
  return false;
68
183
  }
69
184
 
185
+ function extractWriteScopes(permissions) {
186
+ if (!permissions) {
187
+ return [];
188
+ }
189
+ if (typeof permissions === "string") {
190
+ return permissions === "write-all" ? ["all"] : [];
191
+ }
192
+ if (typeof permissions !== "object") {
193
+ return [];
194
+ }
195
+ const scopes = [];
196
+ for (const scope of WRITE_SCOPES) {
197
+ if (permissions[scope] === "write") {
198
+ scopes.push(scope);
199
+ }
200
+ }
201
+ return scopes;
202
+ }
203
+
204
+ function hasIdTokenWritePermission(permissions) {
205
+ if (!permissions) {
206
+ return false;
207
+ }
208
+ if (typeof permissions === "string") {
209
+ return permissions === "write-all";
210
+ }
211
+ if (typeof permissions !== "object") {
212
+ return false;
213
+ }
214
+ return permissions["id-token"] === "write";
215
+ }
216
+
217
+ function getPropertyValueFromProperties(properties, propName) {
218
+ return properties.find((property) => property.name === propName)?.value;
219
+ }
220
+
221
+ function appendSensitiveOperationProperties(properties) {
222
+ const sensitiveOperations = new Set();
223
+ if (
224
+ getPropertyValueFromProperties(
225
+ properties,
226
+ "cdx:github:step:referencesSensitiveContext",
227
+ ) === "true"
228
+ ) {
229
+ sensitiveOperations.add("references-sensitive-context");
230
+ }
231
+ if (
232
+ getPropertyValueFromProperties(
233
+ properties,
234
+ "cdx:github:step:dispatchesWorkflow",
235
+ ) === "true"
236
+ ) {
237
+ sensitiveOperations.add("dispatches-workflow");
238
+ }
239
+ if (
240
+ getPropertyValueFromProperties(
241
+ properties,
242
+ "cdx:github:step:mutatesRunnerState",
243
+ ) === "true"
244
+ ) {
245
+ sensitiveOperations.add("mutates-runner-state");
246
+ }
247
+ if (
248
+ getPropertyValueFromProperties(
249
+ properties,
250
+ "cdx:github:step:usesLegacyPublishToken",
251
+ ) === "true"
252
+ ) {
253
+ sensitiveOperations.add("legacy-publish-token");
254
+ }
255
+ if (
256
+ getPropertyValueFromProperties(
257
+ properties,
258
+ "cdx:github:step:hasOutboundNetworkCommand",
259
+ ) === "true" &&
260
+ getPropertyValueFromProperties(
261
+ properties,
262
+ "cdx:github:step:referencesSensitiveContext",
263
+ ) === "true"
264
+ ) {
265
+ sensitiveOperations.add("outbound-network-with-sensitive-context");
266
+ }
267
+ const actionUses = getPropertyValueFromProperties(
268
+ properties,
269
+ "cdx:github:action:uses",
270
+ );
271
+ const persistCredentials = getPropertyValueFromProperties(
272
+ properties,
273
+ "cdx:github:checkout:persistCredentials",
274
+ );
275
+ if (
276
+ actionUses?.includes("actions/checkout") &&
277
+ persistCredentials !== "false"
278
+ ) {
279
+ sensitiveOperations.add("checkout-persist-credentials");
280
+ }
281
+ if (!sensitiveOperations.size) {
282
+ return;
283
+ }
284
+ properties.push({
285
+ name: "cdx:github:step:hasSensitiveOperations",
286
+ value: "true",
287
+ });
288
+ properties.push({
289
+ name: "cdx:github:step:sensitiveOperations",
290
+ value: Array.from(sensitiveOperations).join(","),
291
+ });
292
+ }
293
+
294
+ function normalizeRunnerLabels(runsOn) {
295
+ if (!runsOn) {
296
+ return [];
297
+ }
298
+ if (Array.isArray(runsOn)) {
299
+ return runsOn.map((label) => String(label).trim()).filter(Boolean);
300
+ }
301
+ if (typeof runsOn === "string") {
302
+ return runsOn
303
+ .split(",")
304
+ .map((label) => label.trim())
305
+ .filter(Boolean);
306
+ }
307
+ return [];
308
+ }
309
+
310
+ function isSelfHostedRunner(runsOn) {
311
+ return normalizeRunnerLabels(runsOn).some((label) =>
312
+ label.toLowerCase().includes("self-hosted"),
313
+ );
314
+ }
315
+
70
316
  /**
71
317
  * Detect if a step uses `actions/checkout` and extract the
72
318
  * `persist-credentials` setting (defaults to `true` when absent).
@@ -78,14 +324,66 @@ function analyzeCheckoutStep(step) {
78
324
  const props = [];
79
325
  if (step.uses?.includes("actions/checkout")) {
80
326
  const persistCreds = step.with?.["persist-credentials"] ?? true;
327
+ const checkoutRef = step.with?.ref;
328
+ const checkoutRepository = step.with?.repository;
81
329
  props.push({
82
330
  name: "cdx:github:checkout:persistCredentials",
83
331
  value: String(persistCreds),
84
332
  });
333
+ if (checkoutRef) {
334
+ props.push({ name: "cdx:github:checkout:ref", value: checkoutRef });
335
+ }
336
+ if (checkoutRepository) {
337
+ props.push({
338
+ name: "cdx:github:checkout:repository",
339
+ value: checkoutRepository,
340
+ });
341
+ }
342
+ const untrustedCheckoutContexts = [
343
+ ...detectCheckoutUntrustedContexts(checkoutRef),
344
+ ...detectCheckoutUntrustedContexts(checkoutRepository),
345
+ ];
346
+ if (untrustedCheckoutContexts.length) {
347
+ props.push({
348
+ name: "cdx:github:checkout:checksOutUntrustedRef",
349
+ value: "true",
350
+ });
351
+ props.push({
352
+ name: "cdx:github:checkout:untrustedRefContexts",
353
+ value: [...new Set(untrustedCheckoutContexts)].join(","),
354
+ });
355
+ }
356
+ const forkContextRefs = [
357
+ ...detectForkContextReferences(checkoutRef),
358
+ ...detectForkContextReferences(checkoutRepository),
359
+ ];
360
+ if (forkContextRefs.length) {
361
+ props.push({
362
+ name: "cdx:github:checkout:referencesForkContext",
363
+ value: "true",
364
+ });
365
+ props.push({
366
+ name: "cdx:github:checkout:forkContextRefs",
367
+ value: [...new Set(forkContextRefs)].join(","),
368
+ });
369
+ }
85
370
  }
86
371
  return props;
87
372
  }
88
373
 
374
+ function detectCheckoutUntrustedContexts(textValue) {
375
+ if (!textValue || typeof textValue !== "string") {
376
+ return [];
377
+ }
378
+ const refs = [];
379
+ UNTRUSTED_CHECKOUT_CONTEXT_PATTERNS.forEach(([name, pattern]) => {
380
+ if (pattern.test(textValue)) {
381
+ refs.push(name);
382
+ }
383
+ });
384
+ return refs;
385
+ }
386
+
89
387
  /**
90
388
  * Detect `actions/cache` usage and extract key, path, and restore-keys
91
389
  * metadata from the step's `with` block.
@@ -96,8 +394,15 @@ function analyzeCheckoutStep(step) {
96
394
  function analyzeCacheStep(step) {
97
395
  const props = [];
98
396
  if (step.uses?.includes("actions/cache")) {
397
+ const cacheKey = step.with?.key;
99
398
  if (step.with?.key) {
100
- props.push({ name: "cdx:github:cache:key", value: step.with.key });
399
+ props.push({ name: "cdx:github:cache:key", value: cacheKey });
400
+ if (/hashFiles\s*\(/i.test(cacheKey)) {
401
+ props.push({
402
+ name: "cdx:github:cache:keyUsesHashFiles",
403
+ value: "true",
404
+ });
405
+ }
101
406
  }
102
407
  if (step.with?.path) {
103
408
  props.push({ name: "cdx:github:cache:path", value: step.with.path });
@@ -114,6 +419,7 @@ function analyzeCacheStep(step) {
114
419
  .join(",");
115
420
  }
116
421
  props.push({ name: "cdx:github:cache:restoreKeys", value: keys });
422
+ props.push({ name: "cdx:github:cache:hasRestoreKeys", value: "true" });
117
423
  }
118
424
  }
119
425
  return props;
@@ -140,10 +446,19 @@ function detectUntrustedInterpolation(runValue) {
140
446
 
141
447
  for (const match of matches) {
142
448
  const expr = match[1].trim();
449
+ if (LOW_RISK_INTERPOLATION_PATTERNS.some((pattern) => pattern.test(expr))) {
450
+ continue;
451
+ }
143
452
  if (
144
- expr.startsWith("github.event.pull_request") ||
145
- expr.startsWith("github.event.issue") ||
146
- expr.startsWith("github.event.comment") ||
453
+ expr.startsWith("github.event.pull_request.title") ||
454
+ expr.startsWith("github.event.pull_request.body") ||
455
+ expr.startsWith("github.event.pull_request.head.ref") ||
456
+ expr.startsWith("github.event.pull_request.head.label") ||
457
+ expr.startsWith("github.event.issue.title") ||
458
+ expr.startsWith("github.event.issue.body") ||
459
+ expr.startsWith("github.event.comment.body") ||
460
+ expr.startsWith("github.event.review.body") ||
461
+ expr.startsWith("github.event.review_comment.body") ||
147
462
  expr.startsWith("github.head_ref") ||
148
463
  expr.startsWith("inputs.")
149
464
  ) {
@@ -157,6 +472,504 @@ function detectUntrustedInterpolation(runValue) {
157
472
  };
158
473
  }
159
474
 
475
+ function isLegacyPublishTokenEnvName(envName) {
476
+ if (!envName || typeof envName !== "string") {
477
+ return false;
478
+ }
479
+ return (
480
+ envName.endsWith("_TOKEN") ||
481
+ envName.startsWith("POETRY_PYPI_TOKEN") ||
482
+ LEGACY_PUBLISH_TOKEN_ENV_NAMES.has(envName)
483
+ );
484
+ }
485
+
486
+ function detectPublishEcosystem(runValue) {
487
+ if (!runValue || typeof runValue !== "string") {
488
+ return undefined;
489
+ }
490
+ if (/\b(?:npm|pnpm|yarn|bun)\s+publish\b/i.test(runValue)) {
491
+ return "npm";
492
+ }
493
+ if (
494
+ /\btwine\s+upload\b/i.test(runValue) ||
495
+ /\bpoetry\s+publish\b/i.test(runValue) ||
496
+ /\bflit\s+publish\b/i.test(runValue)
497
+ ) {
498
+ return "pypi";
499
+ }
500
+ return undefined;
501
+ }
502
+
503
+ function analyzeLegacyPublishStep(step, effectiveEnv) {
504
+ const props = [];
505
+ const publishEcosystem = detectPublishEcosystem(step?.run);
506
+ if (!publishEcosystem) {
507
+ return props;
508
+ }
509
+ const tokenSources = [];
510
+ if (/\B--token(?:=|\s+\S+)/i.test(step.run)) {
511
+ tokenSources.push("cli-flag");
512
+ }
513
+ const legacyEnvNames = Object.keys(effectiveEnv || {}).filter(
514
+ isLegacyPublishTokenEnvName,
515
+ );
516
+ legacyEnvNames.forEach((envName) => {
517
+ tokenSources.push(`env:${envName}`);
518
+ });
519
+ props.push({
520
+ name: "cdx:github:step:isPublishCommand",
521
+ value: "true",
522
+ });
523
+ props.push({
524
+ name: "cdx:github:step:publishEcosystem",
525
+ value: publishEcosystem,
526
+ });
527
+ if (!tokenSources.length) {
528
+ return props;
529
+ }
530
+ props.push({
531
+ name: "cdx:github:step:usesLegacyPublishToken",
532
+ value: "true",
533
+ });
534
+ props.push({
535
+ name: "cdx:github:step:legacyPublishTokenSources",
536
+ value: tokenSources.join(","),
537
+ });
538
+ return props;
539
+ }
540
+
541
+ function detectRunnerStateMutation(runValue) {
542
+ if (!runValue || typeof runValue !== "string") {
543
+ return { hasMutation: false, targets: [] };
544
+ }
545
+ const targets = new Set();
546
+ const patterns = [
547
+ [
548
+ "GITHUB_ENV",
549
+ /(?:>>?|1>>?)\s*["']?(?:\$GITHUB_ENV|\$\{GITHUB_ENV}|%GITHUB_ENV%|\$env:GITHUB_ENV)["']?/i,
550
+ ],
551
+ [
552
+ "GITHUB_PATH",
553
+ /(?:>>?|1>>?)\s*["']?(?:\$GITHUB_PATH|\$\{GITHUB_PATH}|%GITHUB_PATH%|\$env:GITHUB_PATH)["']?/i,
554
+ ],
555
+ [
556
+ "GITHUB_OUTPUT",
557
+ /(?:>>?|1>>?)\s*["']?(?:\$GITHUB_OUTPUT|\$\{GITHUB_OUTPUT}|%GITHUB_OUTPUT%|\$env:GITHUB_OUTPUT)["']?/i,
558
+ ],
559
+ ];
560
+ patterns.forEach(([target, pattern]) => {
561
+ if (pattern.test(runValue)) {
562
+ targets.add(target);
563
+ }
564
+ });
565
+ if (/::set-output\b/i.test(runValue)) {
566
+ targets.add("GITHUB_OUTPUT");
567
+ }
568
+ return {
569
+ hasMutation: targets.size > 0,
570
+ targets: Array.from(targets),
571
+ };
572
+ }
573
+
574
+ function detectOutboundNetworkCommand(runValue) {
575
+ if (!runValue || typeof runValue !== "string") {
576
+ return { hasOutboundCommand: false, tools: [] };
577
+ }
578
+ const tools = [];
579
+ OUTBOUND_NETWORK_TOOLS.forEach(([name, pattern]) => {
580
+ if (pattern.test(runValue)) {
581
+ tools.push(name);
582
+ }
583
+ });
584
+ return {
585
+ hasOutboundCommand: tools.length > 0,
586
+ tools,
587
+ };
588
+ }
589
+
590
+ function collectSensitiveEnvBindings(effectiveEnv) {
591
+ const sensitiveRefs = [];
592
+ Object.entries(effectiveEnv || {}).forEach(([envName, envValue]) => {
593
+ if (isSensitiveEnvBinding(envName, envValue)) {
594
+ sensitiveRefs.push(`env:${envName}`);
595
+ }
596
+ });
597
+ return sensitiveRefs;
598
+ }
599
+
600
+ function isSensitiveEnvBinding(envName, envValue) {
601
+ if (!envName || typeof envName !== "string") {
602
+ return false;
603
+ }
604
+ if (IMPLICIT_SENSITIVE_ENV_NAMES.includes(envName)) {
605
+ return true;
606
+ }
607
+ if (SECRET_LIKE_ENV_NAME_PATTERN.test(envName)) {
608
+ return true;
609
+ }
610
+ if (typeof envValue !== "string") {
611
+ return false;
612
+ }
613
+ return SENSITIVE_ENV_VALUE_PATTERN.test(envValue);
614
+ }
615
+
616
+ function detectSensitiveContextReferences(runValue, effectiveEnv) {
617
+ if (!runValue || typeof runValue !== "string") {
618
+ return [];
619
+ }
620
+ const sensitiveRefs = new Set();
621
+ Object.entries(effectiveEnv || {}).forEach(([envName, envValue]) => {
622
+ if (!isSensitiveEnvBinding(envName, envValue)) {
623
+ return;
624
+ }
625
+ const envPattern = new RegExp(
626
+ `(?:\\$${envName}\\b|\\$\\{${envName}\\}|%${envName}%|\\$env:${envName}\\b|process\\.env\\.${envName}\\b|process\\.env\\[['"]${envName}['"]])`,
627
+ "i",
628
+ );
629
+ if (envPattern.test(runValue)) {
630
+ sensitiveRefs.add(`env:${envName}`);
631
+ }
632
+ });
633
+ const contextPatterns = [
634
+ ["context:github.token", /github\.token/i],
635
+ ["context:secrets", /secrets\.[A-Za-z0-9_]+/i],
636
+ [
637
+ "context:github-token-input",
638
+ /github-token|process\.env\.GITHUB_TOKEN|process\.env\[['"]GITHUB_TOKEN['"]]/i,
639
+ ],
640
+ [
641
+ "context:actions-id-token",
642
+ /ACTIONS_ID_TOKEN_REQUEST_(?:TOKEN|URL)|id-token/i,
643
+ ],
644
+ ];
645
+ contextPatterns.forEach(([name, pattern]) => {
646
+ if (pattern.test(runValue)) {
647
+ sensitiveRefs.add(name);
648
+ }
649
+ });
650
+ return Array.from(sensitiveRefs);
651
+ }
652
+
653
+ function detectOutboundExfiltrationIndicators(runValue, sensitiveContextRefs) {
654
+ if (
655
+ !runValue ||
656
+ typeof runValue !== "string" ||
657
+ !Array.isArray(sensitiveContextRefs) ||
658
+ !sensitiveContextRefs.length
659
+ ) {
660
+ return [];
661
+ }
662
+ const indicators = new Set();
663
+ if (
664
+ /(?:^|\s)(?:--header|-H)\s+[^\n]*(?:authorization|x-(?:api-key|auth-token|github-token)|private-token|token:)/i.test(
665
+ runValue,
666
+ ) &&
667
+ SHELL_VARIABLE_REFERENCE_PATTERN.test(runValue)
668
+ ) {
669
+ indicators.add("auth-header");
670
+ }
671
+ if (
672
+ /\b(?:--data(?:-raw|-binary|-urlencode)?|--body|--form|--upload-file|-InFile|-Body|-Form)\b|(?:^|\s)-[dFT]\b/i.test(
673
+ runValue,
674
+ )
675
+ ) {
676
+ indicators.add("request-payload");
677
+ }
678
+ if (
679
+ /(?:^|\s)(?:-X|--request)\s*(?:POST|PUT|PATCH)\b|\b-Method\s+(?:Post|Put|Patch)\b/i.test(
680
+ runValue,
681
+ )
682
+ ) {
683
+ indicators.add("state-changing-method");
684
+ }
685
+ if (
686
+ /\?[^\n"'\s]*(?:token|sig|signature|auth|secret|key)=/i.test(runValue) &&
687
+ SHELL_VARIABLE_REFERENCE_PATTERN.test(runValue)
688
+ ) {
689
+ indicators.add("query-parameter");
690
+ }
691
+ if (/\b(?:scp|rsync)\b/i.test(runValue)) {
692
+ indicators.add("file-transfer");
693
+ }
694
+ if (
695
+ /\b(?:nc|ncat|netcat)\b[^\n]*(?:<|<<)/i.test(runValue) ||
696
+ /\|\s*(?:nc|ncat|netcat)\b/i.test(runValue)
697
+ ) {
698
+ indicators.add("stream-transfer");
699
+ }
700
+ if (
701
+ /\b(?:base64|openssl\s+enc)\b[^\n|]*\|\s*(?:curl|wget|nc|ncat|netcat)\b/i.test(
702
+ runValue,
703
+ )
704
+ ) {
705
+ indicators.add("encoded-payload");
706
+ }
707
+ if (
708
+ sensitiveContextRefs.some(
709
+ (ref) =>
710
+ ref === "context:actions-id-token" ||
711
+ ref === "context:github.token" ||
712
+ ref.startsWith("context:secrets"),
713
+ )
714
+ ) {
715
+ indicators.add("platform-credential");
716
+ }
717
+ return Array.from(indicators);
718
+ }
719
+
720
+ function detectForkContextReferences(textValue) {
721
+ if (!textValue || typeof textValue !== "string") {
722
+ return [];
723
+ }
724
+ const refs = [];
725
+ FORK_CONTEXT_PATTERNS.forEach(([name, pattern]) => {
726
+ if (pattern.test(textValue)) {
727
+ refs.push(name);
728
+ }
729
+ });
730
+ return refs;
731
+ }
732
+
733
+ function addDispatchTarget(targets, prefix, value) {
734
+ if (!value || typeof value !== "string") {
735
+ return;
736
+ }
737
+ const normalizedValue = value.trim();
738
+ if (!normalizedValue) {
739
+ return;
740
+ }
741
+ targets.add(`${prefix}:${normalizedValue}`);
742
+ }
743
+
744
+ function normalizeDispatchTargetPrefix(key) {
745
+ if (!key) {
746
+ return "unknown";
747
+ }
748
+ if (["repository", "repo"].includes(key)) {
749
+ return "repo";
750
+ }
751
+ if (["workflow", "workflow_id"].includes(key)) {
752
+ return "workflow";
753
+ }
754
+ if (key === "event-type") {
755
+ return "event";
756
+ }
757
+ return key.replace(/_/g, "-");
758
+ }
759
+
760
+ function detectWorkflowDispatchInvocations(textValue) {
761
+ const kinds = new Set();
762
+ const mechanisms = new Set();
763
+ const targets = new Set();
764
+ if (!textValue || typeof textValue !== "string") {
765
+ return {
766
+ hasDispatch: false,
767
+ kinds: [],
768
+ mechanisms: [],
769
+ targets: [],
770
+ usesExplicitRepositoryTarget: false,
771
+ };
772
+ }
773
+ const ghWorkflowRunMatch = textValue.match(
774
+ /\bgh\s+workflow\s+run\s+([^\s"'`]+)/i,
775
+ );
776
+ if (ghWorkflowRunMatch) {
777
+ kinds.add("workflow_dispatch");
778
+ mechanisms.add("gh-workflow-run");
779
+ addDispatchTarget(targets, "workflow", ghWorkflowRunMatch[1]);
780
+ }
781
+ const ghRepoMatch = textValue.match(
782
+ /\b--repo\s+([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)/i,
783
+ );
784
+ if (ghRepoMatch) {
785
+ addDispatchTarget(targets, "repo", ghRepoMatch[1]);
786
+ }
787
+ for (const match of textValue.matchAll(
788
+ /\/repos\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/actions\/workflows\/([^/\s"'`]+)\/dispatches\b/gi,
789
+ )) {
790
+ kinds.add("workflow_dispatch");
791
+ mechanisms.add("github-api-workflow-dispatch");
792
+ addDispatchTarget(targets, "repo", match[1]);
793
+ addDispatchTarget(targets, "workflow", match[2]);
794
+ }
795
+ for (const match of textValue.matchAll(
796
+ /\/repos\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/dispatches\b/gi,
797
+ )) {
798
+ kinds.add("repository_dispatch");
799
+ mechanisms.add("github-api-repository-dispatch");
800
+ addDispatchTarget(targets, "repo", match[1]);
801
+ }
802
+ if (
803
+ /\b(?:github|octokit)\.rest\.actions\.createWorkflowDispatch\b/i.test(
804
+ textValue,
805
+ )
806
+ ) {
807
+ kinds.add("workflow_dispatch");
808
+ mechanisms.add("github-script-workflow-dispatch");
809
+ }
810
+ if (
811
+ /\b(?:github|octokit)\.rest\.repos\.createDispatchEvent\b/i.test(textValue)
812
+ ) {
813
+ kinds.add("repository_dispatch");
814
+ mechanisms.add("github-script-repository-dispatch");
815
+ }
816
+ if (
817
+ /\b(?:github|octokit)\.request\s*\(\s*["'`](?:POST\s+)?\/repos\/\{owner}\/\{repo}\/actions\/workflows\/\{workflow_id}\/dispatches/i.test(
818
+ textValue,
819
+ )
820
+ ) {
821
+ kinds.add("workflow_dispatch");
822
+ mechanisms.add("octokit-request-workflow-dispatch");
823
+ }
824
+ if (
825
+ /\b(?:github|octokit)\.request\s*\(\s*["'`](?:POST\s+)?\/repos\/\{owner}\/\{repo}\/dispatches/i.test(
826
+ textValue,
827
+ )
828
+ ) {
829
+ kinds.add("repository_dispatch");
830
+ mechanisms.add("octokit-request-repository-dispatch");
831
+ }
832
+ const ownerMatch = textValue.match(/\bowner\s*:\s*["'`]([^"'`]+)["'`]/i);
833
+ const repoMatch = textValue.match(/\brepo\s*:\s*["'`]([^"'`]+)["'`]/i);
834
+ const workflowMatch = textValue.match(
835
+ /\bworkflow(?:_id)?\s*:\s*["'`]([^"'`]+)["'`]/i,
836
+ );
837
+ const eventTypeMatch = textValue.match(
838
+ /\bevent_type\s*:\s*["'`]([^"'`]+)["'`]/i,
839
+ );
840
+ const refMatch = textValue.match(/\bref\s*:\s*["'`]([^"'`]+)["'`]/i);
841
+ if (ownerMatch && repoMatch) {
842
+ addDispatchTarget(targets, "repo", `${ownerMatch[1]}/${repoMatch[1]}`);
843
+ }
844
+ if (workflowMatch) {
845
+ addDispatchTarget(targets, "workflow", workflowMatch[1]);
846
+ }
847
+ if (eventTypeMatch) {
848
+ addDispatchTarget(targets, "event", eventTypeMatch[1]);
849
+ }
850
+ if (refMatch) {
851
+ addDispatchTarget(targets, "ref", refMatch[1]);
852
+ }
853
+ const targetList = Array.from(targets);
854
+ return {
855
+ hasDispatch: kinds.size > 0,
856
+ kinds: Array.from(kinds),
857
+ mechanisms: Array.from(mechanisms),
858
+ targets: targetList,
859
+ usesExplicitRepositoryTarget: targetList.some((target) =>
860
+ target.startsWith("repo:"),
861
+ ),
862
+ };
863
+ }
864
+
865
+ function analyzeDispatchActionStep(step) {
866
+ const props = [];
867
+ if (!step?.uses || typeof step.uses !== "string") {
868
+ return props;
869
+ }
870
+ const dispatchAction = KNOWN_DISPATCH_ACTIONS.find((candidate) =>
871
+ candidate.pattern.test(step.uses),
872
+ );
873
+ if (!dispatchAction) {
874
+ return props;
875
+ }
876
+ const targets = new Set();
877
+ dispatchAction.repoKeys.forEach((key) => {
878
+ addDispatchTarget(
879
+ targets,
880
+ normalizeDispatchTargetPrefix(key),
881
+ step.with?.[key],
882
+ );
883
+ });
884
+ dispatchAction.targetKeys.forEach((key) => {
885
+ addDispatchTarget(
886
+ targets,
887
+ normalizeDispatchTargetPrefix(key),
888
+ step.with?.[key],
889
+ );
890
+ });
891
+ props.push({ name: "cdx:github:step:dispatchesWorkflow", value: "true" });
892
+ props.push({
893
+ name: "cdx:github:step:dispatchKinds",
894
+ value: dispatchAction.kind,
895
+ });
896
+ props.push({
897
+ name: "cdx:github:step:dispatchMechanisms",
898
+ value: dispatchAction.mechanism,
899
+ });
900
+ if (targets.size) {
901
+ props.push({
902
+ name: "cdx:github:step:dispatchTargets",
903
+ value: Array.from(targets).join(","),
904
+ });
905
+ }
906
+ if (Array.from(targets).some((target) => target.startsWith("repo:"))) {
907
+ props.push({
908
+ name: "cdx:github:step:dispatchUsesExplicitRepositoryTarget",
909
+ value: "true",
910
+ });
911
+ }
912
+ return props;
913
+ }
914
+
915
+ function appendDispatchProperties(properties, dispatchInfo) {
916
+ if (!dispatchInfo?.hasDispatch) {
917
+ return;
918
+ }
919
+ properties.push({
920
+ name: "cdx:github:step:dispatchesWorkflow",
921
+ value: "true",
922
+ });
923
+ properties.push({
924
+ name: "cdx:github:step:dispatchKinds",
925
+ value: dispatchInfo.kinds.join(","),
926
+ });
927
+ properties.push({
928
+ name: "cdx:github:step:dispatchMechanisms",
929
+ value: dispatchInfo.mechanisms.join(","),
930
+ });
931
+ if (dispatchInfo.targets.length) {
932
+ properties.push({
933
+ name: "cdx:github:step:dispatchTargets",
934
+ value: dispatchInfo.targets.join(","),
935
+ });
936
+ }
937
+ if (dispatchInfo.usesExplicitRepositoryTarget) {
938
+ properties.push({
939
+ name: "cdx:github:step:dispatchUsesExplicitRepositoryTarget",
940
+ value: "true",
941
+ });
942
+ }
943
+ }
944
+
945
+ function appendHiddenUnicodeProperties(properties, scan, prefix) {
946
+ if (!scan?.hasHiddenUnicode) {
947
+ return;
948
+ }
949
+ properties.push({
950
+ name: `${prefix}:hasHiddenUnicode`,
951
+ value: "true",
952
+ });
953
+ properties.push({
954
+ name: `${prefix}:hiddenUnicodeCodePoints`,
955
+ value: scan.codePoints.join(","),
956
+ });
957
+ properties.push({
958
+ name: `${prefix}:hiddenUnicodeLineNumbers`,
959
+ value: scan.lineNumbers.join(","),
960
+ });
961
+ if (scan.inComments) {
962
+ properties.push({
963
+ name: `${prefix}:hiddenUnicodeInComments`,
964
+ value: "true",
965
+ });
966
+ properties.push({
967
+ name: `${prefix}:hiddenUnicodeCommentCodePoints`,
968
+ value: scan.commentCodePoints.join(","),
969
+ });
970
+ }
971
+ }
972
+
160
973
  /**
161
974
  * Classify a GitHub Actions version reference as `"sha"`, `"tag"`, or `"branch"`.
162
975
  *
@@ -167,7 +980,7 @@ function getVersionPinningType(versionRef) {
167
980
  if (!versionRef) {
168
981
  return "unknown";
169
982
  }
170
- if (/^[a-f0-9]{40}$/.test(versionRef) || /^[a-f0-9]{7,}$/.test(versionRef)) {
983
+ if (/^[a-f0-9]{40}$/.test(versionRef)) {
171
984
  return "sha";
172
985
  }
173
986
  if (
@@ -199,6 +1012,58 @@ function normalizeTriggers(triggers) {
199
1012
  return Object.keys(triggers).join(",");
200
1013
  }
201
1014
 
1015
+ function extractWorkflowDispatchInputs(triggers) {
1016
+ if (!triggers || typeof triggers !== "object") {
1017
+ return [];
1018
+ }
1019
+ if (!triggers.workflow_dispatch?.inputs) {
1020
+ return [];
1021
+ }
1022
+ return Object.keys(triggers.workflow_dispatch.inputs);
1023
+ }
1024
+
1025
+ function extractRepositoryDispatchTypes(triggers) {
1026
+ if (!triggers || typeof triggers !== "object") {
1027
+ return [];
1028
+ }
1029
+ const repositoryDispatch = triggers.repository_dispatch;
1030
+ if (!repositoryDispatch || typeof repositoryDispatch !== "object") {
1031
+ return [];
1032
+ }
1033
+ if (!Array.isArray(repositoryDispatch.types)) {
1034
+ return [];
1035
+ }
1036
+ return repositoryDispatch.types
1037
+ .map((eventType) => String(eventType || "").trim())
1038
+ .filter(Boolean);
1039
+ }
1040
+
1041
+ function normalizeTriggerNames(triggers) {
1042
+ const csv = normalizeTriggers(triggers);
1043
+ if (!csv) {
1044
+ return [];
1045
+ }
1046
+ return csv
1047
+ .split(",")
1048
+ .map((trigger) => trigger.trim())
1049
+ .filter(Boolean);
1050
+ }
1051
+
1052
+ function extractWorkflowCallMetadata(triggers) {
1053
+ if (!triggers || typeof triggers !== "object") {
1054
+ return { inputs: [], outputs: [], secrets: [] };
1055
+ }
1056
+ const workflowCall = triggers.workflow_call;
1057
+ if (!workflowCall || typeof workflowCall !== "object") {
1058
+ return { inputs: [], outputs: [], secrets: [] };
1059
+ }
1060
+ return {
1061
+ inputs: Object.keys(workflowCall.inputs || {}),
1062
+ outputs: Object.keys(workflowCall.outputs || {}),
1063
+ secrets: Object.keys(workflowCall.secrets || {}),
1064
+ };
1065
+ }
1066
+
202
1067
  /**
203
1068
  * Determine whether the given trigger value includes at least one high-risk
204
1069
  * trigger (`pull_request_target`, `issue_comment`, or `workflow_run`).
@@ -227,13 +1092,29 @@ function hasHighRiskTrigger(triggers) {
227
1092
  * @returns {Array<{name: string, value: string}>}
228
1093
  */
229
1094
  function buildWorkflowContextProperties({
1095
+ hasExplicitPermissionsBlock,
1096
+ hasAnyExplicitPermissionsBlock,
230
1097
  hasWritePermissions,
231
1098
  hasIdTokenWrite,
232
1099
  triggers,
1100
+ triggerNames,
233
1101
  isHighRisk,
234
1102
  concurrencyGroup,
1103
+ writeScopes,
1104
+ dispatchInputs,
1105
+ repositoryDispatchTypes,
1106
+ workflowReceiverAliases,
1107
+ workflowCallMetadata,
235
1108
  }) {
236
1109
  const props = [];
1110
+ props.push({
1111
+ name: "cdx:github:workflow:hasExplicitPermissionsBlock",
1112
+ value: String(Boolean(hasExplicitPermissionsBlock)),
1113
+ });
1114
+ props.push({
1115
+ name: "cdx:github:workflow:hasAnyExplicitPermissionsBlock",
1116
+ value: String(Boolean(hasAnyExplicitPermissionsBlock)),
1117
+ });
237
1118
  if (hasWritePermissions) {
238
1119
  props.push({
239
1120
  name: "cdx:github:workflow:hasWritePermissions",
@@ -246,9 +1127,30 @@ function buildWorkflowContextProperties({
246
1127
  value: "true",
247
1128
  });
248
1129
  }
1130
+ if (writeScopes?.length) {
1131
+ props.push({
1132
+ name: "cdx:github:workflow:writeScopes",
1133
+ value: [...new Set(writeScopes)].join(","),
1134
+ });
1135
+ }
249
1136
  if (triggers) {
250
1137
  props.push({ name: "cdx:github:workflow:triggers", value: triggers });
251
1138
  }
1139
+ const triggerSet = new Set(triggerNames || normalizeTriggerNames(triggers));
1140
+ const triggerFlags = [
1141
+ ["pull_request", "cdx:github:workflow:hasPullRequestTrigger"],
1142
+ ["pull_request_target", "cdx:github:workflow:hasPullRequestTargetTrigger"],
1143
+ ["issue_comment", "cdx:github:workflow:hasIssueCommentTrigger"],
1144
+ ["repository_dispatch", "cdx:github:workflow:hasRepositoryDispatchTrigger"],
1145
+ ["workflow_run", "cdx:github:workflow:hasWorkflowRunTrigger"],
1146
+ ["workflow_dispatch", "cdx:github:workflow:hasWorkflowDispatchTrigger"],
1147
+ ["workflow_call", "cdx:github:workflow:hasWorkflowCallTrigger"],
1148
+ ];
1149
+ triggerFlags.forEach(([triggerName, propName]) => {
1150
+ if (triggerSet.has(triggerName)) {
1151
+ props.push({ name: propName, value: "true" });
1152
+ }
1153
+ });
252
1154
  if (isHighRisk) {
253
1155
  props.push({
254
1156
  name: "cdx:github:workflow:hasHighRiskTrigger",
@@ -261,21 +1163,477 @@ function buildWorkflowContextProperties({
261
1163
  value: concurrencyGroup,
262
1164
  });
263
1165
  }
1166
+ if (dispatchInputs?.length) {
1167
+ props.push({
1168
+ name: "cdx:github:workflow:hasWorkflowDispatchInputs",
1169
+ value: "true",
1170
+ });
1171
+ props.push({
1172
+ name: "cdx:github:workflow:workflowDispatchInputs",
1173
+ value: dispatchInputs.join(","),
1174
+ });
1175
+ }
1176
+ if (repositoryDispatchTypes?.length) {
1177
+ props.push({
1178
+ name: "cdx:github:workflow:repositoryDispatchTypes",
1179
+ value: repositoryDispatchTypes.join(","),
1180
+ });
1181
+ }
1182
+ if (workflowReceiverAliases?.length) {
1183
+ props.push({
1184
+ name: "cdx:github:workflow:workflowDispatchReceiverAliases",
1185
+ value: workflowReceiverAliases.join(","),
1186
+ });
1187
+ }
1188
+ if (workflowCallMetadata?.inputs?.length) {
1189
+ props.push({
1190
+ name: "cdx:github:workflow:workflowCallInputs",
1191
+ value: workflowCallMetadata.inputs.join(","),
1192
+ });
1193
+ }
1194
+ if (workflowCallMetadata?.secrets?.length) {
1195
+ props.push({
1196
+ name: "cdx:github:workflow:workflowCallSecrets",
1197
+ value: workflowCallMetadata.secrets.join(","),
1198
+ });
1199
+ }
1200
+ if (workflowCallMetadata?.outputs?.length) {
1201
+ props.push({
1202
+ name: "cdx:github:workflow:workflowCallOutputs",
1203
+ value: workflowCallMetadata.outputs.join(","),
1204
+ });
1205
+ }
1206
+ if (
1207
+ workflowCallMetadata?.inputs?.length ||
1208
+ workflowCallMetadata?.secrets?.length ||
1209
+ workflowCallMetadata?.outputs?.length
1210
+ ) {
1211
+ props.push({
1212
+ name: "cdx:github:workflow:isWorkflowCallProducer",
1213
+ value: "true",
1214
+ });
1215
+ }
1216
+ return props;
1217
+ }
1218
+
1219
+ function buildJobContextProperties({
1220
+ hasExplicitPermissionsBlock,
1221
+ hasWritePermissions,
1222
+ hasIdTokenWrite,
1223
+ isSelfHosted,
1224
+ writeScopes,
1225
+ condition,
1226
+ }) {
1227
+ const props = [];
1228
+ props.push({
1229
+ name: "cdx:github:job:hasExplicitPermissionsBlock",
1230
+ value: String(Boolean(hasExplicitPermissionsBlock)),
1231
+ });
1232
+ if (hasWritePermissions) {
1233
+ props.push({
1234
+ name: "cdx:github:job:hasWritePermissions",
1235
+ value: "true",
1236
+ });
1237
+ }
1238
+ if (hasIdTokenWrite) {
1239
+ props.push({
1240
+ name: "cdx:github:job:hasIdTokenWrite",
1241
+ value: "true",
1242
+ });
1243
+ }
1244
+ if (isSelfHosted) {
1245
+ props.push({
1246
+ name: "cdx:github:job:isSelfHosted",
1247
+ value: "true",
1248
+ });
1249
+ }
1250
+ if (writeScopes?.length) {
1251
+ props.push({
1252
+ name: "cdx:github:job:writeScopes",
1253
+ value: [...new Set(writeScopes)].join(","),
1254
+ });
1255
+ }
1256
+ if (condition) {
1257
+ props.push({ name: "cdx:github:job:if", value: condition });
1258
+ }
264
1259
  return props;
265
1260
  }
266
1261
 
267
1262
  /**
268
- * Parse a single GitHub Actions workflow file and return formulation-shaped data.
269
- *
270
- * Reads and parses the YAML, then walks every job and step to produce:
271
- * - **workflows** – CycloneDX formulation workflow objects with tasks
272
- * - **components** action references (`pkg:github/…`) and run-step processes
273
- * - **dependencies** – workflow→job and job→action/step edges
1263
+ * @param {string} filePath workflow file path
1264
+ * @returns {string} workflow name derived from the file stem
1265
+ */
1266
+ function deriveWorkflowNameFromPath(filePath) {
1267
+ const pathImpl = filePath.includes("\\") ? path.win32 : path.posix;
1268
+ return pathImpl.parse(pathImpl.basename(filePath)).name;
1269
+ }
1270
+
1271
+ function deriveWorkflowReceiverAliases(filePath, workflowName) {
1272
+ const aliases = new Set();
1273
+ if (workflowName) {
1274
+ aliases.add(String(workflowName).trim());
1275
+ }
1276
+ if (filePath) {
1277
+ const normalizedPath = String(filePath).replace(/\\/g, "/");
1278
+ const fileName = normalizedPath.split("/").pop() || normalizedPath;
1279
+ const fileStem = fileName.replace(/\.ya?ml$/i, "");
1280
+ aliases.add(fileName);
1281
+ aliases.add(fileStem);
1282
+ aliases.add(normalizedPath);
1283
+ }
1284
+ return Array.from(aliases)
1285
+ .map((alias) => alias.trim())
1286
+ .filter(Boolean);
1287
+ }
1288
+
1289
+ function getPropertyValue(obj, propName) {
1290
+ return obj?.properties?.find((property) => property.name === propName)?.value;
1291
+ }
1292
+
1293
+ function upsertCsvProperty(properties, name, values) {
1294
+ const normalizedValues = [...new Set((values || []).filter(Boolean))];
1295
+ if (!normalizedValues.length) {
1296
+ return;
1297
+ }
1298
+ const existingProperty = properties.find(
1299
+ (property) => property.name === name,
1300
+ );
1301
+ if (!existingProperty) {
1302
+ properties.push({ name, value: normalizedValues.join(",") });
1303
+ return;
1304
+ }
1305
+ existingProperty.value = [
1306
+ ...new Set([
1307
+ ...String(existingProperty.value || "")
1308
+ .split(",")
1309
+ .map((value) => value.trim())
1310
+ .filter(Boolean),
1311
+ ...normalizedValues,
1312
+ ]),
1313
+ ].join(",");
1314
+ }
1315
+
1316
+ function upsertBooleanProperty(properties, name) {
1317
+ const existingProperty = properties.find(
1318
+ (property) => property.name === name,
1319
+ );
1320
+ if (existingProperty) {
1321
+ existingProperty.value = "true";
1322
+ return;
1323
+ }
1324
+ properties.push({ name, value: "true" });
1325
+ }
1326
+
1327
+ function parseDispatchTargets(value) {
1328
+ return String(value || "")
1329
+ .split(",")
1330
+ .map((target) => target.trim())
1331
+ .filter(Boolean)
1332
+ .map((target) => {
1333
+ const separatorIndex = target.indexOf(":");
1334
+ if (separatorIndex === -1) {
1335
+ return { type: "unknown", value: target };
1336
+ }
1337
+ return {
1338
+ type: target.slice(0, separatorIndex),
1339
+ value: target.slice(separatorIndex + 1),
1340
+ };
1341
+ });
1342
+ }
1343
+
1344
+ function normalizeDispatchTargetValue(value) {
1345
+ return String(value || "")
1346
+ .trim()
1347
+ .toLowerCase();
1348
+ }
1349
+
1350
+ function buildLocalDispatchReceiverIndexes(workflows) {
1351
+ const workflowDispatchAliasIndex = new Map();
1352
+ const repositoryDispatchTypeIndex = new Map();
1353
+ (workflows || []).forEach((workflow) => {
1354
+ if (
1355
+ getPropertyValue(
1356
+ workflow,
1357
+ "cdx:github:workflow:hasWorkflowDispatchTrigger",
1358
+ ) === "true"
1359
+ ) {
1360
+ const aliases = String(
1361
+ getPropertyValue(
1362
+ workflow,
1363
+ "cdx:github:workflow:workflowDispatchReceiverAliases",
1364
+ ) || "",
1365
+ )
1366
+ .split(",")
1367
+ .map((alias) => alias.trim())
1368
+ .filter(Boolean);
1369
+ aliases.forEach((alias) => {
1370
+ const normalizedAlias = normalizeDispatchTargetValue(alias);
1371
+ if (!workflowDispatchAliasIndex.has(normalizedAlias)) {
1372
+ workflowDispatchAliasIndex.set(normalizedAlias, []);
1373
+ }
1374
+ workflowDispatchAliasIndex.get(normalizedAlias).push(workflow);
1375
+ });
1376
+ }
1377
+ if (
1378
+ getPropertyValue(
1379
+ workflow,
1380
+ "cdx:github:workflow:hasRepositoryDispatchTrigger",
1381
+ ) === "true"
1382
+ ) {
1383
+ const eventTypes = String(
1384
+ getPropertyValue(
1385
+ workflow,
1386
+ "cdx:github:workflow:repositoryDispatchTypes",
1387
+ ) || "",
1388
+ )
1389
+ .split(",")
1390
+ .map((eventType) => eventType.trim())
1391
+ .filter(Boolean);
1392
+ eventTypes.forEach((eventType) => {
1393
+ const normalizedEventType = normalizeDispatchTargetValue(eventType);
1394
+ if (!repositoryDispatchTypeIndex.has(normalizedEventType)) {
1395
+ repositoryDispatchTypeIndex.set(normalizedEventType, []);
1396
+ }
1397
+ repositoryDispatchTypeIndex.get(normalizedEventType).push(workflow);
1398
+ });
1399
+ }
1400
+ });
1401
+ return {
1402
+ repositoryDispatchTypeIndex,
1403
+ workflowDispatchAliasIndex,
1404
+ };
1405
+ }
1406
+
1407
+ function enrichLocalDispatchRelationships(workflows, components) {
1408
+ const { repositoryDispatchTypeIndex, workflowDispatchAliasIndex } =
1409
+ buildLocalDispatchReceiverIndexes(workflows);
1410
+ (components || []).forEach((component) => {
1411
+ if (
1412
+ getPropertyValue(component, "cdx:github:step:dispatchesWorkflow") !==
1413
+ "true"
1414
+ ) {
1415
+ return;
1416
+ }
1417
+ const dispatchTargets = parseDispatchTargets(
1418
+ getPropertyValue(component, "cdx:github:step:dispatchTargets"),
1419
+ );
1420
+ if (dispatchTargets.some((target) => target.type === "repo")) {
1421
+ return;
1422
+ }
1423
+ const matchedWorkflows = [];
1424
+ const matchBases = [];
1425
+ dispatchTargets.forEach((target) => {
1426
+ if (target.type === "workflow") {
1427
+ const candidates =
1428
+ workflowDispatchAliasIndex.get(
1429
+ normalizeDispatchTargetValue(target.value),
1430
+ ) || [];
1431
+ if (candidates.length === 1) {
1432
+ matchedWorkflows.push(candidates[0]);
1433
+ matchBases.push(`workflow:${target.value}`);
1434
+ }
1435
+ }
1436
+ if (target.type === "event") {
1437
+ const candidates =
1438
+ repositoryDispatchTypeIndex.get(
1439
+ normalizeDispatchTargetValue(target.value),
1440
+ ) || [];
1441
+ if (candidates.length === 1) {
1442
+ matchedWorkflows.push(candidates[0]);
1443
+ matchBases.push(`repository_dispatch:${target.value}`);
1444
+ }
1445
+ }
1446
+ });
1447
+ const uniqueMatchedWorkflows = [...new Set(matchedWorkflows)];
1448
+ if (!uniqueMatchedWorkflows.length) {
1449
+ return;
1450
+ }
1451
+ const receiverWorkflowFiles = uniqueMatchedWorkflows
1452
+ .map((workflow) => getPropertyValue(workflow, "cdx:github:workflow:file"))
1453
+ .filter(Boolean);
1454
+ const receiverWorkflowNames = uniqueMatchedWorkflows
1455
+ .map((workflow) => getPropertyValue(workflow, "cdx:github:workflow:name"))
1456
+ .filter(Boolean);
1457
+ upsertBooleanProperty(
1458
+ component.properties,
1459
+ "cdx:github:step:hasLocalDispatchReceiver",
1460
+ );
1461
+ upsertCsvProperty(
1462
+ component.properties,
1463
+ "cdx:github:step:dispatchReceiverWorkflowFiles",
1464
+ receiverWorkflowFiles,
1465
+ );
1466
+ upsertCsvProperty(
1467
+ component.properties,
1468
+ "cdx:github:step:dispatchReceiverWorkflowNames",
1469
+ receiverWorkflowNames,
1470
+ );
1471
+ upsertCsvProperty(
1472
+ component.properties,
1473
+ "cdx:github:step:dispatchReceiverMatchBasis",
1474
+ matchBases,
1475
+ );
1476
+ upsertCsvProperty(
1477
+ component.properties,
1478
+ "cdx:github:step:dispatchReceiverConfidence",
1479
+ ["high"],
1480
+ );
1481
+ uniqueMatchedWorkflows.forEach((workflow) => {
1482
+ const senderWorkflowFile = getPropertyValue(
1483
+ component,
1484
+ "cdx:github:workflow:file",
1485
+ );
1486
+ const senderWorkflowName = getPropertyValue(
1487
+ component,
1488
+ "cdx:github:workflow:name",
1489
+ );
1490
+ upsertBooleanProperty(
1491
+ workflow.properties,
1492
+ "cdx:github:workflow:hasLocalDispatchSender",
1493
+ );
1494
+ upsertCsvProperty(
1495
+ workflow.properties,
1496
+ "cdx:github:workflow:dispatchSenderWorkflowFiles",
1497
+ [senderWorkflowFile],
1498
+ );
1499
+ upsertCsvProperty(
1500
+ workflow.properties,
1501
+ "cdx:github:workflow:dispatchSenderWorkflowNames",
1502
+ [senderWorkflowName],
1503
+ );
1504
+ upsertCsvProperty(
1505
+ workflow.properties,
1506
+ "cdx:github:workflow:dispatchSenderMatchBasis",
1507
+ matchBases,
1508
+ );
1509
+ });
1510
+ });
1511
+ }
1512
+
1513
+ function buildReusableWorkflowComponent(
1514
+ job,
1515
+ jobName,
1516
+ filePath,
1517
+ workflowName,
1518
+ jobRunner,
1519
+ jobContextProperties,
1520
+ workflowContextProperties,
1521
+ options,
1522
+ ) {
1523
+ const uses = job?.uses;
1524
+ if (!uses || typeof uses !== "string") {
1525
+ return undefined;
1526
+ }
1527
+ let group;
1528
+ let name = uses;
1529
+ let purl;
1530
+ let versionRef;
1531
+ let versionPinningType = "unknown";
1532
+ let isShaPinned = false;
1533
+ const isExternal = !uses.startsWith("./");
1534
+
1535
+ if (isExternal) {
1536
+ const tmpA = uses.split("@");
1537
+ const workflowRef = tmpA[0];
1538
+ versionRef = tmpA[1];
1539
+ versionPinningType = getVersionPinningType(versionRef);
1540
+ isShaPinned = versionPinningType === "sha";
1541
+ if (workflowRef.includes("/.github/workflows/")) {
1542
+ const [repoPath, workflowPath] = workflowRef.split("/.github/workflows/");
1543
+ group = repoPath;
1544
+ name = workflowPath;
1545
+ } else {
1546
+ const refParts = workflowRef.split("/");
1547
+ name = refParts.pop() || workflowRef;
1548
+ group = refParts.join("/");
1549
+ }
1550
+ if (versionRef) {
1551
+ purl = new PackageURL(
1552
+ "github",
1553
+ group || undefined,
1554
+ name,
1555
+ versionRef,
1556
+ null,
1557
+ null,
1558
+ ).toString();
1559
+ }
1560
+ } else {
1561
+ const pathImpl = uses.includes("\\") ? path.win32 : path.posix;
1562
+ name = pathImpl.basename(uses);
1563
+ }
1564
+
1565
+ const componentRef = purl || `github-workflow:${uses}`;
1566
+ const properties = [
1567
+ { name: "SrcFile", value: filePath },
1568
+ { name: "cdx:github:workflow:name", value: workflowName },
1569
+ { name: "cdx:github:workflow:file", value: filePath },
1570
+ { name: "cdx:github:job:name", value: jobName },
1571
+ {
1572
+ name: "cdx:github:job:runner",
1573
+ value: Array.isArray(jobRunner) ? jobRunner.join(",") : jobRunner,
1574
+ },
1575
+ { name: "cdx:github:reusableWorkflow:uses", value: uses },
1576
+ {
1577
+ name: "cdx:github:reusableWorkflow:isExternal",
1578
+ value: String(isExternal),
1579
+ },
1580
+ {
1581
+ name: "cdx:github:reusableWorkflow:versionPinningType",
1582
+ value: versionPinningType,
1583
+ },
1584
+ {
1585
+ name: "cdx:github:reusableWorkflow:isShaPinned",
1586
+ value: String(isShaPinned),
1587
+ },
1588
+ ];
1589
+ if (versionRef) {
1590
+ properties.push({
1591
+ name: "cdx:github:reusableWorkflow:ref",
1592
+ value: versionRef,
1593
+ });
1594
+ }
1595
+ if (job.secrets === "inherit") {
1596
+ properties.push({
1597
+ name: "cdx:github:reusableWorkflow:secretsInherit",
1598
+ value: "true",
1599
+ });
1600
+ }
1601
+ if (job.with && typeof job.with === "object") {
1602
+ const withKeys = Object.keys(job.with);
1603
+ if (withKeys.length) {
1604
+ properties.push({
1605
+ name: "cdx:github:reusableWorkflow:withKeys",
1606
+ value: withKeys.join(","),
1607
+ });
1608
+ }
1609
+ }
1610
+ properties.push(...jobContextProperties);
1611
+ properties.push(...workflowContextProperties);
1612
+ const component = {
1613
+ "bom-ref": componentRef,
1614
+ type: "application",
1615
+ group,
1616
+ name,
1617
+ version: versionRef,
1618
+ purl,
1619
+ properties,
1620
+ scope: isExternal ? "required" : "excluded",
1621
+ tags: ["reusable-workflow"],
1622
+ };
1623
+ if (options?.specVersion >= 1.7 && isExternal) {
1624
+ component.isExternal = true;
1625
+ }
1626
+ return component;
1627
+ }
1628
+
1629
+ /**
1630
+ * Parse a single GitHub Actions workflow file into workflow, component, and dependency data.
274
1631
  *
275
- * @param {string} f - Absolute path to a workflow YAML file.
276
- * @param {Object} options - CLI options
1632
+ * @param {string} f Absolute path to a workflow YAML file
1633
+ * @param {Object} options CLI options
277
1634
  * @returns {{ workflows: Object[], components: Object[], dependencies: Object[] }}
278
1635
  */
1636
+
279
1637
  export function parseWorkflowFile(f, options) {
280
1638
  const workflows = [];
281
1639
  const components = [];
@@ -289,6 +1647,7 @@ export function parseWorkflowFile(f, options) {
289
1647
  }
290
1648
 
291
1649
  let yamlObj;
1650
+ const hiddenUnicodeScan = scanTextForHiddenUnicode(raw, { syntax: "yaml" });
292
1651
  try {
293
1652
  yamlObj = _load(raw);
294
1653
  } catch (_e) {
@@ -298,23 +1657,40 @@ export function parseWorkflowFile(f, options) {
298
1657
  if (!yamlObj?.jobs) {
299
1658
  return { workflows, components, dependencies };
300
1659
  }
301
- const workflowName =
302
- yamlObj.name ||
303
- f
304
- .split("/")
305
- .pop()
306
- .replace(/\.[^.]+$/, "");
1660
+ const workflowName = yamlObj.name || deriveWorkflowNameFromPath(f);
307
1661
  const workflowTriggers = yamlObj.on || yamlObj.true;
308
- const workflowPermissions = yamlObj.permissions || {};
1662
+ const workflowHasExplicitPermissionsBlock = Object.hasOwn(
1663
+ yamlObj,
1664
+ "permissions",
1665
+ );
1666
+ const workflowPermissions = workflowHasExplicitPermissionsBlock
1667
+ ? yamlObj.permissions || {}
1668
+ : {};
1669
+ const workflowEnv = yamlObj.env || {};
309
1670
  const workflowHasWritePermissions = analyzePermissions(workflowPermissions);
1671
+ const workflowWriteScopes = new Set(extractWriteScopes(workflowPermissions));
310
1672
  const workflowConcurrency = yamlObj.concurrency || {};
311
- const hasIdTokenWrite = workflowPermissions?.["id-token"] === "write";
1673
+ const workflowHasIdTokenWrite =
1674
+ hasIdTokenWritePermission(workflowPermissions);
312
1675
  const triggers = normalizeTriggers(workflowTriggers);
1676
+ const triggerNames = normalizeTriggerNames(workflowTriggers);
313
1677
  const isHighRisk = hasHighRiskTrigger(workflowTriggers);
1678
+ const workflowDispatchInputs =
1679
+ extractWorkflowDispatchInputs(workflowTriggers);
1680
+ const repositoryDispatchTypes =
1681
+ extractRepositoryDispatchTypes(workflowTriggers);
1682
+ const workflowCallMetadata = extractWorkflowCallMetadata(workflowTriggers);
1683
+ const workflowReceiverAliases = deriveWorkflowReceiverAliases(
1684
+ f,
1685
+ workflowName,
1686
+ );
314
1687
 
315
1688
  const workflowRef = uuidv4();
316
1689
  const tasks = [];
317
1690
  const workflowDependsOn = [];
1691
+ let anyJobHasExplicitPermissionsBlock = false;
1692
+ let anyJobHasWritePermissions = false;
1693
+ let anyJobHasIdTokenWrite = false;
318
1694
 
319
1695
  for (const jobName of Object.keys(yamlObj.jobs)) {
320
1696
  const job = yamlObj.jobs[jobName];
@@ -330,19 +1706,54 @@ export function parseWorkflowFile(f, options) {
330
1706
 
331
1707
  const jobRunner = job["runs-on"] || "unknown";
332
1708
  const jobEnvironment = job.environment?.name || job.environment || "";
1709
+ const jobEnv = job.env || {};
333
1710
  const jobTimeout = job["timeout-minutes"] || null;
334
- const jobPermissions = job.permissions || {};
1711
+ const jobHasExplicitPermissionsBlock = Object.hasOwn(job, "permissions");
1712
+ const jobPermissions = jobHasExplicitPermissionsBlock
1713
+ ? job.permissions || {}
1714
+ : {};
335
1715
  const jobHasWritePermissions = analyzePermissions(jobPermissions);
1716
+ const jobWriteScopes = extractWriteScopes(jobPermissions);
1717
+ const jobHasIdTokenWrite = hasIdTokenWritePermission(jobPermissions);
336
1718
  const jobServices = job.services ? Object.keys(job.services) : [];
1719
+ const jobIsSelfHosted = isSelfHostedRunner(jobRunner);
337
1720
  const effectiveWritePerms =
338
1721
  workflowHasWritePermissions || jobHasWritePermissions;
1722
+ const effectiveIdTokenWrite = workflowHasIdTokenWrite || jobHasIdTokenWrite;
1723
+ const effectiveWriteScopes = [
1724
+ ...workflowWriteScopes,
1725
+ ...jobWriteScopes,
1726
+ ].filter(Boolean);
1727
+ anyJobHasExplicitPermissionsBlock ||= jobHasExplicitPermissionsBlock;
1728
+ anyJobHasWritePermissions ||= jobHasWritePermissions;
1729
+ anyJobHasIdTokenWrite ||= jobHasIdTokenWrite;
1730
+ jobWriteScopes.forEach((scope) => {
1731
+ workflowWriteScopes.add(scope);
1732
+ });
339
1733
 
340
1734
  // Shared workflow-context properties for this job's components
341
1735
  const sharedCtxProps = buildWorkflowContextProperties({
1736
+ hasExplicitPermissionsBlock: workflowHasExplicitPermissionsBlock,
1737
+ hasAnyExplicitPermissionsBlock:
1738
+ workflowHasExplicitPermissionsBlock || jobHasExplicitPermissionsBlock,
342
1739
  hasWritePermissions: effectiveWritePerms,
343
- hasIdTokenWrite,
1740
+ hasIdTokenWrite: effectiveIdTokenWrite,
344
1741
  triggers,
1742
+ triggerNames,
345
1743
  isHighRisk,
1744
+ writeScopes: effectiveWriteScopes,
1745
+ dispatchInputs: workflowDispatchInputs,
1746
+ repositoryDispatchTypes,
1747
+ workflowReceiverAliases,
1748
+ workflowCallMetadata,
1749
+ });
1750
+ const sharedJobCtxProps = buildJobContextProperties({
1751
+ hasExplicitPermissionsBlock: jobHasExplicitPermissionsBlock,
1752
+ hasWritePermissions: jobHasWritePermissions,
1753
+ hasIdTokenWrite: jobHasIdTokenWrite,
1754
+ isSelfHosted: jobIsSelfHosted,
1755
+ writeScopes: jobWriteScopes,
1756
+ condition: job.if,
346
1757
  });
347
1758
 
348
1759
  const jobProperties = [
@@ -364,12 +1775,6 @@ export function parseWorkflowFile(f, options) {
364
1775
  value: jobTimeout.toString(),
365
1776
  });
366
1777
  }
367
- if (jobHasWritePermissions) {
368
- jobProperties.push({
369
- name: "cdx:github:job:hasWritePermissions",
370
- value: "true",
371
- });
372
- }
373
1778
  if (jobServices.length) {
374
1779
  jobProperties.push({
375
1780
  name: "cdx:github:job:services",
@@ -382,12 +1787,40 @@ export function parseWorkflowFile(f, options) {
382
1787
  value: jobNeeds.join(","),
383
1788
  });
384
1789
  }
1790
+ if (job.uses) {
1791
+ jobProperties.push({ name: "cdx:github:job:uses", value: job.uses });
1792
+ jobProperties.push({
1793
+ name: "cdx:github:job:isReusableWorkflowCall",
1794
+ value: "true",
1795
+ });
1796
+ }
1797
+ jobProperties.push(...sharedJobCtxProps);
385
1798
  jobProperties.push(...sharedCtxProps);
386
1799
 
1800
+ const reusableWorkflowComponent = buildReusableWorkflowComponent(
1801
+ job,
1802
+ jobName,
1803
+ f,
1804
+ workflowName,
1805
+ jobRunner,
1806
+ sharedJobCtxProps,
1807
+ sharedCtxProps,
1808
+ options,
1809
+ );
1810
+ if (reusableWorkflowComponent) {
1811
+ components.push(reusableWorkflowComponent);
1812
+ jobDependsOn.push(reusableWorkflowComponent["bom-ref"]);
1813
+ steps.push({
1814
+ name: job.uses,
1815
+ commands: [{ executed: job.uses }],
1816
+ });
1817
+ }
1818
+
387
1819
  for (const step of job.steps || []) {
388
1820
  const stepName = step.name || step.uses || "unnamed step";
389
1821
  const commands = [];
390
1822
  let actionProperties = [];
1823
+ const effectiveEnv = { ...workflowEnv, ...jobEnv, ...(step.env || {}) };
391
1824
  if (step.uses) {
392
1825
  commands.push({ executed: step.uses });
393
1826
  // Collect action references as components
@@ -441,6 +1874,10 @@ export function parseWorkflowFile(f, options) {
441
1874
  name: "cdx:github:step:condition",
442
1875
  value: step.if,
443
1876
  });
1877
+ actionProperties.push({
1878
+ name: "cdx:github:step:if",
1879
+ value: step.if,
1880
+ });
444
1881
  }
445
1882
  if (step["continue-on-error"]) {
446
1883
  actionProperties.push({
@@ -454,20 +1891,64 @@ export function parseWorkflowFile(f, options) {
454
1891
  value: step.timeout.toString(),
455
1892
  });
456
1893
  }
457
- if (group?.startsWith("github/") || group === "actions") {
458
- actionProperties.push({
459
- name: "cdx:actions:isOfficial",
460
- value: "true",
461
- });
462
- }
463
- if (group?.startsWith("github/")) {
464
- actionProperties.push({
465
- name: "cdx:actions:isVerified",
466
- value: "true",
467
- });
468
- }
1894
+ const isOfficial =
1895
+ group?.startsWith("github/") || group === "actions";
1896
+ const isVerified = group?.startsWith("github/");
1897
+ actionProperties.push({
1898
+ name: "cdx:actions:isOfficial",
1899
+ value: String(isOfficial),
1900
+ });
1901
+ actionProperties.push({
1902
+ name: "cdx:actions:isVerified",
1903
+ value: String(isVerified),
1904
+ });
469
1905
  actionProperties.push(...analyzeCheckoutStep(step));
470
1906
  actionProperties.push(...analyzeCacheStep(step));
1907
+ actionProperties.push(...analyzeDispatchActionStep(step));
1908
+ if (
1909
+ step.uses?.includes("actions/github-script") &&
1910
+ typeof step.with?.script === "string"
1911
+ ) {
1912
+ const scriptDispatchInfo = detectWorkflowDispatchInvocations(
1913
+ step.with.script,
1914
+ );
1915
+ appendDispatchProperties(actionProperties, scriptDispatchInfo);
1916
+ const githubScriptSensitiveRefs = [
1917
+ ...detectSensitiveContextReferences(
1918
+ step.with.script,
1919
+ effectiveEnv,
1920
+ ),
1921
+ ...collectSensitiveEnvBindings(effectiveEnv),
1922
+ ];
1923
+ if (step.with["github-token"]) {
1924
+ githubScriptSensitiveRefs.push("input:github-token");
1925
+ }
1926
+ if (githubScriptSensitiveRefs.length) {
1927
+ actionProperties.push({
1928
+ name: "cdx:github:step:referencesSensitiveContext",
1929
+ value: "true",
1930
+ });
1931
+ actionProperties.push({
1932
+ name: "cdx:github:step:sensitiveContextRefs",
1933
+ value: [...new Set(githubScriptSensitiveRefs)].join(","),
1934
+ });
1935
+ }
1936
+ const forkContextRefs = detectForkContextReferences(
1937
+ step.with.script,
1938
+ );
1939
+ if (forkContextRefs.length) {
1940
+ actionProperties.push({
1941
+ name: "cdx:github:step:referencesForkContext",
1942
+ value: "true",
1943
+ });
1944
+ actionProperties.push({
1945
+ name: "cdx:github:step:forkContextRefs",
1946
+ value: [...new Set(forkContextRefs)].join(","),
1947
+ });
1948
+ }
1949
+ }
1950
+ appendSensitiveOperationProperties(actionProperties);
1951
+ actionProperties.push(...sharedJobCtxProps);
471
1952
  actionProperties.push(...sharedCtxProps);
472
1953
  const evidence = {
473
1954
  identity: [
@@ -502,7 +1983,7 @@ export function parseWorkflowFile(f, options) {
502
1983
  jobDependsOn.push(purl);
503
1984
  }
504
1985
  } else if (step.run) {
505
- commands.push({ executed: step.run.trim().split("\n")[0] });
1986
+ commands.push({ executed: step?.run?.trim().split("\n")[0] });
506
1987
  const stepRef = `${jobRef}-step-${steps.length + 1}`;
507
1988
  const runProperties = [
508
1989
  { name: "SrcFile", value: f },
@@ -512,9 +1993,26 @@ export function parseWorkflowFile(f, options) {
512
1993
  { name: "cdx:github:step:type", value: "run" },
513
1994
  {
514
1995
  name: "cdx:github:step:command",
515
- value: step.run.trim().split("\n")[0],
1996
+ value: step?.run?.trim().split("\n")[0],
516
1997
  },
517
1998
  ];
1999
+ if (step.if) {
2000
+ runProperties.push({
2001
+ name: "cdx:github:step:condition",
2002
+ value: step.if,
2003
+ });
2004
+ runProperties.push({
2005
+ name: "cdx:github:step:if",
2006
+ value: step.if,
2007
+ });
2008
+ }
2009
+ if (step["continue-on-error"]) {
2010
+ runProperties.push({
2011
+ name: "cdx:github:step:continueOnError",
2012
+ value: "true",
2013
+ });
2014
+ }
2015
+ runProperties.push(...sharedJobCtxProps);
518
2016
  runProperties.push(...sharedCtxProps);
519
2017
 
520
2018
  const { hasInterpolation, vars } = detectUntrustedInterpolation(
@@ -530,6 +2028,77 @@ export function parseWorkflowFile(f, options) {
530
2028
  value: vars.join(","),
531
2029
  });
532
2030
  }
2031
+ const { hasMutation, targets } = detectRunnerStateMutation(step.run);
2032
+ if (hasMutation) {
2033
+ runProperties.push({
2034
+ name: "cdx:github:step:mutatesRunnerState",
2035
+ value: "true",
2036
+ });
2037
+ runProperties.push({
2038
+ name: "cdx:github:step:runnerStateTargets",
2039
+ value: targets.join(","),
2040
+ });
2041
+ }
2042
+ const { hasOutboundCommand, tools } = detectOutboundNetworkCommand(
2043
+ step.run,
2044
+ );
2045
+ if (hasOutboundCommand) {
2046
+ runProperties.push({
2047
+ name: "cdx:github:step:hasOutboundNetworkCommand",
2048
+ value: "true",
2049
+ });
2050
+ runProperties.push({
2051
+ name: "cdx:github:step:outboundNetworkTools",
2052
+ value: tools.join(","),
2053
+ });
2054
+ }
2055
+ const sensitiveContextRefs = detectSensitiveContextReferences(
2056
+ step.run,
2057
+ effectiveEnv,
2058
+ );
2059
+ const dispatchInfo = detectWorkflowDispatchInvocations(step.run);
2060
+ if (dispatchInfo.hasDispatch) {
2061
+ collectSensitiveEnvBindings(effectiveEnv).forEach((ref) => {
2062
+ sensitiveContextRefs.push(ref);
2063
+ });
2064
+ }
2065
+ if (sensitiveContextRefs.length) {
2066
+ runProperties.push({
2067
+ name: "cdx:github:step:referencesSensitiveContext",
2068
+ value: "true",
2069
+ });
2070
+ runProperties.push({
2071
+ name: "cdx:github:step:sensitiveContextRefs",
2072
+ value: sensitiveContextRefs.join(","),
2073
+ });
2074
+ }
2075
+ appendDispatchProperties(runProperties, dispatchInfo);
2076
+ const forkContextRefs = detectForkContextReferences(step.run);
2077
+ if (forkContextRefs.length) {
2078
+ runProperties.push({
2079
+ name: "cdx:github:step:referencesForkContext",
2080
+ value: "true",
2081
+ });
2082
+ runProperties.push({
2083
+ name: "cdx:github:step:forkContextRefs",
2084
+ value: [...new Set(forkContextRefs)].join(","),
2085
+ });
2086
+ }
2087
+ const exfiltrationIndicators = hasOutboundCommand
2088
+ ? detectOutboundExfiltrationIndicators(step.run, sensitiveContextRefs)
2089
+ : [];
2090
+ if (exfiltrationIndicators.length) {
2091
+ runProperties.push({
2092
+ name: "cdx:github:step:likelyExfiltration",
2093
+ value: "true",
2094
+ });
2095
+ runProperties.push({
2096
+ name: "cdx:github:step:exfiltrationIndicators",
2097
+ value: exfiltrationIndicators.join(","),
2098
+ });
2099
+ }
2100
+ runProperties.push(...analyzeLegacyPublishStep(step, effectiveEnv));
2101
+ appendSensitiveOperationProperties(runProperties);
533
2102
  components.push({
534
2103
  "bom-ref": stepRef,
535
2104
  purl: undefined,
@@ -570,14 +2139,31 @@ export function parseWorkflowFile(f, options) {
570
2139
  // Build workflow-level properties using the same helpers
571
2140
  const workflowProperties = [
572
2141
  { name: "cdx:github:workflow:file", value: f },
2142
+ { name: "cdx:github:workflow:name", value: workflowName },
573
2143
  ...buildWorkflowContextProperties({
574
- hasWritePermissions: workflowHasWritePermissions,
575
- hasIdTokenWrite,
2144
+ hasExplicitPermissionsBlock: workflowHasExplicitPermissionsBlock,
2145
+ hasAnyExplicitPermissionsBlock:
2146
+ workflowHasExplicitPermissionsBlock ||
2147
+ anyJobHasExplicitPermissionsBlock,
2148
+ hasWritePermissions:
2149
+ workflowHasWritePermissions || anyJobHasWritePermissions,
2150
+ hasIdTokenWrite: workflowHasIdTokenWrite || anyJobHasIdTokenWrite,
576
2151
  triggers,
2152
+ triggerNames,
577
2153
  isHighRisk,
578
2154
  concurrencyGroup: workflowConcurrency?.group,
2155
+ writeScopes: Array.from(workflowWriteScopes),
2156
+ dispatchInputs: workflowDispatchInputs,
2157
+ repositoryDispatchTypes,
2158
+ workflowReceiverAliases,
2159
+ workflowCallMetadata,
579
2160
  }),
580
2161
  ];
2162
+ appendHiddenUnicodeProperties(
2163
+ workflowProperties,
2164
+ hiddenUnicodeScan,
2165
+ "cdx:github:workflow",
2166
+ );
581
2167
  const workflow = {
582
2168
  "bom-ref": workflowRef,
583
2169
  uid: workflowRef,
@@ -625,6 +2211,7 @@ export const githubActionsParser = {
625
2211
  components.push(...result.components);
626
2212
  dependencies.push(...result.dependencies);
627
2213
  }
2214
+ enrichLocalDispatchRelationships(workflows, components);
628
2215
  return {
629
2216
  workflows,
630
2217
  components,