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.
@@ -1,4 +1,6 @@
1
1
  {
2
2
  "actions/checkout": ["v3"],
3
- "tj-actions/changed-files": ["v39"]
3
+ "tj-actions/changed-files": ["v39"],
4
+ "aquasecurity/trivy-action": ["<0.35.0"],
5
+ "aquasecurity/setup-trivy": [">=0.1.0 <0.2.7"]
4
6
  }
@@ -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
- function writesToGithubEnv(run) {
4
- return typeof run === "string" && />>\s*["']?\$?\{?GITHUB_ENV\}?/iu.test(run);
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
- if (!writesToGithubEnv(step.run)) {
71
+ const writtenCommandFiles = collectWrittenCommandFiles(step.run);
72
+ if (writtenCommandFiles.length === 0) {
18
73
  return;
19
74
  }
20
- const evidence = buildFindingEvidence({
21
- textContent: input.textContent,
22
- searchTerms: [step.run ?? "", "GITHUB_ENV"],
23
- fallbackValue: step.run ?? "write to GITHUB_ENV",
24
- });
25
- findings.push({
26
- rule_id: "workflow-github-env",
27
- finding_id: `WORKFLOW_GITHUB_ENV-${input.filePath}-${jobIndex}-${stepIndex}`,
28
- severity: "HIGH",
29
- category: "CI_SUPPLY_CHAIN",
30
- layer: "L2",
31
- file_path: input.filePath,
32
- location: { field: `jobs.${job.id}.steps[${stepIndex}].run` },
33
- description: "Run step writes to GITHUB_ENV",
34
- affected_tools: ["github-actions"],
35
- cve: null,
36
- owasp: ["ASI02"],
37
- cwe: "CWE-94",
38
- confidence: "HIGH",
39
- fixable: false,
40
- remediation_actions: [
41
- "Avoid writing attacker-controlled values to GITHUB_ENV and prefer validated environment variables",
42
- ],
43
- evidence: evidence?.evidence ?? null,
44
- suppressed: false,
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 || !vulnerableVersions.includes(parsedUses.ref)) {
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 (hasTemplateExpression(step.run)) {
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
- if (!sinkFields || sinkFields.length === 0) {
62
- return;
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 sinkField of sinkFields) {
65
- const sinkValue = step.with[sinkField];
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-SINK-${input.filePath}-${jobIndex}-${stepIndex}-${sinkField}`,
72
- severity: "HIGH",
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.${sinkField}` },
77
- description: "Template expression reaches an action input known to execute code or evaluate scripts",
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-94",
169
+ cwe: "CWE-20",
82
170
  confidence: "HIGH",
83
171
  fixable: false,
84
172
  remediation_actions: [
85
- "Avoid passing untrusted template expressions into code execution sink inputs",
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: sinkValue,
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 cached;
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codegate-ai",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "Pre-flight security scanner for AI coding tool configurations.",
5
5
  "license": "MIT",
6
6
  "type": "module",