codegate-ai 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -40
- package/dist/cli.js +85 -7
- package/dist/commands/scan-command.d.ts +6 -1
- package/dist/commands/scan-command.js +1 -1
- package/dist/commands/scan-content-command.d.ts +16 -0
- package/dist/commands/scan-content-command.js +65 -0
- package/dist/config/inline-ignore.d.ts +11 -0
- package/dist/config/inline-ignore.js +57 -0
- package/dist/config/suppression-policy.d.ts +22 -0
- package/dist/config/suppression-policy.js +146 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.js +188 -3
- 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/agent-components.json +62 -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/advisory-intelligence.d.ts +7 -0
- package/dist/layer2-static/detectors/advisory-intelligence.js +170 -0
- package/dist/layer2-static/detectors/command-exec.js +6 -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/rule-file.js +5 -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 +13 -2
- package/dist/layer2-static/engine.js +578 -41
- 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/rule-engine.d.ts +1 -1
- package/dist/layer2-static/rule-engine.js +1 -13
- package/dist/layer2-static/rule-pack-loader.d.ts +10 -0
- package/dist/layer2-static/rule-pack-loader.js +187 -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/layer3-dynamic/toxic-flow.js +6 -0
- package/dist/pipeline.d.ts +1 -1
- package/dist/pipeline.js +11 -10
- package/dist/report/finding-fingerprint.d.ts +5 -0
- package/dist/report/finding-fingerprint.js +47 -0
- package/dist/reporter/markdown.js +25 -3
- package/dist/reporter/sarif.js +25 -5
- package/dist/reporter/terminal.js +37 -0
- package/dist/scan-target/fetch-plan.d.ts +8 -0
- package/dist/scan-target/fetch-plan.js +30 -0
- package/dist/scan-target/staging.js +60 -5
- package/dist/scan.d.ts +7 -1
- package/dist/scan.js +157 -40
- package/dist/types/finding.d.ts +10 -1
- package/dist/types/finding.js +5 -0
- package/package.json +1 -1
|
@@ -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[];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
const INSECURE_COMMAND_PATTERNS = [
|
|
4
|
+
{
|
|
5
|
+
pattern: /\b(?:curl|wget)\b[\s\S]*?\|\s*(?:sh|bash)\b/iu,
|
|
6
|
+
description: "remote download piped directly into a shell",
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
pattern: /\bbash\s*<\s*\(\s*curl\b/iu,
|
|
10
|
+
description: "process substitution executes downloaded shell content",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
pattern: /\bsh\s*<\s*\(\s*curl\b/iu,
|
|
14
|
+
description: "process substitution executes downloaded shell content",
|
|
15
|
+
},
|
|
16
|
+
];
|
|
17
|
+
function findInsecureCommand(run) {
|
|
18
|
+
if (typeof run !== "string") {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
for (const entry of INSECURE_COMMAND_PATTERNS) {
|
|
22
|
+
if (entry.pattern.test(run)) {
|
|
23
|
+
return { match: run, description: entry.description };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
export function detectWorkflowInsecureCommands(input) {
|
|
29
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
33
|
+
if (!facts) {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
const findings = [];
|
|
37
|
+
facts.jobs.forEach((job, jobIndex) => {
|
|
38
|
+
job.steps.forEach((step, stepIndex) => {
|
|
39
|
+
const insecure = findInsecureCommand(step.run);
|
|
40
|
+
if (!insecure) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const evidence = buildFindingEvidence({
|
|
44
|
+
textContent: input.textContent,
|
|
45
|
+
searchTerms: [insecure.match],
|
|
46
|
+
fallbackValue: insecure.match,
|
|
47
|
+
});
|
|
48
|
+
findings.push({
|
|
49
|
+
rule_id: "workflow-insecure-commands",
|
|
50
|
+
finding_id: `WORKFLOW_INSECURE_COMMANDS-${input.filePath}-${jobIndex}-${stepIndex}`,
|
|
51
|
+
severity: "HIGH",
|
|
52
|
+
category: "COMMAND_EXEC",
|
|
53
|
+
layer: "L2",
|
|
54
|
+
file_path: input.filePath,
|
|
55
|
+
location: { field: `jobs.${job.id}.steps[${stepIndex}].run` },
|
|
56
|
+
description: `Workflow uses ${insecure.description}`,
|
|
57
|
+
affected_tools: ["github-actions"],
|
|
58
|
+
cve: null,
|
|
59
|
+
owasp: ["ASI02"],
|
|
60
|
+
cwe: "CWE-78",
|
|
61
|
+
confidence: "HIGH",
|
|
62
|
+
fixable: false,
|
|
63
|
+
remediation_actions: [
|
|
64
|
+
"Download artifacts separately, verify integrity, and run them only after review",
|
|
65
|
+
],
|
|
66
|
+
evidence: evidence?.evidence ?? null,
|
|
67
|
+
suppressed: false,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
return findings;
|
|
72
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RuntimeMode } from "../../config.js";
|
|
2
|
+
import type { Finding } from "../../types/finding.js";
|
|
3
|
+
export interface WorkflowKnownVulnActionInput {
|
|
4
|
+
filePath: string;
|
|
5
|
+
parsed: unknown;
|
|
6
|
+
runtimeMode?: RuntimeMode;
|
|
7
|
+
}
|
|
8
|
+
export declare function detectWorkflowKnownVulnAction(input: WorkflowKnownVulnActionInput): Finding[];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { loadKnownVulnerableActions } from "../advisories/gha-advisory-client.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
function parseRepositoryUses(value) {
|
|
4
|
+
const trimmed = value.trim();
|
|
5
|
+
if (trimmed.startsWith("./") || trimmed.startsWith("docker://")) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
const atIndex = trimmed.lastIndexOf("@");
|
|
9
|
+
if (atIndex < 0) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const slug = trimmed.slice(0, atIndex).toLowerCase();
|
|
13
|
+
const ref = trimmed.slice(atIndex + 1).toLowerCase();
|
|
14
|
+
if (!slug.includes("/") || ref.length === 0) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return { slug, ref };
|
|
18
|
+
}
|
|
19
|
+
export function detectWorkflowKnownVulnAction(input) {
|
|
20
|
+
if (input.runtimeMode !== "online") {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
27
|
+
if (!facts) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
const advisories = loadKnownVulnerableActions({ runtimeMode: input.runtimeMode }).advisories;
|
|
31
|
+
const findings = [];
|
|
32
|
+
facts.jobs.forEach((job, jobIndex) => {
|
|
33
|
+
job.steps.forEach((step, stepIndex) => {
|
|
34
|
+
const uses = step.uses;
|
|
35
|
+
if (!uses) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const parsedUses = parseRepositoryUses(uses);
|
|
39
|
+
if (!parsedUses) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const vulnerableVersions = advisories[parsedUses.slug];
|
|
43
|
+
if (!vulnerableVersions || !vulnerableVersions.includes(parsedUses.ref)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
findings.push({
|
|
47
|
+
rule_id: "workflow-known-vuln-action",
|
|
48
|
+
finding_id: `WORKFLOW_KNOWN_VULN_ACTION-${input.filePath}-${jobIndex}-${stepIndex}`,
|
|
49
|
+
severity: "HIGH",
|
|
50
|
+
category: "CI_VULNERABLE_ACTION",
|
|
51
|
+
layer: "L2",
|
|
52
|
+
file_path: input.filePath,
|
|
53
|
+
location: { field: `jobs.${job.id}.steps[${stepIndex}].uses` },
|
|
54
|
+
description: `Action ${parsedUses.slug}@${parsedUses.ref} is listed in known vulnerable references`,
|
|
55
|
+
affected_tools: ["github-actions"],
|
|
56
|
+
cve: null,
|
|
57
|
+
owasp: ["ASI02"],
|
|
58
|
+
cwe: "CWE-937",
|
|
59
|
+
confidence: "HIGH",
|
|
60
|
+
fixable: false,
|
|
61
|
+
remediation_actions: [
|
|
62
|
+
"Upgrade to a non-vulnerable action release and pin to a reviewed commit SHA",
|
|
63
|
+
],
|
|
64
|
+
evidence: uses,
|
|
65
|
+
suppressed: false,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
return findings;
|
|
70
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
const SECURITY_TOKENS = [
|
|
4
|
+
"codeql",
|
|
5
|
+
"security",
|
|
6
|
+
"sast",
|
|
7
|
+
"secret",
|
|
8
|
+
"dependency review",
|
|
9
|
+
"npm audit",
|
|
10
|
+
"trivy",
|
|
11
|
+
];
|
|
12
|
+
function asRecord(value) {
|
|
13
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function isContinueOnErrorEnabled(value) {
|
|
19
|
+
if (value === true) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
return typeof value === "string" && value.trim().toLowerCase() === "true";
|
|
23
|
+
}
|
|
24
|
+
function isSecurityStep(candidate) {
|
|
25
|
+
const combined = [candidate.name, candidate.run, candidate.uses]
|
|
26
|
+
.filter((value) => typeof value === "string")
|
|
27
|
+
.join(" ")
|
|
28
|
+
.toLowerCase();
|
|
29
|
+
return SECURITY_TOKENS.some((token) => combined.includes(token));
|
|
30
|
+
}
|
|
31
|
+
function gatherCandidates(parsed) {
|
|
32
|
+
const root = asRecord(parsed);
|
|
33
|
+
const jobs = asRecord(root?.jobs);
|
|
34
|
+
if (!jobs) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const candidates = [];
|
|
38
|
+
for (const [jobId, jobValue] of Object.entries(jobs)) {
|
|
39
|
+
const job = asRecord(jobValue);
|
|
40
|
+
const steps = Array.isArray(job?.steps) ? job.steps : [];
|
|
41
|
+
steps.forEach((stepValue, stepIndex) => {
|
|
42
|
+
const step = asRecord(stepValue);
|
|
43
|
+
if (!step) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
candidates.push({
|
|
47
|
+
jobId,
|
|
48
|
+
stepIndex,
|
|
49
|
+
name: typeof step.name === "string" ? step.name : undefined,
|
|
50
|
+
run: typeof step.run === "string" ? step.run : undefined,
|
|
51
|
+
uses: typeof step.uses === "string" ? step.uses : undefined,
|
|
52
|
+
continueOnError: isContinueOnErrorEnabled(step["continue-on-error"]),
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return candidates;
|
|
57
|
+
}
|
|
58
|
+
export function detectWorkflowMisfeature(input) {
|
|
59
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
63
|
+
if (!facts) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const findings = [];
|
|
67
|
+
for (const candidate of gatherCandidates(input.parsed)) {
|
|
68
|
+
if (!candidate.continueOnError || !isSecurityStep(candidate)) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const evidence = buildFindingEvidence({
|
|
72
|
+
textContent: input.textContent,
|
|
73
|
+
searchTerms: ["continue-on-error: true", candidate.run ?? "", candidate.name ?? ""],
|
|
74
|
+
fallbackValue: "security step continues on error",
|
|
75
|
+
});
|
|
76
|
+
findings.push({
|
|
77
|
+
rule_id: "workflow-misfeature",
|
|
78
|
+
finding_id: `WORKFLOW_MISFEATURE-${input.filePath}-${candidate.jobId}-${candidate.stepIndex}`,
|
|
79
|
+
severity: "MEDIUM",
|
|
80
|
+
category: "CI_TRIGGER",
|
|
81
|
+
layer: "L2",
|
|
82
|
+
file_path: input.filePath,
|
|
83
|
+
location: {
|
|
84
|
+
field: `jobs.${candidate.jobId}.steps[${candidate.stepIndex}].continue-on-error`,
|
|
85
|
+
},
|
|
86
|
+
description: "Security-relevant step is configured with continue-on-error, which can hide failed checks",
|
|
87
|
+
affected_tools: ["github-actions"],
|
|
88
|
+
cve: null,
|
|
89
|
+
owasp: ["ASI02"],
|
|
90
|
+
cwe: "CWE-703",
|
|
91
|
+
confidence: "HIGH",
|
|
92
|
+
fixable: false,
|
|
93
|
+
remediation_actions: [
|
|
94
|
+
"Remove continue-on-error from security-critical steps or split non-blocking diagnostics into separate jobs",
|
|
95
|
+
],
|
|
96
|
+
evidence: evidence?.evidence ?? null,
|
|
97
|
+
suppressed: false,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return findings;
|
|
101
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
const OBFUSCATION_PATTERNS = [
|
|
4
|
+
{
|
|
5
|
+
pattern: /\bbase64\s+-d\b/iu,
|
|
6
|
+
description: "runtime decoding of base64-encoded payload data",
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
pattern: /\bbase64\s+-d\b[\s\S]*\|\s*(?:bash|sh)\b/iu,
|
|
10
|
+
description: "base64-decoded payload piped into a shell",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
pattern: /\beval\b[\s\S]*\$\([^)]+base64[^)]*\)/iu,
|
|
14
|
+
description: "eval executes command substitution that decodes base64 content",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
pattern: /\bprintf\b[\s\S]*\\x[0-9a-f]{2}/iu,
|
|
18
|
+
description: "hex-encoded shell payload reconstruction",
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
function detectObfuscation(run) {
|
|
22
|
+
if (!run) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
for (const entry of OBFUSCATION_PATTERNS) {
|
|
26
|
+
if (entry.pattern.test(run)) {
|
|
27
|
+
return {
|
|
28
|
+
run,
|
|
29
|
+
description: entry.description,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
export function detectWorkflowObfuscation(input) {
|
|
36
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
40
|
+
if (!facts) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
const findings = [];
|
|
44
|
+
facts.jobs.forEach((job, jobIndex) => {
|
|
45
|
+
job.steps.forEach((step, stepIndex) => {
|
|
46
|
+
const match = detectObfuscation(step.run);
|
|
47
|
+
if (!match) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const evidence = buildFindingEvidence({
|
|
51
|
+
textContent: input.textContent,
|
|
52
|
+
searchTerms: [match.run, "base64", "eval"],
|
|
53
|
+
fallbackValue: match.run,
|
|
54
|
+
});
|
|
55
|
+
findings.push({
|
|
56
|
+
rule_id: "workflow-obfuscation",
|
|
57
|
+
finding_id: `WORKFLOW_OBFUSCATION-${input.filePath}-${jobIndex}-${stepIndex}`,
|
|
58
|
+
severity: "HIGH",
|
|
59
|
+
category: "COMMAND_EXEC",
|
|
60
|
+
layer: "L2",
|
|
61
|
+
file_path: input.filePath,
|
|
62
|
+
location: { field: `jobs.${job.id}.steps[${stepIndex}].run` },
|
|
63
|
+
description: `Workflow run step uses obfuscated command execution (${match.description})`,
|
|
64
|
+
affected_tools: ["github-actions"],
|
|
65
|
+
cve: null,
|
|
66
|
+
owasp: ["ASI02"],
|
|
67
|
+
cwe: "CWE-506",
|
|
68
|
+
confidence: "HIGH",
|
|
69
|
+
fixable: false,
|
|
70
|
+
remediation_actions: [
|
|
71
|
+
"Replace obfuscated command pipelines with explicit, reviewable commands and integrity checks",
|
|
72
|
+
],
|
|
73
|
+
evidence: evidence?.evidence ?? null,
|
|
74
|
+
suppressed: false,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
return findings;
|
|
79
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Finding } from "../../types/finding.js";
|
|
2
|
+
export interface WorkflowOverprovisionedSecretsInput {
|
|
3
|
+
filePath: string;
|
|
4
|
+
parsed: unknown;
|
|
5
|
+
textContent: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function detectWorkflowOverprovisionedSecrets(input: WorkflowOverprovisionedSecretsInput): Finding[];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
const FULL_SECRETS_SERIALIZATION = /\btojson\s*\(\s*secrets\s*\)/iu;
|
|
4
|
+
function asRecord(value) {
|
|
5
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
function appendPath(base, segment) {
|
|
11
|
+
if (typeof segment === "number") {
|
|
12
|
+
return `${base}[${segment}]`;
|
|
13
|
+
}
|
|
14
|
+
return base.length > 0 ? `${base}.${segment}` : segment;
|
|
15
|
+
}
|
|
16
|
+
function findSerializedSecrets(value, path) {
|
|
17
|
+
if (typeof value === "string") {
|
|
18
|
+
return FULL_SECRETS_SERIALIZATION.test(value) ? { path, value } : null;
|
|
19
|
+
}
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
22
|
+
const found = findSerializedSecrets(value[index], appendPath(path, index));
|
|
23
|
+
if (found) {
|
|
24
|
+
return found;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const record = asRecord(value);
|
|
30
|
+
if (!record) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
for (const [key, child] of Object.entries(record)) {
|
|
34
|
+
const found = findSerializedSecrets(child, appendPath(path, key));
|
|
35
|
+
if (found) {
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
export function detectWorkflowOverprovisionedSecrets(input) {
|
|
42
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
46
|
+
if (!facts) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const found = findSerializedSecrets(input.parsed, "");
|
|
50
|
+
if (!found) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const evidence = buildFindingEvidence({
|
|
54
|
+
textContent: input.textContent,
|
|
55
|
+
searchTerms: [found.value, "toJSON(secrets)", "toJson(secrets)"],
|
|
56
|
+
fallbackValue: found.value,
|
|
57
|
+
});
|
|
58
|
+
return [
|
|
59
|
+
{
|
|
60
|
+
rule_id: "workflow-overprovisioned-secrets",
|
|
61
|
+
finding_id: `WORKFLOW_OVERPROVISIONED_SECRETS-${input.filePath}`,
|
|
62
|
+
severity: "HIGH",
|
|
63
|
+
category: "CI_PERMISSIONS",
|
|
64
|
+
layer: "L2",
|
|
65
|
+
file_path: input.filePath,
|
|
66
|
+
location: { field: found.path },
|
|
67
|
+
description: "Workflow serializes the entire secrets context",
|
|
68
|
+
affected_tools: ["github-actions"],
|
|
69
|
+
cve: null,
|
|
70
|
+
owasp: ["ASI02"],
|
|
71
|
+
cwe: "CWE-200",
|
|
72
|
+
confidence: "HIGH",
|
|
73
|
+
fixable: false,
|
|
74
|
+
remediation_actions: [
|
|
75
|
+
"Reference only the specific secrets each step needs instead of serializing the full secrets context",
|
|
76
|
+
],
|
|
77
|
+
evidence: evidence?.evidence ?? null,
|
|
78
|
+
suppressed: false,
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
}
|