@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.
- package/README.md +27 -9
- package/bin/cdxgen.js +1 -1
- package/data/spdx.schema.json +35 -2
- package/data/templates/asvs-5.0.cdx.json +1727 -3471
- package/lib/cli/index.js +32 -4
- package/lib/evinser/evinser.js +2 -8
- package/lib/helpers/display.js +1 -1
- package/lib/helpers/envcontext.js +10 -2
- package/lib/helpers/utils.js +487 -115
- package/lib/helpers/utils.poku.js +200 -3
- package/lib/helpers/validator.js +37 -3
- package/lib/managers/binary.js +34 -12
- package/lib/managers/containerutils.js +68 -0
- package/lib/managers/docker.getConnection.poku.js +61 -0
- package/lib/managers/docker.js +72 -119
- package/lib/parsers/iri.js +1 -2
- package/lib/server/server.js +164 -34
- package/lib/server/server.poku.js +232 -10
- package/lib/stages/postgen/annotator.js +281 -3
- package/lib/stages/postgen/postgen.js +4 -7
- package/lib/third-party/arborist/lib/diff.js +1 -1
- package/lib/third-party/arborist/lib/node.js +1 -1
- package/lib/third-party/arborist/lib/yarn-lock.js +1 -1
- package/package.json +22 -326
- package/types/bin/dependencies.d.ts.map +1 -1
- package/types/bin/licenses.d.ts +3 -0
- package/types/bin/licenses.d.ts.map +1 -0
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/helpers/envcontext.d.ts.map +1 -1
- package/types/lib/helpers/utils.d.ts +1 -1
- package/types/lib/helpers/utils.d.ts.map +1 -1
- package/types/lib/helpers/validator.d.ts.map +1 -1
- package/types/lib/managers/binary.d.ts.map +1 -1
- package/types/lib/managers/containerutils.d.ts +3 -0
- package/types/lib/managers/containerutils.d.ts.map +1 -0
- package/types/lib/managers/docker.d.ts +0 -2
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/parsers/iri.d.ts.map +1 -1
- package/types/lib/server/server.d.ts +14 -0
- package/types/lib/server/server.d.ts.map +1 -1
- package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/bin/dependencies.js +0 -131
- 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
|
-
|
|
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.
|
|
150
|
+
assert.strictEqual(isAllowedPath("/any/path"), true);
|
|
138
151
|
});
|
|
139
152
|
|
|
140
|
-
it("
|
|
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.
|
|
143
|
-
assert.
|
|
160
|
+
assert.strictEqual(isAllowedPath("/api"), true);
|
|
161
|
+
assert.strictEqual(isAllowedPath("/public"), true);
|
|
144
162
|
});
|
|
145
163
|
|
|
146
|
-
it("returns
|
|
164
|
+
it("returns true for files safely nested inside allowed directories", () => {
|
|
147
165
|
process.env.CDXGEN_SERVER_ALLOWED_PATHS = "/api,/public";
|
|
148
|
-
assert.
|
|
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("
|
|
152
|
-
process.env.CDXGEN_SERVER_ALLOWED_PATHS = "";
|
|
153
|
-
assert.
|
|
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(/[
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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];
|