@clear-capabilities/agentic-security-scanner 0.174.0 → 0.176.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 (103) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/bin/agentic-security.js +199 -28
  3. package/dist/2066.index.js +850 -0
  4. package/dist/2199.index.js +1 -1
  5. package/dist/2584.index.js +3332 -0
  6. package/dist/2923.index.js +13 -0
  7. package/dist/2946.index.js +81 -0
  8. package/dist/3196.index.js +27 -0
  9. package/dist/3727.index.js +292 -0
  10. package/dist/4168.index.js +509 -0
  11. package/dist/4281.index.js +993 -0
  12. package/dist/4978bec8fdf5c733dc85.json +283 -0
  13. package/dist/5099.index.js +96 -0
  14. package/dist/5616.index.js +1471 -0
  15. package/dist/5620.index.js +2370 -0
  16. package/dist/5817.index.js +714 -0
  17. package/dist/6333.index.js +98 -0
  18. package/dist/6655.index.js +3221 -0
  19. package/dist/7838.index.js +446 -974
  20. package/dist/818.index.js +4 -3
  21. package/dist/83.index.js +159 -0
  22. package/dist/8551.index.js +82 -0
  23. package/dist/8633.index.js +93 -0
  24. package/dist/8714.index.js +76 -76
  25. package/dist/8825.index.js +84 -0
  26. package/dist/932.index.js +186 -0
  27. package/dist/9560.index.js +358 -19
  28. package/dist/9800.index.js +6 -1
  29. package/dist/agentic-security.mjs +10 -10
  30. package/dist/agentic-security.mjs.sha256 +1 -1
  31. package/dist/df1e8683e7eda7b2b79c.js +29 -0
  32. package/package.json +7 -6
  33. package/src/artifact/CLAUDE.md +4 -0
  34. package/src/artifact/artifact-evidence.js +63 -0
  35. package/src/artifact/cosign.js +61 -0
  36. package/src/artifact/engine-source.js +72 -0
  37. package/src/artifact/oci-layout.js +47 -0
  38. package/src/artifact/registry.js +213 -0
  39. package/src/capabilities/tool-registry.js +2 -0
  40. package/src/language/haskell-security-rules.js +1 -1
  41. package/src/language/nixos-hardening.js +37 -0
  42. package/src/lineage/CLAUDE.md +4 -0
  43. package/src/lineage/identity-token.js +11 -0
  44. package/src/lineage/observation-adapters.js +13 -0
  45. package/src/lineage/runtime-edge-states.js +139 -0
  46. package/src/lineage/runtime-explorer-view.js +82 -0
  47. package/src/lineage/runtime-observation.js +11 -0
  48. package/src/mcp/CLAUDE.md +3 -1
  49. package/src/mcp/proof-tools.js +132 -0
  50. package/src/mcp/server.js +1 -1
  51. package/src/mcp/tools.js +3 -1
  52. package/src/posture/CLAUDE.md +23 -0
  53. package/src/posture/artifact-registry.js +4 -0
  54. package/src/posture/dnr/attack-facts.js +31 -0
  55. package/src/posture/dnr/cli.js +138 -0
  56. package/src/posture/dnr/detection-ir.js +123 -0
  57. package/src/posture/dnr/exposure-window.js +134 -0
  58. package/src/posture/dnr/hunt.js +84 -0
  59. package/src/posture/dnr/log-reader.js +189 -0
  60. package/src/posture/dnr/matchers.js +90 -0
  61. package/src/posture/dnr/outputs.js +262 -0
  62. package/src/posture/dnr/payload-classes.js +120 -0
  63. package/src/posture/dnr/redact.js +25 -0
  64. package/src/posture/dnr/store.js +57 -0
  65. package/src/posture/dnr/validate.js +64 -0
  66. package/src/posture/dnr/view.js +51 -0
  67. package/src/posture/evaluation/lab/arm-ollama.js +88 -0
  68. package/src/posture/evaluation/lab/arms.js +142 -0
  69. package/src/posture/evaluation/lab/cli.js +135 -0
  70. package/src/posture/evaluation/lab/dataset.js +139 -0
  71. package/src/posture/evaluation/lab/materialize.js +44 -0
  72. package/src/posture/evaluation/lab/neutralize.js +144 -0
  73. package/src/posture/evaluation/lab/report.js +176 -0
  74. package/src/posture/evaluation/lab/run.js +225 -0
  75. package/src/posture/evaluation/lab/score.js +135 -0
  76. package/src/posture/evaluation/lab/scoreboard.js +48 -0
  77. package/src/posture/evaluation/lab/stats.js +41 -0
  78. package/src/posture/evaluation/lab/store.js +53 -0
  79. package/src/posture/fleet/cli.js +248 -63
  80. package/src/posture/fleet/discover-edges.js +135 -0
  81. package/src/posture/fleet/enterprise-graph.js +148 -0
  82. package/src/posture/fleet/fleet-graph.js +9 -4
  83. package/src/posture/fleet/fleet-manifest.js +50 -5
  84. package/src/posture/fleet/job-runner.js +153 -0
  85. package/src/posture/fleet/job-store.js +136 -0
  86. package/src/posture/fleet/org-health.js +56 -0
  87. package/src/posture/fleet/scan-cache.js +112 -0
  88. package/src/posture/fleet/scan-child.js +29 -0
  89. package/src/posture/proof-fabric/cli.js +34 -6
  90. package/src/posture/proof-fabric/container.js +57 -0
  91. package/src/posture/proof-fabric/fix-proof.js +2 -1
  92. package/src/posture/proof-fabric/generate-python.js +129 -0
  93. package/src/posture/proof-fabric/generate.js +213 -0
  94. package/src/posture/proof-fabric/reproducer.js +3 -0
  95. package/src/posture/proof-fabric/runner.js +20 -11
  96. package/src/posture/proof-fabric/siblings.js +77 -0
  97. package/src/posture/routing/policy-router.js +103 -0
  98. package/src/posture/rule-compiler/cli.js +64 -6
  99. package/src/posture/rule-compiler/corpus.js +10 -2
  100. package/src/posture/rule-compiler/mutation.js +72 -1
  101. package/src/posture/rule-compiler/promotion.js +21 -5
  102. package/src/posture/rule-compiler/proposal.js +132 -0
  103. package/src/posture/rule-compiler/store.js +16 -0
@@ -14,7 +14,10 @@ import { checkEligibility } from './eligibility.js';
14
14
  import { buildCorpus } from './corpus.js';
15
15
  import { gatePromotion, checkLeakage, PROMOTION_DEFAULTS } from './promotion.js';
16
16
  import { recordTransition, stateOf, historyFor } from './lifecycle.js';
17
- import { loadLedger, saveLedger, saveIR, loadIR, saveEvaluation, writeFixtures, publishCustomRule, unpublishCustomRule } from './store.js';
17
+ import { loadLedger, saveLedger, saveIR, loadIR, saveEvaluation, writeFixtures, publishCustomRule, unpublishCustomRule, saveCorpus, loadCorpus, saveSynthesis, loadSynthesis, loadProvenance, saveProvenance } from './store.js';
18
+ import { synthesizeCandidate } from './proposal.js';
19
+ import { mutatorCoverage } from './mutation.js';
20
+ import { sha256 } from '../proof-fabric/reproducer.js';
18
21
 
19
22
  function loadFinding(root, id) {
20
23
  const p = statePath(root, 'last-scan.json');
@@ -35,7 +38,8 @@ export async function cmdRules(args, root) {
35
38
  if (sub === 'promote') return cmdPromote(args, root);
36
39
  if (sub === 'status') return cmdStatus(args, root);
37
40
  if (sub === 'rollback') return cmdRollback(args, root);
38
- console.error('usage: rules synthesize <finding-id> --spec <file.json> | evaluate <rule-id> | promote <rule-id> | status [rule-id] | rollback <rule-id>');
41
+ if (sub === 'cost') return cmdCost(args, root);
42
+ 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>');
39
43
  return 4;
40
44
  }
41
45
 
@@ -51,7 +55,22 @@ async function cmdSynthesize(args, root) {
51
55
  const elig = checkEligibility(finding, { proofState: args.flags['proof-state'] || null });
52
56
  if (!elig.eligible) { console.error(`Not eligible for rule synthesis: ${elig.reasons.join('; ')}`); return 4; }
53
57
 
54
- const ir = draftRuleIR({ ...spec, originatingFindingId: finding.stableId ?? finding.id, originatingCommit: spec.originatingCommit ?? null, evidenceState: elig.evidenceState });
58
+ // The candidate pattern is the model's or the person's, when they gave one. Without one, or with --auto, the synthesis loop proposes
59
+ // from the vulnerable and patched code, scores each attempt on the real corpus and its mutations, and revises (see proposal.js).
60
+ let finalSpec = spec; let synthesis = null;
61
+ const corpusSpec = { originalCode: spec.originalCode, patchedCode: spec.patchedCode ?? null, nearbyCleanCode: spec.nearbyCleanCode ?? [], protectedVariantsCode: spec.protectedVariantsCode ?? [] };
62
+ const leak = checkLeakage(JSON.stringify(corpusSpec), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [], fileNames: spec.fileNames ?? [] });
63
+ 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) {
65
+ if (!isStr(spec.originalCode)) { console.error('Automatic proposal needs originalCode (the vulnerable snippet) in the spec.'); return 4; }
66
+ const { id, title, severity, cwe, family, languages, remediation } = spec;
67
+ 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
+ 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
+ if (!synthesis.best) { console.error('No valid candidate could be proposed from this material.'); return 1; }
70
+ finalSpec = { ...spec, match: synthesis.best.match };
71
+ 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
+ }
73
+ const ir = draftRuleIR({ ...finalSpec, originatingFindingId: finding.stableId ?? finding.id, originatingCommit: spec.originatingCommit ?? null, evidenceState: elig.evidenceState });
55
74
  const errs = validateRuleIR(ir);
56
75
  if (errs.length) { console.error(`Invalid rule: ${errs.join('; ')}`); return 4; }
57
76
 
@@ -61,6 +80,8 @@ async function cmdSynthesize(args, root) {
61
80
  const cand = recordTransition(ledger, { ruleId: ir.id, from: 'DRAFT', to: 'CANDIDATE', reason: 'eligibility passed', evidence: { eligibility: elig }, actor: args.flags.actor || 'agent' });
62
81
  saveLedger(root, ledger);
63
82
  saveIR(root, { ...ir, state: 'CANDIDATE' });
83
+ saveCorpus(root, ir.id, { ...corpusSpec, fileNames: spec.fileNames ?? [], discoveryCost: spec.discoveryCost ?? (args.flags['discovery-cost-usd'] ? { usd: Number(args.flags['discovery-cost-usd']), tokens: Number(args.flags['discovery-tokens']) || null, model: spec.model ?? null } : null) });
84
+ if (synthesis) saveSynthesis(root, ir.id, { attempts: synthesis.attempts, best: synthesis.best });
64
85
  console.log(`Rule ${ir.id}: DRAFT -> CANDIDATE (digest ${ruleIRDigest(ir).slice(0, 12)})`);
65
86
  if (!cand.ok) console.error(cand.reason);
66
87
  return 0;
@@ -71,10 +92,10 @@ async function cmdEvaluate(args, root) {
71
92
  if (!id) { console.error('usage: rules evaluate <rule-id> --corpus <file.json>'); return 4; }
72
93
  const ir = loadIR(root, id);
73
94
  if (!ir) { console.error(`No rule IR found for ${id}. Run rules synthesize first.`); return 4; }
74
- const corpusSpec = readJsonArg(args.flags.corpus);
95
+ const corpusSpec = readJsonArg(args.flags.corpus) ?? loadCorpus(root, id);
75
96
  if (!corpusSpec) { console.error('No corpus supplied. Pass --corpus <file.json> with originalCode (and optionally patchedCode/nearbyCleanCode/protectedVariantsCode).'); return 4; }
76
97
  const corpus = buildCorpus(ir, corpusSpec);
77
- const leakage = checkLeakage(JSON.stringify(corpusSpec), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [] });
98
+ const leakage = checkLeakage(JSON.stringify({ ...corpusSpec, fileNames: undefined, discoveryCost: undefined }), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [], fileNames: corpusSpec.fileNames ?? [] });
78
99
  const gate = gatePromotion({ ir, corpus, leakageCheck: leakage, thresholds: args.flags.thresholds ? readJsonArg(args.flags.thresholds) : PROMOTION_DEFAULTS });
79
100
 
80
101
  const ledger = loadLedger(root);
@@ -82,6 +103,17 @@ async function cmdEvaluate(args, root) {
82
103
  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' });
83
104
  saveLedger(root, ledger);
84
105
  saveEvaluation(root, id, gate);
106
+ const corpusHash = (list) => sha256(JSON.stringify(list.map((x) => [x.label, x.code]).sort()));
107
+ const prev = loadProvenance(root, id);
108
+ saveProvenance(root, id, {
109
+ schema: 'agentic-security/rule-provenance', version: 1, ruleId: id, ruleVersion: 1, ruleHash: ruleIRDigest(ir),
110
+ originatingFinding: ir.provenance.originatingFindingId, originatingCommit: ir.provenance.originatingCommit, evidenceState: ir.provenance.evidenceState,
111
+ generatingModel: ir.provenance.model, promptConfigVersion: ir.provenance.promptConfigVersion,
112
+ positiveCorpusHash: corpusHash(corpus.positives), negativeCorpusHash: corpusHash(corpus.negatives),
113
+ benchmarkResults: { precision: gate.evaluation.score.precision, recall: gate.evaluation.score.recall, tp: gate.evaluation.score.tp, fp: gate.evaluation.score.fp, fn: gate.evaluation.score.fn, tn: gate.evaluation.score.tn, protectedFailures: gate.evaluation.score.protectedFailures, thresholds: gate.evaluation.thresholds, gate: gate.pass ? 'passed' : 'failed', reasons: gate.reasons },
114
+ leakageCheck: leakage, mutatorCoverage: mutatorCoverage(corpusSpec.originalCode), discoveryCost: corpusSpec.discoveryCost ?? prev?.discoveryCost ?? null,
115
+ promotionEvent: prev?.promotionEvent ?? null, evaluatedAt: new Date().toISOString(),
116
+ });
85
117
  if (args.flags.json) console.log(JSON.stringify(gate, null, 2));
86
118
  else {
87
119
  console.log(`Rule ${id}: ${gate.pass ? 'VALIDATED' : 'gate FAILED'}`);
@@ -91,6 +123,7 @@ async function cmdEvaluate(args, root) {
91
123
  if (gate.pass) { const f = writeFixtures(root, ir, corpus); if (f.ok) console.log(` regression fixtures written: ${f.written.length} file(s) under .agentic-security/rule-compiler/${id}/fixtures/`); }
92
124
  return gate.pass ? 0 : 1;
93
125
  }
126
+ function isStr(v) { return typeof v === 'string' && v.length > 0; }
94
127
  const fmtPct = (n) => (n == null ? 'unmeasured' : `${(n * 100).toFixed(1)}%`);
95
128
 
96
129
  async function cmdPromote(args, root) {
@@ -105,6 +138,8 @@ async function cmdPromote(args, root) {
105
138
  if (!pub.ok) { console.error(`Promotion refused: ${pub.reason}`); return 4; }
106
139
  const t = recordTransition(ledger, { ruleId: id, from: 'VALIDATED', to: 'PROMOTED', reason: args.flags.reason || 'promoted', actor: args.flags.actor || 'operator' });
107
140
  saveLedger(root, ledger);
141
+ const prov = loadProvenance(root, id);
142
+ if (prov) saveProvenance(root, id, { ...prov, promotionEvent: { at: new Date().toISOString(), actor: args.flags.actor || 'operator', reason: args.flags.reason || 'promoted', ruleHash: ruleIRDigest(ir) } });
108
143
  console.log(`Rule ${id}: VALIDATED -> PROMOTED. Published to ${pub.file}.`);
109
144
  if (!t.ok) console.error(t.reason);
110
145
  return 0;
@@ -130,7 +165,10 @@ async function cmdStatus(args, root) {
130
165
  const h = historyFor(ledger, id);
131
166
  if (!h.length) { console.error(`No history for rule ${id}.`); return 4; }
132
167
  if (args.flags.json) console.log(JSON.stringify(h, null, 2));
133
- else { console.log(`Rule ${id}: ${stateOf(ledger, id)}`); for (const e of h) console.log(` ${e.at} ${e.from ?? '(new)'} -> ${e.to} (${e.reason || ''})`); }
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 || ''})`);
169
+ const syn = loadSynthesis(root, id);
170
+ 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
+ }
134
172
  return 0;
135
173
  }
136
174
  const ids = [...new Set(ledger.entries.map((e) => e.ruleId))];
@@ -139,3 +177,23 @@ async function cmdStatus(args, root) {
139
177
  for (const rid of ids) console.log(` ${rid.padEnd(40)} ${stateOf(ledger, rid)}`);
140
178
  return 0;
141
179
  }
180
+
181
+ // PRD 3 business metric: what the first, model-assisted discovery cost, against what every later deterministic detection costs.
182
+ // The discovery cost is whatever the agent run reported for it (recorded at synthesis); a deterministic detection makes no model
183
+ // call and is $0 of model spend. The count of later detections is read from the LAST scan only, so it is a floor, not a history.
184
+ async function cmdCost(args, root) {
185
+ const id = args._[2];
186
+ if (!id) { console.error('usage: rules cost <rule-id>'); return 4; }
187
+ const prov = loadProvenance(root, id);
188
+ if (!prov) { console.error(`No provenance for ${id}. Run rules evaluate first.`); return 4; }
189
+ let detections = null;
190
+ try { const last = JSON.parse(fs.readFileSync(statePath(root, 'last-scan.json'), 'utf8')); detections = (last.findings || []).filter((f) => String(f.id || '').startsWith(`custom:rule-compiler/${id}:`)).length; } catch { /* no scan */ }
191
+ const first = prov.discoveryCost;
192
+ const out = { ruleId: id, firstDiscovery: first ? { usd: first.usd ?? null, tokens: first.tokens ?? null, model: first.model ?? null } : null, laterDeterministicDetections: detections, laterDeterministicModelUsd: 0, state: prov.promotionEvent ? 'promoted' : 'not promoted' };
193
+ if (args.flags.json) { console.log(JSON.stringify(out, null, 2)); return 0; }
194
+ console.log(`Rule ${id} (${out.state})`);
195
+ console.log(first ? ` first discovery: ${first.usd != null ? `$${first.usd}` : 'cost not recorded'}${first.tokens ? `, ${first.tokens} tokens` : ''}${first.model ? `, ${first.model}` : ''}` : ' first discovery: cost was not recorded for this rule');
196
+ console.log(` later detections by this rule in the last scan: ${detections ?? 'no scan available'}, each at $0 of model spend`);
197
+ if (first?.usd != null && detections) console.log(` discovery cost spread over ${detections + 1} detection(s): $${(first.usd / (detections + 1)).toFixed(4)} each, falling with every further one`);
198
+ return 0;
199
+ }
@@ -33,8 +33,16 @@ export function buildCorpus(ir, { originalCode, patchedCode = null, nearbyCleanC
33
33
  negatives.push({ label: 'patched', code: patchedCode });
34
34
  for (const v of generateVariants(patchedCode)) negatives.push({ label: `patched-sibling:${v.mutator}`, code: v.code });
35
35
  }
36
- nearbyCleanCode.forEach((code, i) => negatives.push({ label: `nearby-clean-${i}`, code }));
37
- protectedVariantsCode.forEach((code, i) => negatives.push({ label: `protected-${i}`, code }));
36
+ // The safe side is mutated too (rename, wrapper, equivalent API, ALTERNATE SANITIZER, framework variant, ...): a rule keyed to one
37
+ // sanitizer's name or one framework's accessor fires on the sibling that uses another, and that is a protected-negative failure.
38
+ nearbyCleanCode.forEach((code, i) => {
39
+ negatives.push({ label: `nearby-clean-${i}`, code });
40
+ for (const v of generateVariants(code)) negatives.push({ label: `nearby-clean-${i}-sibling:${v.mutator}`, code: v.code });
41
+ });
42
+ protectedVariantsCode.forEach((code, i) => {
43
+ negatives.push({ label: `protected-${i}`, code });
44
+ for (const v of generateVariants(code)) negatives.push({ label: `protected-${i}-sibling:${v.mutator}`, code: v.code });
45
+ });
38
46
 
39
47
  return { positives, negatives };
40
48
  }
@@ -29,6 +29,7 @@ export function variableRename(code, { suffix = '_v2' } = {}) {
29
29
  }
30
30
 
31
31
  export function wrapInFunction(code, { name = 'wrapped_v2' } = {}) {
32
+ if (!String(code).trim()) return null;
32
33
  const indented = code.split('\n').map((l) => (l ? ` ${l}` : l)).join('\n');
33
34
  return `function ${name}() {\n${indented}\n}\n${name}();\n`;
34
35
  }
@@ -46,7 +47,71 @@ function insertIndirection(code) {
46
47
 
47
48
  function _hash(s) { let h = 0; for (const c of s) h = (h * 31 + c.charCodeAt(0)) | 0; return h; }
48
49
 
49
- const MUTATORS = Object.freeze({ 'variable-rename': variableRename, 'wrap-in-function': wrapInFunction, 'insert-indirection': insertIndirection });
50
+ // ---- the four mutators the PRD lists beyond rename / wrapper / indirection --------------------------------------------------
51
+ // Each is a deterministic, table-driven textual rewrite that returns null when it does not apply. Tables are deliberately
52
+ // small and conservative: an entry is here only when both sides are the same API family used the same way, so the claim "same
53
+ // vulnerability, different shape" stays true. They are applied to the vulnerable side (does the rule still fire?) and to the safe
54
+ // side (does it still decline?); the safe side is where a rule keyed to ONE sanitizer's name is caught.
55
+
56
+ // equivalent APIs: the same operation through a sibling entry point
57
+ const EQUIVALENT_APIS = Object.freeze([
58
+ [/\bchild_process\.exec\(/, 'child_process.execSync('], [/\bexecSync\(/, 'exec('], [/\bos\.system\(/, 'os.popen('],
59
+ [/\bdb\.query\(/, 'db.execute('], [/\bconnection\.query\(/, 'connection.execute('], [/\bcursor\.execute\(/, 'cursor.executemany('],
60
+ [/\bfs\.readFile\(/, 'fs.readFileSync('], [/\bfs\.readFileSync\(/, 'fs.readFile('], [/\bfs\.writeFile\(/, 'fs.writeFileSync('],
61
+ [/\bhttp\.get\(/, 'https.get('], [/\baxios\.get\(/, 'axios.request('], [/\bres\.send\(/, 'res.end('],
62
+ [/\bos\.Open\(/, 'os.OpenFile('], [/\bsubprocess\.call\(/, 'subprocess.run('], [/\bsubprocess\.run\(/, 'subprocess.call('],
63
+ ]);
64
+ export function equivalentApi(code) {
65
+ for (const [from, to] of EQUIVALENT_APIS) if (from.test(code)) return code.replace(from, to);
66
+ return null;
67
+ }
68
+
69
+ // alternate sanitizers: a different, equally valid way to neutralize the same input
70
+ const ALTERNATE_SANITIZERS = Object.freeze([
71
+ [/\bescapeHtml\(/, 'validator.escape('], [/\bvalidator\.escape\(/, 'escapeHtml('], [/\bDOMPurify\.sanitize\(/, 'sanitizeHtml('], [/\bsanitizeHtml\(/, 'DOMPurify.sanitize('],
72
+ [/\bshlex\.quote\(/, 'pipes.quote('], [/\bhtml\.escape\(/, 'markupsafe.escape('], [/\bhtml\.EscapeString\(/, 'template.HTMLEscapeString('],
73
+ [/\bencodeURIComponent\(/, 'encodeURI('], [/\bpath\.basename\(/, 'path.parse('], [/\bparseInt\(/, 'Number('], [/\bNumber\(/, 'parseInt('],
74
+ [/\bstrconv\.Atoi\(/, 'strconv.ParseInt('], [/\bint\(/, 'abs('],
75
+ ]);
76
+ export function alternateSanitizer(code) {
77
+ for (const [from, to] of ALTERNATE_SANITIZERS) if (from.test(code)) return code.replace(from, to);
78
+ return null;
79
+ }
80
+
81
+ // framework variants: the same untrusted input reached through another framework's accessor
82
+ const FRAMEWORK_VARIANTS = Object.freeze([
83
+ [/\breq\.body\./g, 'ctx.request.body.'], [/\breq\.query\./g, 'ctx.query.'], [/\breq\.params\./g, 'ctx.params.'],
84
+ [/\brequest\.args\.get\(/g, 'request.form.get('], [/\brequest\.form\.get\(/g, 'request.args.get('], [/\$_GET\[/g, '$_REQUEST['], [/\$_POST\[/g, '$_REQUEST['],
85
+ [/\br\.URL\.Query\(\)\.Get\(/g, 'r.FormValue('], [/\brequest\.getParameter\(/g, 'request.getHeader('],
86
+ ]);
87
+ export function frameworkVariant(code) {
88
+ for (const [from, to] of FRAMEWORK_VARIANTS) if (from.test(code)) { from.lastIndex = 0; return code.replace(from, to); }
89
+ return null;
90
+ }
91
+
92
+ // reordering: swap the first pair of ADJACENT simple statements that share no identifier, so neither feeds the other. This is a
93
+ // syntactic independence test, not a data-flow proof; it is applied to rule-corpus snippets, where a swap of two unrelated
94
+ // statements is what a different author's version of the same code would plausibly look like.
95
+ const CONTROL = /^\s*(?:if|else|for|while|switch|case|return|try|catch|finally|do|break|continue|throw|function|def|class|import|from|package|\}|\{|@|\/\/|#)\b|^\s*[}{]\s*$/;
96
+ export function reorderStatements(code) {
97
+ const lines = code.split('\n');
98
+ const simple = (l) => l.trim() && !CONTROL.test(l) && /[;)]\s*$|^[^=]*=[^=].*[^{]$/.test(l.trim()) && !/[{]\s*$/.test(l);
99
+ const ids = (l) => new Set([...l.matchAll(/\b[A-Za-z_][A-Za-z0-9_]*\b/g)].map((m) => m[0]).filter((x) => !RESERVED.has(x)));
100
+ for (let i = 0; i + 1 < lines.length; i++) {
101
+ if (!simple(lines[i]) || !simple(lines[i + 1])) continue;
102
+ const a = ids(lines[i]); const b = ids(lines[i + 1]);
103
+ if ([...a].some((x) => b.has(x))) continue;
104
+ const out = [...lines]; [out[i], out[i + 1]] = [out[i + 1], out[i]];
105
+ return out.join('\n');
106
+ }
107
+ return null;
108
+ }
109
+
110
+ const MUTATORS = Object.freeze({
111
+ 'variable-rename': variableRename, 'wrap-in-function': wrapInFunction, 'insert-indirection': insertIndirection,
112
+ 'equivalent-api': equivalentApi, 'reorder-statements': reorderStatements, 'alternate-sanitizer': alternateSanitizer, 'framework-variant': frameworkVariant,
113
+ });
114
+ export const MUTATOR_NAMES = Object.freeze(Object.keys(MUTATORS));
50
115
 
51
116
  /** Every mutator applied to `code`; mutators that don't apply (return null) are omitted, never silently skipped without saying so. */
52
117
  export function generateVariants(code) {
@@ -58,3 +123,9 @@ export function generateVariants(code) {
58
123
  }
59
124
  return out;
60
125
  }
126
+
127
+ /** Which of the seven mutators produced a variant for this code, and which did not (so a report can say what was not exercised). */
128
+ export function mutatorCoverage(code) {
129
+ const applied = new Set(generateVariants(code).map((v) => v.mutator));
130
+ return { applied: MUTATOR_NAMES.filter((n) => applied.has(n)), notApplicable: MUTATOR_NAMES.filter((n) => !applied.has(n)) };
131
+ }
@@ -13,14 +13,30 @@ export const PROMOTION_DEFAULTS = Object.freeze({ minPrecision: 0.95, minRecall:
13
13
  // checker rather than reaching across that isolation boundary.
14
14
  const ADVISORY_ID = /\b(?:CVE|GHSA|CWE)-[A-Za-z0-9-]+\b/i;
15
15
 
16
- /** Anti-cheating: the synthesis agent must never see benchmark answer keys, hidden labels, CWE-bearing hidden
17
- * filenames or vulnerability-comment metadata. */
18
- export function checkLeakage(promptText, { benchmarkNames = [] } = {}) {
16
+ // What a synthesis agent must never see (PRD 3, anti-cheating): benchmark answer keys, hidden test labels, CWE-bearing hidden file
17
+ // names, vulnerability comments and protected test metadata. These defaults are always applied; a caller adds its own benchmark names.
18
+ const DEFAULT_BENCHMARK_NAMES = Object.freeze(['owasp benchmark', 'juliet', 'sard test', 'cve-replay', 'corpus-baseline', 'answer key', 'ground truth', 'adjudicated', 'hidden test', 'expectedresults']);
19
+ const CWE_FILENAME = /(^|[\\/])[^\\/]*cwe[-_]?\d+[^\\/]*$/i;
20
+ const ANSWER_KEY_FILENAME = /(^|[\\/])(?:labels?|answers?|expected|ground[-_]?truth|answer[-_]?key|oracle|result)[^\\/]*\.(?:json|jsonl|ya?ml|csv|txt)$/i;
21
+ const VULNERABILITY_COMMENT = /(?:\/\/|#|\/\*|^\s*\*)\s*(?:POTENTIAL FLAW|FLAW\b|INCIDENTAL|BAD\b|GOOD\b|vulnerab\w*|insecure|exploit\w*)/im;
22
+
23
+ /**
24
+ * Anti-cheating gate. Fails closed: any hit makes `ok` false, and the synthesis loop will not report a pass.
25
+ * @param {string} promptText everything the synthesis agent would be shown (corpus code, spec text)
26
+ * @param {{benchmarkNames?: string[], fileNames?: string[]}} opts
27
+ */
28
+ export function checkLeakage(promptText, { benchmarkNames = [], fileNames = [] } = {}) {
19
29
  const hay = String(promptText ?? '');
30
+ const lower = hay.toLowerCase();
20
31
  const offending = [];
21
- for (const name of benchmarkNames) if (name && hay.toLowerCase().includes(String(name).toLowerCase())) offending.push(name);
32
+ for (const name of [...DEFAULT_BENCHMARK_NAMES, ...benchmarkNames]) if (name && lower.includes(String(name).toLowerCase())) offending.push(`benchmark name: ${name}`);
22
33
  const m = ADVISORY_ID.exec(hay);
23
- if (m) offending.push(m[0]);
34
+ if (m) offending.push(`identifier: ${m[0]}`);
35
+ if (VULNERABILITY_COMMENT.test(hay)) offending.push('a comment that describes the vulnerability');
36
+ for (const f of fileNames) {
37
+ if (CWE_FILENAME.test(String(f))) offending.push(`weakness-bearing file name: ${f}`);
38
+ if (ANSWER_KEY_FILENAME.test(String(f))) offending.push(`answer-key file name: ${f}`);
39
+ }
24
40
  return { ok: offending.length === 0, offendingTerms: offending };
25
41
  }
26
42
 
@@ -0,0 +1,132 @@
1
+ // The synthesis loop for the rule compiler (PRD 3, requirement 5): propose, compile, test positives, test negatives, mutation test,
2
+ // score, revise, repeat with bounded attempts, emit a candidate.
3
+ //
4
+ // "Models propose rules. Evidence promotes rules." A model's proposal (a `match` in the spec) is tried first when there is one.
5
+ // When there is none, or it fails, this module proposes deterministically from the two pieces of evidence it has: the vulnerable
6
+ // code and its patched twin. The lines that exist only in the vulnerable version say what the flaw looks like; the calls that exist
7
+ // only in the patched version say what a safe version looks like (and become the `notMatch` that keeps the rule quiet on safe files).
8
+ // No model is consulted here and nothing here promotes a rule: the loop returns attempts and a best candidate, and promotion stays
9
+ // with the gate and the lifecycle ledger.
10
+ //
11
+ // Every attempt is scored against the real corpus (positives with their mutations, negatives with theirs) by the same matcher the
12
+ // rule will run on once promoted. An attempt that does not clear the gate is revised from WHY it failed: low recall generalizes,
13
+ // a negative that fires adds the safe version's names to `notMatch`.
14
+ import { draftRuleIR, validateRuleIR } from './ir.js';
15
+ import { buildCorpus } from './corpus.js';
16
+ import { evaluateRule } from './promotion.js';
17
+
18
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&');
19
+ const KEYWORDS = new Set(['if', 'else', 'for', 'while', 'return', 'function', 'const', 'let', 'var', 'new', 'await', 'async', 'try', 'catch', 'throw', 'typeof', 'def', 'class', 'import', 'from', 'with', 'in', 'of', 'and', 'or', 'not', 'is', 'func', 'fn', 'public', 'private', 'static', 'void', 'int', 'string']);
20
+ const SOURCES = [
21
+ String.raw`req(?:uest)?\.(?:body|query|params|headers|cookies)\b`, String.raw`ctx\.(?:query|params|request\.body)\b`, String.raw`request\.(?:args|form|values|json|GET|POST)\b`,
22
+ String.raw`\$_(?:GET|POST|REQUEST|COOKIE)\b`, String.raw`process\.argv\b`, String.raw`os\.Args\b`, String.raw`\bgetParameter\s*\(`, String.raw`r\.(?:URL\.Query\(\)|FormValue)\b`,
23
+ ];
24
+
25
+ const meaningful = (l) => { const t = l.trim(); return t.length >= 8 && !/^(?:\/\/|#|\/\*|\*|[{}()\];,]+$)/.test(t); };
26
+
27
+ /** Lines (trimmed) present only in `a`. */
28
+ function onlyIn(a, b) {
29
+ const bs = new Set(String(b ?? '').split('\n').map((l) => l.trim()));
30
+ return String(a ?? '').split('\n').filter((l) => meaningful(l) && !bs.has(l.trim())).map((l) => l.trim());
31
+ }
32
+
33
+ /** Call names (a.b.c) in some code, in order of appearance, without keywords. */
34
+ function callNames(code) {
35
+ const out = [];
36
+ for (const m of String(code).matchAll(/\b([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/g)) if (!KEYWORDS.has(m[1]) && !out.includes(m[1])) out.push(m[1]);
37
+ return out;
38
+ }
39
+
40
+ /** A regular expression for one source line: the call names stay, strings and the other identifiers become classes. */
41
+ export function generalizeLine(line) {
42
+ const keep = new Set(callNames(line));
43
+ let out = ''; let i = 0;
44
+ while (i < line.length) {
45
+ const c = line[i];
46
+ if (/\s/.test(c)) { const before = line[i - 1] ?? ''; while (/\s/.test(line[i] ?? '')) i++; out += /[\w$]/.test(before) && /[\w$]/.test(line[i] ?? '') ? String.raw`\s+` : String.raw`\s*`; continue; }
47
+ if (c === '"' || c === "'" || c === '`') { const q = c; let j = i + 1; while (j < line.length && line[j] !== q) j += line[j] === '\\' ? 2 : 1; out += q === '`' ? '`[^`]*`' : `(?:'[^']*'|"[^"]*")`; i = j + 1; continue; }
48
+ const id = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/.exec(line.slice(i));
49
+ if (id) { const tok = id[0]; const isCall = line[i + tok.length] === '(' || keep.has(tok); out += isCall || KEYWORDS.has(tok) ? esc(tok) : String.raw`[A-Za-z_$][\w$.]*`; i += tok.length; continue; }
50
+ const num = /^\d+(?:\.\d+)?/.exec(line.slice(i));
51
+ if (num) { out += String.raw`\d+(?:\.\d+)?`; i += num[0].length; continue; }
52
+ out += esc(c); i++;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ const sinkOf = (line) => callNames(line).filter((n) => !/^(?:console|logger|log)\b/.test(n))[0] ?? null;
58
+ const interpolation = String.raw`(?:\+|\$\{|%s|%d|\.format\s*\(|\bf["']|\|\|)`;
59
+
60
+ /** Ordered proposals, most specific first. Each is {strategy, match}; `match.notMatch` is added by `withSafeNames`. */
61
+ export function proposeCandidates({ vulnerableCode, patchedCode = null }) {
62
+ const vuln = onlyIn(vulnerableCode, patchedCode);
63
+ const lines = vuln.length ? vuln : String(vulnerableCode).split('\n').filter(meaningful).map((l) => l.trim()).filter((l) => callNames(l).length);
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 }); } };
66
+ for (const l of lines.slice(0, 3)) add('line-shape', { pattern: generalizeLine(l) });
67
+ for (const l of lines.slice(0, 3)) {
68
+ const sink = sinkOf(l);
69
+ if (sink) add('sink-with-interpolation', { pattern: `${esc(sink)}\\s*\\([^)\\n]*${interpolation}` });
70
+ }
71
+ const sink = lines.map(sinkOf).find(Boolean);
72
+ const src = SOURCES.find((s) => new RegExp(s).test(vulnerableCode));
73
+ if (sink && src) add('source-and-sink', { allOf: [`${esc(sink)}\\s*\\(`, src], window: 6 });
74
+ if (sink) add('sink-call', { pattern: `${esc(sink)}\\s*\\(` });
75
+ return out;
76
+ }
77
+
78
+ /** Names that appear in the patched code and not in the vulnerable code: the safe version's vocabulary. */
79
+ export function safeNames({ vulnerableCode, patchedCode }) {
80
+ if (!patchedCode) return [];
81
+ const v = new Set(callNames(vulnerableCode));
82
+ const names = callNames(patchedCode).filter((n) => !v.has(n));
83
+ if (/\?['"`]?\s*[,)]/.test(patchedCode) && !/\?['"`]?\s*[,)]/.test(vulnerableCode)) names.push('?');
84
+ return names;
85
+ }
86
+
87
+ const PLACEHOLDER_RE = '\\?[\'"`]?\\s*[,)]';
88
+ const safeRegex = (names) => (names.length ? names.map((n) => (n === '?' ? PLACEHOLDER_RE : esc(n))).join('|') : null);
89
+
90
+ const summary = (score) => ({ precision: score.precision, recall: score.recall, tp: score.tp, fp: score.fp, fn: score.fn, tn: score.tn, protectedFailures: score.protectedFailures });
91
+ const quality = (e) => (e.pass ? 10 : 0) + (e.score.precision ?? 0) * (e.score.recall ?? 0) - e.score.protectedFailures * 0.5;
92
+
93
+ /**
94
+ * @param {object} o
95
+ * @param {object} o.base the IR fields that are not the match: id, title, severity, cwe, family, languages, remediation, evidence
96
+ * @param {object} o.corpusSpec { originalCode, patchedCode?, nearbyCleanCode?, protectedVariantsCode? }
97
+ * @param {object|null} o.proposed a match supplied by a model or a person, tried first
98
+ * @returns {{attempts: object[], best: object|null, exhausted: boolean}}
99
+ */
100
+ export function synthesizeCandidate({ base, corpusSpec, proposed = null, maxAttempts = 6, thresholds, leakageOk = true }) {
101
+ const cap = Math.max(1, Math.min(12, maxAttempts));
102
+ const queue = [];
103
+ if (proposed) queue.push({ strategy: 'proposed', match: proposed });
104
+ for (const p of proposeCandidates({ vulnerableCode: corpusSpec.originalCode, patchedCode: corpusSpec.patchedCode })) queue.push(p);
105
+ const names = safeNames({ vulnerableCode: corpusSpec.originalCode, patchedCode: corpusSpec.patchedCode });
106
+ const attempts = []; const tried = new Set();
107
+ const tryOne = (strategy, match) => {
108
+ const key = JSON.stringify(match);
109
+ if (tried.has(key) || attempts.length >= cap) return null;
110
+ tried.add(key);
111
+ const ir = draftRuleIR({ ...base, match });
112
+ 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; }
114
+ const corpus = buildCorpus(ir, corpusSpec);
115
+ 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 } };
117
+ attempts.push(rec);
118
+ return { rec, ev };
119
+ };
120
+ for (let qi = 0; qi < queue.length && attempts.length < cap; qi++) {
121
+ const first = tryOne(queue[qi].strategy, queue[qi].match);
122
+ if (!first) continue;
123
+ if (first.rec.pass) break;
124
+ // revise from the failure: a negative that fired means the safe version is not excluded yet
125
+ const fired = first.ev.score.negatives.filter((n) => n.fired);
126
+ 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; }
128
+ }
129
+ const valid = attempts.filter((a) => a.valid);
130
+ 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) };
132
+ }
@@ -41,6 +41,22 @@ export function loadIR(scanRoot, id) {
41
41
  try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; }
42
42
  }
43
43
 
44
+ function saveJson(scanRoot, id, name, doc) {
45
+ if (!ensureStateDir(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
46
+ const f = path.join(ruleDir(scanRoot, id), name);
47
+ return { ok: safeWriteState(f, JSON.stringify(doc, null, 2)), file: f };
48
+ }
49
+ function loadJson(scanRoot, id, name) { try { return JSON.parse(fs.readFileSync(path.join(ruleDir(scanRoot, id), name), 'utf8')); } catch { return null; } }
50
+ /** The corpus a rule was synthesized on, kept so `rules evaluate <id>` needs no flag and the evidence is the same corpus. */
51
+ export const saveCorpus = (r, id, spec) => saveJson(r, id, 'corpus.json', spec);
52
+ export const loadCorpus = (r, id) => loadJson(r, id, 'corpus.json');
53
+ /** Every synthesis attempt: strategy, match, score and why it did not pass. */
54
+ export const saveSynthesis = (r, id, syn) => saveJson(r, id, 'synthesis.json', syn);
55
+ export const loadSynthesis = (r, id) => loadJson(r, id, 'synthesis.json');
56
+ /** The rule's provenance record (PRD 3 requirement 7). */
57
+ export const saveProvenance = (r, id, doc) => saveJson(r, id, 'provenance.json', doc);
58
+ export const loadProvenance = (r, id) => loadJson(r, id, 'provenance.json');
59
+
44
60
  export function saveEvaluation(scanRoot, id, evaluation) {
45
61
  if (!ensureStateDir(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
46
62
  const f = path.join(ruleDir(scanRoot, id), 'evaluation.json');