codegate-ai 0.7.0 → 0.9.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.
Files changed (135) hide show
  1. package/README.md +109 -40
  2. package/dist/cli.js +85 -7
  3. package/dist/commands/scan-command.d.ts +6 -1
  4. package/dist/commands/scan-command.js +1 -1
  5. package/dist/commands/scan-content-command.d.ts +16 -0
  6. package/dist/commands/scan-content-command.js +65 -0
  7. package/dist/config/inline-ignore.d.ts +11 -0
  8. package/dist/config/inline-ignore.js +57 -0
  9. package/dist/config/suppression-policy.d.ts +22 -0
  10. package/dist/config/suppression-policy.js +146 -0
  11. package/dist/config.d.ts +23 -0
  12. package/dist/config.js +188 -3
  13. package/dist/layer2-static/action/parser.d.ts +3 -0
  14. package/dist/layer2-static/action/parser.js +165 -0
  15. package/dist/layer2-static/action/types.d.ts +39 -0
  16. package/dist/layer2-static/action/types.js +1 -0
  17. package/dist/layer2-static/advisories/agent-components.json +62 -0
  18. package/dist/layer2-static/advisories/cache.d.ts +6 -0
  19. package/dist/layer2-static/advisories/cache.js +44 -0
  20. package/dist/layer2-static/advisories/cache.ts +62 -0
  21. package/dist/layer2-static/advisories/gha-advisory-client.d.ts +7 -0
  22. package/dist/layer2-static/advisories/gha-advisory-client.js +4 -0
  23. package/dist/layer2-static/advisories/gha-advisory-client.ts +13 -0
  24. package/dist/layer2-static/advisories/gha-known-vulnerable-actions.json +4 -0
  25. package/dist/layer2-static/audits/registry.d.ts +3 -0
  26. package/dist/layer2-static/audits/registry.js +29 -0
  27. package/dist/layer2-static/audits/types.d.ts +12 -0
  28. package/dist/layer2-static/audits/types.js +1 -0
  29. package/dist/layer2-static/dependabot/parser.d.ts +3 -0
  30. package/dist/layer2-static/dependabot/parser.js +189 -0
  31. package/dist/layer2-static/dependabot/types.d.ts +50 -0
  32. package/dist/layer2-static/dependabot/types.js +1 -0
  33. package/dist/layer2-static/detectors/advisory-intelligence.d.ts +7 -0
  34. package/dist/layer2-static/detectors/advisory-intelligence.js +170 -0
  35. package/dist/layer2-static/detectors/command-exec.js +6 -0
  36. package/dist/layer2-static/detectors/dependabot-cooldown.d.ts +7 -0
  37. package/dist/layer2-static/detectors/dependabot-cooldown.js +54 -0
  38. package/dist/layer2-static/detectors/dependabot-execution.d.ts +7 -0
  39. package/dist/layer2-static/detectors/dependabot-execution.js +68 -0
  40. package/dist/layer2-static/detectors/rule-file.js +5 -0
  41. package/dist/layer2-static/detectors/workflow-anonymous-definition.d.ts +7 -0
  42. package/dist/layer2-static/detectors/workflow-anonymous-definition.js +48 -0
  43. package/dist/layer2-static/detectors/workflow-archived-uses.d.ts +7 -0
  44. package/dist/layer2-static/detectors/workflow-archived-uses.js +147 -0
  45. package/dist/layer2-static/detectors/workflow-artipacked.d.ts +7 -0
  46. package/dist/layer2-static/detectors/workflow-artipacked.js +62 -0
  47. package/dist/layer2-static/detectors/workflow-bot-conditions.d.ts +7 -0
  48. package/dist/layer2-static/detectors/workflow-bot-conditions.js +108 -0
  49. package/dist/layer2-static/detectors/workflow-cache-poisoning.d.ts +7 -0
  50. package/dist/layer2-static/detectors/workflow-cache-poisoning.js +67 -0
  51. package/dist/layer2-static/detectors/workflow-concurrency-limits.d.ts +7 -0
  52. package/dist/layer2-static/detectors/workflow-concurrency-limits.js +76 -0
  53. package/dist/layer2-static/detectors/workflow-dangerous-triggers.d.ts +6 -0
  54. package/dist/layer2-static/detectors/workflow-dangerous-triggers.js +38 -0
  55. package/dist/layer2-static/detectors/workflow-excessive-permissions.d.ts +6 -0
  56. package/dist/layer2-static/detectors/workflow-excessive-permissions.js +65 -0
  57. package/dist/layer2-static/detectors/workflow-forbidden-uses.d.ts +12 -0
  58. package/dist/layer2-static/detectors/workflow-forbidden-uses.js +148 -0
  59. package/dist/layer2-static/detectors/workflow-github-env.d.ts +7 -0
  60. package/dist/layer2-static/detectors/workflow-github-env.js +49 -0
  61. package/dist/layer2-static/detectors/workflow-hardcoded-container-credentials.d.ts +7 -0
  62. package/dist/layer2-static/detectors/workflow-hardcoded-container-credentials.js +90 -0
  63. package/dist/layer2-static/detectors/workflow-impostor-commit.d.ts +9 -0
  64. package/dist/layer2-static/detectors/workflow-impostor-commit.js +127 -0
  65. package/dist/layer2-static/detectors/workflow-insecure-commands.d.ts +7 -0
  66. package/dist/layer2-static/detectors/workflow-insecure-commands.js +72 -0
  67. package/dist/layer2-static/detectors/workflow-known-vuln-action.d.ts +8 -0
  68. package/dist/layer2-static/detectors/workflow-known-vuln-action.js +70 -0
  69. package/dist/layer2-static/detectors/workflow-misfeature.d.ts +7 -0
  70. package/dist/layer2-static/detectors/workflow-misfeature.js +101 -0
  71. package/dist/layer2-static/detectors/workflow-obfuscation.d.ts +7 -0
  72. package/dist/layer2-static/detectors/workflow-obfuscation.js +79 -0
  73. package/dist/layer2-static/detectors/workflow-overprovisioned-secrets.d.ts +7 -0
  74. package/dist/layer2-static/detectors/workflow-overprovisioned-secrets.js +81 -0
  75. package/dist/layer2-static/detectors/workflow-ref-confusion.d.ts +7 -0
  76. package/dist/layer2-static/detectors/workflow-ref-confusion.js +69 -0
  77. package/dist/layer2-static/detectors/workflow-ref-version-mismatch.d.ts +9 -0
  78. package/dist/layer2-static/detectors/workflow-ref-version-mismatch.js +179 -0
  79. package/dist/layer2-static/detectors/workflow-secrets-inherit.d.ts +7 -0
  80. package/dist/layer2-static/detectors/workflow-secrets-inherit.js +59 -0
  81. package/dist/layer2-static/detectors/workflow-secrets-outside-env.d.ts +7 -0
  82. package/dist/layer2-static/detectors/workflow-secrets-outside-env.js +99 -0
  83. package/dist/layer2-static/detectors/workflow-self-hosted-runner.d.ts +7 -0
  84. package/dist/layer2-static/detectors/workflow-self-hosted-runner.js +67 -0
  85. package/dist/layer2-static/detectors/workflow-stale-action-refs.d.ts +7 -0
  86. package/dist/layer2-static/detectors/workflow-stale-action-refs.js +178 -0
  87. package/dist/layer2-static/detectors/workflow-superfluous-actions.d.ts +7 -0
  88. package/dist/layer2-static/detectors/workflow-superfluous-actions.js +70 -0
  89. package/dist/layer2-static/detectors/workflow-template-injection.d.ts +6 -0
  90. package/dist/layer2-static/detectors/workflow-template-injection.js +94 -0
  91. package/dist/layer2-static/detectors/workflow-undocumented-permissions.d.ts +7 -0
  92. package/dist/layer2-static/detectors/workflow-undocumented-permissions.js +101 -0
  93. package/dist/layer2-static/detectors/workflow-unpinned-images.d.ts +7 -0
  94. package/dist/layer2-static/detectors/workflow-unpinned-images.js +108 -0
  95. package/dist/layer2-static/detectors/workflow-unpinned-uses.d.ts +7 -0
  96. package/dist/layer2-static/detectors/workflow-unpinned-uses.js +60 -0
  97. package/dist/layer2-static/detectors/workflow-unredacted-secrets.d.ts +7 -0
  98. package/dist/layer2-static/detectors/workflow-unredacted-secrets.js +92 -0
  99. package/dist/layer2-static/detectors/workflow-unsound-condition.d.ts +7 -0
  100. package/dist/layer2-static/detectors/workflow-unsound-condition.js +90 -0
  101. package/dist/layer2-static/detectors/workflow-unsound-contains.d.ts +7 -0
  102. package/dist/layer2-static/detectors/workflow-unsound-contains.js +88 -0
  103. package/dist/layer2-static/detectors/workflow-use-trusted-publishing.d.ts +7 -0
  104. package/dist/layer2-static/detectors/workflow-use-trusted-publishing.js +66 -0
  105. package/dist/layer2-static/engine.d.ts +13 -2
  106. package/dist/layer2-static/engine.js +578 -41
  107. package/dist/layer2-static/github/cache.d.ts +8 -0
  108. package/dist/layer2-static/github/cache.js +41 -0
  109. package/dist/layer2-static/github/client.d.ts +17 -0
  110. package/dist/layer2-static/github/client.js +43 -0
  111. package/dist/layer2-static/rule-engine.d.ts +1 -1
  112. package/dist/layer2-static/rule-engine.js +1 -13
  113. package/dist/layer2-static/rule-pack-loader.d.ts +10 -0
  114. package/dist/layer2-static/rule-pack-loader.js +187 -0
  115. package/dist/layer2-static/workflow/injection-sinks.json +5 -0
  116. package/dist/layer2-static/workflow/parser.d.ts +3 -0
  117. package/dist/layer2-static/workflow/parser.js +88 -0
  118. package/dist/layer2-static/workflow/types.d.ts +15 -0
  119. package/dist/layer2-static/workflow/types.js +1 -0
  120. package/dist/layer3-dynamic/toxic-flow.js +6 -0
  121. package/dist/pipeline.d.ts +1 -1
  122. package/dist/pipeline.js +11 -10
  123. package/dist/report/finding-fingerprint.d.ts +5 -0
  124. package/dist/report/finding-fingerprint.js +47 -0
  125. package/dist/reporter/markdown.js +25 -3
  126. package/dist/reporter/sarif.js +25 -5
  127. package/dist/reporter/terminal.js +37 -0
  128. package/dist/scan-target/fetch-plan.d.ts +8 -0
  129. package/dist/scan-target/fetch-plan.js +30 -0
  130. package/dist/scan-target/staging.js +60 -5
  131. package/dist/scan.d.ts +7 -1
  132. package/dist/scan.js +157 -40
  133. package/dist/types/finding.d.ts +10 -1
  134. package/dist/types/finding.js +5 -0
  135. package/package.json +1 -1
@@ -0,0 +1,178 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const staleActionCacheByFetch = new WeakMap();
4
+ const GITHUB_API_HEADERS = {
5
+ Accept: "application/vnd.github+json",
6
+ "User-Agent": "CodeGate",
7
+ };
8
+ const MAX_TAG_PAGES = 10;
9
+ function asRecord(value) {
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
11
+ return null;
12
+ }
13
+ return value;
14
+ }
15
+ function isExternalRepositoryUse(value) {
16
+ return value.startsWith("./") || value.startsWith("../") || value.startsWith("docker://");
17
+ }
18
+ function trimTrailingSlashes(value) {
19
+ return value.replace(/\/+$/u, "");
20
+ }
21
+ function parseRepositoryUses(value) {
22
+ const trimmed = value.trim();
23
+ if (trimmed.length === 0 || isExternalRepositoryUse(trimmed)) {
24
+ return null;
25
+ }
26
+ const atIndex = trimmed.lastIndexOf("@");
27
+ if (atIndex < 0) {
28
+ return null;
29
+ }
30
+ const slug = trimTrailingSlashes(trimmed.slice(0, atIndex).trim());
31
+ const ref = trimmed.slice(atIndex + 1).trim();
32
+ const [owner, repo] = slug.split("/");
33
+ if (!owner || !repo || ref.length === 0) {
34
+ return null;
35
+ }
36
+ return {
37
+ raw: trimmed,
38
+ owner: owner.toLowerCase(),
39
+ repo: repo.toLowerCase(),
40
+ ref,
41
+ };
42
+ }
43
+ function isCommitSha(ref) {
44
+ return /^[a-f0-9]{40}$/iu.test(ref.trim());
45
+ }
46
+ async function commitPointsToTag(owner, repo, ref) {
47
+ const fetchFn = globalThis.fetch;
48
+ if (typeof fetchFn !== "function") {
49
+ return false;
50
+ }
51
+ let cache = staleActionCacheByFetch.get(fetchFn);
52
+ if (!cache) {
53
+ cache = new Map();
54
+ staleActionCacheByFetch.set(fetchFn, cache);
55
+ }
56
+ const normalizedRef = ref.toLowerCase();
57
+ const cacheKey = `${owner}/${repo}@${normalizedRef}`;
58
+ const cached = cache.get(cacheKey);
59
+ if (cached !== undefined) {
60
+ return cached;
61
+ }
62
+ try {
63
+ for (let page = 1; page <= MAX_TAG_PAGES; page += 1) {
64
+ const url = new URL(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tags`);
65
+ url.searchParams.set("per_page", "100");
66
+ url.searchParams.set("page", String(page));
67
+ const response = await fetchFn(url, {
68
+ headers: GITHUB_API_HEADERS,
69
+ });
70
+ if (!response.ok) {
71
+ cache.set(cacheKey, false);
72
+ return false;
73
+ }
74
+ const payload = (await response.json());
75
+ if (!Array.isArray(payload)) {
76
+ cache.set(cacheKey, false);
77
+ return false;
78
+ }
79
+ for (const tag of payload) {
80
+ const tagRecord = asRecord(tag);
81
+ const commitRecord = asRecord(tagRecord?.commit);
82
+ const tagSha = typeof commitRecord?.sha === "string" ? commitRecord.sha : undefined;
83
+ if (tagSha && tagSha.toLowerCase() === normalizedRef) {
84
+ cache.set(cacheKey, true);
85
+ return true;
86
+ }
87
+ }
88
+ const linkHeader = response.headers.get("link") ?? "";
89
+ if (!linkHeader.includes('rel="next"') || payload.length < 100) {
90
+ break;
91
+ }
92
+ }
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ cache.set(cacheKey, false);
98
+ return false;
99
+ }
100
+ function gatherUsesTargets(parsed) {
101
+ const root = asRecord(parsed);
102
+ const jobsRecord = asRecord(root?.jobs);
103
+ if (!jobsRecord) {
104
+ return [];
105
+ }
106
+ const targets = [];
107
+ const addTarget = (uses, field) => {
108
+ const parsedUses = parseRepositoryUses(uses);
109
+ if (parsedUses) {
110
+ targets.push({
111
+ ...parsedUses,
112
+ field,
113
+ });
114
+ }
115
+ };
116
+ for (const [jobId, jobValue] of Object.entries(jobsRecord)) {
117
+ const jobRecord = asRecord(jobValue);
118
+ if (!jobRecord) {
119
+ continue;
120
+ }
121
+ if (typeof jobRecord.uses === "string") {
122
+ addTarget(jobRecord.uses, `jobs.${jobId}.uses`);
123
+ }
124
+ const steps = Array.isArray(jobRecord.steps) ? jobRecord.steps : [];
125
+ steps.forEach((step, stepIndex) => {
126
+ const stepRecord = asRecord(step);
127
+ if (stepRecord && typeof stepRecord.uses === "string") {
128
+ addTarget(stepRecord.uses, `jobs.${jobId}.steps[${stepIndex}].uses`);
129
+ }
130
+ });
131
+ }
132
+ return targets;
133
+ }
134
+ export async function detectWorkflowStaleActionRefs(input) {
135
+ if (!isGitHubWorkflowPath(input.filePath)) {
136
+ return [];
137
+ }
138
+ if (!extractWorkflowFacts(input.parsed)) {
139
+ return [];
140
+ }
141
+ const targets = gatherUsesTargets(input.parsed);
142
+ const findings = [];
143
+ for (const target of targets) {
144
+ if (!isCommitSha(target.ref)) {
145
+ continue;
146
+ }
147
+ if (await commitPointsToTag(target.owner, target.repo, target.ref)) {
148
+ continue;
149
+ }
150
+ const evidence = buildFindingEvidence({
151
+ textContent: input.textContent,
152
+ searchTerms: [target.raw, target.ref],
153
+ fallbackValue: target.raw,
154
+ });
155
+ findings.push({
156
+ rule_id: "workflow-stale-action-refs",
157
+ finding_id: `WORKFLOW_STALE_ACTION_REFS-${input.filePath}-${target.field}`,
158
+ severity: "LOW",
159
+ category: "CI_VULNERABLE_ACTION",
160
+ layer: "L2",
161
+ file_path: input.filePath,
162
+ location: { field: target.field },
163
+ description: "Workflow action reference pins a commit hash that does not resolve to a tag",
164
+ affected_tools: ["github-actions"],
165
+ cve: null,
166
+ owasp: ["ASI02"],
167
+ cwe: "CWE-829",
168
+ confidence: "HIGH",
169
+ fixable: false,
170
+ remediation_actions: [
171
+ "Pin the action to a commit hash that corresponds to a release tag, or document why the raw commit is required",
172
+ ],
173
+ evidence: evidence?.evidence ?? null,
174
+ suppressed: false,
175
+ });
176
+ }
177
+ return findings;
178
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowSuperfluousActionsInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowSuperfluousActions(input: WorkflowSuperfluousActionsInput): Finding[];
@@ -0,0 +1,70 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ function normalizeUses(value) {
4
+ return value.trim().toLowerCase();
5
+ }
6
+ function isExternalUses(value) {
7
+ const normalized = value.trim();
8
+ return (normalized.length > 0 &&
9
+ !normalized.startsWith("./") &&
10
+ !normalized.startsWith("../") &&
11
+ !normalized.startsWith("docker://"));
12
+ }
13
+ export function detectWorkflowSuperfluousActions(input) {
14
+ if (!isGitHubWorkflowPath(input.filePath)) {
15
+ return [];
16
+ }
17
+ const facts = extractWorkflowFacts(input.parsed);
18
+ if (!facts) {
19
+ return [];
20
+ }
21
+ const findings = [];
22
+ facts.jobs.forEach((job, jobIndex) => {
23
+ const usesCounts = new Map();
24
+ job.steps.forEach((step, stepIndex) => {
25
+ const uses = step.uses?.trim();
26
+ if (!uses || !isExternalUses(uses)) {
27
+ return;
28
+ }
29
+ const key = normalizeUses(uses);
30
+ const current = usesCounts.get(key);
31
+ if (!current) {
32
+ usesCounts.set(key, { count: 1, firstStep: stepIndex, rawUses: uses });
33
+ return;
34
+ }
35
+ current.count += 1;
36
+ });
37
+ for (const [key, value] of usesCounts.entries()) {
38
+ if (value.count < 2) {
39
+ continue;
40
+ }
41
+ const evidence = buildFindingEvidence({
42
+ textContent: input.textContent,
43
+ searchTerms: [value.rawUses],
44
+ fallbackValue: `${value.rawUses} repeated ${value.count} times`,
45
+ });
46
+ findings.push({
47
+ rule_id: "workflow-superfluous-actions",
48
+ finding_id: `WORKFLOW_SUPERFLUOUS_ACTIONS-${input.filePath}-${jobIndex}-${key}`,
49
+ severity: "LOW",
50
+ category: "CI_SUPPLY_CHAIN",
51
+ layer: "L2",
52
+ file_path: input.filePath,
53
+ location: { field: `jobs.${job.id}.steps[${value.firstStep}].uses` },
54
+ description: "Workflow repeats the same external action in a single job",
55
+ affected_tools: ["github-actions"],
56
+ cve: null,
57
+ owasp: ["ASI02"],
58
+ cwe: "CWE-1059",
59
+ confidence: "MEDIUM",
60
+ fixable: false,
61
+ remediation_actions: [
62
+ "Remove duplicate external action invocations unless repetition is explicitly required",
63
+ ],
64
+ evidence: evidence?.evidence ?? null,
65
+ suppressed: false,
66
+ });
67
+ }
68
+ });
69
+ return findings;
70
+ }
@@ -0,0 +1,6 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowTemplateInjectionInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ }
6
+ export declare function detectWorkflowTemplateInjection(input: WorkflowTemplateInjectionInput): Finding[];
@@ -0,0 +1,94 @@
1
+ import sinkMap from "../workflow/injection-sinks.json" with { type: "json" };
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const UNTRUSTED_TRIGGERS = new Set([
4
+ "pull_request",
5
+ "pull_request_target",
6
+ "issue_comment",
7
+ "discussion_comment",
8
+ "workflow_run",
9
+ ]);
10
+ function hasTemplateExpression(value) {
11
+ return typeof value === "string" && value.includes("${{");
12
+ }
13
+ function normalizeUsesSlug(value) {
14
+ const beforeRef = value.split("@")[0] ?? value;
15
+ return beforeRef.replace(/\/+$/u, "").toLowerCase();
16
+ }
17
+ export function detectWorkflowTemplateInjection(input) {
18
+ if (!isGitHubWorkflowPath(input.filePath)) {
19
+ return [];
20
+ }
21
+ const facts = extractWorkflowFacts(input.parsed);
22
+ if (!facts) {
23
+ return [];
24
+ }
25
+ const hasUntrustedTrigger = facts.triggers.some((trigger) => UNTRUSTED_TRIGGERS.has(trigger));
26
+ if (!hasUntrustedTrigger) {
27
+ return [];
28
+ }
29
+ const findings = [];
30
+ facts.jobs.forEach((job, jobIndex) => {
31
+ job.steps.forEach((step, stepIndex) => {
32
+ if (hasTemplateExpression(step.run)) {
33
+ findings.push({
34
+ rule_id: "workflow-template-injection",
35
+ finding_id: `WORKFLOW_TEMPLATE_INJECTION-RUN-${input.filePath}-${jobIndex}-${stepIndex}`,
36
+ severity: "HIGH",
37
+ category: "CI_TEMPLATE_INJECTION",
38
+ layer: "L2",
39
+ file_path: input.filePath,
40
+ location: { field: `jobs.${job.id}.steps[${stepIndex}].run` },
41
+ description: "Template expression in run step may allow untrusted input to reach shell execution",
42
+ affected_tools: ["github-actions"],
43
+ cve: null,
44
+ owasp: ["ASI02"],
45
+ cwe: "CWE-94",
46
+ confidence: "HIGH",
47
+ fixable: false,
48
+ remediation_actions: [
49
+ "Move untrusted template expressions into validated environment variables before execution",
50
+ ],
51
+ evidence: step.run ?? null,
52
+ suppressed: false,
53
+ });
54
+ }
55
+ const uses = step.uses?.trim();
56
+ if (!uses || !step.with) {
57
+ return;
58
+ }
59
+ const slug = normalizeUsesSlug(uses);
60
+ const sinkFields = sinkMap[slug];
61
+ if (!sinkFields || sinkFields.length === 0) {
62
+ return;
63
+ }
64
+ for (const sinkField of sinkFields) {
65
+ const sinkValue = step.with[sinkField];
66
+ if (!hasTemplateExpression(sinkValue)) {
67
+ continue;
68
+ }
69
+ findings.push({
70
+ rule_id: "workflow-template-injection",
71
+ finding_id: `WORKFLOW_TEMPLATE_INJECTION-SINK-${input.filePath}-${jobIndex}-${stepIndex}-${sinkField}`,
72
+ severity: "HIGH",
73
+ category: "CI_TEMPLATE_INJECTION",
74
+ layer: "L2",
75
+ 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",
78
+ affected_tools: ["github-actions"],
79
+ cve: null,
80
+ owasp: ["ASI02"],
81
+ cwe: "CWE-94",
82
+ confidence: "HIGH",
83
+ fixable: false,
84
+ remediation_actions: [
85
+ "Avoid passing untrusted template expressions into code execution sink inputs",
86
+ ],
87
+ evidence: sinkValue,
88
+ suppressed: false,
89
+ });
90
+ }
91
+ });
92
+ });
93
+ return findings;
94
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowUndocumentedPermissionsInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowUndocumentedPermissions(input: WorkflowUndocumentedPermissionsInput): Finding[];
@@ -0,0 +1,101 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ function isWritePermission(value) {
4
+ if (typeof value === "string") {
5
+ return value === "write" || value === "write-all";
6
+ }
7
+ return false;
8
+ }
9
+ function lineContaining(textContent, term) {
10
+ const lines = textContent.split(/\r?\n/u);
11
+ for (const line of lines) {
12
+ if (line.includes(term)) {
13
+ return line;
14
+ }
15
+ }
16
+ return null;
17
+ }
18
+ function hasLineComment(textContent, term) {
19
+ const line = lineContaining(textContent, term);
20
+ return line ? line.includes("#") : false;
21
+ }
22
+ function pushPermissionFinding(findings, input, field, evidenceTerm, description, permissionValue) {
23
+ const evidence = buildFindingEvidence({
24
+ textContent: input.textContent,
25
+ searchTerms: [evidenceTerm, permissionValue],
26
+ fallbackValue: evidenceTerm,
27
+ });
28
+ findings.push({
29
+ rule_id: "workflow-undocumented-permissions",
30
+ finding_id: `WORKFLOW_UNDOCUMENTED_PERMISSIONS-${input.filePath}-${field}`,
31
+ severity: "HIGH",
32
+ category: "CI_PERMISSIONS",
33
+ layer: "L2",
34
+ file_path: input.filePath,
35
+ location: { field },
36
+ description,
37
+ affected_tools: ["github-actions"],
38
+ cve: null,
39
+ owasp: ["ASI02"],
40
+ cwe: "CWE-732",
41
+ confidence: "MEDIUM",
42
+ fixable: false,
43
+ remediation_actions: [
44
+ "Document why elevated permissions are required or reduce the permission scope",
45
+ ],
46
+ evidence: evidence?.evidence ?? null,
47
+ suppressed: false,
48
+ });
49
+ }
50
+ export function detectWorkflowUndocumentedPermissions(input) {
51
+ if (!isGitHubWorkflowPath(input.filePath)) {
52
+ return [];
53
+ }
54
+ const facts = extractWorkflowFacts(input.parsed);
55
+ if (!facts) {
56
+ return [];
57
+ }
58
+ const findings = [];
59
+ if (typeof facts.workflowPermissions === "string") {
60
+ const value = facts.workflowPermissions.trim().toLowerCase();
61
+ if ((value === "write-all" || value === "write") &&
62
+ !hasLineComment(input.textContent, "permissions:")) {
63
+ pushPermissionFinding(findings, input, "permissions", `permissions: ${facts.workflowPermissions}`, "Workflow defines elevated permissions without documenting why they are needed", `permissions: ${facts.workflowPermissions}`);
64
+ }
65
+ }
66
+ else if (facts.workflowPermissions && typeof facts.workflowPermissions === "object") {
67
+ for (const [scope, value] of Object.entries(facts.workflowPermissions)) {
68
+ if (!isWritePermission(value)) {
69
+ continue;
70
+ }
71
+ const term = `${scope}: ${value}`;
72
+ if (hasLineComment(input.textContent, term)) {
73
+ continue;
74
+ }
75
+ pushPermissionFinding(findings, input, `permissions.${scope}`, term, "Workflow defines elevated permissions without documenting why they are needed", term);
76
+ }
77
+ }
78
+ facts.jobs.forEach((job) => {
79
+ if (typeof job.permissions === "string") {
80
+ const value = job.permissions.trim().toLowerCase();
81
+ if (value === "write-all" && !hasLineComment(input.textContent, "permissions:")) {
82
+ pushPermissionFinding(findings, input, `jobs.${job.id}.permissions`, `permissions: ${job.permissions}`, "Job defines elevated permissions without documenting why they are needed", `permissions: ${job.permissions}`);
83
+ }
84
+ return;
85
+ }
86
+ if (!job.permissions || typeof job.permissions !== "object") {
87
+ return;
88
+ }
89
+ for (const [scope, value] of Object.entries(job.permissions)) {
90
+ if (!isWritePermission(value)) {
91
+ continue;
92
+ }
93
+ const term = `${scope}: ${value}`;
94
+ if (hasLineComment(input.textContent, term)) {
95
+ continue;
96
+ }
97
+ pushPermissionFinding(findings, input, `jobs.${job.id}.permissions.${scope}`, term, "Job defines elevated permissions without documenting why they are needed", term);
98
+ }
99
+ });
100
+ return findings;
101
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowUnpinnedImagesInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowUnpinnedImages(input: WorkflowUnpinnedImagesInput): Finding[];
@@ -0,0 +1,108 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const SHA256_DIGEST_RE = /@sha256:[a-f0-9]{64}$/iu;
4
+ const MUTABLE_LATEST_TAG = "latest";
5
+ function asRecord(value) {
6
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
7
+ return null;
8
+ }
9
+ return value;
10
+ }
11
+ function getImageTag(image) {
12
+ const trimmed = image.trim();
13
+ if (trimmed.length === 0 || trimmed.includes("@")) {
14
+ return null;
15
+ }
16
+ const lastSlash = trimmed.lastIndexOf("/");
17
+ const lastColon = trimmed.lastIndexOf(":");
18
+ if (lastColon <= lastSlash) {
19
+ return null;
20
+ }
21
+ const tag = trimmed.slice(lastColon + 1).trim();
22
+ return tag.length > 0 ? tag : null;
23
+ }
24
+ function isShaPinned(image) {
25
+ return SHA256_DIGEST_RE.test(image.trim());
26
+ }
27
+ function gatherImageTargets(parsed) {
28
+ const root = asRecord(parsed);
29
+ const jobsRecord = asRecord(root?.jobs);
30
+ if (!jobsRecord) {
31
+ return [];
32
+ }
33
+ const targets = [];
34
+ for (const [jobId, jobValue] of Object.entries(jobsRecord)) {
35
+ const jobRecord = asRecord(jobValue);
36
+ if (!jobRecord) {
37
+ continue;
38
+ }
39
+ const containerRecord = asRecord(jobRecord.container);
40
+ if (typeof containerRecord?.image === "string") {
41
+ targets.push({
42
+ field: `jobs.${jobId}.container.image`,
43
+ image: containerRecord.image,
44
+ });
45
+ }
46
+ const servicesRecord = asRecord(jobRecord.services);
47
+ if (servicesRecord) {
48
+ for (const [serviceName, serviceValue] of Object.entries(servicesRecord)) {
49
+ const serviceRecord = asRecord(serviceValue);
50
+ if (typeof serviceRecord?.image === "string") {
51
+ targets.push({
52
+ field: `jobs.${jobId}.services.${serviceName}.image`,
53
+ image: serviceRecord.image,
54
+ });
55
+ }
56
+ }
57
+ }
58
+ }
59
+ return targets;
60
+ }
61
+ export function detectWorkflowUnpinnedImages(input) {
62
+ if (!isGitHubWorkflowPath(input.filePath)) {
63
+ return [];
64
+ }
65
+ if (!extractWorkflowFacts(input.parsed)) {
66
+ return [];
67
+ }
68
+ const findings = [];
69
+ for (const target of gatherImageTargets(input.parsed)) {
70
+ const trimmedImage = target.image.trim();
71
+ if (trimmedImage.length === 0 || isShaPinned(trimmedImage)) {
72
+ continue;
73
+ }
74
+ const tag = getImageTag(trimmedImage);
75
+ if (tag !== null && tag !== MUTABLE_LATEST_TAG) {
76
+ continue;
77
+ }
78
+ const evidence = buildFindingEvidence({
79
+ textContent: input.textContent,
80
+ searchTerms: [trimmedImage, "image:"],
81
+ fallbackValue: trimmedImage,
82
+ });
83
+ findings.push({
84
+ rule_id: "workflow-unpinned-images",
85
+ finding_id: `WORKFLOW_UNPINNED_IMAGES-${input.filePath}-${target.field}`,
86
+ severity: "HIGH",
87
+ category: "CI_SUPPLY_CHAIN",
88
+ layer: "L2",
89
+ file_path: input.filePath,
90
+ location: { field: target.field },
91
+ description: tag === MUTABLE_LATEST_TAG
92
+ ? "Workflow container image uses the mutable latest tag"
93
+ : "Workflow container image is not pinned to a SHA256 digest",
94
+ affected_tools: ["github-actions"],
95
+ cve: null,
96
+ owasp: ["ASI02"],
97
+ cwe: "CWE-829",
98
+ confidence: "HIGH",
99
+ fixable: false,
100
+ remediation_actions: [
101
+ "Pin container images to immutable sha256 digests instead of mutable tags",
102
+ ],
103
+ evidence: evidence?.evidence ?? null,
104
+ suppressed: false,
105
+ });
106
+ }
107
+ return findings;
108
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowUnpinnedUsesInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowUnpinnedUses(input: WorkflowUnpinnedUsesInput): Finding[];
@@ -0,0 +1,60 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ function isPinnedToCommit(ref) {
4
+ return /^[a-f0-9]{40}$/iu.test(ref.trim());
5
+ }
6
+ function isRepositoryUses(value) {
7
+ return /^[a-z0-9._-]+\/[a-z0-9._-]+(?:\/[^@]+)?@[^\s]+$/iu.test(value.trim());
8
+ }
9
+ export function detectWorkflowUnpinnedUses(input) {
10
+ if (!isGitHubWorkflowPath(input.filePath)) {
11
+ return [];
12
+ }
13
+ const facts = extractWorkflowFacts(input.parsed);
14
+ if (!facts) {
15
+ return [];
16
+ }
17
+ const findings = [];
18
+ facts.jobs.forEach((job, jobIndex) => {
19
+ job.steps.forEach((step, stepIndex) => {
20
+ const uses = step.uses?.trim();
21
+ if (!uses || uses.startsWith("./") || uses.startsWith("docker://")) {
22
+ return;
23
+ }
24
+ if (!isRepositoryUses(uses)) {
25
+ return;
26
+ }
27
+ const ref = uses.split("@").slice(1).join("@").trim();
28
+ if (ref.length === 0 || isPinnedToCommit(ref)) {
29
+ return;
30
+ }
31
+ const evidence = buildFindingEvidence({
32
+ textContent: input.textContent,
33
+ searchTerms: [uses],
34
+ fallbackValue: `uses: ${uses}`,
35
+ });
36
+ findings.push({
37
+ rule_id: "workflow-unpinned-uses",
38
+ finding_id: `WORKFLOW_UNPINNED_USES-${input.filePath}-${jobIndex}-${stepIndex}`,
39
+ severity: "HIGH",
40
+ category: "CI_SUPPLY_CHAIN",
41
+ layer: "L2",
42
+ file_path: input.filePath,
43
+ location: { field: `jobs.${job.id}.steps[${stepIndex}].uses` },
44
+ description: "Workflow action reference is not pinned to an immutable commit hash",
45
+ affected_tools: ["github-actions"],
46
+ cve: null,
47
+ owasp: ["ASI02"],
48
+ cwe: "CWE-829",
49
+ confidence: "HIGH",
50
+ fixable: false,
51
+ remediation_actions: [
52
+ "Pin external actions to a full commit SHA and track tag intent in comments",
53
+ ],
54
+ evidence: evidence?.evidence ?? null,
55
+ suppressed: false,
56
+ });
57
+ });
58
+ });
59
+ return findings;
60
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowUnredactedSecretsInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowUnredactedSecrets(input: WorkflowUnredactedSecretsInput): Finding[];