codegate-ai 0.9.0 → 0.10.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 (28) hide show
  1. package/dist/layer2-static/detectors/dependabot-auto-merge.d.ts +7 -0
  2. package/dist/layer2-static/detectors/dependabot-auto-merge.js +118 -0
  3. package/dist/layer2-static/detectors/workflow-artifact-trust-chain.d.ts +7 -0
  4. package/dist/layer2-static/detectors/workflow-artifact-trust-chain.js +89 -0
  5. package/dist/layer2-static/detectors/workflow-call-boundary.d.ts +7 -0
  6. package/dist/layer2-static/detectors/workflow-call-boundary.js +92 -0
  7. package/dist/layer2-static/detectors/workflow-dynamic-matrix-injection.d.ts +7 -0
  8. package/dist/layer2-static/detectors/workflow-dynamic-matrix-injection.js +149 -0
  9. package/dist/layer2-static/detectors/workflow-local-action-mutation.d.ts +7 -0
  10. package/dist/layer2-static/detectors/workflow-local-action-mutation.js +125 -0
  11. package/dist/layer2-static/detectors/workflow-oidc-untrusted-context.d.ts +7 -0
  12. package/dist/layer2-static/detectors/workflow-oidc-untrusted-context.js +166 -0
  13. package/dist/layer2-static/detectors/workflow-pr-target-checkout-head.d.ts +7 -0
  14. package/dist/layer2-static/detectors/workflow-pr-target-checkout-head.js +99 -0
  15. package/dist/layer2-static/detectors/workflow-secret-exfiltration.d.ts +8 -0
  16. package/dist/layer2-static/detectors/workflow-secret-exfiltration.js +97 -0
  17. package/dist/layer2-static/engine.js +89 -0
  18. package/dist/layer2-static/rules/claude-code.json +16 -0
  19. package/dist/layer2-static/rules/codex.json +16 -0
  20. package/dist/layer2-static/rules/common.json +15 -0
  21. package/dist/layer2-static/rules/copilot.json +15 -0
  22. package/dist/layer2-static/rules/cursor.json +15 -0
  23. package/dist/layer2-static/rules/opencode.json +15 -0
  24. package/dist/layer2-static/workflow/analysis.d.ts +24 -0
  25. package/dist/layer2-static/workflow/analysis.js +201 -0
  26. package/dist/layer2-static/workflow/parser.js +30 -10
  27. package/dist/layer2-static/workflow/types.d.ts +6 -0
  28. package/package.json +1 -1
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface DependabotAutoMergeInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectDependabotAutoMerge(input: DependabotAutoMergeInput): Finding[];
@@ -0,0 +1,118 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const MERGE_COMMAND_PATTERN = /\b(gh\s+pr\s+merge|gh\s+pr\s+review|gh\s+api\s+[^#\n]*\/pulls\/[^#\n]*\/merge)\b/iu;
4
+ const MERGE_ACTIONS = new Set([
5
+ "ahmadnassri/action-dependabot-auto-merge",
6
+ "hmarr/auto-approve-action",
7
+ "fastify/github-action-merge-dependabot",
8
+ "ad-m/github-push-action",
9
+ "peter-evans/create-pull-request",
10
+ ]);
11
+ function normalizeUses(value) {
12
+ if (!value) {
13
+ return null;
14
+ }
15
+ const normalized = value.trim().toLowerCase();
16
+ if (normalized.length === 0) {
17
+ return null;
18
+ }
19
+ const atIndex = normalized.indexOf("@");
20
+ return atIndex === -1 ? normalized : normalized.slice(0, atIndex);
21
+ }
22
+ function hasDependabotActorConstraint(condition) {
23
+ if (!condition) {
24
+ return false;
25
+ }
26
+ const normalized = condition.toLowerCase();
27
+ return (normalized.includes("dependabot[bot]") ||
28
+ normalized.includes("dependabot-preview[bot]") ||
29
+ normalized.includes("github.actor") ||
30
+ normalized.includes("github.triggering_actor"));
31
+ }
32
+ function hasStrictRepoBoundary(condition) {
33
+ if (!condition) {
34
+ return false;
35
+ }
36
+ const normalized = condition.toLowerCase();
37
+ return (normalized.includes("github.repository == github.event.pull_request.head.repo.full_name") ||
38
+ normalized.includes("github.event.pull_request.head.repo.fork == false") ||
39
+ normalized.includes("github.event.pull_request.user.login") ||
40
+ normalized.includes("github.ref == 'refs/heads/main'") ||
41
+ normalized.includes("github.base_ref == 'main'"));
42
+ }
43
+ function isRiskyTrigger(trigger) {
44
+ const normalized = trigger.trim().toLowerCase();
45
+ return normalized === "pull_request_target" || normalized === "workflow_run";
46
+ }
47
+ export function detectDependabotAutoMerge(input) {
48
+ if (!isGitHubWorkflowPath(input.filePath)) {
49
+ return [];
50
+ }
51
+ const facts = extractWorkflowFacts(input.parsed);
52
+ if (!facts) {
53
+ return [];
54
+ }
55
+ const riskyTrigger = facts.triggers.find((trigger) => isRiskyTrigger(trigger));
56
+ if (!riskyTrigger) {
57
+ return [];
58
+ }
59
+ const findings = [];
60
+ facts.jobs.forEach((job, jobIndex) => {
61
+ job.steps.forEach((step, stepIndex) => {
62
+ const mergesByCommand = Boolean(step.run && MERGE_COMMAND_PATTERN.test(step.run));
63
+ const mergesByAction = (() => {
64
+ const normalizedUses = normalizeUses(step.uses);
65
+ return normalizedUses ? MERGE_ACTIONS.has(normalizedUses) : false;
66
+ })();
67
+ if (!mergesByCommand && !mergesByAction) {
68
+ return;
69
+ }
70
+ const mergedCondition = step.if ?? job.if;
71
+ if (!hasDependabotActorConstraint(mergedCondition)) {
72
+ return;
73
+ }
74
+ if (hasStrictRepoBoundary(mergedCondition)) {
75
+ return;
76
+ }
77
+ const evidence = buildFindingEvidence({
78
+ textContent: input.textContent,
79
+ searchTerms: [
80
+ "pull_request_target",
81
+ "dependabot[bot]",
82
+ "gh pr merge",
83
+ step.uses ?? "",
84
+ step.run ?? "",
85
+ ],
86
+ fallbackValue: `${job.id} auto-merge flow uses weak bot-only gating`,
87
+ });
88
+ findings.push({
89
+ rule_id: "dependabot-auto-merge",
90
+ finding_id: `DEPENDABOT_AUTO_MERGE-${input.filePath}-${jobIndex}-${stepIndex}`,
91
+ severity: riskyTrigger === "pull_request_target" ? "HIGH" : "MEDIUM",
92
+ category: "CI_TRIGGER",
93
+ layer: "L2",
94
+ file_path: input.filePath,
95
+ location: {
96
+ field: step.run
97
+ ? `jobs.${job.id}.steps[${stepIndex}].run`
98
+ : `jobs.${job.id}.steps[${stepIndex}].uses`,
99
+ },
100
+ description: "Dependabot auto-merge flow relies on weak actor-only conditions in a privileged trigger context",
101
+ affected_tools: ["github-actions", "dependabot"],
102
+ cve: null,
103
+ owasp: ["ASI02"],
104
+ cwe: "CWE-285",
105
+ confidence: "HIGH",
106
+ fixable: false,
107
+ remediation_actions: [
108
+ "Require strict repository boundary checks before executing auto-merge operations",
109
+ "Avoid pull_request_target auto-merge flows unless actor, repo, and branch checks are explicit",
110
+ "Prefer dedicated Dependabot metadata and permission-check actions before merge approval",
111
+ ],
112
+ evidence: evidence?.evidence ?? null,
113
+ suppressed: false,
114
+ });
115
+ });
116
+ });
117
+ return findings;
118
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowArtifactTrustChainInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowArtifactTrustChain(input: WorkflowArtifactTrustChainInput): Finding[];
@@ -0,0 +1,89 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { collectArtifactTransferEdges, collectUntrustedReachableJobIds, } from "../workflow/analysis.js";
3
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
4
+ function hasWritePermission(value) {
5
+ if (typeof value === "string") {
6
+ return value.trim().toLowerCase() === "write-all";
7
+ }
8
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
9
+ return false;
10
+ }
11
+ return Object.values(value).some((permission) => typeof permission === "string" && permission.trim().toLowerCase() === "write");
12
+ }
13
+ function hasInheritedSecrets(secrets) {
14
+ return typeof secrets === "string" && secrets.trim().toLowerCase() === "inherit";
15
+ }
16
+ function hasExecutableRunStep(jobSteps) {
17
+ return jobSteps.some((step) => typeof step.run === "string" && step.run.trim().length > 0);
18
+ }
19
+ export function detectWorkflowArtifactTrustChain(input) {
20
+ if (!isGitHubWorkflowPath(input.filePath)) {
21
+ return [];
22
+ }
23
+ const facts = extractWorkflowFacts(input.parsed);
24
+ if (!facts) {
25
+ return [];
26
+ }
27
+ const untrustedJobIds = collectUntrustedReachableJobIds(facts);
28
+ if (untrustedJobIds.size === 0) {
29
+ return [];
30
+ }
31
+ const workflowHasWritePermissions = hasWritePermission(facts.workflowPermissions);
32
+ const jobsById = new Map(facts.jobs.map((job) => [job.id, job]));
33
+ const findings = [];
34
+ const dedupe = new Set();
35
+ for (const edge of collectArtifactTransferEdges(facts)) {
36
+ if (!untrustedJobIds.has(edge.producerJobId)) {
37
+ continue;
38
+ }
39
+ const consumerJob = jobsById.get(edge.consumerJobId);
40
+ if (!consumerJob) {
41
+ continue;
42
+ }
43
+ const consumerPrivileged = workflowHasWritePermissions ||
44
+ hasWritePermission(consumerJob.permissions) ||
45
+ hasInheritedSecrets(consumerJob.secrets);
46
+ if (!consumerPrivileged || !hasExecutableRunStep(consumerJob.steps)) {
47
+ continue;
48
+ }
49
+ const dedupeKey = `${edge.producerJobId}|${edge.consumerJobId}|${edge.artifactName}`;
50
+ if (dedupe.has(dedupeKey)) {
51
+ continue;
52
+ }
53
+ dedupe.add(dedupeKey);
54
+ const evidence = buildFindingEvidence({
55
+ textContent: input.textContent,
56
+ searchTerms: [
57
+ "actions/upload-artifact",
58
+ "actions/download-artifact",
59
+ edge.artifactName,
60
+ "pull_request",
61
+ ],
62
+ fallbackValue: `${edge.consumerJobId} consumes artifact ${edge.artifactName} from untrusted producer ${edge.producerJobId}`,
63
+ });
64
+ findings.push({
65
+ rule_id: "workflow-artifact-trust-chain",
66
+ finding_id: `WORKFLOW_ARTIFACT_TRUST_CHAIN-${input.filePath}-${edge.producerJobId}-${edge.consumerJobId}-${edge.artifactName}`,
67
+ severity: edge.consumerDownloadsAll ? "CRITICAL" : "HIGH",
68
+ category: "CI_SUPPLY_CHAIN",
69
+ layer: "L2",
70
+ file_path: input.filePath,
71
+ location: { field: `jobs.${edge.consumerJobId}.steps[${edge.consumerStepIndex}]` },
72
+ description: "Privileged job executes after downloading artifacts produced in an untrusted workflow path",
73
+ affected_tools: ["github-actions"],
74
+ cve: null,
75
+ owasp: ["ASI02"],
76
+ cwe: "CWE-829",
77
+ confidence: "HIGH",
78
+ fixable: false,
79
+ remediation_actions: [
80
+ "Separate untrusted artifact production from privileged execution jobs",
81
+ "Require integrity verification before consuming downloaded artifacts",
82
+ "Avoid executing downloaded artifacts in jobs with write tokens or inherited secrets",
83
+ ],
84
+ evidence: evidence?.evidence ?? null,
85
+ suppressed: false,
86
+ });
87
+ }
88
+ return findings;
89
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowCallBoundaryInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowCallBoundary(input: WorkflowCallBoundaryInput): Finding[];
@@ -0,0 +1,92 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowCallBoundaryContext } from "../workflow/analysis.js";
3
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
4
+ function collectExpressionKeys(textContent, prefix) {
5
+ const keys = new Set();
6
+ const pattern = new RegExp(`${prefix}\\.([a-zA-Z0-9_-]+)`, "giu");
7
+ for (const match of textContent.matchAll(pattern)) {
8
+ const key = match[1]?.trim();
9
+ if (!key) {
10
+ continue;
11
+ }
12
+ keys.add(key);
13
+ }
14
+ return keys;
15
+ }
16
+ export function detectWorkflowCallBoundary(input) {
17
+ if (!isGitHubWorkflowPath(input.filePath)) {
18
+ return [];
19
+ }
20
+ const facts = extractWorkflowFacts(input.parsed);
21
+ if (!facts) {
22
+ return [];
23
+ }
24
+ const boundary = extractWorkflowCallBoundaryContext(input.parsed, facts);
25
+ if (!boundary.hasWorkflowCall) {
26
+ return [];
27
+ }
28
+ const declaredInputs = new Set(boundary.declaredInputKeys);
29
+ const declaredSecrets = new Set(boundary.declaredSecretKeys);
30
+ const referencedInputs = collectExpressionKeys(input.textContent, "inputs");
31
+ const referencedSecrets = collectExpressionKeys(input.textContent, "secrets");
32
+ const undeclaredInputs = Array.from(referencedInputs).filter((key) => !declaredInputs.has(key));
33
+ const undeclaredSecrets = Array.from(referencedSecrets).filter((key) => !declaredSecrets.has(key));
34
+ const findings = [];
35
+ for (const inputKey of undeclaredInputs) {
36
+ const evidence = buildFindingEvidence({
37
+ textContent: input.textContent,
38
+ searchTerms: [`inputs.${inputKey}`, "workflow_call"],
39
+ fallbackValue: `workflow_call references undeclared input ${inputKey}`,
40
+ });
41
+ findings.push({
42
+ rule_id: "workflow-call-boundary",
43
+ finding_id: `WORKFLOW_CALL_BOUNDARY-INPUT-${input.filePath}-${inputKey}`,
44
+ severity: "HIGH",
45
+ category: "CI_PERMISSIONS",
46
+ layer: "L2",
47
+ file_path: input.filePath,
48
+ location: { field: "on.workflow_call.inputs" },
49
+ description: `workflow_call references undeclared input '${inputKey}'`,
50
+ affected_tools: ["github-actions"],
51
+ cve: null,
52
+ owasp: ["ASI02"],
53
+ cwe: "CWE-20",
54
+ confidence: "HIGH",
55
+ fixable: false,
56
+ remediation_actions: [
57
+ `Declare input '${inputKey}' under on.workflow_call.inputs with explicit type and required policy`,
58
+ ],
59
+ evidence: evidence?.evidence ?? null,
60
+ suppressed: false,
61
+ });
62
+ }
63
+ for (const secretKey of undeclaredSecrets) {
64
+ const evidence = buildFindingEvidence({
65
+ textContent: input.textContent,
66
+ searchTerms: [`secrets.${secretKey}`, "workflow_call"],
67
+ fallbackValue: `workflow_call references undeclared secret ${secretKey}`,
68
+ });
69
+ findings.push({
70
+ rule_id: "workflow-call-boundary",
71
+ finding_id: `WORKFLOW_CALL_BOUNDARY-SECRET-${input.filePath}-${secretKey}`,
72
+ severity: "HIGH",
73
+ category: "CI_PERMISSIONS",
74
+ layer: "L2",
75
+ file_path: input.filePath,
76
+ location: { field: "on.workflow_call.secrets" },
77
+ description: `workflow_call references undeclared secret '${secretKey}'`,
78
+ affected_tools: ["github-actions"],
79
+ cve: null,
80
+ owasp: ["ASI02"],
81
+ cwe: "CWE-862",
82
+ confidence: "HIGH",
83
+ fixable: false,
84
+ remediation_actions: [
85
+ `Declare secret '${secretKey}' under on.workflow_call.secrets and pass only required values from callers`,
86
+ ],
87
+ evidence: evidence?.evidence ?? null,
88
+ suppressed: false,
89
+ });
90
+ }
91
+ return findings;
92
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowDynamicMatrixInjectionInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowDynamicMatrixInjection(input: WorkflowDynamicMatrixInjectionInput): Finding[];
@@ -0,0 +1,149 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { collectUntrustedReachableJobIds } from "../workflow/analysis.js";
3
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
4
+ const MATRIX_REFERENCE_PATTERN = /\$\{\{\s*matrix\.[^}]+\}\}/iu;
5
+ function asRecord(value) {
6
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
7
+ return null;
8
+ }
9
+ return value;
10
+ }
11
+ function asString(value) {
12
+ return typeof value === "string" ? value : undefined;
13
+ }
14
+ function isUntrustedDynamicMatrixExpression(value) {
15
+ const normalized = value.toLowerCase();
16
+ if (!normalized.includes("${{")) {
17
+ return false;
18
+ }
19
+ const untrustedEventRefs = [
20
+ "github.event.pull_request.",
21
+ "github.event.issue.",
22
+ "github.event.comment.",
23
+ "github.event.review.",
24
+ "github.event.discussion.",
25
+ "github.event.head_commit.",
26
+ ];
27
+ return untrustedEventRefs.some((ref) => normalized.includes(ref));
28
+ }
29
+ function isStaticFromJsonExpression(value) {
30
+ const normalized = value.toLowerCase().replace(/\s+/gu, "");
31
+ return (normalized.includes("fromjson('") ||
32
+ normalized.includes('fromjson("') ||
33
+ normalized.includes("fromjson(`"));
34
+ }
35
+ function hasMatrixAllowListValidation(condition) {
36
+ if (!condition) {
37
+ return false;
38
+ }
39
+ const normalized = condition.toLowerCase();
40
+ return normalized.includes("contains(fromjson(") && normalized.includes("matrix.");
41
+ }
42
+ export function detectWorkflowDynamicMatrixInjection(input) {
43
+ if (!isGitHubWorkflowPath(input.filePath)) {
44
+ return [];
45
+ }
46
+ const facts = extractWorkflowFacts(input.parsed);
47
+ if (!facts) {
48
+ return [];
49
+ }
50
+ const root = asRecord(input.parsed);
51
+ const jobsRecord = root ? asRecord(root.jobs) : null;
52
+ if (!jobsRecord) {
53
+ return [];
54
+ }
55
+ const reachableJobIds = collectUntrustedReachableJobIds(facts);
56
+ if (reachableJobIds.size === 0) {
57
+ return [];
58
+ }
59
+ const findings = [];
60
+ facts.jobs.forEach((job, jobIndex) => {
61
+ if (!reachableJobIds.has(job.id)) {
62
+ return;
63
+ }
64
+ const rawJob = asRecord(jobsRecord[job.id]);
65
+ if (!rawJob) {
66
+ return;
67
+ }
68
+ const strategyRecord = asRecord(rawJob.strategy);
69
+ const matrixExpression = strategyRecord ? asString(strategyRecord.matrix) : undefined;
70
+ if (!matrixExpression || !isUntrustedDynamicMatrixExpression(matrixExpression)) {
71
+ return;
72
+ }
73
+ if (isStaticFromJsonExpression(matrixExpression) &&
74
+ !matrixExpression.includes("github.event.")) {
75
+ return;
76
+ }
77
+ const hasAllowList = hasMatrixAllowListValidation(job.if);
78
+ const severity = hasAllowList ? "MEDIUM" : "HIGH";
79
+ const matrixEvidence = buildFindingEvidence({
80
+ textContent: input.textContent,
81
+ searchTerms: ["strategy:", "matrix:", "fromJSON", "github.event"],
82
+ fallbackValue: `${job.id} strategy.matrix is built from untrusted event data`,
83
+ });
84
+ findings.push({
85
+ rule_id: "workflow-dynamic-matrix-injection",
86
+ finding_id: `WORKFLOW_DYNAMIC_MATRIX_INJECTION-${input.filePath}-${jobIndex}`,
87
+ severity,
88
+ category: "CI_TEMPLATE_INJECTION",
89
+ layer: "L2",
90
+ file_path: input.filePath,
91
+ location: { field: `jobs.${job.id}.strategy.matrix` },
92
+ description: "Workflow strategy.matrix is derived from untrusted event payload content",
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 building strategy.matrix from attacker-controlled event fields",
101
+ "Use static allow-list matrices or validate and sanitize dynamic matrix payloads",
102
+ "Do not interpolate untrusted matrix values directly into shell commands",
103
+ ],
104
+ metadata: {
105
+ risk_tags: [hasAllowList ? "allow-list-guard" : "no-allow-list-guard"],
106
+ origin: "workflow-audit",
107
+ },
108
+ evidence: matrixEvidence?.evidence ?? null,
109
+ suppressed: false,
110
+ });
111
+ job.steps.forEach((step, stepIndex) => {
112
+ if (!step.run || !MATRIX_REFERENCE_PATTERN.test(step.run)) {
113
+ return;
114
+ }
115
+ const runEvidence = buildFindingEvidence({
116
+ textContent: input.textContent,
117
+ searchTerms: [step.run],
118
+ fallbackValue: `${job.id} run step interpolates matrix value in shell command`,
119
+ });
120
+ findings.push({
121
+ rule_id: "workflow-dynamic-matrix-injection",
122
+ finding_id: `WORKFLOW_DYNAMIC_MATRIX_INJECTION_RUN-${input.filePath}-${jobIndex}-${stepIndex}`,
123
+ severity,
124
+ category: "CI_TEMPLATE_INJECTION",
125
+ layer: "L2",
126
+ file_path: input.filePath,
127
+ location: { field: `jobs.${job.id}.steps[${stepIndex}].run` },
128
+ description: "Workflow run step interpolates dynamic matrix values sourced from untrusted event data",
129
+ affected_tools: ["github-actions"],
130
+ cve: null,
131
+ owasp: ["ASI02"],
132
+ cwe: "CWE-94",
133
+ confidence: "HIGH",
134
+ fixable: false,
135
+ remediation_actions: [
136
+ "Validate matrix values against explicit allow-lists before shell interpolation",
137
+ "Move untrusted values into strictly validated variables before command execution",
138
+ ],
139
+ metadata: {
140
+ risk_tags: [hasAllowList ? "allow-list-guard" : "no-allow-list-guard"],
141
+ origin: "workflow-audit",
142
+ },
143
+ evidence: runEvidence?.evidence ?? null,
144
+ suppressed: false,
145
+ });
146
+ });
147
+ });
148
+ return findings;
149
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowLocalActionMutationInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowLocalActionMutation(input: WorkflowLocalActionMutationInput): Finding[];
@@ -0,0 +1,125 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { collectUntrustedReachableJobIds } from "../workflow/analysis.js";
3
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
4
+ function hasWritePermission(value) {
5
+ if (typeof value === "string") {
6
+ return value.trim().toLowerCase() === "write-all";
7
+ }
8
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
9
+ return false;
10
+ }
11
+ return Object.values(value).some((permission) => {
12
+ if (typeof permission !== "string") {
13
+ return false;
14
+ }
15
+ return permission.trim().toLowerCase() === "write";
16
+ });
17
+ }
18
+ function hasIdTokenWrite(value) {
19
+ if (typeof value === "string") {
20
+ return value.trim().toLowerCase() === "write-all";
21
+ }
22
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
23
+ return false;
24
+ }
25
+ const idTokenPermission = value["id-token"];
26
+ return (typeof idTokenPermission === "string" && idTokenPermission.trim().toLowerCase() === "write");
27
+ }
28
+ function hasInheritedSecrets(secrets) {
29
+ return typeof secrets === "string" && secrets.trim().toLowerCase() === "inherit";
30
+ }
31
+ function isLocalUsesReference(value) {
32
+ if (!value) {
33
+ return false;
34
+ }
35
+ const normalized = value.trim();
36
+ return normalized.startsWith("./") || normalized.startsWith(".\\");
37
+ }
38
+ export function detectWorkflowLocalActionMutation(input) {
39
+ if (!isGitHubWorkflowPath(input.filePath)) {
40
+ return [];
41
+ }
42
+ const facts = extractWorkflowFacts(input.parsed);
43
+ if (!facts) {
44
+ return [];
45
+ }
46
+ const reachableJobIds = collectUntrustedReachableJobIds(facts);
47
+ if (reachableJobIds.size === 0) {
48
+ return [];
49
+ }
50
+ const findings = [];
51
+ const workflowPrivileged = hasWritePermission(facts.workflowPermissions) || hasIdTokenWrite(facts.workflowPermissions);
52
+ facts.jobs.forEach((job, jobIndex) => {
53
+ if (!reachableJobIds.has(job.id)) {
54
+ return;
55
+ }
56
+ const jobPrivileged = workflowPrivileged ||
57
+ hasWritePermission(job.permissions) ||
58
+ hasIdTokenWrite(job.permissions) ||
59
+ hasInheritedSecrets(job.secrets);
60
+ const severity = jobPrivileged ? "HIGH" : "MEDIUM";
61
+ if (isLocalUsesReference(job.uses)) {
62
+ const evidence = buildFindingEvidence({
63
+ textContent: input.textContent,
64
+ searchTerms: [job.uses ?? "", "uses: ./", "pull_request_target"],
65
+ fallbackValue: `${job.id} invokes local reusable workflow in untrusted context`,
66
+ });
67
+ findings.push({
68
+ rule_id: "workflow-local-action-mutation",
69
+ finding_id: `WORKFLOW_LOCAL_ACTION_MUTATION-JOB-${input.filePath}-${jobIndex}`,
70
+ severity,
71
+ category: "CI_SUPPLY_CHAIN",
72
+ layer: "L2",
73
+ file_path: input.filePath,
74
+ location: { field: `jobs.${job.id}.uses` },
75
+ description: "Untrusted workflow path executes a local reusable workflow reference that can be mutated by pull request content",
76
+ affected_tools: ["github-actions"],
77
+ cve: null,
78
+ owasp: ["ASI02"],
79
+ cwe: "CWE-494",
80
+ confidence: "HIGH",
81
+ fixable: false,
82
+ remediation_actions: [
83
+ "Avoid executing local reusable workflows from untrusted trigger contexts",
84
+ "Move privileged operations to immutable pinned actions or trusted workflow_call boundaries",
85
+ "Use read-only contexts when local action references are unavoidable",
86
+ ],
87
+ evidence: evidence?.evidence ?? null,
88
+ suppressed: false,
89
+ });
90
+ }
91
+ job.steps.forEach((step, stepIndex) => {
92
+ if (!isLocalUsesReference(step.uses)) {
93
+ return;
94
+ }
95
+ const evidence = buildFindingEvidence({
96
+ textContent: input.textContent,
97
+ searchTerms: [step.uses ?? "", "uses: ./", "pull_request_target"],
98
+ fallbackValue: `${job.id} executes mutable local action from untrusted context`,
99
+ });
100
+ findings.push({
101
+ rule_id: "workflow-local-action-mutation",
102
+ finding_id: `WORKFLOW_LOCAL_ACTION_MUTATION-STEP-${input.filePath}-${jobIndex}-${stepIndex}`,
103
+ severity,
104
+ category: "CI_SUPPLY_CHAIN",
105
+ layer: "L2",
106
+ file_path: input.filePath,
107
+ location: { field: `jobs.${job.id}.steps[${stepIndex}].uses` },
108
+ description: "Untrusted workflow path executes a local action reference that can be modified by the same pull request",
109
+ affected_tools: ["github-actions"],
110
+ cve: null,
111
+ owasp: ["ASI02"],
112
+ cwe: "CWE-494",
113
+ confidence: "HIGH",
114
+ fixable: false,
115
+ remediation_actions: [
116
+ "Avoid local action execution in untrusted trigger workflows with privileged permissions",
117
+ "Pin to immutable third-party actions or split untrusted and privileged jobs",
118
+ ],
119
+ evidence: evidence?.evidence ?? null,
120
+ suppressed: false,
121
+ });
122
+ });
123
+ });
124
+ return findings;
125
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowOidcUntrustedContextInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowOidcUntrustedContext(input: WorkflowOidcUntrustedContextInput): Finding[];