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,170 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
5
|
+
const COMPONENTS_PATH = join(dirname(fileURLToPath(import.meta.url)), "../advisories/agent-components.json");
|
|
6
|
+
const GENERIC_AFFECTED_TOOLS = [
|
|
7
|
+
"claude-code",
|
|
8
|
+
"codex-cli",
|
|
9
|
+
"opencode",
|
|
10
|
+
"cursor",
|
|
11
|
+
"windsurf",
|
|
12
|
+
"github-copilot",
|
|
13
|
+
];
|
|
14
|
+
function globToRegExp(glob) {
|
|
15
|
+
const pattern = glob.replaceAll("\\", "/").trim();
|
|
16
|
+
if (pattern.length === 0) {
|
|
17
|
+
return /^$/u;
|
|
18
|
+
}
|
|
19
|
+
let regex = "^";
|
|
20
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
21
|
+
const char = pattern[index];
|
|
22
|
+
if (char === "*") {
|
|
23
|
+
if (pattern[index + 1] === "*") {
|
|
24
|
+
regex += ".*";
|
|
25
|
+
index += 1;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
regex += "[^/]*";
|
|
29
|
+
}
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (char === "?") {
|
|
33
|
+
regex += "[^/]";
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if ("\\^$.*+?()[]{}|".includes(char)) {
|
|
37
|
+
regex += `\\${char}`;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
regex += char;
|
|
41
|
+
}
|
|
42
|
+
regex += "$";
|
|
43
|
+
return new RegExp(regex, "u");
|
|
44
|
+
}
|
|
45
|
+
function matchesGlob(value, glob) {
|
|
46
|
+
const normalizedValue = value.replaceAll("\\", "/");
|
|
47
|
+
const normalizedGlob = glob.replaceAll("\\", "/").trim();
|
|
48
|
+
if (normalizedGlob.startsWith("**/")) {
|
|
49
|
+
const suffix = normalizedGlob.slice(3);
|
|
50
|
+
return normalizedValue === suffix || normalizedValue.endsWith(`/${suffix}`);
|
|
51
|
+
}
|
|
52
|
+
return globToRegExp(normalizedGlob).test(normalizedValue);
|
|
53
|
+
}
|
|
54
|
+
function loadComponents() {
|
|
55
|
+
const raw = readFileSync(COMPONENTS_PATH, "utf8");
|
|
56
|
+
const parsed = JSON.parse(raw);
|
|
57
|
+
return parsed;
|
|
58
|
+
}
|
|
59
|
+
const ADVISORY_COMPONENTS = loadComponents();
|
|
60
|
+
function normalizeToken(value) {
|
|
61
|
+
return value.trim().toLowerCase();
|
|
62
|
+
}
|
|
63
|
+
function collectStringCandidates(value, path = [], output = []) {
|
|
64
|
+
if (typeof value === "string") {
|
|
65
|
+
output.push({ path: path.join("."), value });
|
|
66
|
+
return output;
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
value.forEach((entry, index) => {
|
|
70
|
+
collectStringCandidates(entry, [...path, String(index)], output);
|
|
71
|
+
});
|
|
72
|
+
return output;
|
|
73
|
+
}
|
|
74
|
+
if (!value || typeof value !== "object") {
|
|
75
|
+
return output;
|
|
76
|
+
}
|
|
77
|
+
for (const [key, child] of Object.entries(value)) {
|
|
78
|
+
collectStringCandidates(child, [...path, key], output);
|
|
79
|
+
}
|
|
80
|
+
return output;
|
|
81
|
+
}
|
|
82
|
+
function makeFinding(filePath, matchedPath, component, signature, evidence) {
|
|
83
|
+
const location = { field: matchedPath };
|
|
84
|
+
if (typeof evidence?.line === "number") {
|
|
85
|
+
location.line = evidence.line;
|
|
86
|
+
}
|
|
87
|
+
if (typeof evidence?.column === "number") {
|
|
88
|
+
location.column = evidence.column;
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
rule_id: component.rule_id,
|
|
92
|
+
finding_id: `${component.rule_id}-${filePath}-${matchedPath}`,
|
|
93
|
+
severity: component.severity,
|
|
94
|
+
category: component.category,
|
|
95
|
+
layer: "L2",
|
|
96
|
+
file_path: filePath,
|
|
97
|
+
location,
|
|
98
|
+
description: component.description,
|
|
99
|
+
affected_tools: GENERIC_AFFECTED_TOOLS,
|
|
100
|
+
cve: null,
|
|
101
|
+
owasp: component.owasp,
|
|
102
|
+
cwe: component.cwe,
|
|
103
|
+
confidence: component.confidence,
|
|
104
|
+
fixable: true,
|
|
105
|
+
remediation_actions: component.remediation_actions,
|
|
106
|
+
metadata: {
|
|
107
|
+
sources: component.metadata?.sources ?? [],
|
|
108
|
+
sinks: component.metadata?.sinks ?? [],
|
|
109
|
+
referenced_secrets: component.metadata?.referenced_secrets ?? [],
|
|
110
|
+
risk_tags: component.metadata?.risk_tags ?? [],
|
|
111
|
+
origin: component.metadata?.origin ?? "agent-components.json",
|
|
112
|
+
},
|
|
113
|
+
evidence: evidence?.evidence ?? null,
|
|
114
|
+
suppressed: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function componentMatchesFile(component, filePath) {
|
|
118
|
+
return component.file_patterns.some((pattern) => matchesGlob(filePath, pattern));
|
|
119
|
+
}
|
|
120
|
+
function signatureMatches(value, signatures) {
|
|
121
|
+
const normalizedValue = normalizeToken(value);
|
|
122
|
+
for (const signature of signatures) {
|
|
123
|
+
const normalizedSignature = normalizeToken(signature);
|
|
124
|
+
if (normalizedSignature.length > 0 && normalizedValue.includes(normalizedSignature)) {
|
|
125
|
+
return signature;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
function detectComponentMatch(input, component) {
|
|
131
|
+
if (!componentMatchesFile(component, input.filePath)) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const stringCandidates = collectStringCandidates(input.parsed);
|
|
135
|
+
for (const candidate of stringCandidates) {
|
|
136
|
+
const signature = signatureMatches(candidate.value, component.signatures);
|
|
137
|
+
if (!signature) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const evidence = buildFindingEvidence({
|
|
141
|
+
textContent: input.textContent,
|
|
142
|
+
jsonPaths: candidate.path.length > 0 ? [candidate.path] : [],
|
|
143
|
+
searchTerms: [signature],
|
|
144
|
+
fallbackValue: `${candidate.path} = ${candidate.value}`,
|
|
145
|
+
});
|
|
146
|
+
return makeFinding(input.filePath, candidate.path || component.id, component, signature, evidence);
|
|
147
|
+
}
|
|
148
|
+
for (const signature of component.signatures) {
|
|
149
|
+
if (!signatureMatches(input.textContent, [signature])) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const evidence = buildFindingEvidence({
|
|
153
|
+
textContent: input.textContent,
|
|
154
|
+
searchTerms: [signature],
|
|
155
|
+
fallbackValue: signature,
|
|
156
|
+
});
|
|
157
|
+
return makeFinding(input.filePath, component.id, component, signature, evidence);
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
export function detectAdvisoryIntelligence(input) {
|
|
162
|
+
const findings = [];
|
|
163
|
+
for (const component of ADVISORY_COMPONENTS) {
|
|
164
|
+
const finding = detectComponentMatch(input, component);
|
|
165
|
+
if (finding) {
|
|
166
|
+
findings.push(finding);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return findings;
|
|
170
|
+
}
|
|
@@ -95,6 +95,12 @@ function makeFinding(filePath, field, ruleId, severity, description, evidence) {
|
|
|
95
95
|
confidence: "HIGH",
|
|
96
96
|
fixable: true,
|
|
97
97
|
remediation_actions: ["remove_field", "replace_with_default"],
|
|
98
|
+
metadata: {
|
|
99
|
+
sources: [filePath, field],
|
|
100
|
+
sinks: ["process-execution"],
|
|
101
|
+
risk_tags: ["command-execution", "shell-pipeline"],
|
|
102
|
+
origin: "command-exec",
|
|
103
|
+
},
|
|
98
104
|
evidence: evidence?.evidence ?? null,
|
|
99
105
|
suppressed: false,
|
|
100
106
|
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractDependabotFacts, isGitHubDependabotPath } from "../dependabot/parser.js";
|
|
3
|
+
function hasCooldown(update) {
|
|
4
|
+
const cooldown = update.cooldown;
|
|
5
|
+
if (!cooldown) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
return (typeof cooldown.defaultDays === "number" ||
|
|
9
|
+
typeof cooldown.semverMajorDays === "number" ||
|
|
10
|
+
typeof cooldown.semverMinorDays === "number" ||
|
|
11
|
+
typeof cooldown.semverPatchDays === "number");
|
|
12
|
+
}
|
|
13
|
+
export function detectDependabotCooldown(input) {
|
|
14
|
+
if (!isGitHubDependabotPath(input.filePath)) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
const facts = extractDependabotFacts(input.parsed);
|
|
18
|
+
if (!facts || facts.updates.length === 0) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const findings = [];
|
|
22
|
+
facts.updates.forEach((update, index) => {
|
|
23
|
+
if (!update.schedule || hasCooldown(update)) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const ecosystem = update.packageEcosystem ?? "unknown-ecosystem";
|
|
27
|
+
const directory = update.directory ?? "/";
|
|
28
|
+
const evidence = buildFindingEvidence({
|
|
29
|
+
textContent: input.textContent,
|
|
30
|
+
searchTerms: [ecosystem, directory, "schedule", "cooldown"],
|
|
31
|
+
fallbackValue: `${ecosystem} ${directory} update rule has no cooldown`,
|
|
32
|
+
});
|
|
33
|
+
findings.push({
|
|
34
|
+
rule_id: "dependabot-cooldown",
|
|
35
|
+
finding_id: `DEPENDABOT_COOLDOWN-${input.filePath}-${index}`,
|
|
36
|
+
severity: "LOW",
|
|
37
|
+
category: "CI_SUPPLY_CHAIN",
|
|
38
|
+
layer: "L2",
|
|
39
|
+
file_path: input.filePath,
|
|
40
|
+
location: { field: `updates[${index}].cooldown` },
|
|
41
|
+
description: "Dependabot update rule has no cooldown window, increasing update churn and review pressure",
|
|
42
|
+
affected_tools: ["dependabot"],
|
|
43
|
+
cve: null,
|
|
44
|
+
owasp: ["ASI02"],
|
|
45
|
+
cwe: "CWE-400",
|
|
46
|
+
confidence: "MEDIUM",
|
|
47
|
+
fixable: false,
|
|
48
|
+
remediation_actions: ["Add cooldown settings to pace update volume and review load"],
|
|
49
|
+
evidence: evidence?.evidence ?? null,
|
|
50
|
+
suppressed: false,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
return findings;
|
|
54
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { isGitHubDependabotPath } from "../dependabot/parser.js";
|
|
3
|
+
function asRecord(value) {
|
|
4
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
function isExternalCodeExecutionAllowed(value) {
|
|
10
|
+
if (value === true) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
if (typeof value === "string") {
|
|
14
|
+
const normalized = value.trim().toLowerCase();
|
|
15
|
+
return normalized === "allow" || normalized === "true";
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
export function detectDependabotExecution(input) {
|
|
20
|
+
if (!isGitHubDependabotPath(input.filePath)) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const root = asRecord(input.parsed);
|
|
24
|
+
const updates = Array.isArray(root?.updates) ? root.updates : [];
|
|
25
|
+
if (updates.length === 0) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
const findings = [];
|
|
29
|
+
updates.forEach((entry, index) => {
|
|
30
|
+
const update = asRecord(entry);
|
|
31
|
+
if (!update) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!isExternalCodeExecutionAllowed(update["insecure-external-code-execution"])) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const ecosystem = typeof update["package-ecosystem"] === "string"
|
|
38
|
+
? update["package-ecosystem"]
|
|
39
|
+
: "unknown-ecosystem";
|
|
40
|
+
const evidence = buildFindingEvidence({
|
|
41
|
+
textContent: input.textContent,
|
|
42
|
+
searchTerms: [ecosystem, "insecure-external-code-execution", "allow"],
|
|
43
|
+
fallbackValue: `${ecosystem} enables insecure external code execution`,
|
|
44
|
+
});
|
|
45
|
+
findings.push({
|
|
46
|
+
rule_id: "dependabot-execution",
|
|
47
|
+
finding_id: `DEPENDABOT_EXECUTION-${input.filePath}-${index}`,
|
|
48
|
+
severity: "HIGH",
|
|
49
|
+
category: "CI_SUPPLY_CHAIN",
|
|
50
|
+
layer: "L2",
|
|
51
|
+
file_path: input.filePath,
|
|
52
|
+
location: { field: `updates[${index}].insecure-external-code-execution` },
|
|
53
|
+
description: "Dependabot update rule allows insecure external code execution",
|
|
54
|
+
affected_tools: ["dependabot"],
|
|
55
|
+
cve: null,
|
|
56
|
+
owasp: ["ASI02"],
|
|
57
|
+
cwe: "CWE-94",
|
|
58
|
+
confidence: "HIGH",
|
|
59
|
+
fixable: false,
|
|
60
|
+
remediation_actions: [
|
|
61
|
+
"Remove insecure-external-code-execution allowances and isolate registries through trusted credentials",
|
|
62
|
+
],
|
|
63
|
+
evidence: evidence?.evidence ?? null,
|
|
64
|
+
suppressed: false,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
return findings;
|
|
68
|
+
}
|
|
@@ -48,6 +48,11 @@ function makeFinding(filePath, field, ruleId, description, evidence, severity =
|
|
|
48
48
|
confidence: "HIGH",
|
|
49
49
|
fixable: true,
|
|
50
50
|
remediation_actions: ["strip_unicode", "remove_block", "quarantine_file"],
|
|
51
|
+
metadata: {
|
|
52
|
+
sources: [filePath, field],
|
|
53
|
+
risk_tags: ["rule-injection", "prompt-injection"],
|
|
54
|
+
origin: "rule-file",
|
|
55
|
+
},
|
|
51
56
|
evidence: evidence?.evidence ?? null,
|
|
52
57
|
observed: narrative.observed ?? null,
|
|
53
58
|
inference: narrative.inference ?? null,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Finding } from "../../types/finding.js";
|
|
2
|
+
export interface WorkflowAnonymousDefinitionInput {
|
|
3
|
+
filePath: string;
|
|
4
|
+
parsed: unknown;
|
|
5
|
+
textContent: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function detectWorkflowAnonymousDefinition(input: WorkflowAnonymousDefinitionInput): Finding[];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
function asRecord(value) {
|
|
4
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
export function detectWorkflowAnonymousDefinition(input) {
|
|
10
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
14
|
+
if (!facts) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
const root = asRecord(input.parsed);
|
|
18
|
+
const name = typeof root?.name === "string" ? root.name.trim() : "";
|
|
19
|
+
if (name.length > 0) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
const evidence = buildFindingEvidence({
|
|
23
|
+
textContent: input.textContent,
|
|
24
|
+
searchTerms: ["name:"],
|
|
25
|
+
fallbackValue: "workflow has no top-level name",
|
|
26
|
+
});
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
rule_id: "workflow-anonymous-definition",
|
|
30
|
+
finding_id: `WORKFLOW_ANONYMOUS_DEFINITION-${input.filePath}`,
|
|
31
|
+
severity: "LOW",
|
|
32
|
+
category: "CI_SUPPLY_CHAIN",
|
|
33
|
+
layer: "L2",
|
|
34
|
+
file_path: input.filePath,
|
|
35
|
+
location: { field: "name" },
|
|
36
|
+
description: "Workflow omits a top-level name, reducing review clarity and incident traceability",
|
|
37
|
+
affected_tools: ["github-actions"],
|
|
38
|
+
cve: null,
|
|
39
|
+
owasp: ["ASI02"],
|
|
40
|
+
cwe: "CWE-200",
|
|
41
|
+
confidence: "HIGH",
|
|
42
|
+
fixable: false,
|
|
43
|
+
remediation_actions: ["Add an explicit top-level workflow name"],
|
|
44
|
+
evidence: evidence?.evidence ?? null,
|
|
45
|
+
suppressed: false,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Finding } from "../../types/finding.js";
|
|
2
|
+
export interface WorkflowArchivedUsesInput {
|
|
3
|
+
filePath: string;
|
|
4
|
+
parsed: unknown;
|
|
5
|
+
textContent: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function detectWorkflowArchivedUses(input: WorkflowArchivedUsesInput): Promise<Finding[]>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
const archivedRepoCacheByFetch = new WeakMap();
|
|
4
|
+
const GITHUB_API_HEADERS = {
|
|
5
|
+
Accept: "application/vnd.github+json",
|
|
6
|
+
"User-Agent": "CodeGate",
|
|
7
|
+
};
|
|
8
|
+
function asRecord(value) {
|
|
9
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
function isExternalRepositoryUse(value) {
|
|
15
|
+
return value.startsWith("./") || value.startsWith("../") || value.startsWith("docker://");
|
|
16
|
+
}
|
|
17
|
+
function trimTrailingSlashes(value) {
|
|
18
|
+
return value.replace(/\/+$/u, "");
|
|
19
|
+
}
|
|
20
|
+
function parseRepositoryUses(value) {
|
|
21
|
+
const trimmed = value.trim();
|
|
22
|
+
if (trimmed.length === 0 || isExternalRepositoryUse(trimmed)) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const atIndex = trimmed.lastIndexOf("@");
|
|
26
|
+
if (atIndex < 0) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const slug = trimTrailingSlashes(trimmed.slice(0, atIndex).trim());
|
|
30
|
+
const [owner, repo] = slug.split("/");
|
|
31
|
+
if (!owner || !repo) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
raw: trimmed,
|
|
36
|
+
owner: owner.toLowerCase(),
|
|
37
|
+
repo: repo.toLowerCase(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async function isArchivedRepository(owner, repo) {
|
|
41
|
+
const fetchFn = globalThis.fetch;
|
|
42
|
+
if (typeof fetchFn !== "function") {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
let cache = archivedRepoCacheByFetch.get(fetchFn);
|
|
46
|
+
if (!cache) {
|
|
47
|
+
cache = new Map();
|
|
48
|
+
archivedRepoCacheByFetch.set(fetchFn, cache);
|
|
49
|
+
}
|
|
50
|
+
const cacheKey = `${owner}/${repo}`;
|
|
51
|
+
const cached = cache.get(cacheKey);
|
|
52
|
+
if (cached !== undefined) {
|
|
53
|
+
return cached;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetchFn(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, {
|
|
57
|
+
headers: GITHUB_API_HEADERS,
|
|
58
|
+
});
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
cache.set(cacheKey, false);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const payload = (await response.json());
|
|
64
|
+
const archived = Boolean(payload && typeof payload === "object" && payload.archived === true);
|
|
65
|
+
cache.set(cacheKey, archived);
|
|
66
|
+
return archived;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function gatherUsesTargets(parsed) {
|
|
73
|
+
const root = asRecord(parsed);
|
|
74
|
+
const jobsRecord = asRecord(root?.jobs);
|
|
75
|
+
if (!jobsRecord) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const targets = [];
|
|
79
|
+
const addTarget = (uses, field) => {
|
|
80
|
+
const parsedUses = parseRepositoryUses(uses);
|
|
81
|
+
if (parsedUses) {
|
|
82
|
+
targets.push({
|
|
83
|
+
...parsedUses,
|
|
84
|
+
field,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
for (const [jobId, jobValue] of Object.entries(jobsRecord)) {
|
|
89
|
+
const jobRecord = asRecord(jobValue);
|
|
90
|
+
if (!jobRecord) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (typeof jobRecord.uses === "string") {
|
|
94
|
+
addTarget(jobRecord.uses, `jobs.${jobId}.uses`);
|
|
95
|
+
}
|
|
96
|
+
const steps = Array.isArray(jobRecord.steps) ? jobRecord.steps : [];
|
|
97
|
+
steps.forEach((step, stepIndex) => {
|
|
98
|
+
const stepRecord = asRecord(step);
|
|
99
|
+
if (stepRecord && typeof stepRecord.uses === "string") {
|
|
100
|
+
addTarget(stepRecord.uses, `jobs.${jobId}.steps[${stepIndex}].uses`);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return targets;
|
|
105
|
+
}
|
|
106
|
+
export async function detectWorkflowArchivedUses(input) {
|
|
107
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
if (!extractWorkflowFacts(input.parsed)) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
const targets = gatherUsesTargets(input.parsed);
|
|
114
|
+
const findings = [];
|
|
115
|
+
for (const target of targets) {
|
|
116
|
+
if (!(await isArchivedRepository(target.owner, target.repo))) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const evidence = buildFindingEvidence({
|
|
120
|
+
textContent: input.textContent,
|
|
121
|
+
searchTerms: [target.raw, `${target.owner}/${target.repo}`],
|
|
122
|
+
fallbackValue: target.raw,
|
|
123
|
+
});
|
|
124
|
+
findings.push({
|
|
125
|
+
rule_id: "workflow-archived-uses",
|
|
126
|
+
finding_id: `WORKFLOW_ARCHIVED_USES-${input.filePath}-${target.field}`,
|
|
127
|
+
severity: "MEDIUM",
|
|
128
|
+
category: "CI_VULNERABLE_ACTION",
|
|
129
|
+
layer: "L2",
|
|
130
|
+
file_path: input.filePath,
|
|
131
|
+
location: { field: target.field },
|
|
132
|
+
description: "Workflow action or reusable workflow comes from an archived repository",
|
|
133
|
+
affected_tools: ["github-actions"],
|
|
134
|
+
cve: null,
|
|
135
|
+
owasp: ["ASI02"],
|
|
136
|
+
cwe: "CWE-829",
|
|
137
|
+
confidence: "HIGH",
|
|
138
|
+
fixable: false,
|
|
139
|
+
remediation_actions: [
|
|
140
|
+
"Replace the archived repository reference with an actively maintained alternative",
|
|
141
|
+
],
|
|
142
|
+
evidence: evidence?.evidence ?? null,
|
|
143
|
+
suppressed: false,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return findings;
|
|
147
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
+
import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
|
|
3
|
+
function isCheckoutStep(uses) {
|
|
4
|
+
return typeof uses === "string" && /^actions\/checkout(?:@.+)?$/iu.test(uses.trim());
|
|
5
|
+
}
|
|
6
|
+
function persistsCredentials(value) {
|
|
7
|
+
if (typeof value !== "string") {
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
return value.trim().toLowerCase() !== "false";
|
|
11
|
+
}
|
|
12
|
+
export function detectWorkflowArtipacked(input) {
|
|
13
|
+
if (!isGitHubWorkflowPath(input.filePath)) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
const facts = extractWorkflowFacts(input.parsed);
|
|
17
|
+
if (!facts) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
const findings = [];
|
|
21
|
+
facts.jobs.forEach((job, jobIndex) => {
|
|
22
|
+
job.steps.forEach((step, stepIndex) => {
|
|
23
|
+
if (!isCheckoutStep(step.uses)) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (!persistsCredentials(step.with?.["persist-credentials"])) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const searchTerms = ["persist-credentials: true"];
|
|
30
|
+
if (step.uses) {
|
|
31
|
+
searchTerms.push(step.uses);
|
|
32
|
+
}
|
|
33
|
+
const evidence = buildFindingEvidence({
|
|
34
|
+
textContent: input.textContent,
|
|
35
|
+
searchTerms,
|
|
36
|
+
fallbackValue: "actions/checkout persists credentials on disk",
|
|
37
|
+
});
|
|
38
|
+
findings.push({
|
|
39
|
+
rule_id: "workflow-artipacked",
|
|
40
|
+
finding_id: `WORKFLOW_ARTIPACKED-${input.filePath}-${jobIndex}-${stepIndex}`,
|
|
41
|
+
severity: "HIGH",
|
|
42
|
+
category: "CI_SUPPLY_CHAIN",
|
|
43
|
+
layer: "L2",
|
|
44
|
+
file_path: input.filePath,
|
|
45
|
+
location: { field: `jobs.${job.id}.steps[${stepIndex}].with.persist-credentials` },
|
|
46
|
+
description: "Checkout step persists credentials on disk",
|
|
47
|
+
affected_tools: ["github-actions"],
|
|
48
|
+
cve: null,
|
|
49
|
+
owasp: ["ASI02"],
|
|
50
|
+
cwe: "CWE-922",
|
|
51
|
+
confidence: "HIGH",
|
|
52
|
+
fixable: false,
|
|
53
|
+
remediation_actions: [
|
|
54
|
+
"Set actions/checkout persist-credentials to false unless the workflow explicitly needs Git credentials",
|
|
55
|
+
],
|
|
56
|
+
evidence: evidence?.evidence ?? null,
|
|
57
|
+
suppressed: false,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
return findings;
|
|
62
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Finding } from "../../types/finding.js";
|
|
2
|
+
export interface WorkflowBotConditionsInput {
|
|
3
|
+
filePath: string;
|
|
4
|
+
parsed: unknown;
|
|
5
|
+
textContent: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function detectWorkflowBotConditions(input: WorkflowBotConditionsInput): Finding[];
|