Files
2026-07-13 13:39:12 +08:00

137 lines
4.8 KiB
TypeScript

import { RISK_PATTERNS, type RiskCategory } from "./riskPatterns.ts";
export interface RiskGateConfig {
enabled: boolean;
/** Subset of categories to scan for. Absent/empty ⇒ all categories. */
categories?: RiskCategory[];
}
export interface RiskSpan {
start: number; // inclusive char offset
end: number; // exclusive
category: RiskCategory;
}
interface Hit {
start: number;
end: number;
category: RiskCategory;
}
const SHORT_SECTION = 200;
const MIN_DDL = 2;
const VCS_LINE = /^(?:commit [0-9a-f]{7,40}|diff --git |@@ |[+-]{3} )/m;
const DIFF_HUNK_LINE = /^[+-]/;
function isLikelyVcsContext(text: string): boolean {
return VCS_LINE.test(text);
}
/** Collect raw regex hits for enabled categories (no guards yet). */
function collectRegexHits(text: string, enabled: Set<RiskCategory>): Hit[] {
const hits: Hit[] = [];
for (const { category, regex } of RISK_PATTERNS) {
if (!enabled.has(category)) continue;
regex.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = regex.exec(text)) !== null) {
if (m[0].length === 0) {
regex.lastIndex++;
continue;
}
hits.push({ start: m.index, end: m.index + m[0].length, category });
}
}
return hits;
}
/** True when the hit's line starts with a diff marker (`+`/`-`). */
function inDiffHunk(text: string, start: number): boolean {
const lineStart = text.lastIndexOf("\n", start - 1) + 1;
return DIFF_HUNK_LINE.test(text.slice(lineStart, lineStart + 1));
}
/** Structural k8s Secret detector: a YAML doc carrying `kind: Secret` + `data:`. */
function detectK8sSecret(text: string): Hit[] {
const hits: Hit[] = [];
const kindRe = /^kind:[ \t]*Secret\b/gm;
let m: RegExpExecArray | null;
while ((m = kindRe.exec(text)) !== null) {
// Document boundaries: nearest `---`/start before, nearest `---`/end after.
const prevSep = text.lastIndexOf("\n---", m.index);
const docStart = prevSep === -1 ? 0 : prevSep + 1;
const nextSep = text.indexOf("\n---", m.index);
const docEnd = nextSep === -1 ? text.length : nextSep + 1;
const doc = text.slice(docStart, docEnd);
if (/^\s*(?:data|stringData):/m.test(doc)) {
hits.push({ start: docStart, end: docEnd, category: "k8s_secret" });
}
}
return hits;
}
function mergeSpans(spans: RiskSpan[]): RiskSpan[] {
if (spans.length <= 1) return spans;
const sorted = [...spans].sort((a, b) => a.start - b.start);
const out: RiskSpan[] = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const last = out[out.length - 1];
const cur = sorted[i];
if (cur.start <= last.end) {
last.end = Math.max(last.end, cur.end);
} else {
out.push(cur);
}
}
return out;
}
/**
* Detect spans that should be shielded from compression. Pure and fail-open:
* any internal error yields an empty result (never throws).
*/
export function detectRiskSpans(text: string, cfg: RiskGateConfig): RiskSpan[] {
try {
if (!cfg.enabled) return [];
if (!text) return [];
const enabled = new Set<RiskCategory>(
cfg.categories?.length
? cfg.categories
: (["stack_trace", "private_key", "secret_assignment", "k8s_secret", "db_migration", "legal"] as RiskCategory[])
);
const vcs = isLikelyVcsContext(text);
const regexHits = collectRegexHits(text, enabled);
const k8sHits = enabled.has("k8s_secret") ? detectK8sSecret(text) : [];
// db_migration: require >=MIN_DDL hits; drop those inside a diff hunk.
const ddl = regexHits.filter((h) => h.category === "db_migration" && !(vcs && inDiffHunk(text, h.start)));
const ddlPromoted: RiskSpan[] =
ddl.length >= MIN_DDL ? [{ start: ddl[0].start, end: ddl[ddl.length - 1].end, category: "db_migration" }] : [];
// Guarded categories: secret_assignment, stack_trace, legal.
const guarded = regexHits.filter(
(h) => h.category === "secret_assignment" || h.category === "stack_trace" || h.category === "legal"
);
// private_key is the only regex-hit self-evident category; k8s_secret and
// db_migration self-promote via their own structural paths below.
const selfEvident = regexHits.filter((h) => h.category === "private_key");
// Count corroborating signals (self-evident + promoted-ddl + k8s + guarded).
const signalCount = selfEvident.length + (ddlPromoted.length ? 1 : 0) + k8sHits.length + guarded.length;
const shortSection = !vcs && text.length < SHORT_SECTION;
const guardedPromoted = signalCount >= 2 || shortSection ? guarded : [];
const promoted: RiskSpan[] = [
...selfEvident.map((h) => ({ start: h.start, end: h.end, category: h.category })),
...k8sHits.map((h) => ({ start: h.start, end: h.end, category: h.category })),
...ddlPromoted,
...guardedPromoted.map((h) => ({ start: h.start, end: h.end, category: h.category })),
];
return mergeSpans(promoted);
} catch {
return [];
}
}