codegate-ai 0.11.0 → 0.12.1
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/dist/layer2-static/advisories/gha-known-vulnerable-actions.json +3 -1
- package/dist/layer2-static/detectors/workflow-floating-action-version.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-floating-action-version.js +63 -0
- package/dist/layer2-static/detectors/workflow-forbidden-uses.js +33 -0
- package/dist/layer2-static/detectors/workflow-github-env.js +89 -28
- package/dist/layer2-static/detectors/workflow-known-vuln-action.js +99 -1
- package/dist/layer2-static/detectors/workflow-ref-confusion.js +30 -0
- package/dist/layer2-static/detectors/workflow-run-untrusted-artifact.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-run-untrusted-artifact.js +126 -0
- package/dist/layer2-static/detectors/workflow-template-injection.js +102 -13
- package/dist/layer2-static/detectors/workflow-unpinned-uses.js +34 -0
- package/dist/layer2-static/detectors/workflow-unsafe-checkout-ref.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-unsafe-checkout-ref.js +102 -0
- package/dist/layer2-static/engine.js +33 -0
- package/dist/layer2-static/github/client.js +13 -1
- package/package.json +1 -1
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Finding } from "../../types/finding.js";
|
|
2
|
+
export interface WorkflowFloatingActionVersionInput {
|
|
3
|
+
filePath: string;
|
|
4
|
+
parsed: unknown;
|
|
5
|
+
textContent: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function detectWorkflowFloatingActionVersion(input: WorkflowFloatingActionVersionInput): Finding[];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
const FLOATING_VERSION_VALUES = new Set(["latest", "stable", "edge", "nightly", "main", "master"]);
|
|
4
|
+
const VERSION_KEY_PATTERN = /version/iu;
|
|
5
|
+
function isRepositoryUses(value) {
|
|
6
|
+
if (typeof value !== "string") {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
return /^[a-z0-9._-]+\/[a-z0-9._-]+(?:\/[^@]+)?@[^\s]+$/iu.test(value.trim());
|
|
10
|
+
}
|
|
11
|
+
function isFloatingVersionValue(value) {
|
|
12
|
+
const normalized = value.trim().toLowerCase();
|
|
13
|
+
return FLOATING_VERSION_VALUES.has(normalized);
|
|
14
|
+
}
|
|
15
|
+
export function detectWorkflowFloatingActionVersion(input) {
|
|
16
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
20
|
+
if (!facts) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const findings = [];
|
|
24
|
+
facts.jobs.forEach((job, jobIndex) => {
|
|
25
|
+
job.steps.forEach((step, stepIndex) => {
|
|
26
|
+
if (!isRepositoryUses(step.uses) || !step.with) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
for (const [withKey, withValue] of Object.entries(step.with)) {
|
|
30
|
+
if (!VERSION_KEY_PATTERN.test(withKey) || !isFloatingVersionValue(withValue)) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const evidence = buildFindingEvidence({
|
|
34
|
+
textContent: input.textContent,
|
|
35
|
+
searchTerms: [step.uses ?? "", withKey, withValue],
|
|
36
|
+
fallbackValue: `${withKey}: ${withValue}`,
|
|
37
|
+
});
|
|
38
|
+
findings.push({
|
|
39
|
+
rule_id: "workflow-floating-action-version",
|
|
40
|
+
finding_id: `WORKFLOW_FLOATING_ACTION_VERSION-${input.filePath}-${jobIndex}-${stepIndex}-${withKey}`,
|
|
41
|
+
severity: "MEDIUM",
|
|
42
|
+
category: "CI_SUPPLY_CHAIN",
|
|
43
|
+
layer: "L2",
|
|
44
|
+
file_path: input.filePath,
|
|
45
|
+
location: { field: `jobs.${job.id}.steps[${stepIndex}].with.${withKey}` },
|
|
46
|
+
description: "Action input uses a floating version selector, which can pull unexpected releases over time",
|
|
47
|
+
affected_tools: ["github-actions"],
|
|
48
|
+
cve: null,
|
|
49
|
+
owasp: ["ASI02"],
|
|
50
|
+
cwe: "CWE-1104",
|
|
51
|
+
confidence: "HIGH",
|
|
52
|
+
fixable: false,
|
|
53
|
+
remediation_actions: [
|
|
54
|
+
"Pin action input versions to explicit releases instead of mutable selectors like latest",
|
|
55
|
+
],
|
|
56
|
+
evidence: evidence?.evidence ?? null,
|
|
57
|
+
suppressed: false,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
return findings;
|
|
63
|
+
}
|
|
@@ -104,6 +104,39 @@ export function detectWorkflowForbiddenUses(input) {
|
|
|
104
104
|
}
|
|
105
105
|
const findings = [];
|
|
106
106
|
facts.jobs.forEach((job, jobIndex) => {
|
|
107
|
+
const jobUses = job.uses?.trim();
|
|
108
|
+
if (jobUses && isForbidden(jobUses, policy)) {
|
|
109
|
+
const evidence = buildFindingEvidence({
|
|
110
|
+
textContent: input.textContent,
|
|
111
|
+
searchTerms: [jobUses],
|
|
112
|
+
fallbackValue: `uses: ${jobUses}`,
|
|
113
|
+
});
|
|
114
|
+
findings.push({
|
|
115
|
+
rule_id: "workflow-forbidden-uses",
|
|
116
|
+
finding_id: `WORKFLOW_FORBIDDEN_USES-JOB-${input.filePath}-${jobIndex}`,
|
|
117
|
+
severity: "HIGH",
|
|
118
|
+
category: "CI_SUPPLY_CHAIN",
|
|
119
|
+
layer: "L2",
|
|
120
|
+
file_path: input.filePath,
|
|
121
|
+
location: { field: `jobs.${job.id}.uses` },
|
|
122
|
+
description: policy.mode === "allow"
|
|
123
|
+
? "Workflow uses repository action outside the configured allowlist"
|
|
124
|
+
: "Workflow uses repository action matching the configured denylist",
|
|
125
|
+
affected_tools: ["github-actions"],
|
|
126
|
+
cve: null,
|
|
127
|
+
owasp: ["ASI02"],
|
|
128
|
+
cwe: "CWE-829",
|
|
129
|
+
confidence: "HIGH",
|
|
130
|
+
fixable: false,
|
|
131
|
+
remediation_actions: [
|
|
132
|
+
policy.mode === "allow"
|
|
133
|
+
? "Add the action to the allowlist only if it is explicitly trusted"
|
|
134
|
+
: "Remove the action or move it to an allowlist-only policy if it is trusted",
|
|
135
|
+
],
|
|
136
|
+
evidence: evidence?.evidence ?? null,
|
|
137
|
+
suppressed: false,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
107
140
|
job.steps.forEach((step, stepIndex) => {
|
|
108
141
|
const uses = step.uses?.trim();
|
|
109
142
|
if (!uses) {
|
|
@@ -1,7 +1,60 @@
|
|
|
1
1
|
import { buildFindingEvidence } from "../evidence.js";
|
|
2
2
|
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
const UNTRUSTED_TRIGGERS = new Set([
|
|
4
|
+
"pull_request",
|
|
5
|
+
"pull_request_target",
|
|
6
|
+
"issue_comment",
|
|
7
|
+
"discussion_comment",
|
|
8
|
+
"pull_request_review_comment",
|
|
9
|
+
"workflow_run",
|
|
10
|
+
]);
|
|
11
|
+
const UNTRUSTED_EVENT_REFERENCE_PATTERNS = [
|
|
12
|
+
/\bgithub\.event\.pull_request\.(?:title|body|head\.ref|head\.label|head\.repo\.full_name)\b/iu,
|
|
13
|
+
/\bgithub\.event\.issue\.(?:title|body)\b/iu,
|
|
14
|
+
/\bgithub\.event\.comment\.body\b/iu,
|
|
15
|
+
/\bgithub\.event\.review\.body\b/iu,
|
|
16
|
+
/\bgithub\.event\.discussion\.body\b/iu,
|
|
17
|
+
/\bgithub\.head_ref\b/iu,
|
|
18
|
+
];
|
|
19
|
+
const COMMAND_FILE_SPECS = [
|
|
20
|
+
{
|
|
21
|
+
name: "GITHUB_ENV",
|
|
22
|
+
severity: "HIGH",
|
|
23
|
+
writePatterns: [/>>\s*["']?\$?\{?GITHUB_ENV\}?/iu, /\btee\s+-a\s+["']?\$?\{?GITHUB_ENV\}?/iu],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "GITHUB_PATH",
|
|
27
|
+
severity: "CRITICAL",
|
|
28
|
+
writePatterns: [/>>\s*["']?\$?\{?GITHUB_PATH\}?/iu, /\btee\s+-a\s+["']?\$?\{?GITHUB_PATH\}?/iu],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "GITHUB_OUTPUT",
|
|
32
|
+
severity: "HIGH",
|
|
33
|
+
writePatterns: [
|
|
34
|
+
/>>\s*["']?\$?\{?GITHUB_OUTPUT\}?/iu,
|
|
35
|
+
/\btee\s+-a\s+["']?\$?\{?GITHUB_OUTPUT\}?/iu,
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "GITHUB_STATE",
|
|
40
|
+
severity: "MEDIUM",
|
|
41
|
+
writePatterns: [
|
|
42
|
+
/>>\s*["']?\$?\{?GITHUB_STATE\}?/iu,
|
|
43
|
+
/\btee\s+-a\s+["']?\$?\{?GITHUB_STATE\}?/iu,
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
function hasUntrustedEventReference(value) {
|
|
48
|
+
if (typeof value !== "string") {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return UNTRUSTED_EVENT_REFERENCE_PATTERNS.some((pattern) => pattern.test(value));
|
|
52
|
+
}
|
|
53
|
+
function collectWrittenCommandFiles(run) {
|
|
54
|
+
if (typeof run !== "string") {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
return COMMAND_FILE_SPECS.filter((spec) => spec.writePatterns.some((pattern) => pattern.test(run)));
|
|
5
58
|
}
|
|
6
59
|
export function detectWorkflowGithubEnv(input) {
|
|
7
60
|
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
@@ -12,36 +65,44 @@ export function detectWorkflowGithubEnv(input) {
|
|
|
12
65
|
return [];
|
|
13
66
|
}
|
|
14
67
|
const findings = [];
|
|
68
|
+
const hasUntrustedTrigger = facts.triggers.some((trigger) => UNTRUSTED_TRIGGERS.has(trigger));
|
|
15
69
|
facts.jobs.forEach((job, jobIndex) => {
|
|
16
70
|
job.steps.forEach((step, stepIndex) => {
|
|
17
|
-
|
|
71
|
+
const writtenCommandFiles = collectWrittenCommandFiles(step.run);
|
|
72
|
+
if (writtenCommandFiles.length === 0) {
|
|
18
73
|
return;
|
|
19
74
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
75
|
+
if (!hasUntrustedTrigger && !hasUntrustedEventReference(step.run)) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
writtenCommandFiles.forEach((commandFile) => {
|
|
79
|
+
const evidence = buildFindingEvidence({
|
|
80
|
+
textContent: input.textContent,
|
|
81
|
+
searchTerms: [step.run ?? "", commandFile.name],
|
|
82
|
+
fallbackValue: step.run ?? `write to ${commandFile.name}`,
|
|
83
|
+
});
|
|
84
|
+
findings.push({
|
|
85
|
+
rule_id: "workflow-command-file-poisoning",
|
|
86
|
+
finding_id: `WORKFLOW_COMMAND_FILE_POISONING-${commandFile.name}-${input.filePath}-${jobIndex}-${stepIndex}`,
|
|
87
|
+
severity: commandFile.severity,
|
|
88
|
+
category: "CI_TEMPLATE_INJECTION",
|
|
89
|
+
layer: "L2",
|
|
90
|
+
file_path: input.filePath,
|
|
91
|
+
location: { field: `jobs.${job.id}.steps[${stepIndex}].run` },
|
|
92
|
+
description: `Run step writes to ${commandFile.name} in an untrusted workflow context`,
|
|
93
|
+
affected_tools: ["github-actions"],
|
|
94
|
+
cve: null,
|
|
95
|
+
owasp: ["ASI02"],
|
|
96
|
+
cwe: "CWE-94",
|
|
97
|
+
confidence: "HIGH",
|
|
98
|
+
fixable: false,
|
|
99
|
+
remediation_actions: [
|
|
100
|
+
"Avoid writing attacker-controlled values into GitHub command files",
|
|
101
|
+
"Use strict allow-lists and sanitization before propagating untrusted data between workflow steps",
|
|
102
|
+
],
|
|
103
|
+
evidence: evidence?.evidence ?? null,
|
|
104
|
+
suppressed: false,
|
|
105
|
+
});
|
|
45
106
|
});
|
|
46
107
|
});
|
|
47
108
|
});
|
|
@@ -16,6 +16,74 @@ function parseRepositoryUses(value) {
|
|
|
16
16
|
}
|
|
17
17
|
return { slug, ref };
|
|
18
18
|
}
|
|
19
|
+
function parseSemverLike(value) {
|
|
20
|
+
const match = value.trim().match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+].*)?$/iu);
|
|
21
|
+
if (!match?.[1]) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return [
|
|
25
|
+
Number.parseInt(match[1], 10),
|
|
26
|
+
Number.parseInt(match[2] ?? "0", 10),
|
|
27
|
+
Number.parseInt(match[3] ?? "0", 10),
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
function compareSemverLike(left, right) {
|
|
31
|
+
for (let index = 0; index < 3; index += 1) {
|
|
32
|
+
if (left[index] < right[index]) {
|
|
33
|
+
return -1;
|
|
34
|
+
}
|
|
35
|
+
if (left[index] > right[index]) {
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
function matchesComparator(ref, comparator) {
|
|
42
|
+
const match = comparator.trim().match(/^(<=|>=|<|>|=)\s*(v?\d+(?:\.\d+){0,2})$/iu);
|
|
43
|
+
if (!match?.[1] || !match[2]) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const refVersion = parseSemverLike(ref);
|
|
47
|
+
const comparatorVersion = parseSemverLike(match[2]);
|
|
48
|
+
if (!refVersion || !comparatorVersion) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const comparison = compareSemverLike(refVersion, comparatorVersion);
|
|
52
|
+
switch (match[1]) {
|
|
53
|
+
case "<":
|
|
54
|
+
return comparison < 0;
|
|
55
|
+
case "<=":
|
|
56
|
+
return comparison <= 0;
|
|
57
|
+
case ">":
|
|
58
|
+
return comparison > 0;
|
|
59
|
+
case ">=":
|
|
60
|
+
return comparison >= 0;
|
|
61
|
+
case "=":
|
|
62
|
+
return comparison === 0;
|
|
63
|
+
default:
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function matchesVulnerablePattern(ref, pattern) {
|
|
68
|
+
const normalizedRef = ref.trim().toLowerCase();
|
|
69
|
+
const normalizedPattern = pattern.trim().toLowerCase();
|
|
70
|
+
if (normalizedPattern.length === 0) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (normalizedPattern.includes("*")) {
|
|
74
|
+
return normalizedPattern.endsWith("*")
|
|
75
|
+
? normalizedRef.startsWith(normalizedPattern.slice(0, -1))
|
|
76
|
+
: normalizedRef === normalizedPattern;
|
|
77
|
+
}
|
|
78
|
+
if (/^(?:<=|>=|<|>|=)/u.test(normalizedPattern)) {
|
|
79
|
+
const comparators = normalizedPattern.split(/\s+/u).filter((token) => token.length > 0);
|
|
80
|
+
return comparators.every((comparator) => matchesComparator(normalizedRef, comparator));
|
|
81
|
+
}
|
|
82
|
+
return normalizedRef === normalizedPattern;
|
|
83
|
+
}
|
|
84
|
+
function isKnownVulnerableRef(ref, patterns) {
|
|
85
|
+
return patterns.some((pattern) => matchesVulnerablePattern(ref, pattern));
|
|
86
|
+
}
|
|
19
87
|
export function detectWorkflowKnownVulnAction(input) {
|
|
20
88
|
if (input.runtimeMode !== "online") {
|
|
21
89
|
return [];
|
|
@@ -30,6 +98,36 @@ export function detectWorkflowKnownVulnAction(input) {
|
|
|
30
98
|
const advisories = loadKnownVulnerableActions({ runtimeMode: input.runtimeMode }).advisories;
|
|
31
99
|
const findings = [];
|
|
32
100
|
facts.jobs.forEach((job, jobIndex) => {
|
|
101
|
+
const jobUses = job.uses;
|
|
102
|
+
if (jobUses) {
|
|
103
|
+
const parsedJobUses = parseRepositoryUses(jobUses);
|
|
104
|
+
if (parsedJobUses) {
|
|
105
|
+
const vulnerableVersions = advisories[parsedJobUses.slug];
|
|
106
|
+
if (vulnerableVersions && isKnownVulnerableRef(parsedJobUses.ref, vulnerableVersions)) {
|
|
107
|
+
findings.push({
|
|
108
|
+
rule_id: "workflow-known-vuln-action",
|
|
109
|
+
finding_id: `WORKFLOW_KNOWN_VULN_ACTION-JOB-${input.filePath}-${jobIndex}`,
|
|
110
|
+
severity: "HIGH",
|
|
111
|
+
category: "CI_VULNERABLE_ACTION",
|
|
112
|
+
layer: "L2",
|
|
113
|
+
file_path: input.filePath,
|
|
114
|
+
location: { field: `jobs.${job.id}.uses` },
|
|
115
|
+
description: `Action ${parsedJobUses.slug}@${parsedJobUses.ref} is listed in known vulnerable references`,
|
|
116
|
+
affected_tools: ["github-actions"],
|
|
117
|
+
cve: null,
|
|
118
|
+
owasp: ["ASI02"],
|
|
119
|
+
cwe: "CWE-937",
|
|
120
|
+
confidence: "HIGH",
|
|
121
|
+
fixable: false,
|
|
122
|
+
remediation_actions: [
|
|
123
|
+
"Upgrade to a non-vulnerable action release and pin to a reviewed commit SHA",
|
|
124
|
+
],
|
|
125
|
+
evidence: jobUses,
|
|
126
|
+
suppressed: false,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
33
131
|
job.steps.forEach((step, stepIndex) => {
|
|
34
132
|
const uses = step.uses;
|
|
35
133
|
if (!uses) {
|
|
@@ -40,7 +138,7 @@ export function detectWorkflowKnownVulnAction(input) {
|
|
|
40
138
|
return;
|
|
41
139
|
}
|
|
42
140
|
const vulnerableVersions = advisories[parsedUses.slug];
|
|
43
|
-
if (!vulnerableVersions || !
|
|
141
|
+
if (!vulnerableVersions || !isKnownVulnerableRef(parsedUses.ref, vulnerableVersions)) {
|
|
44
142
|
return;
|
|
45
143
|
}
|
|
46
144
|
findings.push({
|
|
@@ -30,6 +30,36 @@ export function detectWorkflowRefConfusion(input) {
|
|
|
30
30
|
}
|
|
31
31
|
const findings = [];
|
|
32
32
|
for (const [jobIndex, job] of facts.jobs.entries()) {
|
|
33
|
+
const jobUses = job.uses?.trim();
|
|
34
|
+
if (jobUses) {
|
|
35
|
+
const parsedJobUses = parseRepositoryUses(jobUses);
|
|
36
|
+
if (parsedJobUses && !isHashPinned(parsedJobUses.ref)) {
|
|
37
|
+
const evidence = buildFindingEvidence({
|
|
38
|
+
textContent: input.textContent,
|
|
39
|
+
searchTerms: [jobUses],
|
|
40
|
+
fallbackValue: `uses: ${jobUses}`,
|
|
41
|
+
});
|
|
42
|
+
findings.push({
|
|
43
|
+
rule_id: "workflow-ref-confusion",
|
|
44
|
+
finding_id: `WORKFLOW_REF_CONFUSION-JOB-${input.filePath}-${jobIndex}`,
|
|
45
|
+
severity: "HIGH",
|
|
46
|
+
category: "CI_VULNERABLE_ACTION",
|
|
47
|
+
layer: "L2",
|
|
48
|
+
file_path: input.filePath,
|
|
49
|
+
location: { field: `jobs.${job.id}.uses` },
|
|
50
|
+
description: "Workflow action is pinned to a symbolic ref instead of an immutable commit hash",
|
|
51
|
+
affected_tools: ["github-actions"],
|
|
52
|
+
cve: null,
|
|
53
|
+
owasp: ["ASI02"],
|
|
54
|
+
cwe: "CWE-829",
|
|
55
|
+
confidence: "HIGH",
|
|
56
|
+
fixable: false,
|
|
57
|
+
remediation_actions: ["Pin external actions to a full commit SHA"],
|
|
58
|
+
evidence: evidence?.evidence ?? null,
|
|
59
|
+
suppressed: false,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
33
63
|
for (const [stepIndex, step] of job.steps.entries()) {
|
|
34
64
|
const uses = step.uses?.trim();
|
|
35
65
|
if (!uses) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Finding } from "../../types/finding.js";
|
|
2
|
+
export interface WorkflowRunUntrustedArtifactInput {
|
|
3
|
+
filePath: string;
|
|
4
|
+
parsed: unknown;
|
|
5
|
+
textContent: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function detectWorkflowRunUntrustedArtifact(input: WorkflowRunUntrustedArtifactInput): Finding[];
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
function asRecord(value) {
|
|
4
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
function hasWritePermission(value) {
|
|
10
|
+
if (typeof value === "string") {
|
|
11
|
+
return value.trim().toLowerCase() === "write-all";
|
|
12
|
+
}
|
|
13
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return Object.values(value).some((permission) => typeof permission === "string" && permission.trim().toLowerCase() === "write");
|
|
17
|
+
}
|
|
18
|
+
function hasIdTokenWrite(value) {
|
|
19
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const idTokenPermission = value["id-token"];
|
|
23
|
+
return (typeof idTokenPermission === "string" && idTokenPermission.trim().toLowerCase() === "write");
|
|
24
|
+
}
|
|
25
|
+
function hasInheritedSecrets(secrets) {
|
|
26
|
+
return typeof secrets === "string" && secrets.trim().toLowerCase() === "inherit";
|
|
27
|
+
}
|
|
28
|
+
function hasWorkflowRunBranchFilter(parsed) {
|
|
29
|
+
const root = asRecord(parsed);
|
|
30
|
+
const onValue = asRecord(root?.on);
|
|
31
|
+
const workflowRun = asRecord(onValue?.workflow_run);
|
|
32
|
+
if (!workflowRun) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
const branches = workflowRun.branches;
|
|
36
|
+
if (typeof branches === "string") {
|
|
37
|
+
return branches.trim().length > 0;
|
|
38
|
+
}
|
|
39
|
+
if (!Array.isArray(branches)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return branches.some((branch) => typeof branch === "string" && branch.trim().length > 0);
|
|
43
|
+
}
|
|
44
|
+
function hasWorkflowRunOriginGuard(condition) {
|
|
45
|
+
if (typeof condition !== "string") {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return /github\.event\.workflow_run\.event\s*!=\s*['"]pull_request['"]/iu.test(condition);
|
|
49
|
+
}
|
|
50
|
+
function hasWorkflowRunBranchGuard(condition) {
|
|
51
|
+
if (typeof condition !== "string") {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return /\bgithub\.event\.workflow_run\.head_branch\b/iu.test(condition);
|
|
55
|
+
}
|
|
56
|
+
function isDownloadArtifactStep(stepUses) {
|
|
57
|
+
if (typeof stepUses !== "string") {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return /^actions\/download-artifact@/iu.test(stepUses.trim());
|
|
61
|
+
}
|
|
62
|
+
export function detectWorkflowRunUntrustedArtifact(input) {
|
|
63
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
67
|
+
if (!facts ||
|
|
68
|
+
!facts.triggers.some((trigger) => trigger.trim().toLowerCase() === "workflow_run")) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
const workflowHasBranchFilter = hasWorkflowRunBranchFilter(input.parsed);
|
|
72
|
+
const workflowHasWritePermission = hasWritePermission(facts.workflowPermissions);
|
|
73
|
+
const findings = [];
|
|
74
|
+
facts.jobs.forEach((job, jobIndex) => {
|
|
75
|
+
const hasArtifactDownload = job.steps.some((step) => isDownloadArtifactStep(step.uses));
|
|
76
|
+
if (!hasArtifactDownload) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const hasRunExecution = job.steps.some((step) => typeof step.run === "string" && step.run.trim().length > 0);
|
|
80
|
+
if (!hasRunExecution) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const hasPrivilegedContext = workflowHasWritePermission ||
|
|
84
|
+
hasWritePermission(job.permissions) ||
|
|
85
|
+
hasIdTokenWrite(facts.workflowPermissions) ||
|
|
86
|
+
hasIdTokenWrite(job.permissions) ||
|
|
87
|
+
hasInheritedSecrets(job.secrets);
|
|
88
|
+
if (!hasPrivilegedContext) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const hasOriginGuard = hasWorkflowRunOriginGuard(job.if);
|
|
92
|
+
const hasBranchGuard = workflowHasBranchFilter || hasWorkflowRunBranchGuard(job.if);
|
|
93
|
+
if (hasOriginGuard && hasBranchGuard) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const evidence = buildFindingEvidence({
|
|
97
|
+
textContent: input.textContent,
|
|
98
|
+
searchTerms: ["workflow_run", "actions/download-artifact", "github.event.workflow_run.event"],
|
|
99
|
+
fallbackValue: `${job.id} consumes workflow_run artifacts in privileged context without strict guards`,
|
|
100
|
+
});
|
|
101
|
+
findings.push({
|
|
102
|
+
rule_id: "workflow-run-untrusted-artifact",
|
|
103
|
+
finding_id: `WORKFLOW_RUN_UNTRUSTED_ARTIFACT-${input.filePath}-${jobIndex}`,
|
|
104
|
+
severity: "HIGH",
|
|
105
|
+
category: "CI_SUPPLY_CHAIN",
|
|
106
|
+
layer: "L2",
|
|
107
|
+
file_path: input.filePath,
|
|
108
|
+
location: { field: `jobs.${job.id}` },
|
|
109
|
+
description: "Privileged workflow_run job downloads artifacts and executes commands without strict origin and branch guards",
|
|
110
|
+
affected_tools: ["github-actions"],
|
|
111
|
+
cve: null,
|
|
112
|
+
owasp: ["ASI02"],
|
|
113
|
+
cwe: "CWE-829",
|
|
114
|
+
confidence: "HIGH",
|
|
115
|
+
fixable: false,
|
|
116
|
+
remediation_actions: [
|
|
117
|
+
"Guard workflow_run jobs with github.event.workflow_run.event != 'pull_request'",
|
|
118
|
+
"Restrict workflow_run execution to trusted branches and validate artifact contents before use",
|
|
119
|
+
"Download artifacts into temporary directories and avoid executing untrusted artifact content directly",
|
|
120
|
+
],
|
|
121
|
+
evidence: evidence?.evidence ?? null,
|
|
122
|
+
suppressed: false,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
return findings;
|
|
126
|
+
}
|
|
@@ -5,11 +5,48 @@ const UNTRUSTED_TRIGGERS = new Set([
|
|
|
5
5
|
"pull_request_target",
|
|
6
6
|
"issue_comment",
|
|
7
7
|
"discussion_comment",
|
|
8
|
+
"pull_request_review_comment",
|
|
8
9
|
"workflow_run",
|
|
9
10
|
]);
|
|
10
11
|
function hasTemplateExpression(value) {
|
|
11
12
|
return typeof value === "string" && value.includes("${{");
|
|
12
13
|
}
|
|
14
|
+
const UNTRUSTED_EVENT_REFERENCE_PATTERNS = [
|
|
15
|
+
/\bgithub\.event\.pull_request\.(?:title|body|head\.ref|head\.label|head\.repo\.full_name)\b/iu,
|
|
16
|
+
/\bgithub\.event\.issue\.(?:title|body)\b/iu,
|
|
17
|
+
/\bgithub\.event\.comment\.body\b/iu,
|
|
18
|
+
/\bgithub\.event\.review\.body\b/iu,
|
|
19
|
+
/\bgithub\.event\.discussion\.body\b/iu,
|
|
20
|
+
/\bgithub\.head_ref\b/iu,
|
|
21
|
+
];
|
|
22
|
+
const PRIVILEGED_COMMAND_PATTERNS = [
|
|
23
|
+
/\bgh\s+release\b/iu,
|
|
24
|
+
/\bdeploy\b/iu,
|
|
25
|
+
/\bpublish\b/iu,
|
|
26
|
+
/\brelease\b/iu,
|
|
27
|
+
/\bcurl\b/iu,
|
|
28
|
+
/\bwget\b/iu,
|
|
29
|
+
/\bbash\b/iu,
|
|
30
|
+
/\bsh\b/iu,
|
|
31
|
+
];
|
|
32
|
+
function hasUntrustedEventReference(value) {
|
|
33
|
+
if (typeof value !== "string") {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return UNTRUSTED_EVENT_REFERENCE_PATTERNS.some((pattern) => pattern.test(value));
|
|
37
|
+
}
|
|
38
|
+
function hasUntrustedTemplateExpression(value) {
|
|
39
|
+
return hasTemplateExpression(value) && hasUntrustedEventReference(value);
|
|
40
|
+
}
|
|
41
|
+
function isPrivilegedStep(run, uses) {
|
|
42
|
+
if (typeof uses === "string" && uses.trim().length > 0) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (typeof run !== "string") {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return PRIVILEGED_COMMAND_PATTERNS.some((pattern) => pattern.test(run));
|
|
49
|
+
}
|
|
13
50
|
function normalizeUsesSlug(value) {
|
|
14
51
|
const beforeRef = value.split("@")[0] ?? value;
|
|
15
52
|
return beforeRef.replace(/\/+$/u, "").toLowerCase();
|
|
@@ -29,7 +66,31 @@ export function detectWorkflowTemplateInjection(input) {
|
|
|
29
66
|
const findings = [];
|
|
30
67
|
facts.jobs.forEach((job, jobIndex) => {
|
|
31
68
|
job.steps.forEach((step, stepIndex) => {
|
|
32
|
-
if (
|
|
69
|
+
if (step.if && hasUntrustedEventReference(step.if) && isPrivilegedStep(step.run, step.uses)) {
|
|
70
|
+
findings.push({
|
|
71
|
+
rule_id: "workflow-template-injection",
|
|
72
|
+
finding_id: `WORKFLOW_TEMPLATE_INJECTION-CONDITION-${input.filePath}-${jobIndex}-${stepIndex}`,
|
|
73
|
+
severity: "HIGH",
|
|
74
|
+
category: "CI_TEMPLATE_INJECTION",
|
|
75
|
+
layer: "L2",
|
|
76
|
+
file_path: input.filePath,
|
|
77
|
+
location: { field: `jobs.${job.id}.steps[${stepIndex}].if` },
|
|
78
|
+
description: "Step condition trusts attacker-controlled issue, comment, or pull request content before privileged execution",
|
|
79
|
+
affected_tools: ["github-actions"],
|
|
80
|
+
cve: null,
|
|
81
|
+
owasp: ["ASI02"],
|
|
82
|
+
cwe: "CWE-20",
|
|
83
|
+
confidence: "HIGH",
|
|
84
|
+
fixable: false,
|
|
85
|
+
remediation_actions: [
|
|
86
|
+
"Do not gate privileged steps on raw issue/comment/pull-request text",
|
|
87
|
+
"Require explicit allow-lists or trusted actor checks before executing privileged paths",
|
|
88
|
+
],
|
|
89
|
+
evidence: step.if,
|
|
90
|
+
suppressed: false,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (hasUntrustedTemplateExpression(step.run)) {
|
|
33
94
|
findings.push({
|
|
34
95
|
rule_id: "workflow-template-injection",
|
|
35
96
|
finding_id: `WORKFLOW_TEMPLATE_INJECTION-RUN-${input.filePath}-${jobIndex}-${stepIndex}`,
|
|
@@ -58,33 +119,61 @@ export function detectWorkflowTemplateInjection(input) {
|
|
|
58
119
|
}
|
|
59
120
|
const slug = normalizeUsesSlug(uses);
|
|
60
121
|
const sinkFields = sinkMap[slug];
|
|
61
|
-
|
|
62
|
-
|
|
122
|
+
const flaggedSinkFields = new Set();
|
|
123
|
+
if (sinkFields && sinkFields.length > 0) {
|
|
124
|
+
for (const sinkField of sinkFields) {
|
|
125
|
+
const sinkValue = step.with[sinkField];
|
|
126
|
+
if (!hasUntrustedTemplateExpression(sinkValue)) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
flaggedSinkFields.add(sinkField);
|
|
130
|
+
findings.push({
|
|
131
|
+
rule_id: "workflow-template-injection",
|
|
132
|
+
finding_id: `WORKFLOW_TEMPLATE_INJECTION-SINK-${input.filePath}-${jobIndex}-${stepIndex}-${sinkField}`,
|
|
133
|
+
severity: "HIGH",
|
|
134
|
+
category: "CI_TEMPLATE_INJECTION",
|
|
135
|
+
layer: "L2",
|
|
136
|
+
file_path: input.filePath,
|
|
137
|
+
location: { field: `jobs.${job.id}.steps[${stepIndex}].with.${sinkField}` },
|
|
138
|
+
description: "Template expression reaches an action input known to execute code or evaluate scripts",
|
|
139
|
+
affected_tools: ["github-actions"],
|
|
140
|
+
cve: null,
|
|
141
|
+
owasp: ["ASI02"],
|
|
142
|
+
cwe: "CWE-94",
|
|
143
|
+
confidence: "HIGH",
|
|
144
|
+
fixable: false,
|
|
145
|
+
remediation_actions: [
|
|
146
|
+
"Avoid passing untrusted template expressions into code execution sink inputs",
|
|
147
|
+
],
|
|
148
|
+
evidence: sinkValue,
|
|
149
|
+
suppressed: false,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
63
152
|
}
|
|
64
|
-
for (const
|
|
65
|
-
|
|
66
|
-
if (!hasTemplateExpression(sinkValue)) {
|
|
153
|
+
for (const [inputField, inputValue] of Object.entries(step.with)) {
|
|
154
|
+
if (flaggedSinkFields.has(inputField) || !hasUntrustedTemplateExpression(inputValue)) {
|
|
67
155
|
continue;
|
|
68
156
|
}
|
|
69
157
|
findings.push({
|
|
70
158
|
rule_id: "workflow-template-injection",
|
|
71
|
-
finding_id: `WORKFLOW_TEMPLATE_INJECTION-
|
|
72
|
-
severity: "
|
|
159
|
+
finding_id: `WORKFLOW_TEMPLATE_INJECTION-WITH-${input.filePath}-${jobIndex}-${stepIndex}-${inputField}`,
|
|
160
|
+
severity: "MEDIUM",
|
|
73
161
|
category: "CI_TEMPLATE_INJECTION",
|
|
74
162
|
layer: "L2",
|
|
75
163
|
file_path: input.filePath,
|
|
76
|
-
location: { field: `jobs.${job.id}.steps[${stepIndex}].with.${
|
|
77
|
-
description: "
|
|
164
|
+
location: { field: `jobs.${job.id}.steps[${stepIndex}].with.${inputField}` },
|
|
165
|
+
description: "Action input receives attacker-controlled issue, comment, or pull request content",
|
|
78
166
|
affected_tools: ["github-actions"],
|
|
79
167
|
cve: null,
|
|
80
168
|
owasp: ["ASI02"],
|
|
81
|
-
cwe: "CWE-
|
|
169
|
+
cwe: "CWE-20",
|
|
82
170
|
confidence: "HIGH",
|
|
83
171
|
fixable: false,
|
|
84
172
|
remediation_actions: [
|
|
85
|
-
"
|
|
173
|
+
"Do not pass raw issue/comment/pull-request text into action inputs without validation",
|
|
174
|
+
"Prefer explicit allow-lists and strict parsing for any user-controlled values",
|
|
86
175
|
],
|
|
87
|
-
evidence:
|
|
176
|
+
evidence: inputValue,
|
|
88
177
|
suppressed: false,
|
|
89
178
|
});
|
|
90
179
|
}
|
|
@@ -16,6 +16,40 @@ export function detectWorkflowUnpinnedUses(input) {
|
|
|
16
16
|
}
|
|
17
17
|
const findings = [];
|
|
18
18
|
facts.jobs.forEach((job, jobIndex) => {
|
|
19
|
+
const jobUses = job.uses?.trim();
|
|
20
|
+
if (jobUses && !jobUses.startsWith("./") && !jobUses.startsWith("docker://")) {
|
|
21
|
+
if (isRepositoryUses(jobUses)) {
|
|
22
|
+
const ref = jobUses.split("@").slice(1).join("@").trim();
|
|
23
|
+
if (ref.length > 0 && !isPinnedToCommit(ref)) {
|
|
24
|
+
const evidence = buildFindingEvidence({
|
|
25
|
+
textContent: input.textContent,
|
|
26
|
+
searchTerms: [jobUses],
|
|
27
|
+
fallbackValue: `uses: ${jobUses}`,
|
|
28
|
+
});
|
|
29
|
+
findings.push({
|
|
30
|
+
rule_id: "workflow-unpinned-uses",
|
|
31
|
+
finding_id: `WORKFLOW_UNPINNED_USES-JOB-${input.filePath}-${jobIndex}`,
|
|
32
|
+
severity: "HIGH",
|
|
33
|
+
category: "CI_SUPPLY_CHAIN",
|
|
34
|
+
layer: "L2",
|
|
35
|
+
file_path: input.filePath,
|
|
36
|
+
location: { field: `jobs.${job.id}.uses` },
|
|
37
|
+
description: "Workflow reusable reference is not pinned to an immutable commit hash",
|
|
38
|
+
affected_tools: ["github-actions"],
|
|
39
|
+
cve: null,
|
|
40
|
+
owasp: ["ASI02"],
|
|
41
|
+
cwe: "CWE-829",
|
|
42
|
+
confidence: "HIGH",
|
|
43
|
+
fixable: false,
|
|
44
|
+
remediation_actions: [
|
|
45
|
+
"Pin reusable workflows to a full commit SHA and track tag intent in comments",
|
|
46
|
+
],
|
|
47
|
+
evidence: evidence?.evidence ?? null,
|
|
48
|
+
suppressed: false,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
19
53
|
job.steps.forEach((step, stepIndex) => {
|
|
20
54
|
const uses = step.uses?.trim();
|
|
21
55
|
if (!uses || uses.startsWith("./") || uses.startsWith("docker://")) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Finding } from "../../types/finding.js";
|
|
2
|
+
export interface WorkflowUnsafeCheckoutRefInput {
|
|
3
|
+
filePath: string;
|
|
4
|
+
parsed: unknown;
|
|
5
|
+
textContent: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function detectWorkflowUnsafeCheckoutRef(input: WorkflowUnsafeCheckoutRefInput): Finding[];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
const UNTRUSTED_REF_PATTERNS = [
|
|
4
|
+
/\bgithub\.event\.pull_request\.head\.ref\b/iu,
|
|
5
|
+
/\bgithub\.head_ref\b/iu,
|
|
6
|
+
/\bgithub\.event\.workflow_run\.head_branch\b/iu,
|
|
7
|
+
];
|
|
8
|
+
function hasWritePermission(value) {
|
|
9
|
+
if (typeof value === "string") {
|
|
10
|
+
return value.trim().toLowerCase() === "write-all";
|
|
11
|
+
}
|
|
12
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return Object.values(value).some((permission) => typeof permission === "string" && permission.trim().toLowerCase() === "write");
|
|
16
|
+
}
|
|
17
|
+
function hasIdTokenWrite(value) {
|
|
18
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const idTokenPermission = value["id-token"];
|
|
22
|
+
return (typeof idTokenPermission === "string" && idTokenPermission.trim().toLowerCase() === "write");
|
|
23
|
+
}
|
|
24
|
+
function hasInheritedSecrets(secrets) {
|
|
25
|
+
return typeof secrets === "string" && secrets.trim().toLowerCase() === "inherit";
|
|
26
|
+
}
|
|
27
|
+
function isCheckoutStep(stepUses) {
|
|
28
|
+
if (typeof stepUses !== "string") {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return /^actions\/checkout@/iu.test(stepUses.trim());
|
|
32
|
+
}
|
|
33
|
+
function hasUnsafeRef(ref) {
|
|
34
|
+
if (typeof ref !== "string") {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return UNTRUSTED_REF_PATTERNS.some((pattern) => pattern.test(ref));
|
|
38
|
+
}
|
|
39
|
+
export function detectWorkflowUnsafeCheckoutRef(input) {
|
|
40
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
44
|
+
if (!facts) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const hasRelevantTrigger = facts.triggers.some((trigger) => {
|
|
48
|
+
const normalized = trigger.trim().toLowerCase();
|
|
49
|
+
return normalized === "pull_request_target" || normalized === "workflow_run";
|
|
50
|
+
});
|
|
51
|
+
if (!hasRelevantTrigger) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const workflowPrivileged = hasWritePermission(facts.workflowPermissions) || hasIdTokenWrite(facts.workflowPermissions);
|
|
55
|
+
const findings = [];
|
|
56
|
+
facts.jobs.forEach((job, jobIndex) => {
|
|
57
|
+
const jobPrivileged = workflowPrivileged ||
|
|
58
|
+
hasWritePermission(job.permissions) ||
|
|
59
|
+
hasIdTokenWrite(job.permissions) ||
|
|
60
|
+
hasInheritedSecrets(job.secrets);
|
|
61
|
+
if (!jobPrivileged) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
job.steps.forEach((step, stepIndex) => {
|
|
65
|
+
if (!isCheckoutStep(step.uses)) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const refValue = step.with?.ref;
|
|
69
|
+
if (!hasUnsafeRef(refValue)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const evidence = buildFindingEvidence({
|
|
73
|
+
textContent: input.textContent,
|
|
74
|
+
searchTerms: [step.uses ?? "", refValue ?? "", "head.ref", "head_branch"],
|
|
75
|
+
fallbackValue: `jobs.${job.id}.steps[${stepIndex}].with.ref`,
|
|
76
|
+
});
|
|
77
|
+
findings.push({
|
|
78
|
+
rule_id: "workflow-unsafe-checkout-ref",
|
|
79
|
+
finding_id: `WORKFLOW_UNSAFE_CHECKOUT_REF-${input.filePath}-${jobIndex}-${stepIndex}`,
|
|
80
|
+
severity: "HIGH",
|
|
81
|
+
category: "CI_TEMPLATE_INJECTION",
|
|
82
|
+
layer: "L2",
|
|
83
|
+
file_path: input.filePath,
|
|
84
|
+
location: { field: `jobs.${job.id}.steps[${stepIndex}].with.ref` },
|
|
85
|
+
description: "Privileged checkout references attacker-influenced ref names; prefer immutable commit SHA values",
|
|
86
|
+
affected_tools: ["github-actions"],
|
|
87
|
+
cve: null,
|
|
88
|
+
owasp: ["ASI02"],
|
|
89
|
+
cwe: "CWE-20",
|
|
90
|
+
confidence: "HIGH",
|
|
91
|
+
fixable: false,
|
|
92
|
+
remediation_actions: [
|
|
93
|
+
"Use github.event.pull_request.head.sha (or an immutable SHA) instead of head.ref/head_branch values",
|
|
94
|
+
"Avoid checking out attacker-controlled refs in privileged workflows",
|
|
95
|
+
],
|
|
96
|
+
evidence: evidence?.evidence ?? refValue ?? null,
|
|
97
|
+
suppressed: false,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
return findings;
|
|
102
|
+
}
|
|
@@ -15,6 +15,7 @@ import { detectWorkflowUnpinnedUses } from "./detectors/workflow-unpinned-uses.j
|
|
|
15
15
|
import { detectWorkflowArtipacked } from "./detectors/workflow-artipacked.js";
|
|
16
16
|
import { detectWorkflowCachePoisoning } from "./detectors/workflow-cache-poisoning.js";
|
|
17
17
|
import { detectWorkflowGithubEnv } from "./detectors/workflow-github-env.js";
|
|
18
|
+
import { detectWorkflowRunUntrustedArtifact } from "./detectors/workflow-run-untrusted-artifact.js";
|
|
18
19
|
import { detectWorkflowInsecureCommands } from "./detectors/workflow-insecure-commands.js";
|
|
19
20
|
import { detectWorkflowSelfHostedRunner } from "./detectors/workflow-self-hosted-runner.js";
|
|
20
21
|
import { detectWorkflowOverprovisionedSecrets } from "./detectors/workflow-overprovisioned-secrets.js";
|
|
@@ -28,7 +29,9 @@ import { detectWorkflowForbiddenUses } from "./detectors/workflow-forbidden-uses
|
|
|
28
29
|
import { detectWorkflowRefConfusion } from "./detectors/workflow-ref-confusion.js";
|
|
29
30
|
import { detectWorkflowRefVersionMismatch } from "./detectors/workflow-ref-version-mismatch.js";
|
|
30
31
|
import { detectWorkflowImpostorCommit } from "./detectors/workflow-impostor-commit.js";
|
|
32
|
+
import { detectWorkflowUnsafeCheckoutRef } from "./detectors/workflow-unsafe-checkout-ref.js";
|
|
31
33
|
import { detectWorkflowUnpinnedImages } from "./detectors/workflow-unpinned-images.js";
|
|
34
|
+
import { detectWorkflowFloatingActionVersion } from "./detectors/workflow-floating-action-version.js";
|
|
32
35
|
import { detectWorkflowAnonymousDefinition } from "./detectors/workflow-anonymous-definition.js";
|
|
33
36
|
import { detectWorkflowConcurrencyLimits } from "./detectors/workflow-concurrency-limits.js";
|
|
34
37
|
import { detectWorkflowSuperfluousActions } from "./detectors/workflow-superfluous-actions.js";
|
|
@@ -307,6 +310,16 @@ function buildFileAudits() {
|
|
|
307
310
|
})
|
|
308
311
|
: [],
|
|
309
312
|
},
|
|
313
|
+
{
|
|
314
|
+
id: "workflow-run-untrusted-artifact",
|
|
315
|
+
run: ({ file, input }) => input.config.workflowAuditsEnabled
|
|
316
|
+
? detectWorkflowRunUntrustedArtifact({
|
|
317
|
+
filePath: file.filePath,
|
|
318
|
+
parsed: file.parsed,
|
|
319
|
+
textContent: file.textContent,
|
|
320
|
+
})
|
|
321
|
+
: [],
|
|
322
|
+
},
|
|
310
323
|
{
|
|
311
324
|
id: "workflow-insecure-commands",
|
|
312
325
|
run: ({ file, input }) => input.config.workflowAuditsEnabled
|
|
@@ -432,6 +445,26 @@ function buildFileAudits() {
|
|
|
432
445
|
})
|
|
433
446
|
: [],
|
|
434
447
|
},
|
|
448
|
+
{
|
|
449
|
+
id: "workflow-unsafe-checkout-ref",
|
|
450
|
+
run: ({ file, input }) => input.config.workflowAuditsEnabled
|
|
451
|
+
? detectWorkflowUnsafeCheckoutRef({
|
|
452
|
+
filePath: file.filePath,
|
|
453
|
+
parsed: file.parsed,
|
|
454
|
+
textContent: file.textContent,
|
|
455
|
+
})
|
|
456
|
+
: [],
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
id: "workflow-floating-action-version",
|
|
460
|
+
run: ({ file, input }) => input.config.workflowAuditsEnabled
|
|
461
|
+
? detectWorkflowFloatingActionVersion({
|
|
462
|
+
filePath: file.filePath,
|
|
463
|
+
parsed: file.parsed,
|
|
464
|
+
textContent: file.textContent,
|
|
465
|
+
})
|
|
466
|
+
: [],
|
|
467
|
+
},
|
|
435
468
|
{
|
|
436
469
|
id: "workflow-impostor-commit",
|
|
437
470
|
onlineRequired: true,
|
|
@@ -9,6 +9,15 @@ function normalizeAdvisoryMap(value) {
|
|
|
9
9
|
}
|
|
10
10
|
return normalized;
|
|
11
11
|
}
|
|
12
|
+
function mergeAdvisoryMaps(bundled, cached) {
|
|
13
|
+
const merged = {};
|
|
14
|
+
const allKeys = new Set([...Object.keys(bundled), ...Object.keys(cached)]);
|
|
15
|
+
for (const key of allKeys) {
|
|
16
|
+
const versions = new Set([...(bundled[key] ?? []), ...(cached[key] ?? [])]);
|
|
17
|
+
merged[key] = Array.from(versions);
|
|
18
|
+
}
|
|
19
|
+
return merged;
|
|
20
|
+
}
|
|
12
21
|
export function createGithubMetadataClient(options = {}) {
|
|
13
22
|
const runtimeMode = options.runtimeMode ?? "offline";
|
|
14
23
|
const cacheDir = options.cacheDir ?? join(homedir(), ".codegate", "cache");
|
|
@@ -31,7 +40,10 @@ export function createGithubMetadataClient(options = {}) {
|
|
|
31
40
|
}
|
|
32
41
|
const cached = loadCachedAdvisoryPayload(cacheDir, cacheMaxAgeMs, now);
|
|
33
42
|
if (cached) {
|
|
34
|
-
return
|
|
43
|
+
return {
|
|
44
|
+
generatedAt: Math.max(payload.generatedAt, cached.generatedAt),
|
|
45
|
+
advisories: mergeAdvisoryMaps(payload.advisories, cached.advisories),
|
|
46
|
+
};
|
|
35
47
|
}
|
|
36
48
|
saveCachedAdvisoryPayload(cacheDir, payload);
|
|
37
49
|
return payload;
|