codegate-ai 0.8.0 → 0.9.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.
Files changed (124) hide show
  1. package/README.md +48 -15
  2. package/dist/cli.js +41 -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.js +5 -1
  6. package/dist/config/inline-ignore.d.ts +11 -0
  7. package/dist/config/inline-ignore.js +57 -0
  8. package/dist/config/suppression-policy.d.ts +8 -0
  9. package/dist/config/suppression-policy.js +66 -1
  10. package/dist/config.d.ts +19 -1
  11. package/dist/config.js +160 -1
  12. package/dist/layer2-static/action/parser.d.ts +3 -0
  13. package/dist/layer2-static/action/parser.js +165 -0
  14. package/dist/layer2-static/action/types.d.ts +39 -0
  15. package/dist/layer2-static/action/types.js +1 -0
  16. package/dist/layer2-static/advisories/cache.d.ts +6 -0
  17. package/dist/layer2-static/advisories/cache.js +44 -0
  18. package/dist/layer2-static/advisories/cache.ts +62 -0
  19. package/dist/layer2-static/advisories/gha-advisory-client.d.ts +7 -0
  20. package/dist/layer2-static/advisories/gha-advisory-client.js +4 -0
  21. package/dist/layer2-static/advisories/gha-advisory-client.ts +13 -0
  22. package/dist/layer2-static/advisories/gha-known-vulnerable-actions.json +4 -0
  23. package/dist/layer2-static/audits/registry.d.ts +3 -0
  24. package/dist/layer2-static/audits/registry.js +29 -0
  25. package/dist/layer2-static/audits/types.d.ts +12 -0
  26. package/dist/layer2-static/audits/types.js +1 -0
  27. package/dist/layer2-static/dependabot/parser.d.ts +3 -0
  28. package/dist/layer2-static/dependabot/parser.js +189 -0
  29. package/dist/layer2-static/dependabot/types.d.ts +50 -0
  30. package/dist/layer2-static/dependabot/types.js +1 -0
  31. package/dist/layer2-static/detectors/dependabot-cooldown.d.ts +7 -0
  32. package/dist/layer2-static/detectors/dependabot-cooldown.js +54 -0
  33. package/dist/layer2-static/detectors/dependabot-execution.d.ts +7 -0
  34. package/dist/layer2-static/detectors/dependabot-execution.js +68 -0
  35. package/dist/layer2-static/detectors/workflow-anonymous-definition.d.ts +7 -0
  36. package/dist/layer2-static/detectors/workflow-anonymous-definition.js +48 -0
  37. package/dist/layer2-static/detectors/workflow-archived-uses.d.ts +7 -0
  38. package/dist/layer2-static/detectors/workflow-archived-uses.js +147 -0
  39. package/dist/layer2-static/detectors/workflow-artipacked.d.ts +7 -0
  40. package/dist/layer2-static/detectors/workflow-artipacked.js +62 -0
  41. package/dist/layer2-static/detectors/workflow-bot-conditions.d.ts +7 -0
  42. package/dist/layer2-static/detectors/workflow-bot-conditions.js +108 -0
  43. package/dist/layer2-static/detectors/workflow-cache-poisoning.d.ts +7 -0
  44. package/dist/layer2-static/detectors/workflow-cache-poisoning.js +67 -0
  45. package/dist/layer2-static/detectors/workflow-concurrency-limits.d.ts +7 -0
  46. package/dist/layer2-static/detectors/workflow-concurrency-limits.js +76 -0
  47. package/dist/layer2-static/detectors/workflow-dangerous-triggers.d.ts +6 -0
  48. package/dist/layer2-static/detectors/workflow-dangerous-triggers.js +38 -0
  49. package/dist/layer2-static/detectors/workflow-excessive-permissions.d.ts +6 -0
  50. package/dist/layer2-static/detectors/workflow-excessive-permissions.js +65 -0
  51. package/dist/layer2-static/detectors/workflow-forbidden-uses.d.ts +12 -0
  52. package/dist/layer2-static/detectors/workflow-forbidden-uses.js +148 -0
  53. package/dist/layer2-static/detectors/workflow-github-env.d.ts +7 -0
  54. package/dist/layer2-static/detectors/workflow-github-env.js +49 -0
  55. package/dist/layer2-static/detectors/workflow-hardcoded-container-credentials.d.ts +7 -0
  56. package/dist/layer2-static/detectors/workflow-hardcoded-container-credentials.js +90 -0
  57. package/dist/layer2-static/detectors/workflow-impostor-commit.d.ts +9 -0
  58. package/dist/layer2-static/detectors/workflow-impostor-commit.js +127 -0
  59. package/dist/layer2-static/detectors/workflow-insecure-commands.d.ts +7 -0
  60. package/dist/layer2-static/detectors/workflow-insecure-commands.js +72 -0
  61. package/dist/layer2-static/detectors/workflow-known-vuln-action.d.ts +8 -0
  62. package/dist/layer2-static/detectors/workflow-known-vuln-action.js +70 -0
  63. package/dist/layer2-static/detectors/workflow-misfeature.d.ts +7 -0
  64. package/dist/layer2-static/detectors/workflow-misfeature.js +101 -0
  65. package/dist/layer2-static/detectors/workflow-obfuscation.d.ts +7 -0
  66. package/dist/layer2-static/detectors/workflow-obfuscation.js +79 -0
  67. package/dist/layer2-static/detectors/workflow-overprovisioned-secrets.d.ts +7 -0
  68. package/dist/layer2-static/detectors/workflow-overprovisioned-secrets.js +81 -0
  69. package/dist/layer2-static/detectors/workflow-ref-confusion.d.ts +7 -0
  70. package/dist/layer2-static/detectors/workflow-ref-confusion.js +69 -0
  71. package/dist/layer2-static/detectors/workflow-ref-version-mismatch.d.ts +9 -0
  72. package/dist/layer2-static/detectors/workflow-ref-version-mismatch.js +179 -0
  73. package/dist/layer2-static/detectors/workflow-secrets-inherit.d.ts +7 -0
  74. package/dist/layer2-static/detectors/workflow-secrets-inherit.js +59 -0
  75. package/dist/layer2-static/detectors/workflow-secrets-outside-env.d.ts +7 -0
  76. package/dist/layer2-static/detectors/workflow-secrets-outside-env.js +99 -0
  77. package/dist/layer2-static/detectors/workflow-self-hosted-runner.d.ts +7 -0
  78. package/dist/layer2-static/detectors/workflow-self-hosted-runner.js +67 -0
  79. package/dist/layer2-static/detectors/workflow-stale-action-refs.d.ts +7 -0
  80. package/dist/layer2-static/detectors/workflow-stale-action-refs.js +178 -0
  81. package/dist/layer2-static/detectors/workflow-superfluous-actions.d.ts +7 -0
  82. package/dist/layer2-static/detectors/workflow-superfluous-actions.js +70 -0
  83. package/dist/layer2-static/detectors/workflow-template-injection.d.ts +6 -0
  84. package/dist/layer2-static/detectors/workflow-template-injection.js +94 -0
  85. package/dist/layer2-static/detectors/workflow-undocumented-permissions.d.ts +7 -0
  86. package/dist/layer2-static/detectors/workflow-undocumented-permissions.js +101 -0
  87. package/dist/layer2-static/detectors/workflow-unpinned-images.d.ts +7 -0
  88. package/dist/layer2-static/detectors/workflow-unpinned-images.js +108 -0
  89. package/dist/layer2-static/detectors/workflow-unpinned-uses.d.ts +7 -0
  90. package/dist/layer2-static/detectors/workflow-unpinned-uses.js +60 -0
  91. package/dist/layer2-static/detectors/workflow-unredacted-secrets.d.ts +7 -0
  92. package/dist/layer2-static/detectors/workflow-unredacted-secrets.js +92 -0
  93. package/dist/layer2-static/detectors/workflow-unsound-condition.d.ts +7 -0
  94. package/dist/layer2-static/detectors/workflow-unsound-condition.js +90 -0
  95. package/dist/layer2-static/detectors/workflow-unsound-contains.d.ts +7 -0
  96. package/dist/layer2-static/detectors/workflow-unsound-contains.js +88 -0
  97. package/dist/layer2-static/detectors/workflow-use-trusted-publishing.d.ts +7 -0
  98. package/dist/layer2-static/detectors/workflow-use-trusted-publishing.js +66 -0
  99. package/dist/layer2-static/engine.d.ts +9 -1
  100. package/dist/layer2-static/engine.js +490 -50
  101. package/dist/layer2-static/github/cache.d.ts +8 -0
  102. package/dist/layer2-static/github/cache.js +41 -0
  103. package/dist/layer2-static/github/client.d.ts +17 -0
  104. package/dist/layer2-static/github/client.js +43 -0
  105. package/dist/layer2-static/rules/claude-code.json +16 -0
  106. package/dist/layer2-static/rules/codex.json +16 -0
  107. package/dist/layer2-static/rules/common.json +15 -0
  108. package/dist/layer2-static/rules/copilot.json +15 -0
  109. package/dist/layer2-static/rules/cursor.json +15 -0
  110. package/dist/layer2-static/rules/opencode.json +15 -0
  111. package/dist/layer2-static/workflow/injection-sinks.json +5 -0
  112. package/dist/layer2-static/workflow/parser.d.ts +3 -0
  113. package/dist/layer2-static/workflow/parser.js +88 -0
  114. package/dist/layer2-static/workflow/types.d.ts +15 -0
  115. package/dist/layer2-static/workflow/types.js +1 -0
  116. package/dist/pipeline.d.ts +1 -1
  117. package/dist/pipeline.js +3 -3
  118. package/dist/reporter/sarif.js +23 -5
  119. package/dist/reporter/terminal.js +12 -0
  120. package/dist/scan.d.ts +7 -1
  121. package/dist/scan.js +154 -40
  122. package/dist/types/finding.d.ts +1 -1
  123. package/dist/types/finding.js +5 -0
  124. package/package.json +1 -1
@@ -0,0 +1,54 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractDependabotFacts, isGitHubDependabotPath } from "../dependabot/parser.js";
3
+ function hasCooldown(update) {
4
+ const cooldown = update.cooldown;
5
+ if (!cooldown) {
6
+ return false;
7
+ }
8
+ return (typeof cooldown.defaultDays === "number" ||
9
+ typeof cooldown.semverMajorDays === "number" ||
10
+ typeof cooldown.semverMinorDays === "number" ||
11
+ typeof cooldown.semverPatchDays === "number");
12
+ }
13
+ export function detectDependabotCooldown(input) {
14
+ if (!isGitHubDependabotPath(input.filePath)) {
15
+ return [];
16
+ }
17
+ const facts = extractDependabotFacts(input.parsed);
18
+ if (!facts || facts.updates.length === 0) {
19
+ return [];
20
+ }
21
+ const findings = [];
22
+ facts.updates.forEach((update, index) => {
23
+ if (!update.schedule || hasCooldown(update)) {
24
+ return;
25
+ }
26
+ const ecosystem = update.packageEcosystem ?? "unknown-ecosystem";
27
+ const directory = update.directory ?? "/";
28
+ const evidence = buildFindingEvidence({
29
+ textContent: input.textContent,
30
+ searchTerms: [ecosystem, directory, "schedule", "cooldown"],
31
+ fallbackValue: `${ecosystem} ${directory} update rule has no cooldown`,
32
+ });
33
+ findings.push({
34
+ rule_id: "dependabot-cooldown",
35
+ finding_id: `DEPENDABOT_COOLDOWN-${input.filePath}-${index}`,
36
+ severity: "LOW",
37
+ category: "CI_SUPPLY_CHAIN",
38
+ layer: "L2",
39
+ file_path: input.filePath,
40
+ location: { field: `updates[${index}].cooldown` },
41
+ description: "Dependabot update rule has no cooldown window, increasing update churn and review pressure",
42
+ affected_tools: ["dependabot"],
43
+ cve: null,
44
+ owasp: ["ASI02"],
45
+ cwe: "CWE-400",
46
+ confidence: "MEDIUM",
47
+ fixable: false,
48
+ remediation_actions: ["Add cooldown settings to pace update volume and review load"],
49
+ evidence: evidence?.evidence ?? null,
50
+ suppressed: false,
51
+ });
52
+ });
53
+ return findings;
54
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface DependabotExecutionInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectDependabotExecution(input: DependabotExecutionInput): Finding[];
@@ -0,0 +1,68 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { isGitHubDependabotPath } from "../dependabot/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 isExternalCodeExecutionAllowed(value) {
10
+ if (value === true) {
11
+ return true;
12
+ }
13
+ if (typeof value === "string") {
14
+ const normalized = value.trim().toLowerCase();
15
+ return normalized === "allow" || normalized === "true";
16
+ }
17
+ return false;
18
+ }
19
+ export function detectDependabotExecution(input) {
20
+ if (!isGitHubDependabotPath(input.filePath)) {
21
+ return [];
22
+ }
23
+ const root = asRecord(input.parsed);
24
+ const updates = Array.isArray(root?.updates) ? root.updates : [];
25
+ if (updates.length === 0) {
26
+ return [];
27
+ }
28
+ const findings = [];
29
+ updates.forEach((entry, index) => {
30
+ const update = asRecord(entry);
31
+ if (!update) {
32
+ return;
33
+ }
34
+ if (!isExternalCodeExecutionAllowed(update["insecure-external-code-execution"])) {
35
+ return;
36
+ }
37
+ const ecosystem = typeof update["package-ecosystem"] === "string"
38
+ ? update["package-ecosystem"]
39
+ : "unknown-ecosystem";
40
+ const evidence = buildFindingEvidence({
41
+ textContent: input.textContent,
42
+ searchTerms: [ecosystem, "insecure-external-code-execution", "allow"],
43
+ fallbackValue: `${ecosystem} enables insecure external code execution`,
44
+ });
45
+ findings.push({
46
+ rule_id: "dependabot-execution",
47
+ finding_id: `DEPENDABOT_EXECUTION-${input.filePath}-${index}`,
48
+ severity: "HIGH",
49
+ category: "CI_SUPPLY_CHAIN",
50
+ layer: "L2",
51
+ file_path: input.filePath,
52
+ location: { field: `updates[${index}].insecure-external-code-execution` },
53
+ description: "Dependabot update rule allows insecure external code execution",
54
+ affected_tools: ["dependabot"],
55
+ cve: null,
56
+ owasp: ["ASI02"],
57
+ cwe: "CWE-94",
58
+ confidence: "HIGH",
59
+ fixable: false,
60
+ remediation_actions: [
61
+ "Remove insecure-external-code-execution allowances and isolate registries through trusted credentials",
62
+ ],
63
+ evidence: evidence?.evidence ?? null,
64
+ suppressed: false,
65
+ });
66
+ });
67
+ return findings;
68
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowAnonymousDefinitionInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowAnonymousDefinition(input: WorkflowAnonymousDefinitionInput): Finding[];
@@ -0,0 +1,48 @@
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
+ export function detectWorkflowAnonymousDefinition(input) {
10
+ if (!isGitHubWorkflowPath(input.filePath)) {
11
+ return [];
12
+ }
13
+ const facts = extractWorkflowFacts(input.parsed);
14
+ if (!facts) {
15
+ return [];
16
+ }
17
+ const root = asRecord(input.parsed);
18
+ const name = typeof root?.name === "string" ? root.name.trim() : "";
19
+ if (name.length > 0) {
20
+ return [];
21
+ }
22
+ const evidence = buildFindingEvidence({
23
+ textContent: input.textContent,
24
+ searchTerms: ["name:"],
25
+ fallbackValue: "workflow has no top-level name",
26
+ });
27
+ return [
28
+ {
29
+ rule_id: "workflow-anonymous-definition",
30
+ finding_id: `WORKFLOW_ANONYMOUS_DEFINITION-${input.filePath}`,
31
+ severity: "LOW",
32
+ category: "CI_SUPPLY_CHAIN",
33
+ layer: "L2",
34
+ file_path: input.filePath,
35
+ location: { field: "name" },
36
+ description: "Workflow omits a top-level name, reducing review clarity and incident traceability",
37
+ affected_tools: ["github-actions"],
38
+ cve: null,
39
+ owasp: ["ASI02"],
40
+ cwe: "CWE-200",
41
+ confidence: "HIGH",
42
+ fixable: false,
43
+ remediation_actions: ["Add an explicit top-level workflow name"],
44
+ evidence: evidence?.evidence ?? null,
45
+ suppressed: false,
46
+ },
47
+ ];
48
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowArchivedUsesInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowArchivedUses(input: WorkflowArchivedUsesInput): Promise<Finding[]>;
@@ -0,0 +1,147 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const archivedRepoCacheByFetch = new WeakMap();
4
+ const GITHUB_API_HEADERS = {
5
+ Accept: "application/vnd.github+json",
6
+ "User-Agent": "CodeGate",
7
+ };
8
+ function asRecord(value) {
9
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
10
+ return null;
11
+ }
12
+ return value;
13
+ }
14
+ function isExternalRepositoryUse(value) {
15
+ return value.startsWith("./") || value.startsWith("../") || value.startsWith("docker://");
16
+ }
17
+ function trimTrailingSlashes(value) {
18
+ return value.replace(/\/+$/u, "");
19
+ }
20
+ function parseRepositoryUses(value) {
21
+ const trimmed = value.trim();
22
+ if (trimmed.length === 0 || isExternalRepositoryUse(trimmed)) {
23
+ return null;
24
+ }
25
+ const atIndex = trimmed.lastIndexOf("@");
26
+ if (atIndex < 0) {
27
+ return null;
28
+ }
29
+ const slug = trimTrailingSlashes(trimmed.slice(0, atIndex).trim());
30
+ const [owner, repo] = slug.split("/");
31
+ if (!owner || !repo) {
32
+ return null;
33
+ }
34
+ return {
35
+ raw: trimmed,
36
+ owner: owner.toLowerCase(),
37
+ repo: repo.toLowerCase(),
38
+ };
39
+ }
40
+ async function isArchivedRepository(owner, repo) {
41
+ const fetchFn = globalThis.fetch;
42
+ if (typeof fetchFn !== "function") {
43
+ return false;
44
+ }
45
+ let cache = archivedRepoCacheByFetch.get(fetchFn);
46
+ if (!cache) {
47
+ cache = new Map();
48
+ archivedRepoCacheByFetch.set(fetchFn, cache);
49
+ }
50
+ const cacheKey = `${owner}/${repo}`;
51
+ const cached = cache.get(cacheKey);
52
+ if (cached !== undefined) {
53
+ return cached;
54
+ }
55
+ try {
56
+ const response = await fetchFn(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, {
57
+ headers: GITHUB_API_HEADERS,
58
+ });
59
+ if (!response.ok) {
60
+ cache.set(cacheKey, false);
61
+ return false;
62
+ }
63
+ const payload = (await response.json());
64
+ const archived = Boolean(payload && typeof payload === "object" && payload.archived === true);
65
+ cache.set(cacheKey, archived);
66
+ return archived;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ function gatherUsesTargets(parsed) {
73
+ const root = asRecord(parsed);
74
+ const jobsRecord = asRecord(root?.jobs);
75
+ if (!jobsRecord) {
76
+ return [];
77
+ }
78
+ const targets = [];
79
+ const addTarget = (uses, field) => {
80
+ const parsedUses = parseRepositoryUses(uses);
81
+ if (parsedUses) {
82
+ targets.push({
83
+ ...parsedUses,
84
+ field,
85
+ });
86
+ }
87
+ };
88
+ for (const [jobId, jobValue] of Object.entries(jobsRecord)) {
89
+ const jobRecord = asRecord(jobValue);
90
+ if (!jobRecord) {
91
+ continue;
92
+ }
93
+ if (typeof jobRecord.uses === "string") {
94
+ addTarget(jobRecord.uses, `jobs.${jobId}.uses`);
95
+ }
96
+ const steps = Array.isArray(jobRecord.steps) ? jobRecord.steps : [];
97
+ steps.forEach((step, stepIndex) => {
98
+ const stepRecord = asRecord(step);
99
+ if (stepRecord && typeof stepRecord.uses === "string") {
100
+ addTarget(stepRecord.uses, `jobs.${jobId}.steps[${stepIndex}].uses`);
101
+ }
102
+ });
103
+ }
104
+ return targets;
105
+ }
106
+ export async function detectWorkflowArchivedUses(input) {
107
+ if (!isGitHubWorkflowPath(input.filePath)) {
108
+ return [];
109
+ }
110
+ if (!extractWorkflowFacts(input.parsed)) {
111
+ return [];
112
+ }
113
+ const targets = gatherUsesTargets(input.parsed);
114
+ const findings = [];
115
+ for (const target of targets) {
116
+ if (!(await isArchivedRepository(target.owner, target.repo))) {
117
+ continue;
118
+ }
119
+ const evidence = buildFindingEvidence({
120
+ textContent: input.textContent,
121
+ searchTerms: [target.raw, `${target.owner}/${target.repo}`],
122
+ fallbackValue: target.raw,
123
+ });
124
+ findings.push({
125
+ rule_id: "workflow-archived-uses",
126
+ finding_id: `WORKFLOW_ARCHIVED_USES-${input.filePath}-${target.field}`,
127
+ severity: "MEDIUM",
128
+ category: "CI_VULNERABLE_ACTION",
129
+ layer: "L2",
130
+ file_path: input.filePath,
131
+ location: { field: target.field },
132
+ description: "Workflow action or reusable workflow comes from an archived repository",
133
+ affected_tools: ["github-actions"],
134
+ cve: null,
135
+ owasp: ["ASI02"],
136
+ cwe: "CWE-829",
137
+ confidence: "HIGH",
138
+ fixable: false,
139
+ remediation_actions: [
140
+ "Replace the archived repository reference with an actively maintained alternative",
141
+ ],
142
+ evidence: evidence?.evidence ?? null,
143
+ suppressed: false,
144
+ });
145
+ }
146
+ return findings;
147
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowArtipackedInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowArtipacked(input: WorkflowArtipackedInput): Finding[];
@@ -0,0 +1,62 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ function isCheckoutStep(uses) {
4
+ return typeof uses === "string" && /^actions\/checkout(?:@.+)?$/iu.test(uses.trim());
5
+ }
6
+ function persistsCredentials(value) {
7
+ if (typeof value !== "string") {
8
+ return true;
9
+ }
10
+ return value.trim().toLowerCase() !== "false";
11
+ }
12
+ export function detectWorkflowArtipacked(input) {
13
+ if (!isGitHubWorkflowPath(input.filePath)) {
14
+ return [];
15
+ }
16
+ const facts = extractWorkflowFacts(input.parsed);
17
+ if (!facts) {
18
+ return [];
19
+ }
20
+ const findings = [];
21
+ facts.jobs.forEach((job, jobIndex) => {
22
+ job.steps.forEach((step, stepIndex) => {
23
+ if (!isCheckoutStep(step.uses)) {
24
+ return;
25
+ }
26
+ if (!persistsCredentials(step.with?.["persist-credentials"])) {
27
+ return;
28
+ }
29
+ const searchTerms = ["persist-credentials: true"];
30
+ if (step.uses) {
31
+ searchTerms.push(step.uses);
32
+ }
33
+ const evidence = buildFindingEvidence({
34
+ textContent: input.textContent,
35
+ searchTerms,
36
+ fallbackValue: "actions/checkout persists credentials on disk",
37
+ });
38
+ findings.push({
39
+ rule_id: "workflow-artipacked",
40
+ finding_id: `WORKFLOW_ARTIPACKED-${input.filePath}-${jobIndex}-${stepIndex}`,
41
+ severity: "HIGH",
42
+ category: "CI_SUPPLY_CHAIN",
43
+ layer: "L2",
44
+ file_path: input.filePath,
45
+ location: { field: `jobs.${job.id}.steps[${stepIndex}].with.persist-credentials` },
46
+ description: "Checkout step persists credentials on disk",
47
+ affected_tools: ["github-actions"],
48
+ cve: null,
49
+ owasp: ["ASI02"],
50
+ cwe: "CWE-922",
51
+ confidence: "HIGH",
52
+ fixable: false,
53
+ remediation_actions: [
54
+ "Set actions/checkout persist-credentials to false unless the workflow explicitly needs Git credentials",
55
+ ],
56
+ evidence: evidence?.evidence ?? null,
57
+ suppressed: false,
58
+ });
59
+ });
60
+ });
61
+ return findings;
62
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowBotConditionsInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowBotConditions(input: WorkflowBotConditionsInput): Finding[];
@@ -0,0 +1,108 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const BOT_CONDITION_PATTERN = /github\.actor\s*(?:==|!=)\s*['"](?:dependabot\[bot\]|renovate\[bot\]|github-actions\[bot\])['"]/iu;
4
+ const PRIVILEGED_RUN_TOKENS = ["publish", "deploy", "release", "npm publish", "gh release"];
5
+ function asRecord(value) {
6
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
7
+ return null;
8
+ }
9
+ return value;
10
+ }
11
+ function gatherConditionalSteps(parsed) {
12
+ const root = asRecord(parsed);
13
+ const jobs = asRecord(root?.jobs);
14
+ if (!jobs) {
15
+ return [];
16
+ }
17
+ const steps = [];
18
+ for (const [jobId, jobValue] of Object.entries(jobs)) {
19
+ const job = asRecord(jobValue);
20
+ const stepEntries = Array.isArray(job?.steps) ? job.steps : [];
21
+ const jobCondition = typeof job?.if === "string" ? job.if : undefined;
22
+ if (jobCondition) {
23
+ const hasPrivilegedStep = stepEntries.some((stepValue) => {
24
+ const step = asRecord(stepValue);
25
+ const run = typeof step?.run === "string" ? step.run : undefined;
26
+ const uses = typeof step?.uses === "string" ? step.uses : undefined;
27
+ return isPrivilegedStep(run, uses);
28
+ });
29
+ if (hasPrivilegedStep) {
30
+ steps.push({
31
+ jobId,
32
+ condition: jobCondition,
33
+ locationField: `jobs.${jobId}.if`,
34
+ run: stepEntries
35
+ .map((stepValue) => asRecord(stepValue))
36
+ .find((step) => step && typeof step.run === "string")?.run,
37
+ uses: stepEntries
38
+ .map((stepValue) => asRecord(stepValue))
39
+ .find((step) => step && typeof step.uses === "string")?.uses,
40
+ });
41
+ }
42
+ }
43
+ stepEntries.forEach((stepValue, stepIndex) => {
44
+ const step = asRecord(stepValue);
45
+ if (!step || typeof step.if !== "string") {
46
+ return;
47
+ }
48
+ steps.push({
49
+ jobId,
50
+ stepIndex,
51
+ condition: step.if,
52
+ locationField: `jobs.${jobId}.steps[${stepIndex}].if`,
53
+ run: typeof step.run === "string" ? step.run : undefined,
54
+ uses: typeof step.uses === "string" ? step.uses : undefined,
55
+ });
56
+ });
57
+ }
58
+ return steps;
59
+ }
60
+ function isPrivilegedStep(run, uses) {
61
+ const runValue = run?.toLowerCase() ?? "";
62
+ if (PRIVILEGED_RUN_TOKENS.some((token) => runValue.includes(token))) {
63
+ return true;
64
+ }
65
+ return typeof uses === "string" && uses.trim().length > 0;
66
+ }
67
+ export function detectWorkflowBotConditions(input) {
68
+ if (!isGitHubWorkflowPath(input.filePath)) {
69
+ return [];
70
+ }
71
+ const facts = extractWorkflowFacts(input.parsed);
72
+ if (!facts) {
73
+ return [];
74
+ }
75
+ const findings = [];
76
+ for (const step of gatherConditionalSteps(input.parsed)) {
77
+ if (!BOT_CONDITION_PATTERN.test(step.condition) || !isPrivilegedStep(step.run, step.uses)) {
78
+ continue;
79
+ }
80
+ const evidence = buildFindingEvidence({
81
+ textContent: input.textContent,
82
+ searchTerms: [step.condition, step.run ?? "", step.uses ?? ""],
83
+ fallbackValue: step.condition,
84
+ });
85
+ findings.push({
86
+ rule_id: "bot-conditions",
87
+ finding_id: `BOT_CONDITIONS-${input.filePath}-${step.locationField}`,
88
+ severity: "MEDIUM",
89
+ category: "CI_TRIGGER",
90
+ layer: "L2",
91
+ file_path: input.filePath,
92
+ location: { field: step.locationField },
93
+ description: "Privileged workflow step is guarded primarily by bot-actor identity checks",
94
+ affected_tools: ["github-actions"],
95
+ cve: null,
96
+ owasp: ["ASI02"],
97
+ cwe: "CWE-287",
98
+ confidence: "HIGH",
99
+ fixable: false,
100
+ remediation_actions: [
101
+ "Use explicit trust-boundary checks (event type, branch protection, ref, permissions) beyond actor-name conditions",
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 WorkflowCachePoisoningInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowCachePoisoning(input: WorkflowCachePoisoningInput): Finding[];
@@ -0,0 +1,67 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const UNTRUSTED_TRIGGERS = new Set(["pull_request", "pull_request_target", "workflow_run"]);
4
+ function normalizeUsesSlug(value) {
5
+ const beforeRef = value.split("@")[0] ?? value;
6
+ return beforeRef.replace(/\/+$/u, "").toLowerCase();
7
+ }
8
+ function isCacheAction(uses) {
9
+ return normalizeUsesSlug(uses).startsWith("actions/cache");
10
+ }
11
+ function hasRestoreKeys(stepWith) {
12
+ const restoreKeys = stepWith?.["restore-keys"]?.trim();
13
+ return restoreKeys && restoreKeys.length > 0 ? restoreKeys : null;
14
+ }
15
+ export function detectWorkflowCachePoisoning(input) {
16
+ if (!isGitHubWorkflowPath(input.filePath)) {
17
+ return [];
18
+ }
19
+ const facts = extractWorkflowFacts(input.parsed);
20
+ if (!facts) {
21
+ return [];
22
+ }
23
+ const hasUntrustedTrigger = facts.triggers.some((trigger) => UNTRUSTED_TRIGGERS.has(trigger));
24
+ if (!hasUntrustedTrigger) {
25
+ return [];
26
+ }
27
+ const findings = [];
28
+ facts.jobs.forEach((job, jobIndex) => {
29
+ job.steps.forEach((step, stepIndex) => {
30
+ const uses = step.uses?.trim();
31
+ if (!uses || !isCacheAction(uses)) {
32
+ return;
33
+ }
34
+ const restoreKeys = hasRestoreKeys(step.with);
35
+ if (!restoreKeys) {
36
+ return;
37
+ }
38
+ const evidence = buildFindingEvidence({
39
+ textContent: input.textContent,
40
+ searchTerms: ["restore-keys:", restoreKeys],
41
+ fallbackValue: restoreKeys,
42
+ });
43
+ findings.push({
44
+ rule_id: "workflow-cache-poisoning",
45
+ finding_id: `WORKFLOW_CACHE_POISONING-${input.filePath}-${jobIndex}-${stepIndex}`,
46
+ severity: "HIGH",
47
+ category: "CI_SUPPLY_CHAIN",
48
+ layer: "L2",
49
+ file_path: input.filePath,
50
+ location: { field: `jobs.${job.id}.steps[${stepIndex}].with.restore-keys` },
51
+ description: "Cache restore keys can enable cache poisoning on untrusted workflow triggers",
52
+ affected_tools: ["github-actions"],
53
+ cve: null,
54
+ owasp: ["ASI02"],
55
+ cwe: "CWE-345",
56
+ confidence: "MEDIUM",
57
+ fixable: false,
58
+ remediation_actions: [
59
+ "Remove broad restore keys or restrict cache reuse to trusted branches and jobs",
60
+ ],
61
+ evidence: evidence?.evidence ?? null,
62
+ suppressed: false,
63
+ });
64
+ });
65
+ });
66
+ return findings;
67
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowConcurrencyLimitsInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowConcurrencyLimits(input: WorkflowConcurrencyLimitsInput): Finding[];