-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsecurity.ts
More file actions
136 lines (124 loc) · 6.37 KB
/
Copy pathsecurity.ts
File metadata and controls
136 lines (124 loc) · 6.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
// Graph-aware security pass (optional, Pro). For injection-class findings, a code graph with
// taint tracing can answer the question pattern rules cannot: does user input actually reach
// this sink? That turns a noisy "looks like SQL concatenation" into either a confirmed taint
// path (kept, with the data-flow trace attached) or — only when the team opts in — a proven
// non-issue that gets down-tiered.
//
// Safety posture: the security graph ENRICHES by default. It only DOWN-tiers a security finding
// when `graph.securityDeescalate` is explicitly enabled, because a false "no taint" would hide a
// real vulnerability. Everything degrades to a no-op when no security graph is present.
//
// NOTE: validated against CodeGraph's documented tool contract and injected fakes, not a live
// Pro binary — see CHANGELOG.
import { recomputeResult } from "./tiers.js";
import { resolveGraphConfig } from "./graph/index.js";
import type { GraphProvider } from "./graph/index.js";
import type { AnalyzeResult, Config, Finding, SecurityVerdict } from "./types.js";
/** Injection-class rules whose risk depends on whether user input reaches the sink. */
export const SECURITY_RULES = new Set([
"sql-injection", "nosql-injection", "xss-sink", "path-traversal", "dangerous-exec", "prototype-pollution",
// AST injection classes (PHP + Python tsast). Blocking by default; SECURITY_RULES membership gives them
// the same trust-label + reachability treatment as sql-injection, instead of falling through unlabeled.
"command-injection", "code-injection", "file-inclusion", "unsafe-deserialization",
// SSRF (advisory across all languages) — request-tainted URL into an outbound-request sink; eligible
// for reachability escalation like the other injection advisories.
"ssrf",
]);
/** Deterministic trust label for a single finding (see Finding.trust). Pure; no graph call. */
export function trustFor(finding: Finding): NonNullable<Finding["trust"]> {
// LLM-derived guideline findings are non-deterministic — never auto-trusted.
if (finding.ruleId === "guideline") return "unconfirmed";
// Pro taint verdict is authoritative when present.
if (finding.security?.tainted === true) return "confirmed"; // graph traced a taint path
if (finding.security?.tainted === false) return "cleared"; // graph proved no taint path
// Community reachability fills the precision gap when the Pro taint engine is silent. Surface it
// so the agent treats "reachable" as block-worthy and "unreachable" as advisory.
if (finding.reachability) return finding.reachability.reachable ? "reachable" : "unreachable";
// Injection-class patterns and the broad cross-language candidate are guesses until a graph
// confirms reachability — honest "unconfirmed", not the false confidence of "confirmed".
if (SECURITY_RULES.has(finding.ruleId) || finding.ruleId === "sql-injection-candidate") return "unconfirmed";
// Other non-security findings come from deterministic pattern/AST detection — the detection IS proof.
return "confirmed";
}
/**
* Attach the deterministic trust label to every finding. Runs whether or not a code graph is
* present (so core-only setups still get honest "unconfirmed" labels on injection guesses).
*/
export function labelTrust(files: AnalyzeResult[]): AnalyzeResult[] {
return files.map((result) => ({
...result,
findings: result.findings.map((f) => (f.trust ? f : { ...f, trust: trustFor(f) })),
}));
}
function tierPinned(config: Partial<Config>, ruleId: string): boolean {
const ov = config.rules?.[ruleId];
return !!(ov && typeof ov === "object" && (ov.tier !== undefined || ov.blocking !== undefined));
}
function taintTrace(verdict: SecurityVerdict): string {
if (verdict.dataFlow.length === 0) return "🔓 Taint path confirmed by the code graph.";
const hops = verdict.dataFlow.map((r) => r.symbol || r.file || "?").slice(0, 6).join(" → ");
return `🔓 Taint path: ${hops}.`;
}
function withSecurity(
finding: Finding,
verdict: SecurityVerdict,
opts: { deescalate: boolean; pinned: boolean }
): Finding {
const next: Finding = { ...finding, security: verdict };
// Confirmed reachable: keep the gate, attach the trace so the reviewer sees the path.
if (verdict.tainted === true) {
next.message = `${finding.message}\n\n${taintTrace(verdict)}`;
return next;
}
// Proven clean by an authoritative graph — down-tier only when the team opted in and the
// rule isn't pinned. Otherwise we just record the verdict without weakening the finding.
if (verdict.tainted === false && verdict.source === "codegraph" && opts.deescalate && !opts.pinned) {
next.tier = "yellow";
next.blocking = false;
next.tierAdjusted = "deescalated";
next.message = `${finding.message}\n\n🛡 No taint path: the code graph found no user-input flow reaching this sink. Down-tiered to review.`;
}
return next;
}
/**
* Attach graph-aware taint verdicts to injection-class findings. No-op when the provider has no
* security capability, when `graph.security` is false, or when the graph returns nothing.
*/
export function attachSecurity(
files: AnalyzeResult[],
opts: { cwd: string; config: Partial<Config>; graph: GraphProvider | null }
): AnalyzeResult[] {
const { graph } = opts;
if (!graph || typeof graph.security !== "function") return files;
const g = resolveGraphConfig(opts.config);
if (g.security === false) return files;
const deescalate = g.securityDeescalate === true;
const cache = new Map<string, SecurityVerdict | null>();
return files.map((result) => {
let changed = false;
const findings = result.findings.map((finding) => {
if (!SECURITY_RULES.has(finding.ruleId)) return finding;
const key = `${result.filePath}::${finding.ruleId}::${finding.line}`;
let verdict = cache.get(key);
if (verdict === undefined) {
try {
verdict = graph.security!({
symbol: finding.symbol || "",
file: result.filePath,
line: finding.line,
cwd: opts.cwd,
ruleId: finding.ruleId,
sink: finding.code,
});
} catch {
verdict = null;
}
cache.set(key, verdict ?? null);
}
if (!verdict) return finding;
changed = true;
return withSecurity(finding, verdict, { deescalate, pinned: tierPinned(opts.config, finding.ruleId) });
});
return changed ? recomputeResult(result, findings) : result;
});
}