@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -1
  3. package/bin/agentic-security.js +9 -2
  4. package/dist/1919.index.js +1 -1
  5. package/dist/2066.index.js +2 -1
  6. package/dist/2584.index.js +19 -1
  7. package/dist/5456.index.js +291 -0
  8. package/dist/5620.index.js +147 -23
  9. package/dist/5937.index.js +672 -0
  10. package/dist/7838.index.js +70 -25
  11. package/dist/agentic-security.mjs +2 -2
  12. package/dist/agentic-security.mjs.sha256 +1 -1
  13. package/dist/{df1e8683e7eda7b2b79c.js → b4289e728659450b5e19.js} +4 -1
  14. package/dist/{4978bec8fdf5c733dc85.json → d175c127fee48e80d04c.json} +8 -3
  15. package/package.json +8 -3
  16. package/src/posture/custom-rules.js +54 -8
  17. package/src/posture/dnr/exploit-correlation.js +25 -0
  18. package/src/posture/evaluation/lab/dataset.js +1 -1
  19. package/src/posture/evaluation/lab/report.js +19 -1
  20. package/src/posture/fleet/cli.js +22 -10
  21. package/src/posture/fleet/enterprise-graph.js +44 -3
  22. package/src/posture/fleet/job-runner.js +7 -7
  23. package/src/posture/fleet/job-store.js +3 -1
  24. package/src/posture/fleet/lineage-summary.js +55 -0
  25. package/src/posture/fleet/scan-child.js +4 -1
  26. package/src/posture/proof-fabric/generate.js +2 -1
  27. package/src/posture/proof-fabric/runner.js +24 -2
  28. package/src/posture/rule-compiler/cli.js +40 -6
  29. package/src/posture/rule-compiler/corpus.js +3 -7
  30. package/src/posture/rule-compiler/ir.js +52 -9
  31. package/src/posture/rule-compiler/proposal.js +14 -9
  32. package/src/posture/rule-compiler/regression.js +95 -0
  33. package/src/posture/rule-compiler/store.js +29 -1
  34. 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
- for (const e of edges.values()) e.discovered = e.evidence.every((x) => x.source === 'discovered-code-literal');
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
- db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;');
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
- process.stdout.write(`${BEGIN}${JSON.stringify({ entry, findings, findingsTruncated: (captured?.findings?.length ?? 0) > MAX_FINDINGS })}${END}`);
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
- for (const dir of pathEnv.split(path.delimiter)) {
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 { fs.accessSync(p, fs.constants.X_OK); if (fs.statSync(p).isFile()) return fs.realpathSync(p); } catch { /* next */ }
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)}`); for (const e of h) console.log(` ${e.at} ${e.from ?? '(new)'} -> ${e.to} (${e.reason || ''})`);
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
- const regexes = doc.match.allOf ? doc.match.allOf.map((p) => new RegExp(p, 'g')) : [new RegExp(doc.match.pattern, 'g')];
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) — no new matching primitive is
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 (!m || typeof m !== 'object') errs.push('match required');
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
- // Declarative-only hint fields (sink/source/propagator/sanitizer/dominatingGuard/frameworkConstraint): free text,
43
- // never executable, carried for provenance/review only and NOT consulted by the matcher.
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] != null && !isStr(ir[k])) errs.push(`${k} must be a string when present`);
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: { ...(ir.match.pattern ? { pattern: ir.match.pattern } : {}), ...(ir.match.allOf ? { allOf: ir.match.allOf, window: ir.match.window ?? 5 } : {}), ...(ir.match.notMatch ? { notMatch: ir.match.notMatch } : {}) },
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 first = tryOne(queue[qi].strategy, queue[qi].match);
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 && !queue[qi].match.notMatch) { const r = tryOne(`${queue[qi].strategy}+exclude-safe`, { ...queue[qi].match, notMatch: rx }); if (r?.rec.pass) break; }
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
  }