@clear-capabilities/agentic-security-scanner 0.176.0 → 0.178.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/CHANGELOG.md +25 -0
- package/README.md +1 -1
- package/bin/agentic-security.js +9 -2
- package/dist/1919.index.js +1 -1
- package/dist/2066.index.js +2 -1
- package/dist/2584.index.js +19 -1
- package/dist/5456.index.js +291 -0
- package/dist/5620.index.js +147 -23
- package/dist/5937.index.js +672 -0
- package/dist/7838.index.js +70 -25
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{df1e8683e7eda7b2b79c.js → b4289e728659450b5e19.js} +4 -1
- package/dist/{4978bec8fdf5c733dc85.json → d175c127fee48e80d04c.json} +8 -3
- package/package.json +8 -3
- package/src/posture/custom-rules.js +54 -8
- package/src/posture/dnr/exploit-correlation.js +25 -0
- package/src/posture/evaluation/lab/dataset.js +1 -1
- package/src/posture/evaluation/lab/report.js +19 -1
- package/src/posture/fleet/cli.js +22 -10
- package/src/posture/fleet/enterprise-graph.js +44 -3
- package/src/posture/fleet/job-runner.js +7 -7
- package/src/posture/fleet/job-store.js +3 -1
- package/src/posture/fleet/lineage-summary.js +55 -0
- package/src/posture/fleet/scan-child.js +4 -1
- package/src/posture/proof-fabric/generate.js +2 -1
- package/src/posture/proof-fabric/runner.js +24 -2
- package/src/posture/rule-compiler/cli.js +40 -6
- package/src/posture/rule-compiler/corpus.js +3 -7
- package/src/posture/rule-compiler/ir.js +52 -9
- package/src/posture/rule-compiler/proposal.js +14 -9
- package/src/posture/rule-compiler/regression.js +95 -0
- package/src/posture/rule-compiler/store.js +29 -1
- package/src/posture/rule-pack-signing.js +4 -2
|
@@ -22,7 +22,7 @@ const AUTH_CWES = new Set(['CWE-287', 'CWE-306', 'CWE-285', 'CWE-862', 'CWE-863'
|
|
|
22
22
|
const edgeId = (type, from, to) => `edge:${type}:${from}:${to}`;
|
|
23
23
|
const hash = (v) => createHash('sha256').update(JSON.stringify(v)).digest('hex');
|
|
24
24
|
|
|
25
|
-
export function buildEnterpriseGraph({ manifest, dependencyGraph, discovered = null }) {
|
|
25
|
+
export function buildEnterpriseGraph({ manifest, dependencyGraph, discovered = null, lineage = null }) {
|
|
26
26
|
const nodes = new Map();
|
|
27
27
|
const edges = new Map();
|
|
28
28
|
const addNode = (n) => nodes.set(n.id, n);
|
|
@@ -50,7 +50,48 @@ export function buildEnterpriseGraph({ manifest, dependencyGraph, discovered = n
|
|
|
50
50
|
// Edges found in the code. They share an id with a declared edge of the same type and endpoints, so the evidence list shows both; an
|
|
51
51
|
// edge with ONLY discovered evidence is marked `discovered: true` so a query can say it rests on a code literal, not on a declaration.
|
|
52
52
|
for (const d of discovered?.edges ?? []) for (const evidence of d.evidence) addEdge(d.type, d.from, d.to, evidence, true);
|
|
53
|
-
|
|
53
|
+
// Code Boundaries (each repository's own data flow graph, summarized): data classes the code handles, and destinations it sends to.
|
|
54
|
+
// These add to what the manifest declared and never replace it; a class the manifest did not declare is listed on the node so a path
|
|
55
|
+
// that exists only because of it can say so.
|
|
56
|
+
const aliasesOf = new Map([...manifest.repositories, ...(manifest.resources ?? [])].map((n) => [n.id, [n.id, ...(n.aliases ?? [])].map((a) => String(a).toLowerCase())]));
|
|
57
|
+
for (const [repoId, sum] of lineage ?? []) {
|
|
58
|
+
const node = nodes.get(repoId);
|
|
59
|
+
if (!node || !sum) continue;
|
|
60
|
+
const declared = new Set(node.dataClasses);
|
|
61
|
+
const found = (sum.dataClasses ?? []).filter((c) => !declared.has(c));
|
|
62
|
+
node.dataClasses = [...declared, ...found];
|
|
63
|
+
node.discoveredDataClasses = found;
|
|
64
|
+
const cb = (extra) => ({ source: 'code-boundaries', repo: repoId, declared: false, file: null, ...extra });
|
|
65
|
+
for (const d of sum.externalDestinations ?? []) {
|
|
66
|
+
if (!d.host) continue;
|
|
67
|
+
const first = d.host.split('.')[0];
|
|
68
|
+
for (const [target, names] of aliasesOf) {
|
|
69
|
+
if (target === repoId || !(names.includes(d.host) || names.includes(first))) continue;
|
|
70
|
+
const tn = nodes.get(target);
|
|
71
|
+
addEdge(tn?.type === 'external-provider' ? 'uses-provider' : 'calls', repoId, target, cb({ host: d.host, nodeKind: 'external' }), true);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const q of sum.queues ?? []) {
|
|
75
|
+
for (const [target, names] of aliasesOf) {
|
|
76
|
+
const tn = nodes.get(target);
|
|
77
|
+
if (tn?.type !== 'queue' || !names.includes(String(q.topic).toLowerCase())) continue;
|
|
78
|
+
if (q.operation === 'consume') addEdge('consumes', target, repoId, cb({ topic: q.topic }), true); else addEdge('produces', repoId, target, cb({ topic: q.topic }), true);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const st of sum.stores ?? []) {
|
|
82
|
+
for (const key of [st.host, st.database]) {
|
|
83
|
+
if (!key) continue;
|
|
84
|
+
const k = String(key).toLowerCase();
|
|
85
|
+
for (const [target, names] of aliasesOf) {
|
|
86
|
+
const tn = nodes.get(target);
|
|
87
|
+
if (tn?.type !== 'database' || !(names.includes(k) || names.includes(k.split('.')[0]))) continue;
|
|
88
|
+
addEdge('connects-to', repoId, target, cb({ store: st.subtype, name: key }), true);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const FOUND_IN_CODE = new Set(['discovered-code-literal', 'code-boundaries']);
|
|
94
|
+
for (const e of edges.values()) e.discovered = e.evidence.every((x) => FOUND_IN_CODE.has(x.source));
|
|
54
95
|
return { schema: ENTERPRISE_GRAPH_SCHEMA, version: 1, organization: manifest.organization, nodes: [...nodes.values()], edges: [...edges.values()], digest: null };
|
|
55
96
|
}
|
|
56
97
|
|
|
@@ -97,7 +138,7 @@ export function attackPaths({ graph, toDataClass, from = INTERNET, involving = n
|
|
|
97
138
|
if (results.length >= maxPaths) { truncated = true; return; }
|
|
98
139
|
if (targets.has(id) && nodePath.length > 1) {
|
|
99
140
|
const inv = involving ? nodePath.filter((n) => involving.all.has(n)) : [];
|
|
100
|
-
if (!involving || inv.length) results.push({ nodes: [...nodePath], edges: edgePath.map((e) => e.id), involving: inv, discoveredEdges: edgePath.filter((e) => e.discovered).map((e) => e.id), evidence: edgePath.flatMap((e) => e.evidence.map((x) => ({ edge: e.id, ...x }))) });
|
|
141
|
+
if (!involving || inv.length) results.push({ nodes: [...nodePath], edges: edgePath.map((e) => e.id), involving: inv, discoveredEdges: edgePath.filter((e) => e.discovered).map((e) => e.id), discoveredDataClassNodes: nodePath.filter((n) => (nodeOf(graph, n)?.discoveredDataClasses ?? []).includes(toDataClass)), evidence: edgePath.flatMap((e) => e.evidence.map((x) => ({ edge: e.id, ...x }))) });
|
|
101
142
|
}
|
|
102
143
|
if (nodePath.length > maxDepth) { truncated = true; return; }
|
|
103
144
|
for (const e of out.get(id) ?? []) if (!nodePath.includes(e.to)) walk(e.to, [...nodePath, e.to], [...edgePath, e]);
|
|
@@ -25,8 +25,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
25
25
|
export const DEFAULTS = Object.freeze({ leaseMs: 30_000, heartbeatMs: 10_000, retryDelayMs: 2_000, pollMs: 100 });
|
|
26
26
|
|
|
27
27
|
/** The default scan: one supervised child process per repository. Throws `{code: 'TIMEOUT'|'ERROR', message}`. */
|
|
28
|
-
export async function childScan(repoPath, { timeoutMs, signal }) {
|
|
29
|
-
const r = await superviseSpawn(process.execPath, [SCAN_CHILD, repoPath], { cwd: repoPath, env: { ...process.env, AGENTIC_SECURITY_NO_STATE: '1' }, timeoutMs, signal, maxOutputBytes: 96 * 1024 * 1024 });
|
|
28
|
+
export async function childScan(repoPath, { timeoutMs, signal, lineage = false }) {
|
|
29
|
+
const r = await superviseSpawn(process.execPath, [SCAN_CHILD, repoPath], { cwd: repoPath, env: { ...process.env, AGENTIC_SECURITY_NO_STATE: '1', ...(lineage ? { AGENTIC_SECURITY_LINEAGE_DEEP: '1' } : {}) }, timeoutMs, signal, maxOutputBytes: 96 * 1024 * 1024 });
|
|
30
30
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
31
31
|
if (r.timedOut) throw fail('TIMEOUT', `no result within ${Math.round(timeoutMs / 1000)}s`);
|
|
32
32
|
if (r.cancelled) throw fail('CANCELLED', 'cancelled');
|
|
@@ -69,7 +69,7 @@ export function planRun({ manifest, manifestHash, store, runId = null, config =
|
|
|
69
69
|
* Run (or continue) a planned run until no job is queued or leased by a live worker. Returns when the work is done, the signal
|
|
70
70
|
* aborts, or `maxWaitMs` passes with only other workers' leases outstanding.
|
|
71
71
|
*/
|
|
72
|
-
export async function runJobs({ manifest, store, runId, root, scan = childScan, workers = manifest.workers, leaseMs = DEFAULTS.leaseMs, heartbeatMs = DEFAULTS.heartbeatMs, retryDelayMs = DEFAULTS.retryDelayMs, pollMs = DEFAULTS.pollMs, maxWaitMs = null, signal, now = () => Date.now(), onEvent = () => {}, cacheDir = null, artifactDir = null, useCache = true }) {
|
|
72
|
+
export async function runJobs({ manifest, store, runId, root, scan = childScan, scanOptions = {}, workers = manifest.workers, leaseMs = DEFAULTS.leaseMs, heartbeatMs = DEFAULTS.heartbeatMs, retryDelayMs = DEFAULTS.retryDelayMs, pollMs = DEFAULTS.pollMs, maxWaitMs = null, signal, now = () => Date.now(), onEvent = () => {}, cacheDir = null, artifactDir = null, useCache = true }) {
|
|
73
73
|
const repoById = new Map(manifest.repositories.map((r) => [r.id, r]));
|
|
74
74
|
const cacheD = cacheDir ?? statePath(root, 'fleet', 'cache');
|
|
75
75
|
const artD = artifactDir ?? statePath(root, 'fleet', 'artifacts', runId);
|
|
@@ -102,7 +102,7 @@ export async function runJobs({ manifest, store, runId, root, scan = childScan,
|
|
|
102
102
|
onEvent({ type: 'start', repo: job.repoId, attempt: job.attempts });
|
|
103
103
|
const cached = useCache ? readCache(cacheD, job.cacheKey) : null;
|
|
104
104
|
if (cached) {
|
|
105
|
-
const a = writeArtifact(job, { entry: cached.entry, findings: cached.findings, findingsTruncated: !!cached.findingsTruncated, cached: true });
|
|
105
|
+
const a = writeArtifact(job, { entry: cached.entry, findings: cached.findings, findingsTruncated: !!cached.findingsTruncated, lineage: cached.lineage ?? null, cached: true });
|
|
106
106
|
const status = cached.entry?.scanHealth && cached.entry.scanHealth.status !== 'complete' ? 'partial' : 'succeeded';
|
|
107
107
|
if (store.complete(job.jobId, job.epoch, { status, artifactSha256: a.sha256, summary: { total: cached.entry?.total ?? null, cached: true, artifact: path.basename(a.file) }, now: now() })) onEvent({ type: 'done', repo: job.repoId, cached: true, status });
|
|
108
108
|
continue;
|
|
@@ -113,15 +113,15 @@ export async function runJobs({ manifest, store, runId, root, scan = childScan,
|
|
|
113
113
|
let lost = false;
|
|
114
114
|
const hb = setInterval(() => { if (!store.heartbeat(job.jobId, job.epoch, { now: now(), leaseMs })) { lost = true; ac.abort(); } }, heartbeatMs);
|
|
115
115
|
try {
|
|
116
|
-
const out = await scan(repo.path, { timeoutMs: job.timeoutMs, signal: ac.signal });
|
|
116
|
+
const out = await scan(repo.path, { timeoutMs: job.timeoutMs, signal: ac.signal, ...scanOptions });
|
|
117
117
|
clearInterval(hb);
|
|
118
118
|
if (lost) { onEvent({ type: 'fenced', repo: job.repoId }); continue; }
|
|
119
119
|
if (!out?.entry) throw Object.assign(new Error('the scan returned no entry'), { code: 'ERROR' });
|
|
120
120
|
if (out.entry.ok === false) throw Object.assign(new Error(out.entry.error || 'the scan failed'), { code: 'ERROR' });
|
|
121
|
-
const a = writeArtifact(job, { entry: out.entry, findings: out.findings ?? [], findingsTruncated: !!out.findingsTruncated });
|
|
121
|
+
const a = writeArtifact(job, { entry: out.entry, findings: out.findings ?? [], findingsTruncated: !!out.findingsTruncated, lineage: out.lineage ?? null });
|
|
122
122
|
const status = out.entry.scanHealth && out.entry.scanHealth.status !== 'complete' ? 'partial' : 'succeeded';
|
|
123
123
|
if (store.complete(job.jobId, job.epoch, { status, artifactSha256: a.sha256, summary: { total: out.entry.total ?? null, cached: false, artifact: path.basename(a.file) }, now: now() })) {
|
|
124
|
-
if (useCache && status === 'succeeded') writeCache(cacheD, job.cacheKey, { entry: out.entry, findings: out.findings ?? [], findingsTruncated: !!out.findingsTruncated });
|
|
124
|
+
if (useCache && status === 'succeeded') writeCache(cacheD, job.cacheKey, { entry: out.entry, findings: out.findings ?? [], findingsTruncated: !!out.findingsTruncated, lineage: out.lineage ?? null });
|
|
125
125
|
onEvent({ type: 'done', repo: job.repoId, cached: false, status });
|
|
126
126
|
} else onEvent({ type: 'fenced', repo: job.repoId });
|
|
127
127
|
} catch (e) {
|
|
@@ -89,7 +89,9 @@ export async function createSqliteJobStore({ file }) {
|
|
|
89
89
|
const { DatabaseSync } = await import('node:sqlite');
|
|
90
90
|
if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
91
91
|
const db = new DatabaseSync(file);
|
|
92
|
-
|
|
92
|
+
// the busy timeout comes FIRST: the very next statements (switching the journal mode, creating tables) take locks, and another process
|
|
93
|
+
// opening the same file at the same instant must wait for them instead of failing with "database is locked"
|
|
94
|
+
db.exec('PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
|
|
93
95
|
db.exec(SCHEMA);
|
|
94
96
|
const tx = (fn) => { db.exec('BEGIN IMMEDIATE'); try { const r = fn(); db.exec('COMMIT'); return r; } catch (e) { try { db.exec('ROLLBACK'); } catch { /* already rolled back */ } throw e; } };
|
|
95
97
|
const q = (sql, ...p) => db.prepare(sql).all(...p);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// A bounded summary of one repository's Code Boundaries graph (the Data Flow Explorer's `DataFlowGraph v1`), for the enterprise graph
|
|
2
|
+
// (PRD 4: "federate Code Boundaries across repositories"). The whole graph is not copied: a fleet of hundreds of repositories does not
|
|
3
|
+
// need each node, only what crosses a repository boundary or changes what the organization knows about it:
|
|
4
|
+
// * the data classes that appear in the code (so a repository holding payment data is known to, even if the manifest forgot to say);
|
|
5
|
+
// * the external destinations the code sends data to, by host;
|
|
6
|
+
// * the queue topics it publishes to or consumes from;
|
|
7
|
+
// * the stores it touches, by host/database/table name;
|
|
8
|
+
// * counts, including flows that carry sensitive classes across a trust boundary.
|
|
9
|
+
// Pure; hostile input (the graph came out of a scan of someone's code), so every string is bounded and shape-checked.
|
|
10
|
+
const MAX_ITEMS = 64;
|
|
11
|
+
const SENSITIVE = new Set(['pii', 'phi', 'pci', 'fin', 'credentials']);
|
|
12
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
13
|
+
const clean = (v, max = 120) => (typeof v === 'string' && v.length > 0 && v.length <= max && !/[\s\u0000-\u001f]/.test(v) ? v : null);
|
|
14
|
+
|
|
15
|
+
function hostOf(literal) {
|
|
16
|
+
if (typeof literal !== 'string') return null;
|
|
17
|
+
try { const h = new URL(literal).hostname.toLowerCase(); return clean(h, 253); } catch { /* not a URL */ }
|
|
18
|
+
const bare = literal.toLowerCase().replace(/:\d+$/, '');
|
|
19
|
+
return /^[a-z0-9][a-z0-9.-]{0,252}$/.test(bare) ? bare : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function summarizeLineage(graph) {
|
|
23
|
+
if (!graph || typeof graph !== 'object') return null;
|
|
24
|
+
const classes = new Set();
|
|
25
|
+
for (const d of arr(graph.dataElements)) for (const c of arr(d?.dataClasses)) { if (typeof c !== 'string') continue; const l = clean(c.toLowerCase(), 31); if (l && /^[a-z][a-z0-9-]*$/.test(l)) classes.add(l); }
|
|
26
|
+
const ext = new Map(); const queues = new Map(); const stores = new Map();
|
|
27
|
+
for (const n of arr(graph.nodes)) {
|
|
28
|
+
if (!n || typeof n !== 'object') continue;
|
|
29
|
+
if (n.kind === 'external') {
|
|
30
|
+
const host = hostOf(n.destination?.literalValue);
|
|
31
|
+
const key = `${n.subtype ?? 'external'}|${host ?? ''}`;
|
|
32
|
+
if (!ext.has(key) && ext.size < MAX_ITEMS) ext.set(key, { subtype: clean(n.subtype, 60) ?? 'external', host });
|
|
33
|
+
} else if (n.kind === 'queue') {
|
|
34
|
+
const topic = clean(n.queueDetail?.topic, 120); const op = n.queueDetail?.operation === 'consume' || n.queueDetail?.operation === 'receive' ? 'consume' : 'publish';
|
|
35
|
+
if (topic && queues.size < MAX_ITEMS) queues.set(`${topic}|${op}`, { topic, operation: op });
|
|
36
|
+
} else if (n.kind === 'store') {
|
|
37
|
+
const d = n.storeDetail ?? {};
|
|
38
|
+
const item = { subtype: clean(n.subtype, 60) ?? 'store', host: hostOf(d.host), database: clean(d.database, 120), table: clean(d.table, 120) };
|
|
39
|
+
const key = JSON.stringify(item);
|
|
40
|
+
if (stores.size < MAX_ITEMS) stores.set(key, item);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const edges = arr(graph.edges);
|
|
44
|
+
const crossing = new Set(edges.filter((e) => arr(e?.boundaryCrossings).length).map((e) => e.id));
|
|
45
|
+
const dataById = new Map(arr(graph.dataElements).map((d) => [d?.id, d]));
|
|
46
|
+
const sensitiveCrossing = arr(graph.flows).filter((f) => arr(f?.edgeIds).some((id) => crossing.has(id)) && arr(f?.dataElementIds).some((id) => arr(dataById.get(id)?.dataClasses).some((c) => SENSITIVE.has(String(c).toLowerCase())))).length;
|
|
47
|
+
return {
|
|
48
|
+
schema: 'agentic-security/fleet-lineage-summary', version: 1,
|
|
49
|
+
dataClasses: [...classes].sort().slice(0, MAX_ITEMS),
|
|
50
|
+
externalDestinations: [...ext.values()],
|
|
51
|
+
queues: [...queues.values()],
|
|
52
|
+
stores: [...stores.values()],
|
|
53
|
+
counts: { nodes: arr(graph.nodes).length, edges: edges.length, flows: arr(graph.flows).length, boundaryCrossingEdges: crossing.size, sensitiveFlowsCrossingBoundary: sensitiveCrossing },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// controller): scanning a repository for the fleet must not change that repository.
|
|
9
9
|
import { runFleet } from '../fleet.js';
|
|
10
10
|
import { runScan } from '../../runScan.js';
|
|
11
|
+
import { summarizeLineage } from './lineage-summary.js';
|
|
11
12
|
|
|
12
13
|
export const BEGIN = '@@FLEET-SCAN-BEGIN@@';
|
|
13
14
|
export const END = '@@FLEET-SCAN-END@@';
|
|
@@ -21,7 +22,9 @@ async function main(repoPath) {
|
|
|
21
22
|
const entry = res.results?.[0];
|
|
22
23
|
if (!entry) throw new Error('the scan produced no entry');
|
|
23
24
|
const findings = (captured?.findings ?? []).slice(0, MAX_FINDINGS).map(slim);
|
|
24
|
-
|
|
25
|
+
// the Code Boundaries graph exists only when the controller asked for it (AGENTIC_SECURITY_LINEAGE_DEEP=1); it is summarized, never copied whole
|
|
26
|
+
const lineage = process.env.AGENTIC_SECURITY_LINEAGE_DEEP === '1' ? summarizeLineage(captured?.lineageGraph) : null;
|
|
27
|
+
process.stdout.write(`${BEGIN}${JSON.stringify({ entry, findings, findingsTruncated: (captured?.findings?.length ?? 0) > MAX_FINDINGS, lineage })}${END}`);
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
|
|
@@ -127,7 +127,8 @@ const P = new Proxy({}, { get: (_t, k) => (k === Symbol.toPrimitive ? () => payl
|
|
|
127
127
|
const res = new Proxy(function () {}, { get: () => res, apply: () => res });
|
|
128
128
|
const fn = ${pick};
|
|
129
129
|
if (typeof fn !== 'function') { console.error('the exported value is not a function'); process.exit(3); }
|
|
130
|
-
try { await Promise.race([Promise.resolve(fn(${args.join(', ')})), new Promise((r) => setTimeout(r, 5000))]); } catch (e) { console.error('target threw:', String(e && e.message).slice(0, 200)); }
|
|
130
|
+
let timer; try { await Promise.race([Promise.resolve(fn(${args.join(', ')})), new Promise((r) => { timer = setTimeout(r, 5000); })]); } catch (e) { console.error('target threw:', String(e && e.message).slice(0, 200)); }
|
|
131
|
+
clearTimeout(timer);
|
|
131
132
|
`;
|
|
132
133
|
}
|
|
133
134
|
|
|
@@ -29,11 +29,33 @@ const CONTAINER_IMAGES = Object.freeze({
|
|
|
29
29
|
|
|
30
30
|
const SIGNALS = { 134: 'SIGABRT', 135: 'SIGBUS', 136: 'SIGFPE', 132: 'SIGILL', 139: 'SIGSEGV' };
|
|
31
31
|
|
|
32
|
+
// only language toolchains are probed; the container engine has its own, richer health check (`container.js`)
|
|
33
|
+
const PROBED_TOOLS = new Set(['java', 'javac', 'python3', 'cc', 'gcc', 'dotnet']);
|
|
34
|
+
const probeCache = new Map();
|
|
35
|
+
/** A launcher that exists is not a toolchain that works: macOS ships /usr/bin/java and /usr/bin/python3 as stubs that only prompt for an
|
|
36
|
+
* install. A tool counts only if it answers its version flag, within a short timeout, with exit 0. */
|
|
37
|
+
function toolWorks(file, name) {
|
|
38
|
+
if (probeCache.has(file)) return probeCache.get(file);
|
|
39
|
+
const args = name === 'java' || name === 'javac' ? ['-version'] : ['--version'];
|
|
40
|
+
let ok = false;
|
|
41
|
+
try { const r = spawnSync(file, args, { stdio: 'ignore', timeout: 8000 }); ok = r.status === 0; } catch { ok = false; }
|
|
42
|
+
probeCache.set(file, ok);
|
|
43
|
+
return ok;
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
function findTool(name, pathEnv = process.env.PATH || '') {
|
|
33
|
-
|
|
47
|
+
const dirs = pathEnv.split(path.delimiter);
|
|
48
|
+
// a configured JDK wins over whatever stub sits on PATH
|
|
49
|
+
if ((name === 'java' || name === 'javac') && process.env.JAVA_HOME) dirs.unshift(path.join(process.env.JAVA_HOME, 'bin'));
|
|
50
|
+
for (const dir of dirs) {
|
|
34
51
|
if (!dir) continue;
|
|
35
52
|
const p = path.join(dir, name);
|
|
36
|
-
try {
|
|
53
|
+
try {
|
|
54
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
55
|
+
if (!fs.statSync(p).isFile()) continue;
|
|
56
|
+
const real = fs.realpathSync(p);
|
|
57
|
+
if (!PROBED_TOOLS.has(name) || toolWorks(real, name)) return real;
|
|
58
|
+
} catch { /* next */ }
|
|
37
59
|
}
|
|
38
60
|
return null;
|
|
39
61
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// CLI surface: `agentic-security rules synthesize|evaluate|promote|status|rollback`.
|
|
1
|
+
// CLI surface: `agentic-security rules synthesize|evaluate|promote|status|rollback|cost|regress`.
|
|
2
2
|
//
|
|
3
3
|
// Distinct from the pre-existing `labs synthesize-rule` (posture/rule-synthesis.js), which proposes SUPPRESSION
|
|
4
4
|
// rules from repeated false-positive triage verdicts — a different input, a different output and a different
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// it never decides promotion).
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import { statePath } from '../state-dir.js';
|
|
12
|
+
import { packTrust, signPublishedRule } from './store.js';
|
|
13
|
+
import { sharedFalsePositiveCorpus, falsePositiveHits, regressRules } from './regression.js';
|
|
12
14
|
import { draftRuleIR, validateRuleIR, ruleIRDigest } from './ir.js';
|
|
13
15
|
import { checkEligibility } from './eligibility.js';
|
|
14
16
|
import { buildCorpus } from './corpus.js';
|
|
@@ -39,6 +41,7 @@ export async function cmdRules(args, root) {
|
|
|
39
41
|
if (sub === 'status') return cmdStatus(args, root);
|
|
40
42
|
if (sub === 'rollback') return cmdRollback(args, root);
|
|
41
43
|
if (sub === 'cost') return cmdCost(args, root);
|
|
44
|
+
if (sub === 'regress') return cmdRegress(args, root);
|
|
42
45
|
console.error('usage: rules synthesize <finding-id> --spec <file.json> [--auto] [--max-attempts N] | evaluate <rule-id> | promote <rule-id> | status [rule-id] | rollback <rule-id> | cost <rule-id>');
|
|
43
46
|
return 4;
|
|
44
47
|
}
|
|
@@ -61,13 +64,13 @@ async function cmdSynthesize(args, root) {
|
|
|
61
64
|
const corpusSpec = { originalCode: spec.originalCode, patchedCode: spec.patchedCode ?? null, nearbyCleanCode: spec.nearbyCleanCode ?? [], protectedVariantsCode: spec.protectedVariantsCode ?? [] };
|
|
62
65
|
const leak = checkLeakage(JSON.stringify(corpusSpec), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [], fileNames: spec.fileNames ?? [] });
|
|
63
66
|
if (!leak.ok) { console.error(`Refusing: the material for this rule shows benchmark or answer-key signals (${leak.offendingTerms.join('; ')}). Nothing was written.`); return 4; }
|
|
64
|
-
if (args.flags.auto || !spec.match) {
|
|
67
|
+
if (args.flags.auto || (!spec.match && !spec.sink)) {
|
|
65
68
|
if (!isStr(spec.originalCode)) { console.error('Automatic proposal needs originalCode (the vulnerable snippet) in the spec.'); return 4; }
|
|
66
69
|
const { id, title, severity, cwe, family, languages, remediation } = spec;
|
|
67
70
|
synthesis = synthesizeCandidate({ base: { id, title, severity, cwe, family, languages, remediation, originatingFindingId: finding.stableId ?? finding.id, evidenceState: elig.evidenceState }, corpusSpec, proposed: spec.match ?? null, maxAttempts: Number(args.flags['max-attempts']) || 6, leakageOk: true });
|
|
68
71
|
for (const a of synthesis.attempts) console.log(` attempt ${a.n}: ${a.strategy.padEnd(34)} ${a.valid ? `precision ${fmtPct(a.score.precision)} recall ${fmtPct(a.score.recall)} protected-failures ${a.score.protectedFailures}${a.pass ? ' PASS' : ''}` : `invalid (${a.errors[0]})`}`);
|
|
69
72
|
if (!synthesis.best) { console.error('No valid candidate could be proposed from this material.'); return 1; }
|
|
70
|
-
finalSpec = { ...spec, match: synthesis.best.match };
|
|
73
|
+
finalSpec = { ...spec, ...(synthesis.best.match ? { match: synthesis.best.match } : {}), ...(synthesis.best.structure ?? {}) };
|
|
71
74
|
console.log(synthesis.best.pass ? ` candidate meets the promotion gate on this corpus (not yet recorded as VALIDATED).` : ` no attempt met the gate; the best is kept as a CANDIDATE only: ${synthesis.best.reasons.join('; ')}`);
|
|
72
75
|
}
|
|
73
76
|
const ir = draftRuleIR({ ...finalSpec, originatingFindingId: finding.stableId ?? finding.id, originatingCommit: spec.originatingCommit ?? null, evidenceState: elig.evidenceState });
|
|
@@ -95,10 +98,17 @@ async function cmdEvaluate(args, root) {
|
|
|
95
98
|
const corpusSpec = readJsonArg(args.flags.corpus) ?? loadCorpus(root, id);
|
|
96
99
|
if (!corpusSpec) { console.error('No corpus supplied. Pass --corpus <file.json> with originalCode (and optionally patchedCode/nearbyCleanCode/protectedVariantsCode).'); return 4; }
|
|
97
100
|
const corpus = buildCorpus(ir, corpusSpec);
|
|
101
|
+
// the corpus a rule is evaluated on is the evidence for it: keep it, so `rules regress` and the shared false-positive corpus use the SAME code
|
|
102
|
+
if (args.flags.corpus) saveCorpus(root, id, { ...(loadCorpus(root, id) ?? {}), ...corpusSpec });
|
|
98
103
|
const leakage = checkLeakage(JSON.stringify({ ...corpusSpec, fileNames: undefined, discoveryCost: undefined }), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [], fileNames: corpusSpec.fileNames ?? [] });
|
|
99
104
|
const gate = gatePromotion({ ir, corpus, leakageCheck: leakage, thresholds: args.flags.thresholds ? readJsonArg(args.flags.thresholds) : PROMOTION_DEFAULTS });
|
|
100
105
|
|
|
101
106
|
const ledger = loadLedger(root);
|
|
107
|
+
// the shared false-positive corpus: the safe code of every other promoted rule, plus the operator's own files. Part of the gate.
|
|
108
|
+
const fpItems = sharedFalsePositiveCorpus(root, ledger, id);
|
|
109
|
+
const fpHits = falsePositiveHits(ir, fpItems);
|
|
110
|
+
gate.fpCorpus = { items: fpItems.length, hits: fpHits.map((h) => h.label) };
|
|
111
|
+
if (fpHits.length) { gate.pass = false; gate.reasons.push(`fires on ${fpHits.length} of ${fpItems.length} item(s) in the shared false-positive corpus: ${fpHits.slice(0, 3).map((h) => h.label).join(', ')}${fpHits.length > 3 ? ', ...' : ''}`); }
|
|
102
112
|
const cur = stateOf(ledger, id);
|
|
103
113
|
if (gate.pass && cur === 'CANDIDATE') recordTransition(ledger, { ruleId: id, from: 'CANDIDATE', to: 'VALIDATED', reason: 'promotion gate passed', evidence: { score: gate.evaluation.score }, actor: args.flags.actor || 'agent' });
|
|
104
114
|
saveLedger(root, ledger);
|
|
@@ -134,6 +144,9 @@ async function cmdPromote(args, root) {
|
|
|
134
144
|
if (stateOf(ledger, id) !== 'VALIDATED') { console.error(`Rule ${id} is not VALIDATED (run rules evaluate first); refusing to promote.`); return 4; }
|
|
135
145
|
const ir = loadIR(root, id);
|
|
136
146
|
if (!ir) { console.error(`No rule IR found for ${id}.`); return 4; }
|
|
147
|
+
// other rules may have been promoted since this one was evaluated: hold it to the false-positive corpus as it is NOW
|
|
148
|
+
const fpNow = falsePositiveHits(ir, sharedFalsePositiveCorpus(root, ledger, id));
|
|
149
|
+
if (fpNow.length) { console.error(`Promotion refused: ${id} fires on ${fpNow.length} item(s) of the shared false-positive corpus (${fpNow.slice(0, 3).map((h) => h.label).join(', ')}). Re-evaluate or narrow the rule.`); return 1; }
|
|
137
150
|
const pub = publishCustomRule(root, ir);
|
|
138
151
|
if (!pub.ok) { console.error(`Promotion refused: ${pub.reason}`); return 4; }
|
|
139
152
|
const t = recordTransition(ledger, { ruleId: id, from: 'VALIDATED', to: 'PROMOTED', reason: args.flags.reason || 'promoted', actor: args.flags.actor || 'operator' });
|
|
@@ -141,10 +154,29 @@ async function cmdPromote(args, root) {
|
|
|
141
154
|
const prov = loadProvenance(root, id);
|
|
142
155
|
if (prov) saveProvenance(root, id, { ...prov, promotionEvent: { at: new Date().toISOString(), actor: args.flags.actor || 'operator', reason: args.flags.reason || 'promoted', ruleHash: ruleIRDigest(ir) } });
|
|
143
156
|
console.log(`Rule ${id}: VALIDATED -> PROMOTED. Published to ${pub.file}.`);
|
|
157
|
+
// Publishing is not trusting. Rule packs are signature-checked so a rule dropped into a repository cannot run in someone's CI; sign this
|
|
158
|
+
// one with the operator's key when there is one, and say plainly whether the scanner will load it.
|
|
159
|
+
const key = args.flags['sign-key'] ?? process.env.AGENTIC_SECURITY_PRIVATE_KEY;
|
|
160
|
+
if (key) { const sg = signPublishedRule(pub.file, key); console.log(sg.ok ? ` signed: ${pub.file}.sig` : ` could not sign (${sg.reason}); the rule is published unsigned`); }
|
|
161
|
+
const trust = packTrust(root, id);
|
|
162
|
+
console.log(trust.live ? ` The scanner will load it (${trust.via}).` : ` NOT LIVE YET: the scanner will refuse this rule (${trust.reason}). To make it live: ${trust.remediation}.`);
|
|
144
163
|
if (!t.ok) console.error(t.reason);
|
|
145
164
|
return 0;
|
|
146
165
|
}
|
|
147
166
|
|
|
167
|
+
/** `rules regress [rule-id]`: re-score every PROMOTED rule against its own corpus and the shared false-positive corpus. Exit 1 on any regression. */
|
|
168
|
+
async function cmdRegress(args, root) {
|
|
169
|
+
const ledger = loadLedger(root);
|
|
170
|
+
if (ledger.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${ledger.tamperReason}).`); return 4; }
|
|
171
|
+
const only = args._[2] ?? null;
|
|
172
|
+
const results = regressRules(root, ledger, { only, thresholds: args.flags.thresholds ? readJsonArg(args.flags.thresholds) : undefined });
|
|
173
|
+
if (args.flags.json) console.log(JSON.stringify({ results }, null, 2));
|
|
174
|
+
else if (!results.length) console.log(only ? `No promoted rule ${only}.` : 'No rule is PROMOTED; nothing to regress.');
|
|
175
|
+
else for (const r of results) { console.log(`${r.id}: ${r.status.toUpperCase()}${r.precision != null ? ` precision ${fmtPct(r.precision)} recall ${fmtPct(r.recall)} false-positive corpus hits ${r.fpCorpusHits}` : ''}`); for (const m of r.reasons) console.log(` - ${m}`); }
|
|
176
|
+
if (only && !results.length) return 4;
|
|
177
|
+
return results.every((r) => r.status === 'ok') ? 0 : 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
148
180
|
async function cmdRollback(args, root) {
|
|
149
181
|
const id = args._[2];
|
|
150
182
|
if (!id) { console.error('usage: rules rollback <rule-id> [--reason text]'); return 4; }
|
|
@@ -165,16 +197,18 @@ async function cmdStatus(args, root) {
|
|
|
165
197
|
const h = historyFor(ledger, id);
|
|
166
198
|
if (!h.length) { console.error(`No history for rule ${id}.`); return 4; }
|
|
167
199
|
if (args.flags.json) console.log(JSON.stringify(h, null, 2));
|
|
168
|
-
else { console.log(`Rule ${id}: ${stateOf(ledger, id)}`);
|
|
200
|
+
else { console.log(`Rule ${id}: ${stateOf(ledger, id)}`);
|
|
201
|
+
if (stateOf(ledger, id) === 'PROMOTED') { const tr = packTrust(root, id); console.log(tr.live ? ` live: yes (${tr.via})` : ` live: NO, the scanner refuses it (${tr.reason}). ${tr.remediation}.`); }
|
|
202
|
+
for (const e of h) console.log(` ${e.at} ${e.from ?? '(new)'} -> ${e.to} (${e.reason || ''})`);
|
|
169
203
|
const syn = loadSynthesis(root, id);
|
|
170
204
|
if (syn?.attempts?.length) { console.log(` synthesis: ${syn.attempts.length} attempt(s), best ${syn.best ? `${syn.best.strategy}${syn.best.pass ? ' (met the gate)' : ' (did not meet the gate)'}` : 'none'}`); }
|
|
171
205
|
}
|
|
172
206
|
return 0;
|
|
173
207
|
}
|
|
174
208
|
const ids = [...new Set(ledger.entries.map((e) => e.ruleId))];
|
|
175
|
-
if (args.flags.json) { console.log(JSON.stringify(ids.map((rid) => ({ id: rid, state: stateOf(ledger, rid) })), null, 2)); return 0; }
|
|
209
|
+
if (args.flags.json) { console.log(JSON.stringify(ids.map((rid) => ({ id: rid, state: stateOf(ledger, rid), ...(stateOf(ledger, rid) === 'PROMOTED' ? { live: packTrust(root, rid).live } : {}) })), null, 2)); return 0; }
|
|
176
210
|
if (!ids.length) { console.log('No rules in the lifecycle ledger yet.'); return 0; }
|
|
177
|
-
for (const rid of ids) console.log(` ${rid.padEnd(40)} ${stateOf(ledger, rid)}`);
|
|
211
|
+
for (const rid of ids) console.log(` ${rid.padEnd(40)} ${stateOf(ledger, rid)}${stateOf(ledger, rid) === 'PROMOTED' && !packTrust(root, rid).live ? ' (NOT LIVE: the scanner refuses it)' : ''}`);
|
|
178
212
|
return 0;
|
|
179
213
|
}
|
|
180
214
|
|
|
@@ -9,17 +9,13 @@
|
|
|
9
9
|
// the score a rule gets here is the score it will actually produce once promoted.
|
|
10
10
|
import { generateVariants } from './mutation.js';
|
|
11
11
|
import { compileToCustomRule } from './ir.js';
|
|
12
|
-
import { runRule } from '../custom-rules.js';
|
|
12
|
+
import { runRule, normalizeCustomRule } from '../custom-rules.js';
|
|
13
13
|
|
|
14
|
+
/** The rule exactly as the scanner would load it: through the loader's own normalizer, not a second implementation. */
|
|
14
15
|
function toRuntimeRule(ir) {
|
|
15
16
|
const { ok, doc, errs } = compileToCustomRule(ir);
|
|
16
17
|
if (!ok) throw new Error(`cannot build corpus for an invalid rule IR: ${errs.join('; ')}`);
|
|
17
|
-
|
|
18
|
-
return {
|
|
19
|
-
id: doc.id, title: doc.title, severity: doc.severity, cwe: doc.cwe, languages: doc.languages, message: doc.message,
|
|
20
|
-
regexes, requireAll: !!doc.match.allOf, windowLines: doc.match.window ?? 5,
|
|
21
|
-
notMatch: doc.match.notMatch ? new RegExp(doc.match.notMatch) : null,
|
|
22
|
-
};
|
|
18
|
+
return normalizeCustomRule(doc);
|
|
23
19
|
}
|
|
24
20
|
|
|
25
21
|
const EXT = { javascript: 'x.js', typescript: 'x.ts', python: 'x.py', go: 'x.go', ruby: 'x.rb', java: 'x.java', rust: 'x.rs', csharp: 'x.cs', php: 'x.php', any: 'x.txt' };
|
|
@@ -4,9 +4,20 @@
|
|
|
4
4
|
// regex/allOf/notMatch/window engine; this module only adds PROVENANCE metadata around it.
|
|
5
5
|
//
|
|
6
6
|
// A rule's match semantics are deliberately the same closed vocabulary custom-rules.js
|
|
7
|
-
// already supports (pattern | allOf+window | notMatch
|
|
8
|
-
// introduced, so a promoted rule is exactly as auditable as a hand-written one.
|
|
7
|
+
// already supports (pattern | allOf+window | notMatch, plus the structural fields below): no
|
|
8
|
+
// separate matching engine is introduced, so a promoted rule is exactly as auditable as a hand-written one.
|
|
9
|
+
//
|
|
10
|
+
// The structural fields are REAL, not annotations: each is a regular expression and each compiles to a constraint the
|
|
11
|
+
// runtime matcher enforces.
|
|
12
|
+
// source, propagator, sink together they become a windowed co-occurrence (allOf) anchored on the SINK; a sink alone is the pattern
|
|
13
|
+
// sanitizer, dominatingGuard a hit is dropped when either matches within `guardWindow` lines before (or on) it
|
|
14
|
+
// frameworkConstraint a file-level precondition (the file must contain it, wherever it is)
|
|
15
|
+
// exclusions a hit is dropped when its own line matches one
|
|
16
|
+
// languages the rule only runs on files of those languages
|
|
17
|
+
// An explicit `match` always wins over a derived one. An AST pattern is NOT a field: the existing engine has no AST matcher for rules to
|
|
18
|
+
// compile to, and a field the matcher ignored would be the old mistake (an annotation that looks like a constraint).
|
|
9
19
|
import { sha256 } from '../proof-fabric/reproducer.js';
|
|
20
|
+
import { normalizeCustomRule } from '../custom-rules.js';
|
|
10
21
|
|
|
11
22
|
const RULE_IR_SCHEMA = 'agentic-security/rule-ir';
|
|
12
23
|
const RULE_IR_VERSION = 1;
|
|
@@ -28,8 +39,9 @@ export function validateRuleIR(ir) {
|
|
|
28
39
|
if (!isStr(ir.family)) errs.push('family required');
|
|
29
40
|
if (!Array.isArray(ir.languages) || !ir.languages.length || !ir.languages.every((l) => VALID_LANGS.has(l))) errs.push('languages must be a non-empty array from the supported set');
|
|
30
41
|
const m = ir.match;
|
|
31
|
-
if (
|
|
32
|
-
else
|
|
42
|
+
if (m == null && !isStr(ir.sink)) errs.push('match required (or at least a sink, from which one is derived)');
|
|
43
|
+
else if (m != null && typeof m !== 'object') errs.push('match must be an object');
|
|
44
|
+
else if (m) {
|
|
33
45
|
const hasPattern = isStr(m.pattern);
|
|
34
46
|
const hasAllOf = Array.isArray(m.allOf) && m.allOf.length > 0 && m.allOf.every(isStr);
|
|
35
47
|
if (!hasPattern && !hasAllOf) errs.push('match needs a pattern or a non-empty allOf');
|
|
@@ -39,12 +51,20 @@ export function validateRuleIR(ir) {
|
|
|
39
51
|
try { new RegExp(re); } catch { errs.push(`invalid regex: ${re}`); }
|
|
40
52
|
}
|
|
41
53
|
}
|
|
42
|
-
//
|
|
43
|
-
// never
|
|
54
|
+
// Structural fields: each is a regular expression the matcher enforces (see the header). Free text is rejected, so a field can
|
|
55
|
+
// never look like a constraint while being ignored.
|
|
44
56
|
for (const k of ['sink', 'source', 'propagator', 'sanitizer', 'dominatingGuard', 'frameworkConstraint']) {
|
|
45
|
-
if (ir[k]
|
|
57
|
+
if (ir[k] == null) continue;
|
|
58
|
+
if (!isStr(ir[k])) { errs.push(`${k} must be a regular-expression string when present`); continue; }
|
|
59
|
+
try { new RegExp(ir[k]); } catch { errs.push(`${k} is not a valid regular expression`); }
|
|
60
|
+
}
|
|
61
|
+
if ((ir.source || ir.propagator) && !ir.sink && !ir.match) errs.push('a source or propagator needs a sink');
|
|
62
|
+
if (ir.window != null && !(Number.isInteger(ir.window) && ir.window > 0 && ir.window <= 200)) errs.push('window must be an integer in 1..200');
|
|
63
|
+
if (ir.guardWindow != null && !(Number.isInteger(ir.guardWindow) && ir.guardWindow > 0 && ir.guardWindow <= 200)) errs.push('guardWindow must be an integer in 1..200');
|
|
64
|
+
if (ir.exclusions != null) {
|
|
65
|
+
if (!(Array.isArray(ir.exclusions) && ir.exclusions.every(isStr))) errs.push('exclusions must be an array of regular-expression strings');
|
|
66
|
+
else for (const re of ir.exclusions) { try { new RegExp(re); } catch { errs.push(`invalid exclusion regex: ${re}`); } }
|
|
46
67
|
}
|
|
47
|
-
if (ir.exclusions != null && !(Array.isArray(ir.exclusions) && ir.exclusions.every(isStr))) errs.push('exclusions must be an array of strings');
|
|
48
68
|
if (!ir.provenance || typeof ir.provenance !== 'object') errs.push('provenance required');
|
|
49
69
|
else {
|
|
50
70
|
if (!isStr(ir.provenance.originatingFindingId)) errs.push('provenance.originatingFindingId required');
|
|
@@ -74,14 +94,37 @@ export function draftRuleIR({ id, title, severity, cwe, family, languages, match
|
|
|
74
94
|
export function compileToCustomRule(ir) {
|
|
75
95
|
const errs = validateRuleIR(ir);
|
|
76
96
|
if (errs.length) return { ok: false, errs };
|
|
97
|
+
const alt = (...parts) => { const p = parts.filter(isStr); return p.length ? p.map((x) => `(?:${x})`).join('|') : null; };
|
|
98
|
+
// the base match: explicit wins; otherwise source -> propagator -> sink as a windowed co-occurrence, or a sink alone
|
|
99
|
+
let base = ir.match;
|
|
100
|
+
if (!base) {
|
|
101
|
+
// the SINK leads: the matcher anchors on the first pattern, and the finding, the guard window and the line exclusions all belong at
|
|
102
|
+
// the sink, where the flaw manifests. The window is symmetric, so the order of the others does not affect co-occurrence.
|
|
103
|
+
const chain = [ir.sink, ir.propagator, ir.source].filter(isStr);
|
|
104
|
+
base = chain.length > 1 ? { allOf: chain, window: ir.window ?? 8 } : { pattern: ir.sink };
|
|
105
|
+
}
|
|
106
|
+
const guard = alt(ir.sanitizer, ir.dominatingGuard);
|
|
77
107
|
const doc = {
|
|
78
108
|
id: `rule-compiler/${ir.id}`,
|
|
79
109
|
title: ir.title,
|
|
80
110
|
severity: ir.severity,
|
|
81
111
|
cwe: ir.cwe,
|
|
82
112
|
languages: ir.languages,
|
|
83
|
-
match: {
|
|
113
|
+
match: {
|
|
114
|
+
...(base.pattern ? { pattern: base.pattern } : {}),
|
|
115
|
+
...(base.allOf ? { allOf: base.allOf, window: base.window ?? 5 } : {}),
|
|
116
|
+
...(base.notMatch ? { notMatch: base.notMatch } : {}),
|
|
117
|
+
...(isStr(ir.frameworkConstraint) ? { fileRequires: [ir.frameworkConstraint] } : {}),
|
|
118
|
+
...(guard ? { guardedBy: guard, guardWindow: ir.guardWindow ?? base.window ?? 5 } : {}),
|
|
119
|
+
...(Array.isArray(ir.exclusions) && ir.exclusions.length ? { excludeLine: ir.exclusions } : {}),
|
|
120
|
+
},
|
|
84
121
|
message: ir.remediation || `${ir.title} (${ir.cwe}, compiled from ${ir.provenance.originatingFindingId})`,
|
|
85
122
|
};
|
|
123
|
+
// The loader has its own safety checks (nested quantifiers and so on). A rule it would refuse must not be reported as compiled,
|
|
124
|
+
// or it would score well here and silently never fire in a scan.
|
|
125
|
+
const origError = console.error; let loaderSaid = '';
|
|
126
|
+
console.error = (...a) => { loaderSaid = a.join(' '); };
|
|
127
|
+
let normalized; try { normalized = normalizeCustomRule(doc); } finally { console.error = origError; }
|
|
128
|
+
if (!normalized) return { ok: false, errs: [`the scanner's rule loader would reject this rule${loaderSaid ? `: ${loaderSaid.replace(/^agentic-security:\s*/, '').slice(0, 160)}` : ''}`] };
|
|
86
129
|
return { ok: true, doc };
|
|
87
130
|
}
|
|
@@ -62,7 +62,7 @@ export function proposeCandidates({ vulnerableCode, patchedCode = null }) {
|
|
|
62
62
|
const vuln = onlyIn(vulnerableCode, patchedCode);
|
|
63
63
|
const lines = vuln.length ? vuln : String(vulnerableCode).split('\n').filter(meaningful).map((l) => l.trim()).filter((l) => callNames(l).length);
|
|
64
64
|
const out = []; const seen = new Set();
|
|
65
|
-
const add = (strategy, match) => { const k = JSON.stringify(match); if (!seen.has(k)) { seen.add(k); out.push({ strategy, match }); } };
|
|
65
|
+
const add = (strategy, match, structure = null) => { const k = JSON.stringify([match, structure]); if (!seen.has(k)) { seen.add(k); out.push({ strategy, ...(match ? { match } : {}), ...(structure ? { structure } : {}) }); } };
|
|
66
66
|
for (const l of lines.slice(0, 3)) add('line-shape', { pattern: generalizeLine(l) });
|
|
67
67
|
for (const l of lines.slice(0, 3)) {
|
|
68
68
|
const sink = sinkOf(l);
|
|
@@ -71,6 +71,9 @@ export function proposeCandidates({ vulnerableCode, patchedCode = null }) {
|
|
|
71
71
|
const sink = lines.map(sinkOf).find(Boolean);
|
|
72
72
|
const src = SOURCES.find((s) => new RegExp(s).test(vulnerableCode));
|
|
73
73
|
if (sink && src) add('source-and-sink', { allOf: [`${esc(sink)}\\s*\\(`, src], window: 6 });
|
|
74
|
+
// the same evidence as an explicit structure: a source and a sink the matcher enforces, so a sanitizer found in the patched code can
|
|
75
|
+
// later be added as a LOCAL guard (dropping one hit) instead of a file-level exclusion (silencing the whole file)
|
|
76
|
+
if (sink && src) add('structural', null, { source: src, sink: `${esc(sink)}\\s*\\(`, window: 6 });
|
|
74
77
|
if (sink) add('sink-call', { pattern: `${esc(sink)}\\s*\\(` });
|
|
75
78
|
return out;
|
|
76
79
|
}
|
|
@@ -104,29 +107,31 @@ export function synthesizeCandidate({ base, corpusSpec, proposed = null, maxAtte
|
|
|
104
107
|
for (const p of proposeCandidates({ vulnerableCode: corpusSpec.originalCode, patchedCode: corpusSpec.patchedCode })) queue.push(p);
|
|
105
108
|
const names = safeNames({ vulnerableCode: corpusSpec.originalCode, patchedCode: corpusSpec.patchedCode });
|
|
106
109
|
const attempts = []; const tried = new Set();
|
|
107
|
-
const tryOne = (strategy, match) => {
|
|
108
|
-
const key = JSON.stringify(match);
|
|
110
|
+
const tryOne = (strategy, match, structure = null) => {
|
|
111
|
+
const key = JSON.stringify([match, structure]);
|
|
109
112
|
if (tried.has(key) || attempts.length >= cap) return null;
|
|
110
113
|
tried.add(key);
|
|
111
|
-
const ir = draftRuleIR({ ...base, match });
|
|
114
|
+
const ir = draftRuleIR({ ...base, ...(match ? { match } : {}), ...(structure ?? {}) });
|
|
112
115
|
const errs = validateRuleIR(ir);
|
|
113
|
-
if (errs.length) { attempts.push({ n: attempts.length + 1, strategy, match, valid: false, errors: errs, pass: false, reasons: errs }); return null; }
|
|
116
|
+
if (errs.length) { attempts.push({ n: attempts.length + 1, strategy, match: match ?? null, structure, valid: false, errors: errs, pass: false, reasons: errs }); return null; }
|
|
114
117
|
const corpus = buildCorpus(ir, corpusSpec);
|
|
115
118
|
const ev = evaluateRule(ir, corpus, thresholds);
|
|
116
|
-
const rec = { n: attempts.length + 1, strategy, match, valid: true, pass: ev.pass && leakageOk, reasons: ev.reasons, score: summary(ev.score), ir, corpusSizes: { positives: corpus.positives.length, negatives: corpus.negatives.length } };
|
|
119
|
+
const rec = { n: attempts.length + 1, strategy, match: match ?? null, structure, valid: true, pass: ev.pass && leakageOk, reasons: ev.reasons, score: summary(ev.score), ir, corpusSizes: { positives: corpus.positives.length, negatives: corpus.negatives.length } };
|
|
117
120
|
attempts.push(rec);
|
|
118
121
|
return { rec, ev };
|
|
119
122
|
};
|
|
120
123
|
for (let qi = 0; qi < queue.length && attempts.length < cap; qi++) {
|
|
121
|
-
const
|
|
124
|
+
const q = queue[qi];
|
|
125
|
+
const first = tryOne(q.strategy, q.match, q.structure);
|
|
122
126
|
if (!first) continue;
|
|
123
127
|
if (first.rec.pass) break;
|
|
124
128
|
// revise from the failure: a negative that fired means the safe version is not excluded yet
|
|
125
129
|
const fired = first.ev.score.negatives.filter((n) => n.fired);
|
|
126
130
|
const rx = safeRegex(names);
|
|
127
|
-
if (fired.length && rx && !
|
|
131
|
+
if (fired.length && rx && q.structure && !q.structure.sanitizer) { const r = tryOne(`${q.strategy}+sanitizer`, null, { ...q.structure, sanitizer: rx }); if (r?.rec.pass) break; }
|
|
132
|
+
else if (fired.length && rx && q.match && !q.match.notMatch) { const r = tryOne(`${q.strategy}+exclude-safe`, { ...q.match, notMatch: rx }); if (r?.rec.pass) break; }
|
|
128
133
|
}
|
|
129
134
|
const valid = attempts.filter((a) => a.valid);
|
|
130
135
|
const best = valid.length ? [...valid].sort((a, b) => quality({ pass: b.pass, score: b.score }) - quality({ pass: a.pass, score: a.score }))[0] : null;
|
|
131
|
-
return { attempts: attempts.map(({ ir, ...rest }) => rest), best: best ? { strategy: best.strategy, match: best.match, pass: best.pass, score: best.score, reasons: best.reasons } : null, exhausted: !attempts.some((a) => a.pass) };
|
|
136
|
+
return { attempts: attempts.map(({ ir, ...rest }) => rest), best: best ? { strategy: best.strategy, match: best.match, structure: best.structure, pass: best.pass, score: best.score, reasons: best.reasons } : null, exhausted: !attempts.some((a) => a.pass) };
|
|
132
137
|
}
|