codegate-ai 0.7.0 → 0.8.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 +61 -25
- package/dist/cli.js +44 -0
- package/dist/commands/scan-content-command.d.ts +16 -0
- package/dist/commands/scan-content-command.js +61 -0
- package/dist/config/suppression-policy.d.ts +14 -0
- package/dist/config/suppression-policy.js +81 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +29 -3
- package/dist/layer2-static/advisories/agent-components.json +62 -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/rule-file.js +5 -0
- package/dist/layer2-static/engine.d.ts +4 -1
- package/dist/layer2-static/engine.js +97 -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/layer3-dynamic/toxic-flow.js +6 -0
- package/dist/pipeline.js +9 -8
- 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 +2 -0
- package/dist/reporter/terminal.js +25 -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.js +3 -0
- package/dist/types/finding.d.ts +9 -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
|
};
|
|
@@ -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,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type GitHookEntry } from "./detectors/git-hooks.js";
|
|
2
2
|
import { type SymlinkEscapeEntry } from "./detectors/symlink.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type Finding } from "../types/finding.js";
|
|
4
4
|
import type { DiscoveryFormat } from "../types/discovery.js";
|
|
5
5
|
export interface StaticFileInput {
|
|
6
6
|
filePath: string;
|
|
@@ -17,6 +17,9 @@ export interface StaticEngineConfig {
|
|
|
17
17
|
trustedApiDomains: string[];
|
|
18
18
|
unicodeAnalysis: boolean;
|
|
19
19
|
checkIdeSettings: boolean;
|
|
20
|
+
rulePackPaths?: string[];
|
|
21
|
+
allowedRules?: string[];
|
|
22
|
+
skipRules?: string[];
|
|
20
23
|
}
|
|
21
24
|
export interface StaticEngineInput {
|
|
22
25
|
projectRoot: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { detectCommandExecution } from "./detectors/command-exec.js";
|
|
2
|
+
import { detectAdvisoryIntelligence } from "./detectors/advisory-intelligence.js";
|
|
2
3
|
import { detectConsentBypass } from "./detectors/consent-bypass.js";
|
|
3
4
|
import { detectEnvOverrides } from "./detectors/env-override.js";
|
|
4
5
|
import { detectGitHookIssues } from "./detectors/git-hooks.js";
|
|
@@ -6,6 +7,78 @@ import { detectIdeSettingsIssues } from "./detectors/ide-settings.js";
|
|
|
6
7
|
import { detectPluginManifestIssues } from "./detectors/plugin-manifest.js";
|
|
7
8
|
import { detectRuleFileIssues } from "./detectors/rule-file.js";
|
|
8
9
|
import { detectSymlinkEscapes } from "./detectors/symlink.js";
|
|
10
|
+
import { FINDING_CATEGORIES } from "../types/finding.js";
|
|
11
|
+
import { buildFindingEvidence } from "./evidence.js";
|
|
12
|
+
import { evaluateRule, loadRulePacks } from "./rule-engine.js";
|
|
13
|
+
const GENERIC_AFFECTED_TOOLS = [
|
|
14
|
+
"claude-code",
|
|
15
|
+
"codex-cli",
|
|
16
|
+
"opencode",
|
|
17
|
+
"cursor",
|
|
18
|
+
"windsurf",
|
|
19
|
+
"github-copilot",
|
|
20
|
+
];
|
|
21
|
+
function parseRuleSeverity(value) {
|
|
22
|
+
const normalized = value.trim().toUpperCase();
|
|
23
|
+
if (normalized === "CRITICAL" ||
|
|
24
|
+
normalized === "HIGH" ||
|
|
25
|
+
normalized === "MEDIUM" ||
|
|
26
|
+
normalized === "LOW") {
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
return "INFO";
|
|
30
|
+
}
|
|
31
|
+
function parseRuleCategory(value) {
|
|
32
|
+
const normalized = value.trim().toUpperCase();
|
|
33
|
+
if (FINDING_CATEGORIES.some((category) => category === normalized)) {
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
36
|
+
return "CONFIG_PRESENT";
|
|
37
|
+
}
|
|
38
|
+
function remediationActionsForRule(rule) {
|
|
39
|
+
if (rule.query_type === "text_pattern") {
|
|
40
|
+
return ["quarantine_file", "remove_block"];
|
|
41
|
+
}
|
|
42
|
+
return ["remove_field", "replace_with_default"];
|
|
43
|
+
}
|
|
44
|
+
function findingFromRulePackMatch(file, rule) {
|
|
45
|
+
const locationField = rule.query_type === "text_pattern" ? "content" : rule.query;
|
|
46
|
+
const evidence = buildFindingEvidence({
|
|
47
|
+
textContent: file.textContent,
|
|
48
|
+
searchTerms: [rule.query],
|
|
49
|
+
fallbackValue: `${locationField} matched rule ${rule.id}`,
|
|
50
|
+
});
|
|
51
|
+
const affectedTools = rule.tool === "*" ? GENERIC_AFFECTED_TOOLS : [rule.tool];
|
|
52
|
+
return {
|
|
53
|
+
rule_id: rule.id,
|
|
54
|
+
finding_id: `RULE_PACK-${rule.id}-${file.filePath}-${locationField}`,
|
|
55
|
+
severity: parseRuleSeverity(rule.severity),
|
|
56
|
+
category: parseRuleCategory(rule.category),
|
|
57
|
+
layer: "L2",
|
|
58
|
+
file_path: file.filePath,
|
|
59
|
+
location: { field: locationField },
|
|
60
|
+
description: rule.description,
|
|
61
|
+
affected_tools: affectedTools,
|
|
62
|
+
cve: rule.cve ?? null,
|
|
63
|
+
owasp: rule.owasp,
|
|
64
|
+
cwe: rule.cwe,
|
|
65
|
+
confidence: "HIGH",
|
|
66
|
+
fixable: true,
|
|
67
|
+
remediation_actions: remediationActionsForRule(rule),
|
|
68
|
+
metadata: {
|
|
69
|
+
sources: [file.filePath, locationField],
|
|
70
|
+
risk_tags: ["rule-pack"],
|
|
71
|
+
origin: "rule-pack",
|
|
72
|
+
},
|
|
73
|
+
evidence: evidence?.evidence ?? null,
|
|
74
|
+
suppressed: false,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function hasEquivalentFinding(findings, candidate) {
|
|
78
|
+
return findings.some((finding) => finding.rule_id === candidate.rule_id &&
|
|
79
|
+
finding.file_path === candidate.file_path &&
|
|
80
|
+
(finding.location.field ?? "") === (candidate.location.field ?? ""));
|
|
81
|
+
}
|
|
9
82
|
function dedupeFindings(findings) {
|
|
10
83
|
const deduped = new Map();
|
|
11
84
|
for (const finding of findings) {
|
|
@@ -32,6 +105,11 @@ function dedupeFindings(findings) {
|
|
|
32
105
|
}
|
|
33
106
|
export function runStaticEngine(input) {
|
|
34
107
|
const findings = [];
|
|
108
|
+
const rulePackRules = loadRulePacks({
|
|
109
|
+
rule_pack_paths: input.config.rulePackPaths ?? [],
|
|
110
|
+
allowed_rules: input.config.allowedRules ?? [],
|
|
111
|
+
skip_rules: input.config.skipRules ?? [],
|
|
112
|
+
});
|
|
35
113
|
for (const file of input.files) {
|
|
36
114
|
findings.push(...detectEnvOverrides({
|
|
37
115
|
filePath: file.filePath,
|
|
@@ -69,6 +147,11 @@ export function runStaticEngine(input) {
|
|
|
69
147
|
trustedApiDomains: input.config.trustedApiDomains,
|
|
70
148
|
blockedCommands: input.config.blockedCommands,
|
|
71
149
|
}));
|
|
150
|
+
findings.push(...detectAdvisoryIntelligence({
|
|
151
|
+
filePath: file.filePath,
|
|
152
|
+
parsed: file.parsed,
|
|
153
|
+
textContent: file.textContent,
|
|
154
|
+
}));
|
|
72
155
|
if (file.format === "text" || file.format === "markdown") {
|
|
73
156
|
findings.push(...detectRuleFileIssues({
|
|
74
157
|
filePath: file.filePath,
|
|
@@ -76,6 +159,20 @@ export function runStaticEngine(input) {
|
|
|
76
159
|
unicodeAnalysis: input.config.unicodeAnalysis,
|
|
77
160
|
}));
|
|
78
161
|
}
|
|
162
|
+
for (const rule of rulePackRules) {
|
|
163
|
+
if (!evaluateRule(rule, {
|
|
164
|
+
filePath: file.filePath,
|
|
165
|
+
format: file.format,
|
|
166
|
+
parsed: file.parsed,
|
|
167
|
+
textContent: file.textContent,
|
|
168
|
+
})) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const candidate = findingFromRulePackMatch(file, rule);
|
|
172
|
+
if (!hasEquivalentFinding(findings, candidate)) {
|
|
173
|
+
findings.push(candidate);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
79
176
|
}
|
|
80
177
|
findings.push(...detectSymlinkEscapes({ symlinkEscapes: input.symlinkEscapes }));
|
|
81
178
|
findings.push(...detectGitHookIssues({ hooks: input.hooks, knownSafeHooks: input.config.knownSafeHooks }));
|
|
@@ -21,4 +21,4 @@ export interface RuleEvaluationInput {
|
|
|
21
21
|
textContent: string;
|
|
22
22
|
}
|
|
23
23
|
export declare function evaluateRule(rule: DetectionRule, input: RuleEvaluationInput): boolean;
|
|
24
|
-
export
|
|
24
|
+
export { loadRulePacks } from "./rule-pack-loader.js";
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
-
import { dirname, extname, join, resolve } from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
const rulesDir = resolve(dirname(fileURLToPath(import.meta.url)), "rules");
|
|
5
1
|
function escapeRegex(value) {
|
|
6
2
|
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
7
3
|
}
|
|
@@ -127,12 +123,4 @@ export function evaluateRule(rule, input) {
|
|
|
127
123
|
}
|
|
128
124
|
return false;
|
|
129
125
|
}
|
|
130
|
-
export
|
|
131
|
-
const files = readdirSync(baseDir)
|
|
132
|
-
.filter((file) => extname(file) === ".json")
|
|
133
|
-
.sort();
|
|
134
|
-
return files.flatMap((file) => {
|
|
135
|
-
const raw = readFileSync(join(baseDir, file), "utf8");
|
|
136
|
-
return JSON.parse(raw);
|
|
137
|
-
});
|
|
138
|
-
}
|
|
126
|
+
export { loadRulePacks } from "./rule-pack-loader.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DetectionRule } from "./rule-engine.js";
|
|
2
|
+
export interface RulePackLoaderOptions {
|
|
3
|
+
baseDir?: string;
|
|
4
|
+
rule_pack_paths?: string[];
|
|
5
|
+
allowed_rules?: string[];
|
|
6
|
+
skip_rules?: string[];
|
|
7
|
+
}
|
|
8
|
+
export declare function loadRulePacks(): DetectionRule[];
|
|
9
|
+
export declare function loadRulePacks(baseDir: string): DetectionRule[];
|
|
10
|
+
export declare function loadRulePacks(options: RulePackLoaderOptions): DetectionRule[];
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, extname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const defaultRulesDir = resolve(dirname(fileURLToPath(import.meta.url)), "rules");
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const Ajv = require("ajv");
|
|
8
|
+
const RULE_SCHEMA = {
|
|
9
|
+
type: "object",
|
|
10
|
+
additionalProperties: true,
|
|
11
|
+
required: [
|
|
12
|
+
"id",
|
|
13
|
+
"severity",
|
|
14
|
+
"category",
|
|
15
|
+
"description",
|
|
16
|
+
"tool",
|
|
17
|
+
"file_pattern",
|
|
18
|
+
"query_type",
|
|
19
|
+
"query",
|
|
20
|
+
"condition",
|
|
21
|
+
"owasp",
|
|
22
|
+
"cwe",
|
|
23
|
+
],
|
|
24
|
+
properties: {
|
|
25
|
+
id: { type: "string", minLength: 1 },
|
|
26
|
+
severity: { type: "string", minLength: 1 },
|
|
27
|
+
category: { type: "string", minLength: 1 },
|
|
28
|
+
description: { type: "string", minLength: 1 },
|
|
29
|
+
tool: { type: "string", minLength: 1 },
|
|
30
|
+
file_pattern: { type: "string", minLength: 1 },
|
|
31
|
+
query_type: {
|
|
32
|
+
type: "string",
|
|
33
|
+
enum: ["json_path", "toml_path", "env_key", "text_pattern"],
|
|
34
|
+
},
|
|
35
|
+
query: { type: "string" },
|
|
36
|
+
condition: {
|
|
37
|
+
type: "string",
|
|
38
|
+
enum: [
|
|
39
|
+
"equals_true",
|
|
40
|
+
"equals_false",
|
|
41
|
+
"exists",
|
|
42
|
+
"not_empty",
|
|
43
|
+
"matches_regex",
|
|
44
|
+
"not_in_allowlist",
|
|
45
|
+
"regex_match",
|
|
46
|
+
"contains",
|
|
47
|
+
"line_length_exceeds",
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
cve: { type: "string" },
|
|
51
|
+
owasp: {
|
|
52
|
+
type: "array",
|
|
53
|
+
items: { type: "string" },
|
|
54
|
+
},
|
|
55
|
+
cwe: { type: "string", minLength: 1 },
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const ruleValidator = new Ajv({ allErrors: true, strict: false }).compile(RULE_SCHEMA);
|
|
59
|
+
function normalizeRuleIds(values) {
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
const normalized = [];
|
|
62
|
+
for (const value of values ?? []) {
|
|
63
|
+
const trimmed = value.trim();
|
|
64
|
+
if (trimmed.length === 0 || seen.has(trimmed)) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
seen.add(trimmed);
|
|
68
|
+
normalized.push(trimmed);
|
|
69
|
+
}
|
|
70
|
+
return normalized;
|
|
71
|
+
}
|
|
72
|
+
function toErrorMessage(errors) {
|
|
73
|
+
if (!errors || errors.length === 0) {
|
|
74
|
+
return "validation error";
|
|
75
|
+
}
|
|
76
|
+
return errors
|
|
77
|
+
.map((error) => {
|
|
78
|
+
const location = error.instancePath === "" ? "<root>" : error.instancePath;
|
|
79
|
+
return `${location}: ${error.message ?? "validation error"}`;
|
|
80
|
+
})
|
|
81
|
+
.join("; ");
|
|
82
|
+
}
|
|
83
|
+
function isPackDirectory(path) {
|
|
84
|
+
try {
|
|
85
|
+
return statSync(path).isDirectory();
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function isPackFile(path) {
|
|
92
|
+
try {
|
|
93
|
+
return statSync(path).isFile();
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function resolvePackPaths(path) {
|
|
100
|
+
const absolutePath = resolve(path);
|
|
101
|
+
if (!existsSync(absolutePath)) {
|
|
102
|
+
throw new Error(`Rule pack path does not exist: ${absolutePath}`);
|
|
103
|
+
}
|
|
104
|
+
if (isPackFile(absolutePath)) {
|
|
105
|
+
return [absolutePath];
|
|
106
|
+
}
|
|
107
|
+
if (!isPackDirectory(absolutePath)) {
|
|
108
|
+
throw new Error(`Rule pack path is not a file or directory: ${absolutePath}`);
|
|
109
|
+
}
|
|
110
|
+
return readdirSync(absolutePath)
|
|
111
|
+
.filter((file) => extname(file) === ".json")
|
|
112
|
+
.filter((file) => file !== "schema.json")
|
|
113
|
+
.sort()
|
|
114
|
+
.map((file) => join(absolutePath, file));
|
|
115
|
+
}
|
|
116
|
+
function loadRulesFromFile(path) {
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
123
|
+
throw new Error(`Failed to parse rule pack ${path}: ${reason}`, { cause: error });
|
|
124
|
+
}
|
|
125
|
+
if (!Array.isArray(parsed)) {
|
|
126
|
+
throw new Error(`Invalid rule pack ${path}: expected a JSON array of rule objects`);
|
|
127
|
+
}
|
|
128
|
+
return parsed.map((candidate, index) => {
|
|
129
|
+
if (!ruleValidator(candidate)) {
|
|
130
|
+
const reasons = toErrorMessage(ruleValidator.errors);
|
|
131
|
+
throw new Error(`Invalid rule pack ${path} [${index}]: ${reasons}`);
|
|
132
|
+
}
|
|
133
|
+
return candidate;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function collectRulesFromPaths(paths) {
|
|
137
|
+
const collected = [];
|
|
138
|
+
for (const path of paths) {
|
|
139
|
+
for (const packPath of resolvePackPaths(path)) {
|
|
140
|
+
collected.push(...loadRulesFromFile(packPath));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return collected;
|
|
144
|
+
}
|
|
145
|
+
function dedupeByRuleId(rules) {
|
|
146
|
+
const deduped = new Map();
|
|
147
|
+
for (const rule of rules) {
|
|
148
|
+
deduped.set(rule.id, rule);
|
|
149
|
+
}
|
|
150
|
+
return Array.from(deduped.values());
|
|
151
|
+
}
|
|
152
|
+
function filterRules(rules, allowedRules, skipRules) {
|
|
153
|
+
const allowed = new Set(allowedRules);
|
|
154
|
+
const skipped = new Set(skipRules);
|
|
155
|
+
return rules.filter((rule) => {
|
|
156
|
+
if (skipped.has(rule.id)) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
if (allowed.size > 0 && !allowed.has(rule.id)) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
function normalizeOptions(arg) {
|
|
166
|
+
if (typeof arg === "string") {
|
|
167
|
+
return {
|
|
168
|
+
baseDir: arg,
|
|
169
|
+
rulePackPaths: [],
|
|
170
|
+
allowedRules: [],
|
|
171
|
+
skipRules: [],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const options = arg ?? {};
|
|
175
|
+
return {
|
|
176
|
+
baseDir: options.baseDir ?? defaultRulesDir,
|
|
177
|
+
rulePackPaths: options.rule_pack_paths ?? [],
|
|
178
|
+
allowedRules: normalizeRuleIds(options.allowed_rules),
|
|
179
|
+
skipRules: normalizeRuleIds(options.skip_rules),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
export function loadRulePacks(arg) {
|
|
183
|
+
const options = normalizeOptions(arg);
|
|
184
|
+
const bundledRules = collectRulesFromPaths([options.baseDir]);
|
|
185
|
+
const externalRules = collectRulesFromPaths(options.rulePackPaths);
|
|
186
|
+
return filterRules(dedupeByRuleId([...bundledRules, ...externalRules]), options.allowedRules, options.skipRules);
|
|
187
|
+
}
|
|
@@ -42,6 +42,12 @@ function makeFinding(input, sourceTool, sensitiveTool, sinkTool) {
|
|
|
42
42
|
confidence: "HIGH",
|
|
43
43
|
fixable: false,
|
|
44
44
|
remediation_actions: [],
|
|
45
|
+
metadata: {
|
|
46
|
+
sources: [sourceTool],
|
|
47
|
+
sinks: [sinkTool],
|
|
48
|
+
risk_tags: ["toxic-flow"],
|
|
49
|
+
origin: "toxic-flow",
|
|
50
|
+
},
|
|
45
51
|
suppressed: false,
|
|
46
52
|
};
|
|
47
53
|
}
|
package/dist/pipeline.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createEmptyReport } from "./types/report.js";
|
|
|
3
3
|
import { scanToolDescriptions, } from "./layer3-dynamic/tool-description-scanner.js";
|
|
4
4
|
import { detectToxicFlows } from "./layer3-dynamic/toxic-flow.js";
|
|
5
5
|
import { applyReportSummary } from "./report-summary.js";
|
|
6
|
+
import { withFindingFingerprint } from "./report/finding-fingerprint.js";
|
|
6
7
|
export function runStaticPipeline(input) {
|
|
7
8
|
const findings = runStaticEngine({
|
|
8
9
|
projectRoot: input.projectRoot,
|
|
@@ -10,7 +11,7 @@ export function runStaticPipeline(input) {
|
|
|
10
11
|
symlinkEscapes: input.symlinkEscapes,
|
|
11
12
|
hooks: input.hooks,
|
|
12
13
|
config: input.config,
|
|
13
|
-
});
|
|
14
|
+
}).map(withFindingFingerprint);
|
|
14
15
|
const report = createEmptyReport({
|
|
15
16
|
version: input.version,
|
|
16
17
|
kbVersion: input.kbVersion,
|
|
@@ -63,7 +64,7 @@ function parseLayer3Response(resourceId, metadata) {
|
|
|
63
64
|
.filter((item) => typeof item === "object" && item !== null)
|
|
64
65
|
.map((item, index) => {
|
|
65
66
|
const findingId = item.id ?? `L3-${resourceId}-${index}`;
|
|
66
|
-
return {
|
|
67
|
+
return withFindingFingerprint({
|
|
67
68
|
rule_id: item.id ?? "layer3-analysis-finding",
|
|
68
69
|
finding_id: findingId,
|
|
69
70
|
severity: parseSeverity(item.severity),
|
|
@@ -82,7 +83,7 @@ function parseLayer3Response(resourceId, metadata) {
|
|
|
82
83
|
remediation_actions: item.remediation_actions ?? [],
|
|
83
84
|
source_config: item.source_config ?? null,
|
|
84
85
|
suppressed: false,
|
|
85
|
-
};
|
|
86
|
+
});
|
|
86
87
|
});
|
|
87
88
|
}
|
|
88
89
|
function asRecord(value) {
|
|
@@ -172,17 +173,17 @@ function deriveLayer3ToolFindings(resourceId, metadata, options = {}) {
|
|
|
172
173
|
serverId: resourceId,
|
|
173
174
|
tools: toolDescriptions,
|
|
174
175
|
unicodeAnalysis: options.unicodeAnalysis,
|
|
175
|
-
}),
|
|
176
|
+
}).map(withFindingFingerprint),
|
|
176
177
|
...detectToxicFlows({
|
|
177
178
|
scopeId: resourceId,
|
|
178
179
|
tools: toolDescriptions,
|
|
179
180
|
knownClassifications,
|
|
180
|
-
}),
|
|
181
|
+
}).map(withFindingFingerprint),
|
|
181
182
|
];
|
|
182
183
|
}
|
|
183
184
|
function layer3ErrorFinding(resourceId, status, description) {
|
|
184
185
|
const severity = status === "timeout" ? "MEDIUM" : status === "skipped_without_consent" ? "INFO" : "LOW";
|
|
185
|
-
return {
|
|
186
|
+
return withFindingFingerprint({
|
|
186
187
|
rule_id: `layer3-${status}`,
|
|
187
188
|
finding_id: `L3-${status}-${resourceId}`,
|
|
188
189
|
severity,
|
|
@@ -199,7 +200,7 @@ function layer3ErrorFinding(resourceId, status, description) {
|
|
|
199
200
|
fixable: false,
|
|
200
201
|
remediation_actions: [],
|
|
201
202
|
suppressed: false,
|
|
202
|
-
};
|
|
203
|
+
});
|
|
203
204
|
}
|
|
204
205
|
function isRegistryMetadataResource(resourceId) {
|
|
205
206
|
return (resourceId.startsWith("npm:") || resourceId.startsWith("pypi:") || resourceId.startsWith("git:"));
|
|
@@ -232,7 +233,7 @@ export function layer3OutcomesToFindings(outcomes, options = {}) {
|
|
|
232
233
|
export function mergeLayer3Findings(baseReport, layer3Findings) {
|
|
233
234
|
return applyReportSummary({
|
|
234
235
|
...baseReport,
|
|
235
|
-
findings: [...baseReport.findings, ...layer3Findings],
|
|
236
|
+
findings: [...baseReport.findings, ...layer3Findings].map(withFindingFingerprint),
|
|
236
237
|
});
|
|
237
238
|
}
|
|
238
239
|
export async function runDeepScanWithConsent(resources, requestConsent, execute) {
|