Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/workflow-audit-parity-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ Use this checklist to track the workflow-audit detectors implemented in CodeGate
- [x] `unredacted-secrets`
- [x] `bot-conditions`

## Wave F

- [x] `workflow-call-boundary`
- [x] `workflow-artifact-trust-chain`
- [x] `workflow-oidc-untrusted-context`
- [x] `workflow-pr-target-checkout-head`
- [x] `workflow-dynamic-matrix-injection`
- [x] `workflow-secret-exfiltration`
- [x] `dependabot-auto-merge`
- [x] `workflow-local-action-mutation`

## Notes

- Checked items are implemented in CodeGate.
Expand Down
44 changes: 43 additions & 1 deletion docs/workflow-audit-real-cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,48 @@ Each fixture is commit-pinned to keep source provenance stable.
- Source: <https://github.com/RoleModel/rolemodel_rails/blob/83f8c13518afd1137405b81fc4723e202f833368/lib/generators/rolemodel/github/templates/dependabot.yml>
- Local file: `test-fixtures/workflow-audits/real-cases/RC-04-dependabot-execution/.github/dependabot.yml`

5. `RC-05-workflow-pr-target-checkout-head`

- Expected rule: `workflow-pr-target-checkout-head`
- Source: <https://github.com/antiwork/gumroad/blob/000969060793173ff7501038e4104794a5f842b1/.github/workflows/tests.yml>
- Local file: `test-fixtures/workflow-audits/real-cases/RC-05-workflow-pr-target-checkout-head/.github/workflows/tests.yml`

6. `RC-06-workflow-artifact-trust-chain`

- Expected rule: `workflow-artifact-trust-chain`
- Source: <https://github.com/facebook/react/blob/3e1abcc8d7083a13adf4774feb0d67ecbe4a2bc4/.github/workflows/runtime_build_and_test.yml>
- Local file: `test-fixtures/workflow-audits/real-cases/RC-06-workflow-artifact-trust-chain/.github/workflows/runtime_build_and_test.yml`

7. `RC-07-workflow-call-boundary`

- Expected rule: `workflow-call-boundary`
- Source: <https://github.com/valkey-io/valkey/blob/543a6b83dffff9d35da046ad2067a94b60cf3f38/.github/workflows/daily.yml>
- Local file: `test-fixtures/workflow-audits/real-cases/RC-07-workflow-call-boundary/.github/workflows/daily.yml`

8. `RC-08-workflow-secret-exfiltration`

- Expected rule: `workflow-secret-exfiltration`
- Source: <https://github.com/r-dbi/odbc/blob/02f4a32cacde3b24168cf4d28a18279e22c4939f/.github/workflows/db-pro.yaml>
- Local file: `test-fixtures/workflow-audits/real-cases/RC-08-workflow-secret-exfiltration/.github/workflows/db-pro.yaml`

9. `RC-09-workflow-oidc-untrusted-context`

- Expected rule: `workflow-oidc-untrusted-context`
- Source: <https://github.com/grafana/grafana/blob/2131a63ca06a161abcc1f46ff0352ca2ce3b06ca/.github/workflows/frontend-lint.yml>
- Local file: `test-fixtures/workflow-audits/real-cases/RC-09-workflow-oidc-untrusted-context/.github/workflows/frontend-lint.yml`

10. `RC-10-dependabot-auto-merge`

- Expected rule: `dependabot-auto-merge`
- Source: <https://github.com/bflad/go-module-two/blob/b34d6ff790df1dec533198da4be2f9857199d725/.github/workflows/dependabot-auto-merge.yml>
- Local file: `test-fixtures/workflow-audits/real-cases/RC-10-dependabot-auto-merge/.github/workflows/dependabot-auto-merge.yml`

11. `RC-11-workflow-local-action-mutation`

- Expected rule: `workflow-local-action-mutation`
- Source: <https://github.com/grafana/grafana/blob/2131a63ca06a161abcc1f46ff0352ca2ce3b06ca/.github/workflows/frontend-lint.yml>
- Local file: `test-fixtures/workflow-audits/real-cases/RC-11-workflow-local-action-mutation/.github/workflows/frontend-lint.yml`

## Validation

Run targeted test:
Expand All @@ -48,5 +90,5 @@ npm test -- tests/layer2/workflow-real-cases.test.ts
Run CLI manually:

```bash
codegate scan test-fixtures/workflow-audits/real-cases/RC-02-obfuscation --workflow-audits --no-tui --format json
codegate scan test-fixtures/workflow-audits/real-cases/RC-06-workflow-artifact-trust-chain --workflow-audits --no-tui --format json
```
146 changes: 146 additions & 0 deletions src/layer2-static/detectors/dependabot-auto-merge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type { Finding } from "../../types/finding.js";
import { buildFindingEvidence } from "../evidence.js";
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";

export interface DependabotAutoMergeInput {
filePath: string;
parsed: unknown;
textContent: string;
}

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;

const MERGE_ACTIONS = new Set([
"ahmadnassri/action-dependabot-auto-merge",
"hmarr/auto-approve-action",
"fastify/github-action-merge-dependabot",
"ad-m/github-push-action",
"peter-evans/create-pull-request",
]);

function normalizeUses(value: string | undefined): string | null {
if (!value) {
return null;
}
const normalized = value.trim().toLowerCase();
if (normalized.length === 0) {
return null;
}
const atIndex = normalized.indexOf("@");
return atIndex === -1 ? normalized : normalized.slice(0, atIndex);
}

function hasDependabotActorConstraint(condition: string | undefined): boolean {
if (!condition) {
return false;
}
const normalized = condition.toLowerCase();
return (
normalized.includes("dependabot[bot]") ||
normalized.includes("dependabot-preview[bot]") ||
normalized.includes("github.actor") ||
normalized.includes("github.triggering_actor")
);
}

function hasStrictRepoBoundary(condition: string | undefined): boolean {
if (!condition) {
return false;
}
const normalized = condition.toLowerCase();
return (
normalized.includes("github.repository == github.event.pull_request.head.repo.full_name") ||
normalized.includes("github.event.pull_request.head.repo.fork == false") ||
normalized.includes("github.event.pull_request.user.login") ||
normalized.includes("github.ref == 'refs/heads/main'") ||
normalized.includes("github.base_ref == 'main'")
);
}

function isRiskyTrigger(trigger: string): boolean {
const normalized = trigger.trim().toLowerCase();
return normalized === "pull_request_target" || normalized === "workflow_run";
}

export function detectDependabotAutoMerge(input: DependabotAutoMergeInput): Finding[] {
if (!isGitHubWorkflowPath(input.filePath)) {
return [];
}

const facts = extractWorkflowFacts(input.parsed);
if (!facts) {
return [];
}

const riskyTrigger = facts.triggers.find((trigger) => isRiskyTrigger(trigger));
if (!riskyTrigger) {
return [];
}

const findings: Finding[] = [];

facts.jobs.forEach((job, jobIndex) => {
job.steps.forEach((step, stepIndex) => {
const mergesByCommand = Boolean(step.run && MERGE_COMMAND_PATTERN.test(step.run));
const mergesByAction = (() => {
const normalizedUses = normalizeUses(step.uses);
return normalizedUses ? MERGE_ACTIONS.has(normalizedUses) : false;
})();
if (!mergesByCommand && !mergesByAction) {
return;
}

const mergedCondition = step.if ?? job.if;
if (!hasDependabotActorConstraint(mergedCondition)) {
return;
}
if (hasStrictRepoBoundary(mergedCondition)) {
return;
}

const evidence = buildFindingEvidence({
textContent: input.textContent,
searchTerms: [
"pull_request_target",
"dependabot[bot]",
"gh pr merge",
step.uses ?? "",
step.run ?? "",
],
fallbackValue: `${job.id} auto-merge flow uses weak bot-only gating`,
});

findings.push({
rule_id: "dependabot-auto-merge",
finding_id: `DEPENDABOT_AUTO_MERGE-${input.filePath}-${jobIndex}-${stepIndex}`,
severity: riskyTrigger === "pull_request_target" ? "HIGH" : "MEDIUM",
category: "CI_TRIGGER",
layer: "L2",
file_path: input.filePath,
location: {
field: step.run
? `jobs.${job.id}.steps[${stepIndex}].run`
: `jobs.${job.id}.steps[${stepIndex}].uses`,
},
description:
"Dependabot auto-merge flow relies on weak actor-only conditions in a privileged trigger context",
affected_tools: ["github-actions", "dependabot"],
cve: null,
owasp: ["ASI02"],
cwe: "CWE-285",
confidence: "HIGH",
fixable: false,
remediation_actions: [
"Require strict repository boundary checks before executing auto-merge operations",
"Avoid pull_request_target auto-merge flows unless actor, repo, and branch checks are explicit",
"Prefer dedicated Dependabot metadata and permission-check actions before merge approval",
],
evidence: evidence?.evidence ?? null,
suppressed: false,
});
});
});

return findings;
}
120 changes: 120 additions & 0 deletions src/layer2-static/detectors/workflow-artifact-trust-chain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import type { Finding } from "../../types/finding.js";
import { buildFindingEvidence } from "../evidence.js";
import {
collectArtifactTransferEdges,
collectUntrustedReachableJobIds,
} from "../workflow/analysis.js";
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";

export interface WorkflowArtifactTrustChainInput {
filePath: string;
parsed: unknown;
textContent: string;
}

function hasWritePermission(value: unknown): boolean {
if (typeof value === "string") {
return value.trim().toLowerCase() === "write-all";
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
return Object.values(value as Record<string, unknown>).some(
(permission) => typeof permission === "string" && permission.trim().toLowerCase() === "write",
);
}

function hasInheritedSecrets(secrets: unknown): boolean {
return typeof secrets === "string" && secrets.trim().toLowerCase() === "inherit";
}

function hasExecutableRunStep(jobSteps: Array<{ run?: string }>): boolean {
return jobSteps.some((step) => typeof step.run === "string" && step.run.trim().length > 0);
}

export function detectWorkflowArtifactTrustChain(
input: WorkflowArtifactTrustChainInput,
): Finding[] {
if (!isGitHubWorkflowPath(input.filePath)) {
return [];
}

const facts = extractWorkflowFacts(input.parsed);
if (!facts) {
return [];
}

const untrustedJobIds = collectUntrustedReachableJobIds(facts);
if (untrustedJobIds.size === 0) {
return [];
}

const workflowHasWritePermissions = hasWritePermission(facts.workflowPermissions);
const jobsById = new Map(facts.jobs.map((job) => [job.id, job]));
const findings: Finding[] = [];
const dedupe = new Set<string>();

for (const edge of collectArtifactTransferEdges(facts)) {
if (!untrustedJobIds.has(edge.producerJobId)) {
continue;
}

const consumerJob = jobsById.get(edge.consumerJobId);
if (!consumerJob) {
continue;
}

const consumerPrivileged =
workflowHasWritePermissions ||
hasWritePermission(consumerJob.permissions) ||
hasInheritedSecrets(consumerJob.secrets);

if (!consumerPrivileged || !hasExecutableRunStep(consumerJob.steps)) {
continue;
}

const dedupeKey = `${edge.producerJobId}|${edge.consumerJobId}|${edge.artifactName}`;
if (dedupe.has(dedupeKey)) {
continue;
}
dedupe.add(dedupeKey);

const evidence = buildFindingEvidence({
textContent: input.textContent,
searchTerms: [
"actions/upload-artifact",
"actions/download-artifact",
edge.artifactName,
"pull_request",
],
fallbackValue: `${edge.consumerJobId} consumes artifact ${edge.artifactName} from untrusted producer ${edge.producerJobId}`,
});

findings.push({
rule_id: "workflow-artifact-trust-chain",
finding_id: `WORKFLOW_ARTIFACT_TRUST_CHAIN-${input.filePath}-${edge.producerJobId}-${edge.consumerJobId}-${edge.artifactName}`,
severity: edge.consumerDownloadsAll ? "CRITICAL" : "HIGH",
category: "CI_SUPPLY_CHAIN",
layer: "L2",
file_path: input.filePath,
location: { field: `jobs.${edge.consumerJobId}.steps[${edge.consumerStepIndex}]` },
description:
"Privileged job executes after downloading artifacts produced in an untrusted workflow path",
affected_tools: ["github-actions"],
cve: null,
owasp: ["ASI02"],
cwe: "CWE-829",
confidence: "HIGH",
fixable: false,
remediation_actions: [
"Separate untrusted artifact production from privileged execution jobs",
"Require integrity verification before consuming downloaded artifacts",
"Avoid executing downloaded artifacts in jobs with write tokens or inherited secrets",
],
evidence: evidence?.evidence ?? null,
suppressed: false,
});
}

return findings;
}
Loading
Loading