@clear-capabilities/agentic-security-scanner 0.176.0 → 0.177.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 (33) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/bin/agentic-security.js +9 -2
  3. package/dist/1919.index.js +1 -1
  4. package/dist/2066.index.js +2 -1
  5. package/dist/2584.index.js +19 -1
  6. package/dist/{4978bec8fdf5c733dc85.json → 37d64df006576248d0d8.json} +5 -2
  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/package.json +5 -2
  15. package/src/posture/custom-rules.js +54 -8
  16. package/src/posture/dnr/exploit-correlation.js +25 -0
  17. package/src/posture/evaluation/lab/dataset.js +1 -1
  18. package/src/posture/evaluation/lab/report.js +19 -1
  19. package/src/posture/fleet/cli.js +22 -10
  20. package/src/posture/fleet/enterprise-graph.js +44 -3
  21. package/src/posture/fleet/job-runner.js +7 -7
  22. package/src/posture/fleet/job-store.js +3 -1
  23. package/src/posture/fleet/lineage-summary.js +55 -0
  24. package/src/posture/fleet/scan-child.js +4 -1
  25. package/src/posture/proof-fabric/generate.js +2 -1
  26. package/src/posture/proof-fabric/runner.js +24 -2
  27. package/src/posture/rule-compiler/cli.js +40 -6
  28. package/src/posture/rule-compiler/corpus.js +3 -7
  29. package/src/posture/rule-compiler/ir.js +52 -9
  30. package/src/posture/rule-compiler/proposal.js +14 -9
  31. package/src/posture/rule-compiler/regression.js +95 -0
  32. package/src/posture/rule-compiler/store.js +29 -1
  33. package/src/posture/rule-pack-signing.js +4 -2
@@ -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
  }
@@ -0,0 +1,95 @@
1
+ // Regression integration for promoted rules (PRD 3, requirement 8): a promoted rule joins a corpus every later rule and every later
2
+ // run is held to, instead of being checked once at promotion and forgotten.
3
+ //
4
+ // shared false-positive corpus the SAFE code of every promoted rule (its patched, nearby-clean and protected variants) plus any file
5
+ // the operator drops in `.agentic-security/rule-compiler/fp-corpus/`. A new candidate must not fire
6
+ // on any of it, so rule N+1 cannot reintroduce the false positive rule N was built to avoid.
7
+ // regression run `rules regress` re-scores every PROMOTED rule against its own corpus, then against the shared false
8
+ // positive corpus, and reports any rule that no longer clears its gate (the matcher, the loader or
9
+ // another rule changed underneath it).
10
+ // The rule-specific unit tests are the fixtures written at evaluation (the existing `rule test` convention); this module adds the cross-rule
11
+ // part, which a per-rule check cannot see.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { statePath } from '../state-dir.js';
15
+ import { buildCorpus, scoreRule } from './corpus.js';
16
+ import { evaluateRule } from './promotion.js';
17
+ import { runRule, normalizeCustomRule } from '../custom-rules.js';
18
+ import { compileToCustomRule } from './ir.js';
19
+ import { loadIR, loadCorpus, loadProvenance } from './store.js';
20
+ import { stateOf } from './lifecycle.js';
21
+
22
+ const FP_DIR = 'rule-compiler/fp-corpus';
23
+ const MAX_FP_FILE = 256 * 1024;
24
+ const MAX_FP_FILES = 500;
25
+ const EXT_OF = { '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.ts': 'typescript', '.py': 'python', '.go': 'go', '.rb': 'ruby', '.java': 'java', '.rs': 'rust', '.cs': 'csharp', '.php': 'php' };
26
+
27
+ /** Operator-supplied false positives (real code a rule should never flag). Plain files; symlinks and oversized files are ignored. */
28
+ function operatorFalsePositives(root) {
29
+ const dir = statePath(root, ...FP_DIR.split('/'));
30
+ const out = [];
31
+ let names = [];
32
+ try { names = fs.readdirSync(dir).sort(); } catch { return out; }
33
+ for (const n of names.slice(0, MAX_FP_FILES)) {
34
+ const f = path.join(dir, n);
35
+ try {
36
+ const st = fs.lstatSync(f);
37
+ if (!st.isFile() || st.isSymbolicLink() || st.size > MAX_FP_FILE) continue;
38
+ out.push({ label: `operator-fp:${n}`, file: n, language: EXT_OF[path.extname(n).toLowerCase()] ?? 'any', code: fs.readFileSync(f, 'utf8') });
39
+ } catch { /* unreadable: skipped */ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function promotedRuleIds(ledger) {
45
+ return [...new Set(ledger.entries.map((e) => e.ruleId))].filter((id) => stateOf(ledger, id) === 'PROMOTED');
46
+ }
47
+
48
+ /** The shared false-positive corpus as seen by `exceptId`: the safe code of every OTHER promoted rule, plus the operator's files. */
49
+ export function sharedFalsePositiveCorpus(root, ledger, exceptId = null) {
50
+ const items = [];
51
+ for (const id of promotedRuleIds(ledger)) {
52
+ if (id === exceptId) continue;
53
+ const ir = loadIR(root, id); const spec = loadCorpus(root, id);
54
+ if (!ir || !spec || typeof spec.originalCode !== 'string') continue;
55
+ let corpus; try { corpus = buildCorpus(ir, spec); } catch { continue; }
56
+ for (const n of corpus.negatives) items.push({ label: `rule:${id}:${n.label}`, language: ir.languages[0] ?? 'any', code: n.code });
57
+ }
58
+ return [...items, ...operatorFalsePositives(root)];
59
+ }
60
+
61
+ const FILE_FOR_LANG = { 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' };
62
+
63
+ /** Which shared false-positive items does this rule fire on? A rule is only run against items written in a language it applies to. */
64
+ export function falsePositiveHits(ir, items) {
65
+ const { ok, doc } = compileToCustomRule(ir);
66
+ if (!ok) return [{ label: '(rule does not compile)' }];
67
+ const rule = normalizeCustomRule(doc);
68
+ if (!rule) return [{ label: '(rule would be rejected by the loader)' }];
69
+ const hits = [];
70
+ for (const it of items) {
71
+ const lang = it.language && it.language !== 'any' ? it.language : (ir.languages[0] ?? 'any');
72
+ if (!ir.languages.includes('any') && !ir.languages.includes(lang)) continue;
73
+ if (runRule(rule, FILE_FOR_LANG[lang] ?? 'x.txt', it.code).length > 0) hits.push({ label: it.label });
74
+ }
75
+ return hits;
76
+ }
77
+
78
+ /** Re-score every promoted rule (or one) against its own corpus and the shared false-positive corpus. */
79
+ export function regressRules(root, ledger, { only = null, thresholds } = {}) {
80
+ const results = [];
81
+ for (const id of promotedRuleIds(ledger)) {
82
+ if (only && id !== only) continue;
83
+ const ir = loadIR(root, id); const spec = loadCorpus(root, id);
84
+ if (!ir || !spec || typeof spec.originalCode !== 'string') { results.push({ id, status: 'unverifiable', reasons: [!ir ? 'the rule IR is missing' : 'no evaluated corpus was kept for this rule (run rules evaluate --corpus again)'] }); continue; }
85
+ let ev; let corpus;
86
+ try { corpus = buildCorpus(ir, spec); ev = evaluateRule(ir, corpus, thresholds); } catch (e) { results.push({ id, status: 'broken', reasons: [`cannot be scored: ${String(e.message).slice(0, 120)}`] }); continue; }
87
+ const fp = falsePositiveHits(ir, sharedFalsePositiveCorpus(root, ledger, id));
88
+ const baseline = loadProvenance(root, id)?.benchmarkResults ?? null;
89
+ const reasons = [...ev.reasons];
90
+ if (fp.length) reasons.push(`fires on ${fp.length} item(s) of the shared false-positive corpus: ${fp.slice(0, 3).map((h) => h.label).join(', ')}${fp.length > 3 ? ', ...' : ''}`);
91
+ if (baseline && ev.score.recall != null && baseline.recall != null && ev.score.recall < baseline.recall) reasons.push(`recall fell from ${(baseline.recall * 100).toFixed(1)}% to ${(ev.score.recall * 100).toFixed(1)}% since promotion`);
92
+ results.push({ id, status: reasons.length ? 'regressed' : 'ok', reasons, precision: ev.score.precision, recall: ev.score.recall, protectedFailures: ev.score.protectedFailures, fpCorpusHits: fp.length });
93
+ }
94
+ return results;
95
+ }
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { statePath, ensureStateDir, safeWriteState } from '../state-dir.js';
9
9
  import { newLedger, verifyLedger } from './lifecycle.js';
10
10
  import { compileToCustomRule } from './ir.js';
11
+ import { verifyRulePack, loadTrustedKeys, signRulePack } from '../rule-pack-signing.js';
11
12
 
12
13
  const DIR = 'rule-compiler';
13
14
  const EXT = { javascript: 'js', typescript: 'ts', python: 'py', go: 'go', ruby: 'rb', java: 'java', rust: 'rs', csharp: 'cs', php: 'php', any: 'txt' };
@@ -100,13 +101,40 @@ export function publishCustomRule(scanRoot, ir) {
100
101
  ...(doc.match.pattern ? [` pattern: ${JSON.stringify(doc.match.pattern)}`] : []),
101
102
  ...(doc.match.allOf ? [` allOf:`, ...doc.match.allOf.map((p) => ` - ${JSON.stringify(p)}`), ` window: ${doc.match.window}`] : []),
102
103
  ...(doc.match.notMatch ? [` notMatch: ${JSON.stringify(doc.match.notMatch)}`] : []),
104
+ ...(doc.match.fileRequires ? [` fileRequires:`, ...doc.match.fileRequires.map((p) => ` - ${JSON.stringify(p)}`)] : []),
105
+ ...(doc.match.guardedBy ? [` guardedBy: ${JSON.stringify(doc.match.guardedBy)}`, ` guardWindow: ${doc.match.guardWindow}`] : []),
106
+ ...(doc.match.excludeLine ? [` excludeLine:`, ...doc.match.excludeLine.map((p) => ` - ${JSON.stringify(p)}`)] : []),
103
107
  ` message: ${JSON.stringify(doc.message)}`,
104
108
  '',
105
109
  ].join('\n');
106
110
  return { ok: safeWriteState(f, yamlText), file: f };
107
111
  }
108
112
 
113
+ /** Sign a published rule with the operator's key (the same Ed25519 scheme `agentic-security-rule sign` uses). The key is never stored here. */
114
+ export function signPublishedRule(file, privateKeyB64) {
115
+ try { signRulePack(file, privateKeyB64); return { ok: true }; } catch (e) { return { ok: false, reason: String(e.message).slice(0, 120) }; }
116
+ }
117
+
118
+ /**
119
+ * Will the scanner actually LOAD this promoted rule? Publishing a file is not the same as the scanner trusting it: rule packs are
120
+ * signature-checked (posture/rule-pack-signing.js) so that a rule dropped into a repository cannot run in someone's CI. A promoted rule
121
+ * the scanner would refuse is not a capability yet, so this is reported next to the lifecycle state instead of being assumed.
122
+ */
123
+ export function packTrust(scanRoot, id) {
124
+ const file = statePath(scanRoot, 'rules', `${id}.yml`);
125
+ if (!fs.existsSync(file)) return { live: false, reason: 'not published', remediation: null };
126
+ let r;
127
+ const origErr = console.error; console.error = () => {};
128
+ try { r = verifyRulePack(file, loadTrustedKeys(scanRoot)); } finally { console.error = origErr; }
129
+ if (r.ok) return { live: true, via: r.passThrough ? 'signed, accepted in pass-through mode (no trusted keys are configured)' : `signed by ${r.keyId}`, reason: null, remediation: null };
130
+ if (r.reason === 'unsigned' && r.allowUnsigned) return { live: true, via: 'unsigned, accepted because AGENTIC_SECURITY_ALLOW_UNSIGNED_PACKS=1', reason: null, remediation: null };
131
+ const remediation = r.reason === 'unsigned'
132
+ ? `sign it (agentic-security-rule sign ${path.relative(process.cwd(), file) || file} with your key, or re-run promote with --sign-key <base64>), or opt in for this environment with AGENTIC_SECURITY_ALLOW_UNSIGNED_PACKS=1`
133
+ : (r.remediation ?? 'see the signing setup in bin/agentic-security-rule.js');
134
+ return { live: false, reason: r.reason, remediation };
135
+ }
136
+
109
137
  export function unpublishCustomRule(scanRoot, id) {
110
138
  const f = statePath(scanRoot, 'rules', `${id}.yml`);
111
- try { if (fs.existsSync(f)) fs.unlinkSync(f); return { ok: true }; } catch (e) { return { ok: false, reason: e.message }; }
139
+ try { for (const x of [f, `${f}.sig`]) if (fs.existsSync(x)) fs.unlinkSync(x); return { ok: true }; } catch (e) { return { ok: false, reason: e.message }; }
112
140
  }
@@ -198,9 +198,11 @@ export function signRulePack(rulePackPath, privateKeyB64) {
198
198
  const body = fs.readFileSync(rulePackPath);
199
199
  const privBytes = Buffer.from(privateKeyB64, 'base64');
200
200
  if (privBytes.length !== 32) throw new Error('private key must be 32 bytes (raw ed25519)');
201
+ // A raw 32-byte Ed25519 seed as a PKCS#8 key. (A JWK with only `d`, which this used to build, is rejected by current Node: it also
202
+ // requires the public `x`, so signing failed with "Invalid JWK OKP key" and no rule pack could be signed through this workflow.)
201
203
  const keyObj = crypto.createPrivateKey({
202
- key: { kty: 'OKP', crv: 'Ed25519', d: privBytes.toString('base64url') },
203
- format: 'jwk',
204
+ key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), privBytes]),
205
+ format: 'der', type: 'pkcs8',
204
206
  });
205
207
  const sig = crypto.sign(null, body, keyObj);
206
208
  fs.writeFileSync(rulePackPath + '.sig', sig);