codegate-ai 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -6
- package/dist/cli.js +24 -0
- package/dist/commands/clawhub-wrapper.d.ts +8 -1
- package/dist/commands/clawhub-wrapper.js +106 -4
- package/dist/commands/skills-wrapper.d.ts +8 -1
- package/dist/commands/skills-wrapper.js +106 -4
- 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
|
@@ -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[];
|