@cyclonedx/cdxgen 12.1.1 → 12.1.3

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 (45) hide show
  1. package/README.md +27 -9
  2. package/bin/cdxgen.js +1 -1
  3. package/data/spdx.schema.json +35 -2
  4. package/data/templates/asvs-5.0.cdx.json +1727 -3471
  5. package/lib/cli/index.js +32 -4
  6. package/lib/evinser/evinser.js +2 -8
  7. package/lib/helpers/display.js +1 -1
  8. package/lib/helpers/envcontext.js +10 -2
  9. package/lib/helpers/utils.js +487 -115
  10. package/lib/helpers/utils.poku.js +200 -3
  11. package/lib/helpers/validator.js +37 -3
  12. package/lib/managers/binary.js +34 -12
  13. package/lib/managers/containerutils.js +68 -0
  14. package/lib/managers/docker.getConnection.poku.js +61 -0
  15. package/lib/managers/docker.js +72 -119
  16. package/lib/parsers/iri.js +1 -2
  17. package/lib/server/server.js +164 -34
  18. package/lib/server/server.poku.js +232 -10
  19. package/lib/stages/postgen/annotator.js +281 -3
  20. package/lib/stages/postgen/postgen.js +4 -7
  21. package/lib/third-party/arborist/lib/diff.js +1 -1
  22. package/lib/third-party/arborist/lib/node.js +1 -1
  23. package/lib/third-party/arborist/lib/yarn-lock.js +1 -1
  24. package/package.json +22 -326
  25. package/types/bin/dependencies.d.ts.map +1 -1
  26. package/types/bin/licenses.d.ts +3 -0
  27. package/types/bin/licenses.d.ts.map +1 -0
  28. package/types/lib/cli/index.d.ts.map +1 -1
  29. package/types/lib/evinser/evinser.d.ts.map +1 -1
  30. package/types/lib/helpers/envcontext.d.ts.map +1 -1
  31. package/types/lib/helpers/utils.d.ts +1 -1
  32. package/types/lib/helpers/utils.d.ts.map +1 -1
  33. package/types/lib/helpers/validator.d.ts.map +1 -1
  34. package/types/lib/managers/binary.d.ts.map +1 -1
  35. package/types/lib/managers/containerutils.d.ts +3 -0
  36. package/types/lib/managers/containerutils.d.ts.map +1 -0
  37. package/types/lib/managers/docker.d.ts +0 -2
  38. package/types/lib/managers/docker.d.ts.map +1 -1
  39. package/types/lib/parsers/iri.d.ts.map +1 -1
  40. package/types/lib/server/server.d.ts +14 -0
  41. package/types/lib/server/server.d.ts.map +1 -1
  42. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  43. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  44. package/bin/dependencies.js +0 -131
  45. package/lib/helpers/dependencies.poku.js +0 -11
@@ -8,6 +8,7 @@ import {
8
8
  isAllowedWinPath,
9
9
  parseQueryString,
10
10
  parseValue,
11
+ validateAndRejectGitSource,
11
12
  } from "./server.js";
12
13
 
13
14
  it("parseValue tests", () => {
@@ -129,28 +130,73 @@ describe("isAllowedPath()", () => {
129
130
  });
130
131
 
131
132
  afterEach(() => {
132
- process.env.CDXGEN_SERVER_ALLOWED_PATHS = originalPaths;
133
+ if (originalPaths === undefined) {
134
+ delete process.env.CDXGEN_SERVER_ALLOWED_PATHS;
135
+ } else {
136
+ process.env.CDXGEN_SERVER_ALLOWED_PATHS = originalPaths;
137
+ }
138
+ });
139
+
140
+ it("returns false for non-string inputs", () => {
141
+ process.env.CDXGEN_SERVER_ALLOWED_PATHS = "/api";
142
+ assert.strictEqual(isAllowedPath(null), false);
143
+ assert.strictEqual(isAllowedPath(123), false);
144
+ assert.strictEqual(isAllowedPath({}), false);
145
+ assert.strictEqual(isAllowedPath(undefined), false);
133
146
  });
134
147
 
135
148
  it("returns true if CDXGEN_SERVER_ALLOWED_PATHS is not set", () => {
136
149
  delete process.env.CDXGEN_SERVER_ALLOWED_PATHS;
137
- assert.deepStrictEqual(isAllowedPath("/any/path"), true);
150
+ assert.strictEqual(isAllowedPath("/any/path"), true);
138
151
  });
139
152
 
140
- it("returns true for paths that start with an allowed prefix", () => {
153
+ it("treats an empty-string env var as unset (returns true)", () => {
154
+ process.env.CDXGEN_SERVER_ALLOWED_PATHS = "";
155
+ assert.strictEqual(isAllowedPath("/anything"), true);
156
+ });
157
+
158
+ it("returns true for exact directory matches", () => {
141
159
  process.env.CDXGEN_SERVER_ALLOWED_PATHS = "/api,/public";
142
- assert.deepStrictEqual(isAllowedPath("/api/resource"), true);
143
- assert.deepStrictEqual(isAllowedPath("/public/index.html"), true);
160
+ assert.strictEqual(isAllowedPath("/api"), true);
161
+ assert.strictEqual(isAllowedPath("/public"), true);
144
162
  });
145
163
 
146
- it("returns false for paths that do not match any prefix", () => {
164
+ it("returns true for files safely nested inside allowed directories", () => {
147
165
  process.env.CDXGEN_SERVER_ALLOWED_PATHS = "/api,/public";
148
- assert.deepStrictEqual(isAllowedPath("/private/data"), false);
166
+ assert.strictEqual(isAllowedPath("/api/resource"), true);
167
+ assert.strictEqual(isAllowedPath("/public/index.html"), true);
168
+ assert.strictEqual(isAllowedPath("/public/assets/css/main.css"), true);
149
169
  });
150
170
 
151
- it("treats an empty-string env var as unset (returns true)", () => {
152
- process.env.CDXGEN_SERVER_ALLOWED_PATHS = "";
153
- assert.deepStrictEqual(isAllowedPath("/anything"), true);
171
+ it("returns false for completely unrelated paths", () => {
172
+ process.env.CDXGEN_SERVER_ALLOWED_PATHS = "/api,/public";
173
+ assert.strictEqual(isAllowedPath("/private/data"), false);
174
+ assert.strictEqual(isAllowedPath("/etc/passwd"), false);
175
+ });
176
+
177
+ it("prevents directory prefix bypass (e.g., /var/www vs /var/www-secret)", () => {
178
+ process.env.CDXGEN_SERVER_ALLOWED_PATHS = "/api,/var/www";
179
+ assert.strictEqual(isAllowedPath("/api-secret/data"), false);
180
+ assert.strictEqual(isAllowedPath("/api-secret"), false);
181
+ assert.strictEqual(isAllowedPath("/var/www-backup"), false);
182
+ });
183
+
184
+ it("prevents path traversal attacks using ../", () => {
185
+ process.env.CDXGEN_SERVER_ALLOWED_PATHS = "/api";
186
+ assert.strictEqual(isAllowedPath("/api/../private"), false);
187
+ assert.strictEqual(isAllowedPath("/api/../../etc/passwd"), false);
188
+ });
189
+
190
+ it("allows paths that contain ../ but safely resolve inside the allowed directory", () => {
191
+ process.env.CDXGEN_SERVER_ALLOWED_PATHS = "/api";
192
+ assert.strictEqual(isAllowedPath("/api/resource/../data"), true);
193
+ });
194
+
195
+ it("gracefully handles comma-separated lists with empty segments", () => {
196
+ process.env.CDXGEN_SERVER_ALLOWED_PATHS = "/api,,/public,";
197
+ assert.strictEqual(isAllowedPath("/api/resource"), true);
198
+ assert.strictEqual(isAllowedPath("/public/index.html"), true);
199
+ assert.strictEqual(isAllowedPath("/private/data"), false);
154
200
  });
155
201
  });
156
202
 
@@ -385,3 +431,179 @@ describe("getQueryParams", () => {
385
431
  });
386
432
  });
387
433
  });
434
+ describe("validateGitSource() tests", () => {
435
+ let originalGitAllow;
436
+ let originalAllowedHosts;
437
+
438
+ beforeEach(() => {
439
+ originalGitAllow = process.env.CDXGEN_SERVER_GIT_ALLOW_PROTOCOL;
440
+ originalAllowedHosts = process.env.CDXGEN_SERVER_ALLOWED_HOSTS;
441
+ delete process.env.CDXGEN_SERVER_GIT_ALLOW_PROTOCOL;
442
+ delete process.env.CDXGEN_SERVER_ALLOWED_HOSTS;
443
+ });
444
+
445
+ afterEach(() => {
446
+ if (originalGitAllow)
447
+ process.env.CDXGEN_SERVER_GIT_ALLOW_PROTOCOL = originalGitAllow;
448
+ if (originalAllowedHosts)
449
+ process.env.CDXGEN_SERVER_ALLOWED_HOSTS = originalAllowedHosts;
450
+ });
451
+
452
+ it("should reject ext:: and fd:: outright", () => {
453
+ assert.deepStrictEqual(
454
+ validateAndRejectGitSource("ext::sh -c id").error,
455
+ "Invalid Protocol",
456
+ );
457
+ assert.deepStrictEqual(
458
+ validateAndRejectGitSource("fd::123").error,
459
+ "Invalid Protocol",
460
+ );
461
+ assert.deepStrictEqual(
462
+ validateAndRejectGitSource("EXT::sh -c id").error,
463
+ "Invalid Protocol",
464
+ );
465
+ });
466
+
467
+ it("should allow standard local paths to bypass validation", () => {
468
+ assert.deepStrictEqual(validateAndRejectGitSource("/tmp/local-path"), null);
469
+ assert.deepStrictEqual(
470
+ validateAndRejectGitSource("C:\\Users\\local"),
471
+ null,
472
+ );
473
+ });
474
+
475
+ it("should handle ssh git@ format gracefully", () => {
476
+ assert.deepStrictEqual(
477
+ validateAndRejectGitSource("git@github.com:foo/bar.git"),
478
+ null,
479
+ );
480
+ });
481
+
482
+ it("should reject malformed git URLs", () => {
483
+ // invalid URL format (can't parse via node's new URL object)
484
+ assert.deepStrictEqual(
485
+ validateAndRejectGitSource("http://[:::1]/bad-ipv6").error,
486
+ "Invalid URL Format",
487
+ );
488
+ });
489
+
490
+ it("should enforce GIT_ALLOW_PROTOCOL default schemes", () => {
491
+ assert.deepStrictEqual(
492
+ validateAndRejectGitSource("https://github.com/repo"),
493
+ null,
494
+ );
495
+ assert.deepStrictEqual(
496
+ validateAndRejectGitSource("http://github.com/repo"),
497
+ {
498
+ status: 400,
499
+ error: "Protocol Not Allowed",
500
+ details: "The protocol 'http:' is not permitted by GIT_ALLOW_PROTOCOL.",
501
+ },
502
+ );
503
+ assert.deepStrictEqual(
504
+ validateAndRejectGitSource("git://github.com/repo"),
505
+ null,
506
+ );
507
+ assert.deepStrictEqual(
508
+ validateAndRejectGitSource("ssh://github.com/repo"),
509
+ null,
510
+ );
511
+ assert.deepStrictEqual(
512
+ validateAndRejectGitSource("git+ssh://github.com/repo"),
513
+ null,
514
+ );
515
+
516
+ // ftp is not allowed by default
517
+ const res = validateAndRejectGitSource("ftp://github.com/repo");
518
+ assert.deepStrictEqual(res.error, "Protocol Not Allowed");
519
+ assert.deepStrictEqual(
520
+ res.details,
521
+ "The protocol 'ftp:' is not permitted by GIT_ALLOW_PROTOCOL.",
522
+ );
523
+ });
524
+
525
+ it("should reject protocol smuggling techniques", () => {
526
+ assert.deepStrictEqual(
527
+ validateAndRejectGitSource("git+ext://github.com/repo").error,
528
+ "Protocol Not Allowed",
529
+ );
530
+ assert.deepStrictEqual(
531
+ validateAndRejectGitSource("http+ext://github.com/repo").error,
532
+ "Protocol Not Allowed",
533
+ );
534
+ });
535
+
536
+ it("should respect custom CDXGEN_SERVER_GIT_ALLOW_PROTOCOL configs", () => {
537
+ process.env.CDXGEN_SERVER_GIT_ALLOW_PROTOCOL = "https:git";
538
+ assert.deepStrictEqual(
539
+ validateAndRejectGitSource("https://github.com/repo"),
540
+ null,
541
+ );
542
+ assert.deepStrictEqual(
543
+ validateAndRejectGitSource("git://github.com/repo"),
544
+ null,
545
+ );
546
+
547
+ // http is no longer allowed
548
+ const res = validateAndRejectGitSource("http://github.com/repo");
549
+ assert.deepStrictEqual(res.error, "Protocol Not Allowed");
550
+ assert.deepStrictEqual(
551
+ res.details,
552
+ "The protocol 'http:' is not permitted by GIT_ALLOW_PROTOCOL.",
553
+ );
554
+ });
555
+
556
+ it("should reject remote helper syntax (::) inside valid schemes", () => {
557
+ assert.deepStrictEqual(
558
+ validateAndRejectGitSource("https://github.com/ext::sh -c id").error,
559
+ "Invalid URL Syntax",
560
+ );
561
+ assert.deepStrictEqual(
562
+ validateAndRejectGitSource("git://foo::bar/repo").error,
563
+ "Invalid URL Format",
564
+ );
565
+ });
566
+
567
+ it("should validate allowed hosts", () => {
568
+ process.env.CDXGEN_SERVER_ALLOWED_HOSTS = "github.com,gitlab.com";
569
+ assert.deepStrictEqual(
570
+ validateAndRejectGitSource("https://github.com/repo"),
571
+ null,
572
+ );
573
+
574
+ const res = validateAndRejectGitSource("https://evil.com/repo");
575
+ assert.deepStrictEqual(res.error, "Host Not Allowed");
576
+ assert.deepStrictEqual(res.status, 403);
577
+ });
578
+ });
579
+ it("should correctly normalize and validate various git@ (SCP-like) formats", () => {
580
+ assert.deepStrictEqual(
581
+ validateAndRejectGitSource("git@gitlab.com:group/project.git"),
582
+ null,
583
+ );
584
+ assert.deepStrictEqual(
585
+ validateAndRejectGitSource("git@bitbucket.org:workspace/repo:name.git"),
586
+ null,
587
+ );
588
+ assert.deepStrictEqual(
589
+ validateAndRejectGitSource("git@github.com/user/repo.git"),
590
+ null,
591
+ );
592
+ assert.deepStrictEqual(
593
+ validateAndRejectGitSource("ssh://git@github.com/user/repo.git"),
594
+ null,
595
+ );
596
+ process.env.CDXGEN_SERVER_ALLOWED_HOSTS = "github.com,bitbucket.org";
597
+ assert.deepStrictEqual(
598
+ validateAndRejectGitSource("git@github.com:user/repo.git"),
599
+ null,
600
+ );
601
+ assert.deepStrictEqual(
602
+ validateAndRejectGitSource("git@bitbucket.org:workspace/repo.git"),
603
+ null,
604
+ );
605
+ const deniedRes = validateAndRejectGitSource("git@evil.com:foo/bar.git");
606
+ assert.deepStrictEqual(deniedRes.status, 403);
607
+ assert.deepStrictEqual(deniedRes.error, "Host Not Allowed");
608
+ delete process.env.CDXGEN_SERVER_ALLOWED_HOSTS;
609
+ });
@@ -39,7 +39,177 @@ function cleanNames(s) {
39
39
  }
40
40
 
41
41
  function cleanTypes(s) {
42
- return s?.replace(/[+-_]/g, " ");
42
+ return s?.replace(/[+_-]/g, " ");
43
+ }
44
+
45
+ /**
46
+ * Count GitHub workflow components and extract security-relevant stats
47
+ *
48
+ * @param {Array} components BOM components array
49
+ *
50
+ * @returns {Object} Statistics about GitHub workflow components
51
+ */
52
+ function getGitHubWorkflowStats(components) {
53
+ const stats = {
54
+ totalActions: 0,
55
+ officialActions: 0,
56
+ verifiedActions: 0,
57
+ shaPinned: 0,
58
+ tagPinned: 0,
59
+ branchPinned: 0,
60
+ unknownPinned: 0,
61
+ workflowCount: 0,
62
+ jobCount: 0,
63
+ hasWritePermissions: 0,
64
+ hasIdTokenWrite: 0,
65
+ continueOnError: 0,
66
+ workflows: new Set(),
67
+ jobs: new Set(),
68
+ runners: new Set(),
69
+ environments: new Set(),
70
+ };
71
+
72
+ if (!components || !Array.isArray(components)) {
73
+ return stats;
74
+ }
75
+
76
+ for (const comp of components) {
77
+ if (comp?.scope === "excluded") {
78
+ continue;
79
+ }
80
+ if (comp?.purl?.startsWith("pkg:github/")) {
81
+ stats.totalActions++;
82
+ const props = comp.properties || [];
83
+ const propMap = {};
84
+ for (const prop of props) {
85
+ propMap[prop.name] = prop.value;
86
+ }
87
+ if (propMap["cdx:github:workflow:name"]) {
88
+ stats.workflows.add(propMap["cdx:github:workflow:name"]);
89
+ }
90
+ if (propMap["cdx:github:job:name"]) {
91
+ stats.jobs.add(propMap["cdx:github:job:name"]);
92
+ }
93
+ if (propMap["cdx:actions:isOfficial"] === "true") {
94
+ stats.officialActions++;
95
+ }
96
+ if (propMap["cdx:actions:isVerified"] === "true") {
97
+ stats.verifiedActions++;
98
+ }
99
+ const pinningType = propMap["cdx:github:action:versionPinningType"];
100
+ if (pinningType === "sha") {
101
+ stats.shaPinned++;
102
+ } else if (pinningType === "tag") {
103
+ stats.tagPinned++;
104
+ } else if (pinningType === "branch") {
105
+ stats.branchPinned++;
106
+ } else {
107
+ stats.unknownPinned++;
108
+ }
109
+ if (propMap["cdx:github:workflow:hasWritePermissions"] === "true") {
110
+ stats.hasWritePermissions++;
111
+ }
112
+ if (propMap["cdx:github:workflow:hasIdTokenWrite"] === "true") {
113
+ stats.hasIdTokenWrite++;
114
+ }
115
+ if (propMap["cdx:github:step:continueOnError"] === "true") {
116
+ stats.continueOnError++;
117
+ }
118
+ if (propMap["cdx:github:job:runner"]) {
119
+ propMap["cdx:github:job:runner"]
120
+ .split(",")
121
+ .filter((r) => r.includes("$"))
122
+ .forEach((r) => {
123
+ stats.runners.add(r.trim());
124
+ });
125
+ }
126
+ if (propMap["cdx:github:job:environment"]) {
127
+ stats.environments.add(propMap["cdx:github:job:environment"]);
128
+ }
129
+ }
130
+ }
131
+ stats.workflowCount = stats.workflows.size;
132
+ stats.jobCount = stats.jobs.size;
133
+ stats.workflows = Array.from(stats.workflows);
134
+ stats.jobs = Array.from(stats.jobs);
135
+ stats.runners = Array.from(stats.runners);
136
+ stats.environments = Array.from(stats.environments);
137
+ return stats;
138
+ }
139
+
140
+ /**
141
+ * Generate security assessment text based on GitHub workflow properties
142
+ *
143
+ * @param {Object} stats GitHub workflow statistics
144
+ *
145
+ * @returns {String} Security assessment text
146
+ */
147
+ function generateSecurityAssessment(stats) {
148
+ let text = "";
149
+ const securityIssues = [];
150
+ const securityStrengths = [];
151
+ if (stats.branchPinned > 0) {
152
+ securityIssues.push(
153
+ `${stats.branchPinned} action(s) use branch references instead of pinned versions, which may introduce supply chain risks`,
154
+ );
155
+ }
156
+ if (stats.unknownPinned > 0) {
157
+ securityIssues.push(
158
+ `${stats.unknownPinned} action(s) have unknown version pinning types`,
159
+ );
160
+ }
161
+ if (stats.shaPinned > 0) {
162
+ securityStrengths.push(
163
+ `${stats.shaPinned} action(s) are pinned to specific commit SHAs for maximum security`,
164
+ );
165
+ }
166
+ if (stats.tagPinned > 0) {
167
+ securityStrengths.push(
168
+ `${stats.tagPinned} action(s) are pinned to version tags`,
169
+ );
170
+ }
171
+ if (stats.hasWritePermissions > 0) {
172
+ securityIssues.push(
173
+ `${stats.hasWritePermissions} workflow(s) have write permissions to repository resources`,
174
+ );
175
+ }
176
+ if (stats.hasIdTokenWrite > 0) {
177
+ securityIssues.push(
178
+ `${stats.hasIdTokenWrite} workflow(s) have id-token write access, enabling OIDC authentication`,
179
+ );
180
+ }
181
+ if (stats.officialActions > 0) {
182
+ securityStrengths.push(
183
+ `${stats.officialActions} action(s) are official GitHub Actions from github.com org`,
184
+ );
185
+ }
186
+ if (stats.verifiedActions > 0) {
187
+ securityStrengths.push(
188
+ `${stats.verifiedActions} action(s) are from verified creators`,
189
+ );
190
+ }
191
+ if (stats.continueOnError > 0) {
192
+ securityIssues.push(
193
+ `${stats.continueOnError} step(s) continue on error, which may mask failures`,
194
+ );
195
+ }
196
+ if (securityStrengths.length > 0) {
197
+ text = `${text} Security strengths: ${joinArray(securityStrengths)}.`;
198
+ }
199
+ if (securityIssues.length > 0) {
200
+ text = `${text} Security considerations: ${joinArray(securityIssues)}.`;
201
+ }
202
+ const totalActions = stats.totalActions || 1;
203
+ const securePinned = stats.shaPinned + stats.tagPinned;
204
+ const pinningScore = (securePinned / totalActions) * 100;
205
+ if (pinningScore >= 80) {
206
+ text = `${text} Overall, the workflow demonstrates good version pinning practices.`;
207
+ } else if (pinningScore >= 50) {
208
+ text = `${text} Overall, the workflow has moderate version pinning practices with room for improvement.`;
209
+ } else {
210
+ text = `${text} Overall, the workflow would benefit from improved version pinning practices.`;
211
+ }
212
+ return text;
43
213
  }
44
214
 
45
215
  /**
@@ -62,8 +232,22 @@ export function findBomType(bomJson) {
62
232
  c?.data?.length > 0 ||
63
233
  (c.modelCard && Object.keys(c?.modelCard).length > 0),
64
234
  ).length;
235
+ const githubActionCount = bomJson?.components?.filter((c) =>
236
+ c?.purl?.startsWith("pkg:github/"),
237
+ ).length;
238
+ const hasWorkflowProperties = bomJson?.components?.some((c) =>
239
+ c?.properties?.some(
240
+ (p) =>
241
+ p.name?.startsWith("cdx:github:") || p.name?.startsWith("cdx:actions:"),
242
+ ),
243
+ );
244
+ // Is this a GitHub Workflow BOM?
245
+ if (githubActionCount > 0 && hasWorkflowProperties) {
246
+ bomType = "SBOM";
247
+ description = "Software Bill-of-Materials (SBOM) including GitHub Actions";
248
+ }
65
249
  // Is this an OBOM?
66
- if (lifecycles.filter((l) => l.phase === "operations").length > 0) {
250
+ else if (lifecycles.filter((l) => l.phase === "operations").length > 0) {
67
251
  bomType = "OBOM";
68
252
  description = "Operations Bill-of-Materials (OBOM)";
69
253
  } else if (cryptoAssetsCount > 0) {
@@ -110,6 +294,8 @@ export function textualMetadata(bomJson) {
110
294
  const swidCount = bomJson?.components?.filter((c) =>
111
295
  c?.purl?.startsWith("pkg:swid"),
112
296
  ).length;
297
+ const githubStats = getGitHubWorkflowStats(bomJson?.components);
298
+ const isGitHubBom = bomType === "SBOM";
113
299
  if (metadata?.timestamp) {
114
300
  text = `This ${bomTypeDescription} document was created on ${humanifyTimestamp(metadata.timestamp)}`;
115
301
  }
@@ -174,6 +360,49 @@ export function textualMetadata(bomJson) {
174
360
  text = `${text} The ${cleanTypeName} also has ${metadata.component.components.length} child modules/components.`;
175
361
  }
176
362
  }
363
+ if (isGitHubBom && githubStats.totalActions > 0) {
364
+ text = `${text} This ${bomType} contains ${githubStats.totalActions} GitHub Action references across ${githubStats.workflowCount} workflow(s) and ${githubStats.jobCount} job(s).`;
365
+ if (githubStats.workflows.length > 0) {
366
+ if (githubStats.workflows.length <= 3) {
367
+ text = `${text} The workflows are: ${joinArray(githubStats.workflows)}.`;
368
+ } else {
369
+ text = `${text} There are ${githubStats.workflows.length} workflows including ${joinArray(githubStats.workflows.slice(0, 3))}${githubStats.workflows.length > 3 ? " and others" : ""}.`;
370
+ }
371
+ }
372
+ if (githubStats.environments.length > 0) {
373
+ text = `${text} Jobs are deployed to ${joinArray(githubStats.environments)} environment(s).`;
374
+ }
375
+ const pinningText = [];
376
+ if (githubStats.shaPinned > 0) {
377
+ pinningText.push(`${githubStats.shaPinned} SHA-pinned`);
378
+ }
379
+ if (githubStats.tagPinned > 0) {
380
+ pinningText.push(`${githubStats.tagPinned} tag-pinned`);
381
+ }
382
+ if (githubStats.branchPinned > 0) {
383
+ pinningText.push(`${githubStats.branchPinned} branch-referenced`);
384
+ }
385
+ if (githubStats.unknownPinned > 0) {
386
+ pinningText.push(`${githubStats.unknownPinned} with unknown pinning`);
387
+ }
388
+ if (pinningText.length > 0) {
389
+ text = `${text} Version pinning breakdown: ${pinningText.join(", ")}.`;
390
+ }
391
+ if (githubStats.officialActions > 0 || githubStats.verifiedActions > 0) {
392
+ const trustText = [];
393
+ if (githubStats.officialActions > 0) {
394
+ trustText.push(`${githubStats.officialActions} official`);
395
+ }
396
+ if (githubStats.verifiedActions > 0) {
397
+ trustText.push(`${githubStats.verifiedActions} verified`);
398
+ }
399
+ text = `${text} ${joinArray(trustText)} action(s) are from trusted sources.`;
400
+ }
401
+ const securityText = generateSecurityAssessment(githubStats);
402
+ if (securityText) {
403
+ text = `${text}${securityText}`;
404
+ }
405
+ }
177
406
  let metadataProperties = metadata.properties || [];
178
407
  if (
179
408
  metadata?.component?.properties &&
@@ -237,7 +466,9 @@ export function textualMetadata(bomJson) {
237
466
  }
238
467
  }
239
468
  if (bomJson?.components?.length) {
240
- text = `${text} There are ${bomJson.components.length} components.`;
469
+ if (!isGitHubBom) {
470
+ text = `${text} There are ${bomJson.components.length} components.`;
471
+ }
241
472
  } else {
242
473
  text = `${text} BOM file is empty without components.`;
243
474
  thoughtLog(
@@ -403,5 +634,52 @@ export function extractTags(
403
634
  }
404
635
  }
405
636
  }
637
+ // GitHub workflow specific tags from properties
638
+ if (bomType === "sbom" || bomType === "all") {
639
+ for (const aprop of compProps) {
640
+ // Security-related tags
641
+ if (
642
+ aprop.name === "cdx:github:action:isShaPinned" &&
643
+ aprop.value === "true"
644
+ ) {
645
+ tags.add("sha-pinned");
646
+ tags.add("secure-versioning");
647
+ }
648
+ if (
649
+ aprop.name === "cdx:github:action:versionPinningType" &&
650
+ aprop.value !== "sha"
651
+ ) {
652
+ tags.add(`pinning-${aprop.value}`);
653
+ }
654
+ if (aprop.name === "cdx:actions:isOfficial" && aprop.value === "true") {
655
+ tags.add("official-action");
656
+ tags.add("trusted-source");
657
+ }
658
+ if (aprop.name === "cdx:actions:isVerified" && aprop.value === "true") {
659
+ tags.add("verified-action");
660
+ tags.add("trusted-source");
661
+ }
662
+ if (
663
+ aprop.name === "cdx:github:workflow:hasWritePermissions" &&
664
+ aprop.value === "true"
665
+ ) {
666
+ tags.add("write-permissions");
667
+ tags.add("elevated-access");
668
+ }
669
+ if (
670
+ aprop.name === "cdx:github:workflow:hasIdTokenWrite" &&
671
+ aprop.value === "true"
672
+ ) {
673
+ tags.add("id-token-write");
674
+ tags.add("oidc-enabled");
675
+ }
676
+ if (
677
+ aprop.name === "cdx:github:step:continueOnError" &&
678
+ aprop.value === "true"
679
+ ) {
680
+ tags.add("continue-on-error");
681
+ }
682
+ }
683
+ }
406
684
  return Array.from(tags).sort();
407
685
  }
@@ -400,9 +400,7 @@ export function filterBom(bomJson, options) {
400
400
  for (const aprop of properties) {
401
401
  if (
402
402
  filterstr.length &&
403
- aprop &&
404
- aprop.value &&
405
- aprop.value.toLowerCase().includes(filterstr.toLowerCase())
403
+ aprop?.value?.toLowerCase().includes(filterstr.toLowerCase())
406
404
  ) {
407
405
  filtered = true;
408
406
  purlfiltered = true;
@@ -454,8 +452,7 @@ export function filterBom(bomJson, options) {
454
452
  if (
455
453
  options.specVersion >= 1.5 &&
456
454
  options.autoCompositions &&
457
- bomJson.metadata &&
458
- bomJson.metadata.component
455
+ bomJson.metadata?.component
459
456
  ) {
460
457
  if (!bomJson.compositions) {
461
458
  bomJson.compositions = [];
@@ -548,10 +545,10 @@ export function annotate(bomJson, options) {
548
545
  }
549
546
  parentBomRef = bomJson.metadata.component["bom-ref"];
550
547
  }
551
- if (metadataAnnotations && parentBomRef) {
548
+ if (metadataAnnotations) {
552
549
  bomAnnotations.push({
553
550
  "bom-ref": "metadata-annotations",
554
- subjects: [parentBomRef],
551
+ subjects: parentBomRef ? [parentBomRef] : [bomJson.serialNumber],
555
552
  annotator: {
556
553
  component: cdxgenAnnotator[0],
557
554
  },
@@ -271,7 +271,7 @@ const diffNode = ({
271
271
  // to get the list of nodes to move, then move them all at once, rather
272
272
  // than moving them one at a time in the first loop.
273
273
  const bd = ideal.package.bundleDependencies;
274
- if (actual && bd && bd.length) {
274
+ if (actual && bd?.length) {
275
275
  const bundledChildren = [];
276
276
  for (const node of actual.children.values()) {
277
277
  if (node.inBundle) {
@@ -270,7 +270,7 @@ class Node {
270
270
 
271
271
  // true for packages installed directly in the global node_modules folder
272
272
  get globalTop() {
273
- return this.global && this.parent && this.parent.isProjectRoot;
273
+ return this.global && this.parent?.isProjectRoot;
274
274
  }
275
275
 
276
276
  get workspaces() {
@@ -188,7 +188,7 @@ class YarnLock {
188
188
  this.current[this.subkey] = {};
189
189
  continue;
190
190
  }
191
- if (SUBVAL.test(line) && this.current && this.current[this.subkey]) {
191
+ if (SUBVAL.test(line) && this.current?.[this.subkey]) {
192
192
  const subval = this.splitQuoted(line.trimLeft(), " ");
193
193
  if (subval.length === 2) {
194
194
  this.current[this.subkey][subval[0]] = subval[1];