codegate-ai 0.8.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.
- package/README.md +48 -15
- package/dist/cli.js +41 -7
- package/dist/commands/scan-command.d.ts +6 -1
- package/dist/commands/scan-command.js +1 -1
- package/dist/commands/scan-content-command.js +5 -1
- package/dist/config/inline-ignore.d.ts +11 -0
- package/dist/config/inline-ignore.js +57 -0
- package/dist/config/suppression-policy.d.ts +8 -0
- package/dist/config/suppression-policy.js +66 -1
- package/dist/config.d.ts +19 -1
- package/dist/config.js +160 -1
- package/dist/layer2-static/action/parser.d.ts +3 -0
- package/dist/layer2-static/action/parser.js +165 -0
- package/dist/layer2-static/action/types.d.ts +39 -0
- package/dist/layer2-static/action/types.js +1 -0
- package/dist/layer2-static/advisories/cache.d.ts +6 -0
- package/dist/layer2-static/advisories/cache.js +44 -0
- package/dist/layer2-static/advisories/cache.ts +62 -0
- package/dist/layer2-static/advisories/gha-advisory-client.d.ts +7 -0
- package/dist/layer2-static/advisories/gha-advisory-client.js +4 -0
- package/dist/layer2-static/advisories/gha-advisory-client.ts +13 -0
- package/dist/layer2-static/advisories/gha-known-vulnerable-actions.json +4 -0
- package/dist/layer2-static/audits/registry.d.ts +3 -0
- package/dist/layer2-static/audits/registry.js +29 -0
- package/dist/layer2-static/audits/types.d.ts +12 -0
- package/dist/layer2-static/audits/types.js +1 -0
- package/dist/layer2-static/dependabot/parser.d.ts +3 -0
- package/dist/layer2-static/dependabot/parser.js +189 -0
- package/dist/layer2-static/dependabot/types.d.ts +50 -0
- package/dist/layer2-static/dependabot/types.js +1 -0
- package/dist/layer2-static/detectors/dependabot-cooldown.d.ts +7 -0
- package/dist/layer2-static/detectors/dependabot-cooldown.js +54 -0
- package/dist/layer2-static/detectors/dependabot-execution.d.ts +7 -0
- package/dist/layer2-static/detectors/dependabot-execution.js +68 -0
- package/dist/layer2-static/detectors/workflow-anonymous-definition.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-anonymous-definition.js +48 -0
- package/dist/layer2-static/detectors/workflow-archived-uses.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-archived-uses.js +147 -0
- package/dist/layer2-static/detectors/workflow-artipacked.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-artipacked.js +62 -0
- package/dist/layer2-static/detectors/workflow-bot-conditions.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-bot-conditions.js +108 -0
- package/dist/layer2-static/detectors/workflow-cache-poisoning.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-cache-poisoning.js +67 -0
- package/dist/layer2-static/detectors/workflow-concurrency-limits.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-concurrency-limits.js +76 -0
- package/dist/layer2-static/detectors/workflow-dangerous-triggers.d.ts +6 -0
- package/dist/layer2-static/detectors/workflow-dangerous-triggers.js +38 -0
- package/dist/layer2-static/detectors/workflow-excessive-permissions.d.ts +6 -0
- package/dist/layer2-static/detectors/workflow-excessive-permissions.js +65 -0
- package/dist/layer2-static/detectors/workflow-forbidden-uses.d.ts +12 -0
- package/dist/layer2-static/detectors/workflow-forbidden-uses.js +148 -0
- package/dist/layer2-static/detectors/workflow-github-env.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-github-env.js +49 -0
- package/dist/layer2-static/detectors/workflow-hardcoded-container-credentials.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-hardcoded-container-credentials.js +90 -0
- package/dist/layer2-static/detectors/workflow-impostor-commit.d.ts +9 -0
- package/dist/layer2-static/detectors/workflow-impostor-commit.js +127 -0
- package/dist/layer2-static/detectors/workflow-insecure-commands.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-insecure-commands.js +72 -0
- package/dist/layer2-static/detectors/workflow-known-vuln-action.d.ts +8 -0
- package/dist/layer2-static/detectors/workflow-known-vuln-action.js +70 -0
- package/dist/layer2-static/detectors/workflow-misfeature.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-misfeature.js +101 -0
- package/dist/layer2-static/detectors/workflow-obfuscation.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-obfuscation.js +79 -0
- package/dist/layer2-static/detectors/workflow-overprovisioned-secrets.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-overprovisioned-secrets.js +81 -0
- package/dist/layer2-static/detectors/workflow-ref-confusion.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-ref-confusion.js +69 -0
- package/dist/layer2-static/detectors/workflow-ref-version-mismatch.d.ts +9 -0
- package/dist/layer2-static/detectors/workflow-ref-version-mismatch.js +179 -0
- package/dist/layer2-static/detectors/workflow-secrets-inherit.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-secrets-inherit.js +59 -0
- package/dist/layer2-static/detectors/workflow-secrets-outside-env.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-secrets-outside-env.js +99 -0
- package/dist/layer2-static/detectors/workflow-self-hosted-runner.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-self-hosted-runner.js +67 -0
- package/dist/layer2-static/detectors/workflow-stale-action-refs.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-stale-action-refs.js +178 -0
- package/dist/layer2-static/detectors/workflow-superfluous-actions.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-superfluous-actions.js +70 -0
- package/dist/layer2-static/detectors/workflow-template-injection.d.ts +6 -0
- package/dist/layer2-static/detectors/workflow-template-injection.js +94 -0
- package/dist/layer2-static/detectors/workflow-undocumented-permissions.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-undocumented-permissions.js +101 -0
- package/dist/layer2-static/detectors/workflow-unpinned-images.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-unpinned-images.js +108 -0
- package/dist/layer2-static/detectors/workflow-unpinned-uses.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-unpinned-uses.js +60 -0
- package/dist/layer2-static/detectors/workflow-unredacted-secrets.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-unredacted-secrets.js +92 -0
- package/dist/layer2-static/detectors/workflow-unsound-condition.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-unsound-condition.js +90 -0
- package/dist/layer2-static/detectors/workflow-unsound-contains.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-unsound-contains.js +88 -0
- package/dist/layer2-static/detectors/workflow-use-trusted-publishing.d.ts +7 -0
- package/dist/layer2-static/detectors/workflow-use-trusted-publishing.js +66 -0
- package/dist/layer2-static/engine.d.ts +9 -1
- package/dist/layer2-static/engine.js +490 -50
- package/dist/layer2-static/github/cache.d.ts +8 -0
- package/dist/layer2-static/github/cache.js +41 -0
- package/dist/layer2-static/github/client.d.ts +17 -0
- package/dist/layer2-static/github/client.js +43 -0
- package/dist/layer2-static/workflow/injection-sinks.json +5 -0
- package/dist/layer2-static/workflow/parser.d.ts +3 -0
- package/dist/layer2-static/workflow/parser.js +88 -0
- package/dist/layer2-static/workflow/types.d.ts +15 -0
- package/dist/layer2-static/workflow/types.js +1 -0
- package/dist/pipeline.d.ts +1 -1
- package/dist/pipeline.js +3 -3
- package/dist/reporter/sarif.js +23 -5
- package/dist/reporter/terminal.js +12 -0
- package/dist/scan.d.ts +7 -1
- package/dist/scan.js +154 -40
- package/dist/types/finding.d.ts +1 -1
- package/dist/types/finding.js +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
function asRecord(value) {
|
|
4
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
function hasRiskyTrigger(triggers) {
|
|
10
|
+
return triggers.some((trigger) => trigger === "pull_request_target" ||
|
|
11
|
+
trigger === "workflow_run" ||
|
|
12
|
+
trigger === "issue_comment");
|
|
13
|
+
}
|
|
14
|
+
function hasWorkflowConcurrency(parsed) {
|
|
15
|
+
const root = asRecord(parsed);
|
|
16
|
+
const concurrency = root?.concurrency;
|
|
17
|
+
if (typeof concurrency === "string") {
|
|
18
|
+
return concurrency.trim().length > 0;
|
|
19
|
+
}
|
|
20
|
+
return asRecord(concurrency) !== null;
|
|
21
|
+
}
|
|
22
|
+
function hasAnyJobConcurrency(parsed) {
|
|
23
|
+
const root = asRecord(parsed);
|
|
24
|
+
const jobs = asRecord(root?.jobs);
|
|
25
|
+
if (!jobs) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return Object.values(jobs).some((jobValue) => {
|
|
29
|
+
const job = asRecord(jobValue);
|
|
30
|
+
const concurrency = job?.concurrency;
|
|
31
|
+
if (typeof concurrency === "string") {
|
|
32
|
+
return concurrency.trim().length > 0;
|
|
33
|
+
}
|
|
34
|
+
return asRecord(concurrency) !== null;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export function detectWorkflowConcurrencyLimits(input) {
|
|
38
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
42
|
+
if (!facts || !hasRiskyTrigger(facts.triggers)) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
if (hasWorkflowConcurrency(input.parsed) || hasAnyJobConcurrency(input.parsed)) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const evidence = buildFindingEvidence({
|
|
49
|
+
textContent: input.textContent,
|
|
50
|
+
searchTerms: ["pull_request_target", "workflow_run", "issue_comment", "concurrency"],
|
|
51
|
+
fallbackValue: "risky trigger without concurrency limits",
|
|
52
|
+
});
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
rule_id: "workflow-concurrency-limits",
|
|
56
|
+
finding_id: `WORKFLOW_CONCURRENCY_LIMITS-${input.filePath}`,
|
|
57
|
+
severity: "MEDIUM",
|
|
58
|
+
category: "CI_TRIGGER",
|
|
59
|
+
layer: "L2",
|
|
60
|
+
file_path: input.filePath,
|
|
61
|
+
location: { field: "concurrency" },
|
|
62
|
+
description: "Workflow uses risky triggers without concurrency controls, increasing race and replay risk",
|
|
63
|
+
affected_tools: ["github-actions"],
|
|
64
|
+
cve: null,
|
|
65
|
+
owasp: ["ASI02"],
|
|
66
|
+
cwe: "CWE-362",
|
|
67
|
+
confidence: "MEDIUM",
|
|
68
|
+
fixable: false,
|
|
69
|
+
remediation_actions: [
|
|
70
|
+
"Define workflow- or job-level concurrency groups with cancel-in-progress controls",
|
|
71
|
+
],
|
|
72
|
+
evidence: evidence?.evidence ?? null,
|
|
73
|
+
suppressed: false,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
2
|
+
const DANGEROUS_TRIGGERS = new Set(["pull_request_target", "workflow_run"]);
|
|
3
|
+
export function detectWorkflowDangerousTriggers(input) {
|
|
4
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
5
|
+
return [];
|
|
6
|
+
}
|
|
7
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
8
|
+
if (!facts) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
const triggers = facts.triggers.filter((trigger) => DANGEROUS_TRIGGERS.has(trigger));
|
|
12
|
+
if (triggers.length === 0) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
rule_id: "workflow-dangerous-triggers",
|
|
18
|
+
finding_id: `WORKFLOW_DANGEROUS_TRIGGERS-${input.filePath}`,
|
|
19
|
+
severity: "HIGH",
|
|
20
|
+
category: "CI_TRIGGER",
|
|
21
|
+
layer: "L2",
|
|
22
|
+
file_path: input.filePath,
|
|
23
|
+
location: { field: "on" },
|
|
24
|
+
description: `Workflow uses high-risk trigger(s): ${triggers.join(", ")}`,
|
|
25
|
+
affected_tools: ["github-actions"],
|
|
26
|
+
cve: null,
|
|
27
|
+
owasp: ["ASI02"],
|
|
28
|
+
cwe: "CWE-693",
|
|
29
|
+
confidence: "MEDIUM",
|
|
30
|
+
fixable: false,
|
|
31
|
+
remediation_actions: [
|
|
32
|
+
"Restrict trigger conditions and avoid running untrusted pull request data in privileged contexts",
|
|
33
|
+
],
|
|
34
|
+
evidence: triggers.join(", "),
|
|
35
|
+
suppressed: false,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
2
|
+
function hasWritePermission(value) {
|
|
3
|
+
if (typeof value === "string") {
|
|
4
|
+
return value === "write-all";
|
|
5
|
+
}
|
|
6
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
return Object.values(value).some((permission) => permission === "write");
|
|
10
|
+
}
|
|
11
|
+
export function detectWorkflowExcessivePermissions(input) {
|
|
12
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
16
|
+
if (!facts) {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
const findings = [];
|
|
20
|
+
if (hasWritePermission(facts.workflowPermissions)) {
|
|
21
|
+
findings.push({
|
|
22
|
+
rule_id: "workflow-excessive-permissions",
|
|
23
|
+
finding_id: `WORKFLOW_EXCESSIVE_PERMISSIONS-WORKFLOW-${input.filePath}`,
|
|
24
|
+
severity: "HIGH",
|
|
25
|
+
category: "CI_PERMISSIONS",
|
|
26
|
+
layer: "L2",
|
|
27
|
+
file_path: input.filePath,
|
|
28
|
+
location: { field: "permissions" },
|
|
29
|
+
description: "Workflow defines overly broad write permissions",
|
|
30
|
+
affected_tools: ["github-actions"],
|
|
31
|
+
cve: null,
|
|
32
|
+
owasp: ["ASI02"],
|
|
33
|
+
cwe: "CWE-732",
|
|
34
|
+
confidence: "HIGH",
|
|
35
|
+
fixable: false,
|
|
36
|
+
remediation_actions: ["Scope GITHUB_TOKEN permissions to least privilege"],
|
|
37
|
+
evidence: typeof facts.workflowPermissions === "string" ? facts.workflowPermissions : null,
|
|
38
|
+
suppressed: false,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
facts.jobs.forEach((job, index) => {
|
|
42
|
+
if (!hasWritePermission(job.permissions)) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
findings.push({
|
|
46
|
+
rule_id: "workflow-excessive-permissions",
|
|
47
|
+
finding_id: `WORKFLOW_EXCESSIVE_PERMISSIONS-JOB-${input.filePath}-${index}`,
|
|
48
|
+
severity: "HIGH",
|
|
49
|
+
category: "CI_PERMISSIONS",
|
|
50
|
+
layer: "L2",
|
|
51
|
+
file_path: input.filePath,
|
|
52
|
+
location: { field: `jobs.${job.id}.permissions` },
|
|
53
|
+
description: "Job defines overly broad write permissions",
|
|
54
|
+
affected_tools: ["github-actions"],
|
|
55
|
+
cve: null,
|
|
56
|
+
owasp: ["ASI02"],
|
|
57
|
+
cwe: "CWE-732",
|
|
58
|
+
confidence: "HIGH",
|
|
59
|
+
fixable: false,
|
|
60
|
+
remediation_actions: ["Reduce job-level permissions to required read scopes"],
|
|
61
|
+
suppressed: false,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Finding } from "../../types/finding.js";
|
|
2
|
+
export interface WorkflowForbiddenUsesRuleConfig {
|
|
3
|
+
allow?: string[];
|
|
4
|
+
deny?: string[];
|
|
5
|
+
}
|
|
6
|
+
export interface WorkflowForbiddenUsesInput {
|
|
7
|
+
filePath: string;
|
|
8
|
+
parsed: unknown;
|
|
9
|
+
textContent: string;
|
|
10
|
+
config?: WorkflowForbiddenUsesRuleConfig;
|
|
11
|
+
}
|
|
12
|
+
export declare function detectWorkflowForbiddenUses(input: WorkflowForbiddenUsesInput): Finding[];
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
function normalizePattern(value) {
|
|
4
|
+
return value.replaceAll("\\", "/").trim().toLowerCase();
|
|
5
|
+
}
|
|
6
|
+
function globToRegExp(glob) {
|
|
7
|
+
const pattern = normalizePattern(glob);
|
|
8
|
+
if (pattern.length === 0) {
|
|
9
|
+
return /^$/;
|
|
10
|
+
}
|
|
11
|
+
let regex = "^";
|
|
12
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
13
|
+
const char = pattern[index];
|
|
14
|
+
if (char === "*") {
|
|
15
|
+
if (pattern[index + 1] === "*") {
|
|
16
|
+
regex += ".*";
|
|
17
|
+
index += 1;
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
regex += "[^/]*";
|
|
21
|
+
}
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (char === "?") {
|
|
25
|
+
regex += "[^/]";
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
regex += char.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
29
|
+
}
|
|
30
|
+
regex += "$";
|
|
31
|
+
return new RegExp(regex);
|
|
32
|
+
}
|
|
33
|
+
function compilePatterns(patterns) {
|
|
34
|
+
return patterns.map((pattern) => globToRegExp(pattern));
|
|
35
|
+
}
|
|
36
|
+
function matchesRepositoryPattern(value, pattern) {
|
|
37
|
+
return pattern.test(value.toLowerCase());
|
|
38
|
+
}
|
|
39
|
+
function parseRepositoryUses(value) {
|
|
40
|
+
const trimmed = value.trim();
|
|
41
|
+
if (trimmed.startsWith("./") || trimmed.startsWith("docker://")) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const atIndex = trimmed.lastIndexOf("@");
|
|
45
|
+
if (atIndex < 0) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const slug = trimmed.slice(0, atIndex).trim().replace(/\/+$/u, "").toLowerCase();
|
|
49
|
+
const ref = trimmed
|
|
50
|
+
.slice(atIndex + 1)
|
|
51
|
+
.trim()
|
|
52
|
+
.toLowerCase();
|
|
53
|
+
if (slug.length === 0 || !slug.includes("/") || ref.length === 0) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return { slug, ref };
|
|
57
|
+
}
|
|
58
|
+
function getForbiddenPolicy(config) {
|
|
59
|
+
const allow = config?.allow?.filter((pattern) => pattern.trim().length > 0) ?? [];
|
|
60
|
+
const deny = config?.deny?.filter((pattern) => pattern.trim().length > 0) ?? [];
|
|
61
|
+
const allowPatterns = compilePatterns(allow);
|
|
62
|
+
const denyPatterns = compilePatterns(deny);
|
|
63
|
+
if (allow.length > 0) {
|
|
64
|
+
return {
|
|
65
|
+
mode: "allow",
|
|
66
|
+
allow: allowPatterns,
|
|
67
|
+
deny: denyPatterns,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (deny.length > 0) {
|
|
71
|
+
return {
|
|
72
|
+
mode: "deny",
|
|
73
|
+
allow: [],
|
|
74
|
+
deny: denyPatterns,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
function isForbidden(uses, policy) {
|
|
80
|
+
const parsed = parseRepositoryUses(uses);
|
|
81
|
+
if (!parsed) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (policy.deny.some((pattern) => matchesRepositoryPattern(parsed.slug, pattern))) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (policy.mode === "allow" &&
|
|
88
|
+
!policy.allow.some((pattern) => matchesRepositoryPattern(parsed.slug, pattern))) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
export function detectWorkflowForbiddenUses(input) {
|
|
94
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
98
|
+
if (!facts) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const policy = getForbiddenPolicy(input.config);
|
|
102
|
+
if (!policy) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const findings = [];
|
|
106
|
+
facts.jobs.forEach((job, jobIndex) => {
|
|
107
|
+
job.steps.forEach((step, stepIndex) => {
|
|
108
|
+
const uses = step.uses?.trim();
|
|
109
|
+
if (!uses) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!isForbidden(uses, policy)) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const evidence = buildFindingEvidence({
|
|
116
|
+
textContent: input.textContent,
|
|
117
|
+
searchTerms: [uses],
|
|
118
|
+
fallbackValue: `uses: ${uses}`,
|
|
119
|
+
});
|
|
120
|
+
findings.push({
|
|
121
|
+
rule_id: "workflow-forbidden-uses",
|
|
122
|
+
finding_id: `WORKFLOW_FORBIDDEN_USES-${input.filePath}-${jobIndex}-${stepIndex}`,
|
|
123
|
+
severity: "HIGH",
|
|
124
|
+
category: "CI_SUPPLY_CHAIN",
|
|
125
|
+
layer: "L2",
|
|
126
|
+
file_path: input.filePath,
|
|
127
|
+
location: { field: `jobs.${job.id}.steps[${stepIndex}].uses` },
|
|
128
|
+
description: policy.mode === "allow"
|
|
129
|
+
? "Workflow uses repository action outside the configured allowlist"
|
|
130
|
+
: "Workflow uses repository action matching the configured denylist",
|
|
131
|
+
affected_tools: ["github-actions"],
|
|
132
|
+
cve: null,
|
|
133
|
+
owasp: ["ASI02"],
|
|
134
|
+
cwe: "CWE-829",
|
|
135
|
+
confidence: "HIGH",
|
|
136
|
+
fixable: false,
|
|
137
|
+
remediation_actions: [
|
|
138
|
+
policy.mode === "allow"
|
|
139
|
+
? "Add the action to the allowlist only if it is explicitly trusted"
|
|
140
|
+
: "Remove the action or move it to an allowlist-only policy if it is trusted",
|
|
141
|
+
],
|
|
142
|
+
evidence: evidence?.evidence ?? null,
|
|
143
|
+
suppressed: false,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
return findings;
|
|
148
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
function writesToGithubEnv(run) {
|
|
4
|
+
return typeof run === "string" && />>\s*["']?\$?\{?GITHUB_ENV\}?/iu.test(run);
|
|
5
|
+
}
|
|
6
|
+
export function detectWorkflowGithubEnv(input) {
|
|
7
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
11
|
+
if (!facts) {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
const findings = [];
|
|
15
|
+
facts.jobs.forEach((job, jobIndex) => {
|
|
16
|
+
job.steps.forEach((step, stepIndex) => {
|
|
17
|
+
if (!writesToGithubEnv(step.run)) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const evidence = buildFindingEvidence({
|
|
21
|
+
textContent: input.textContent,
|
|
22
|
+
searchTerms: [step.run ?? "", "GITHUB_ENV"],
|
|
23
|
+
fallbackValue: step.run ?? "write to GITHUB_ENV",
|
|
24
|
+
});
|
|
25
|
+
findings.push({
|
|
26
|
+
rule_id: "workflow-github-env",
|
|
27
|
+
finding_id: `WORKFLOW_GITHUB_ENV-${input.filePath}-${jobIndex}-${stepIndex}`,
|
|
28
|
+
severity: "HIGH",
|
|
29
|
+
category: "CI_SUPPLY_CHAIN",
|
|
30
|
+
layer: "L2",
|
|
31
|
+
file_path: input.filePath,
|
|
32
|
+
location: { field: `jobs.${job.id}.steps[${stepIndex}].run` },
|
|
33
|
+
description: "Run step writes to GITHUB_ENV",
|
|
34
|
+
affected_tools: ["github-actions"],
|
|
35
|
+
cve: null,
|
|
36
|
+
owasp: ["ASI02"],
|
|
37
|
+
cwe: "CWE-94",
|
|
38
|
+
confidence: "HIGH",
|
|
39
|
+
fixable: false,
|
|
40
|
+
remediation_actions: [
|
|
41
|
+
"Avoid writing attacker-controlled values to GITHUB_ENV and prefer validated environment variables",
|
|
42
|
+
],
|
|
43
|
+
evidence: evidence?.evidence ?? null,
|
|
44
|
+
suppressed: false,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
return findings;
|
|
49
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Finding } from "../../types/finding.js";
|
|
2
|
+
export interface WorkflowHardcodedContainerCredentialsInput {
|
|
3
|
+
filePath: string;
|
|
4
|
+
parsed: unknown;
|
|
5
|
+
textContent: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function detectWorkflowHardcodedContainerCredentials(input: WorkflowHardcodedContainerCredentialsInput): Finding[];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
const CREDENTIAL_IN_IMAGE_PATTERNS = [/:\/\/[^/\s:@]+:[^@\s]+@/iu, /:[^@\s]+@(?!sha256:)/iu];
|
|
4
|
+
function asRecord(value) {
|
|
5
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
function gatherImageTargets(parsed) {
|
|
11
|
+
const root = asRecord(parsed);
|
|
12
|
+
const jobs = asRecord(root?.jobs);
|
|
13
|
+
if (!jobs) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
const targets = [];
|
|
17
|
+
for (const [jobId, jobValue] of Object.entries(jobs)) {
|
|
18
|
+
const job = asRecord(jobValue);
|
|
19
|
+
if (!job) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const container = asRecord(job.container);
|
|
23
|
+
if (typeof container?.image === "string") {
|
|
24
|
+
targets.push({
|
|
25
|
+
field: `jobs.${jobId}.container.image`,
|
|
26
|
+
image: container.image,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const services = asRecord(job.services);
|
|
30
|
+
if (!services) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
for (const [serviceName, serviceValue] of Object.entries(services)) {
|
|
34
|
+
const service = asRecord(serviceValue);
|
|
35
|
+
if (typeof service?.image === "string") {
|
|
36
|
+
targets.push({
|
|
37
|
+
field: `jobs.${jobId}.services.${serviceName}.image`,
|
|
38
|
+
image: service.image,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return targets;
|
|
44
|
+
}
|
|
45
|
+
function hasEmbeddedCredentials(image) {
|
|
46
|
+
const value = image.trim();
|
|
47
|
+
return CREDENTIAL_IN_IMAGE_PATTERNS.some((pattern) => pattern.test(value));
|
|
48
|
+
}
|
|
49
|
+
export function detectWorkflowHardcodedContainerCredentials(input) {
|
|
50
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
54
|
+
if (!facts) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const findings = [];
|
|
58
|
+
for (const target of gatherImageTargets(input.parsed)) {
|
|
59
|
+
if (!hasEmbeddedCredentials(target.image)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const evidence = buildFindingEvidence({
|
|
63
|
+
textContent: input.textContent,
|
|
64
|
+
searchTerms: [target.image, "image:"],
|
|
65
|
+
fallbackValue: target.image,
|
|
66
|
+
});
|
|
67
|
+
findings.push({
|
|
68
|
+
rule_id: "hardcoded-container-credentials",
|
|
69
|
+
finding_id: `HARDCODED_CONTAINER_CREDENTIALS-${input.filePath}-${target.field}`,
|
|
70
|
+
severity: "HIGH",
|
|
71
|
+
category: "CI_SUPPLY_CHAIN",
|
|
72
|
+
layer: "L2",
|
|
73
|
+
file_path: input.filePath,
|
|
74
|
+
location: { field: target.field },
|
|
75
|
+
description: "Container image reference appears to embed static credentials",
|
|
76
|
+
affected_tools: ["github-actions"],
|
|
77
|
+
cve: null,
|
|
78
|
+
owasp: ["ASI02"],
|
|
79
|
+
cwe: "CWE-798",
|
|
80
|
+
confidence: "HIGH",
|
|
81
|
+
fixable: false,
|
|
82
|
+
remediation_actions: [
|
|
83
|
+
"Remove embedded credentials from image references and use short-lived registry auth",
|
|
84
|
+
],
|
|
85
|
+
evidence: evidence?.evidence ?? null,
|
|
86
|
+
suppressed: false,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return findings;
|
|
90
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RuntimeMode } from "../../config.js";
|
|
2
|
+
import type { Finding } from "../../types/finding.js";
|
|
3
|
+
export interface WorkflowImpostorCommitInput {
|
|
4
|
+
filePath: string;
|
|
5
|
+
parsed: unknown;
|
|
6
|
+
textContent: string;
|
|
7
|
+
runtimeMode?: RuntimeMode;
|
|
8
|
+
}
|
|
9
|
+
export declare function detectWorkflowImpostorCommit(input: WorkflowImpostorCommitInput): Promise<Finding[]>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
const GITHUB_API_HEADERS = {
|
|
4
|
+
Accept: "application/vnd.github+json",
|
|
5
|
+
};
|
|
6
|
+
function splitOwnerRepo(slug) {
|
|
7
|
+
const firstSlash = slug.indexOf("/");
|
|
8
|
+
if (firstSlash < 0) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const owner = slug.slice(0, firstSlash);
|
|
12
|
+
const repo = slug.slice(firstSlash + 1).split("/")[0];
|
|
13
|
+
if (!owner || !repo) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return { owner, repo };
|
|
17
|
+
}
|
|
18
|
+
function isPinnedToCommit(ref) {
|
|
19
|
+
return /^[a-f0-9]{40}$/iu.test(ref.trim());
|
|
20
|
+
}
|
|
21
|
+
function parseRepositoryUses(value) {
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
if (trimmed.startsWith("./") || trimmed.startsWith("docker://")) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const atIndex = trimmed.lastIndexOf("@");
|
|
27
|
+
if (atIndex < 0) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const slug = trimmed.slice(0, atIndex).trim().replace(/\/+$/u, "").toLowerCase();
|
|
31
|
+
const ref = trimmed.slice(atIndex + 1).trim();
|
|
32
|
+
if (!slug.includes("/") || ref.length === 0) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return { slug, ref };
|
|
36
|
+
}
|
|
37
|
+
function parseUsesLine(line, lineNumber) {
|
|
38
|
+
const match = line.match(/^\s*(?:-\s*)?uses:\s*([^#]+?)(?:\s*#.*)?\s*$/iu);
|
|
39
|
+
if (!match?.[1]) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const uses = match[1].trim();
|
|
43
|
+
const usesColumn = line.indexOf("uses:");
|
|
44
|
+
return {
|
|
45
|
+
lineNumber,
|
|
46
|
+
column: usesColumn >= 0 ? usesColumn + 1 : 1,
|
|
47
|
+
rawLine: line,
|
|
48
|
+
uses,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async function fetchGitHubJson(url) {
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetch(url, {
|
|
54
|
+
headers: GITHUB_API_HEADERS,
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return (await response.json());
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function repositoryHasCommit(owner, repo, sha) {
|
|
66
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/commits/${encodeURIComponent(sha)}`;
|
|
67
|
+
const payload = (await fetchGitHubJson(url));
|
|
68
|
+
return typeof payload?.sha === "string" && payload.sha.length > 0;
|
|
69
|
+
}
|
|
70
|
+
export async function detectWorkflowImpostorCommit(input) {
|
|
71
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
if (!extractWorkflowFacts(input.parsed)) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const mode = input.runtimeMode ?? "offline";
|
|
78
|
+
if (mode !== "online") {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const findings = [];
|
|
82
|
+
const lines = input.textContent.split(/\r?\n/u);
|
|
83
|
+
for (const [index, line] of lines.entries()) {
|
|
84
|
+
const parsedLine = parseUsesLine(line, index + 1);
|
|
85
|
+
if (!parsedLine) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const parsedUses = parseRepositoryUses(parsedLine.uses);
|
|
89
|
+
if (!parsedUses || !isPinnedToCommit(parsedUses.ref)) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const ownerRepo = splitOwnerRepo(parsedUses.slug);
|
|
93
|
+
if (!ownerRepo) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (await repositoryHasCommit(ownerRepo.owner, ownerRepo.repo, parsedUses.ref)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const evidence = buildFindingEvidence({
|
|
100
|
+
textContent: input.textContent,
|
|
101
|
+
searchTerms: [parsedLine.rawLine, parsedUses.ref, parsedUses.slug],
|
|
102
|
+
fallbackValue: parsedLine.rawLine.trim(),
|
|
103
|
+
});
|
|
104
|
+
findings.push({
|
|
105
|
+
rule_id: "workflow-impostor-commit",
|
|
106
|
+
finding_id: `WORKFLOW_IMPOSTOR_COMMIT-${input.filePath}-${index + 1}`,
|
|
107
|
+
severity: "HIGH",
|
|
108
|
+
category: "CI_SUPPLY_CHAIN",
|
|
109
|
+
layer: "L2",
|
|
110
|
+
file_path: input.filePath,
|
|
111
|
+
location: { line: parsedLine.lineNumber, column: parsedLine.column },
|
|
112
|
+
description: "Pinned action commit is not present in the referenced repository",
|
|
113
|
+
affected_tools: ["github-actions"],
|
|
114
|
+
cve: null,
|
|
115
|
+
owasp: ["ASI02"],
|
|
116
|
+
cwe: "CWE-829",
|
|
117
|
+
confidence: "HIGH",
|
|
118
|
+
fixable: false,
|
|
119
|
+
remediation_actions: [
|
|
120
|
+
"Pin the action to a commit that exists in the referenced repository and review the upstream history before updating",
|
|
121
|
+
],
|
|
122
|
+
evidence: evidence?.evidence ?? parsedLine.rawLine.trim(),
|
|
123
|
+
suppressed: false,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return findings;
|
|
127
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Finding } from "../../types/finding.js";
|
|
2
|
+
export interface WorkflowInsecureCommandsInput {
|
|
3
|
+
filePath: string;
|
|
4
|
+
parsed: unknown;
|
|
5
|
+
textContent: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function detectWorkflowInsecureCommands(input: WorkflowInsecureCommandsInput): Finding[];
|