@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
@@ -11,7 +11,7 @@ export const modules = {
11
11
  /* harmony export */ });
12
12
  /* harmony import */ var _mutation_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(85127);
13
13
  /* harmony import */ var _ir_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(87539);
14
- /* harmony import */ var _custom_rules_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(9049);
14
+ /* harmony import */ var _custom_rules_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(69400);
15
15
  // Automatic corpus creation and scoring for ONE candidate rule.
16
16
  //
17
17
  // Positive corpus: the original vulnerable snippet plus its generated sibling variants (mutation.js).
@@ -25,15 +25,11 @@ export const modules = {
25
25
 
26
26
 
27
27
 
28
+ /** The rule exactly as the scanner would load it: through the loader's own normalizer, not a second implementation. */
28
29
  function toRuntimeRule(ir) {
29
30
  const { ok, doc, errs } = (0,_ir_js__WEBPACK_IMPORTED_MODULE_1__/* .compileToCustomRule */ .Mo)(ir);
30
31
  if (!ok) throw new Error(`cannot build corpus for an invalid rule IR: ${errs.join('; ')}`);
31
- const regexes = doc.match.allOf ? doc.match.allOf.map((p) => new RegExp(p, 'g')) : [new RegExp(doc.match.pattern, 'g')];
32
- return {
33
- id: doc.id, title: doc.title, severity: doc.severity, cwe: doc.cwe, languages: doc.languages, message: doc.message,
34
- regexes, requireAll: !!doc.match.allOf, windowLines: doc.match.window ?? 5,
35
- notMatch: doc.match.notMatch ? new RegExp(doc.match.notMatch) : null,
36
- };
32
+ return (0,_custom_rules_js__WEBPACK_IMPORTED_MODULE_2__/* .normalizeCustomRule */ .Lj)(doc);
37
33
  }
38
34
 
39
35
  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' };
@@ -96,14 +92,26 @@ function scoreRule(ir, corpus) {
96
92
  /* harmony export */ pc: () => (/* binding */ draftRuleIR)
97
93
  /* harmony export */ });
98
94
  /* harmony import */ var _proof_fabric_reproducer_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(37055);
95
+ /* harmony import */ var _custom_rules_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(69400);
99
96
  // Rule intermediate representation: compiles DOWN into the existing pattern-rule engine
100
97
  // (`../custom-rules.js`), never a parallel matcher. "Compile" here means "emit a
101
98
  // custom-rules.js-compatible document" — detection at runtime is 100% the already-proven
102
99
  // regex/allOf/notMatch/window engine; this module only adds PROVENANCE metadata around it.
103
100
  //
104
101
  // A rule's match semantics are deliberately the same closed vocabulary custom-rules.js
105
- // already supports (pattern | allOf+window | notMatch) — no new matching primitive is
106
- // introduced, so a promoted rule is exactly as auditable as a hand-written one.
102
+ // already supports (pattern | allOf+window | notMatch, plus the structural fields below): no
103
+ // separate matching engine is introduced, so a promoted rule is exactly as auditable as a hand-written one.
104
+ //
105
+ // The structural fields are REAL, not annotations: each is a regular expression and each compiles to a constraint the
106
+ // runtime matcher enforces.
107
+ // source, propagator, sink together they become a windowed co-occurrence (allOf) anchored on the SINK; a sink alone is the pattern
108
+ // sanitizer, dominatingGuard a hit is dropped when either matches within `guardWindow` lines before (or on) it
109
+ // frameworkConstraint a file-level precondition (the file must contain it, wherever it is)
110
+ // exclusions a hit is dropped when its own line matches one
111
+ // languages the rule only runs on files of those languages
112
+ // 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
113
+ // compile to, and a field the matcher ignored would be the old mistake (an annotation that looks like a constraint).
114
+
107
115
 
108
116
 
109
117
  const RULE_IR_SCHEMA = 'agentic-security/rule-ir';
@@ -126,8 +134,9 @@ function validateRuleIR(ir) {
126
134
  if (!isStr(ir.family)) errs.push('family required');
127
135
  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');
128
136
  const m = ir.match;
129
- if (!m || typeof m !== 'object') errs.push('match required');
130
- else {
137
+ if (m == null && !isStr(ir.sink)) errs.push('match required (or at least a sink, from which one is derived)');
138
+ else if (m != null && typeof m !== 'object') errs.push('match must be an object');
139
+ else if (m) {
131
140
  const hasPattern = isStr(m.pattern);
132
141
  const hasAllOf = Array.isArray(m.allOf) && m.allOf.length > 0 && m.allOf.every(isStr);
133
142
  if (!hasPattern && !hasAllOf) errs.push('match needs a pattern or a non-empty allOf');
@@ -137,12 +146,20 @@ function validateRuleIR(ir) {
137
146
  try { new RegExp(re); } catch { errs.push(`invalid regex: ${re}`); }
138
147
  }
139
148
  }
140
- // Declarative-only hint fields (sink/source/propagator/sanitizer/dominatingGuard/frameworkConstraint): free text,
141
- // never executable, carried for provenance/review only and NOT consulted by the matcher.
149
+ // Structural fields: each is a regular expression the matcher enforces (see the header). Free text is rejected, so a field can
150
+ // never look like a constraint while being ignored.
142
151
  for (const k of ['sink', 'source', 'propagator', 'sanitizer', 'dominatingGuard', 'frameworkConstraint']) {
143
- if (ir[k] != null && !isStr(ir[k])) errs.push(`${k} must be a string when present`);
152
+ if (ir[k] == null) continue;
153
+ if (!isStr(ir[k])) { errs.push(`${k} must be a regular-expression string when present`); continue; }
154
+ try { new RegExp(ir[k]); } catch { errs.push(`${k} is not a valid regular expression`); }
155
+ }
156
+ if ((ir.source || ir.propagator) && !ir.sink && !ir.match) errs.push('a source or propagator needs a sink');
157
+ if (ir.window != null && !(Number.isInteger(ir.window) && ir.window > 0 && ir.window <= 200)) errs.push('window must be an integer in 1..200');
158
+ if (ir.guardWindow != null && !(Number.isInteger(ir.guardWindow) && ir.guardWindow > 0 && ir.guardWindow <= 200)) errs.push('guardWindow must be an integer in 1..200');
159
+ if (ir.exclusions != null) {
160
+ if (!(Array.isArray(ir.exclusions) && ir.exclusions.every(isStr))) errs.push('exclusions must be an array of regular-expression strings');
161
+ else for (const re of ir.exclusions) { try { new RegExp(re); } catch { errs.push(`invalid exclusion regex: ${re}`); } }
144
162
  }
145
- if (ir.exclusions != null && !(Array.isArray(ir.exclusions) && ir.exclusions.every(isStr))) errs.push('exclusions must be an array of strings');
146
163
  if (!ir.provenance || typeof ir.provenance !== 'object') errs.push('provenance required');
147
164
  else {
148
165
  if (!isStr(ir.provenance.originatingFindingId)) errs.push('provenance.originatingFindingId required');
@@ -172,15 +189,38 @@ function draftRuleIR({ id, title, severity, cwe, family, languages, match, remed
172
189
  function compileToCustomRule(ir) {
173
190
  const errs = validateRuleIR(ir);
174
191
  if (errs.length) return { ok: false, errs };
192
+ const alt = (...parts) => { const p = parts.filter(isStr); return p.length ? p.map((x) => `(?:${x})`).join('|') : null; };
193
+ // the base match: explicit wins; otherwise source -> propagator -> sink as a windowed co-occurrence, or a sink alone
194
+ let base = ir.match;
195
+ if (!base) {
196
+ // the SINK leads: the matcher anchors on the first pattern, and the finding, the guard window and the line exclusions all belong at
197
+ // the sink, where the flaw manifests. The window is symmetric, so the order of the others does not affect co-occurrence.
198
+ const chain = [ir.sink, ir.propagator, ir.source].filter(isStr);
199
+ base = chain.length > 1 ? { allOf: chain, window: ir.window ?? 8 } : { pattern: ir.sink };
200
+ }
201
+ const guard = alt(ir.sanitizer, ir.dominatingGuard);
175
202
  const doc = {
176
203
  id: `rule-compiler/${ir.id}`,
177
204
  title: ir.title,
178
205
  severity: ir.severity,
179
206
  cwe: ir.cwe,
180
207
  languages: ir.languages,
181
- 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 } : {}) },
208
+ match: {
209
+ ...(base.pattern ? { pattern: base.pattern } : {}),
210
+ ...(base.allOf ? { allOf: base.allOf, window: base.window ?? 5 } : {}),
211
+ ...(base.notMatch ? { notMatch: base.notMatch } : {}),
212
+ ...(isStr(ir.frameworkConstraint) ? { fileRequires: [ir.frameworkConstraint] } : {}),
213
+ ...(guard ? { guardedBy: guard, guardWindow: ir.guardWindow ?? base.window ?? 5 } : {}),
214
+ ...(Array.isArray(ir.exclusions) && ir.exclusions.length ? { excludeLine: ir.exclusions } : {}),
215
+ },
182
216
  message: ir.remediation || `${ir.title} (${ir.cwe}, compiled from ${ir.provenance.originatingFindingId})`,
183
217
  };
218
+ // The loader has its own safety checks (nested quantifiers and so on). A rule it would refuse must not be reported as compiled,
219
+ // or it would score well here and silently never fire in a scan.
220
+ const origError = console.error; let loaderSaid = '';
221
+ console.error = (...a) => { loaderSaid = a.join(' '); };
222
+ let normalized; try { normalized = (0,_custom_rules_js__WEBPACK_IMPORTED_MODULE_1__/* .normalizeCustomRule */ .Lj)(doc); } finally { console.error = origError; }
223
+ if (!normalized) return { ok: false, errs: [`the scanner's rule loader would reject this rule${loaderSaid ? `: ${loaderSaid.replace(/^agentic-security:\s*/, '').slice(0, 160)}` : ''}`] };
184
224
  return { ok: true, doc };
185
225
  }
186
226
 
@@ -482,7 +522,7 @@ function proposeCandidates({ vulnerableCode, patchedCode = null }) {
482
522
  const vuln = onlyIn(vulnerableCode, patchedCode);
483
523
  const lines = vuln.length ? vuln : String(vulnerableCode).split('\n').filter(meaningful).map((l) => l.trim()).filter((l) => callNames(l).length);
484
524
  const out = []; const seen = new Set();
485
- const add = (strategy, match) => { const k = JSON.stringify(match); if (!seen.has(k)) { seen.add(k); out.push({ strategy, match }); } };
525
+ 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 } : {}) }); } };
486
526
  for (const l of lines.slice(0, 3)) add('line-shape', { pattern: generalizeLine(l) });
487
527
  for (const l of lines.slice(0, 3)) {
488
528
  const sink = sinkOf(l);
@@ -491,6 +531,9 @@ function proposeCandidates({ vulnerableCode, patchedCode = null }) {
491
531
  const sink = lines.map(sinkOf).find(Boolean);
492
532
  const src = SOURCES.find((s) => new RegExp(s).test(vulnerableCode));
493
533
  if (sink && src) add('source-and-sink', { allOf: [`${esc(sink)}\\s*\\(`, src], window: 6 });
534
+ // the same evidence as an explicit structure: a source and a sink the matcher enforces, so a sanitizer found in the patched code can
535
+ // later be added as a LOCAL guard (dropping one hit) instead of a file-level exclusion (silencing the whole file)
536
+ if (sink && src) add('structural', null, { source: src, sink: `${esc(sink)}\\s*\\(`, window: 6 });
494
537
  if (sink) add('sink-call', { pattern: `${esc(sink)}\\s*\\(` });
495
538
  return out;
496
539
  }
@@ -524,31 +567,33 @@ function synthesizeCandidate({ base, corpusSpec, proposed = null, maxAttempts =
524
567
  for (const p of proposeCandidates({ vulnerableCode: corpusSpec.originalCode, patchedCode: corpusSpec.patchedCode })) queue.push(p);
525
568
  const names = safeNames({ vulnerableCode: corpusSpec.originalCode, patchedCode: corpusSpec.patchedCode });
526
569
  const attempts = []; const tried = new Set();
527
- const tryOne = (strategy, match) => {
528
- const key = JSON.stringify(match);
570
+ const tryOne = (strategy, match, structure = null) => {
571
+ const key = JSON.stringify([match, structure]);
529
572
  if (tried.has(key) || attempts.length >= cap) return null;
530
573
  tried.add(key);
531
- const ir = (0,_ir_js__WEBPACK_IMPORTED_MODULE_0__/* .draftRuleIR */ .pc)({ ...base, match });
574
+ const ir = (0,_ir_js__WEBPACK_IMPORTED_MODULE_0__/* .draftRuleIR */ .pc)({ ...base, ...(match ? { match } : {}), ...(structure ?? {}) });
532
575
  const errs = (0,_ir_js__WEBPACK_IMPORTED_MODULE_0__/* .validateRuleIR */ .g)(ir);
533
- if (errs.length) { attempts.push({ n: attempts.length + 1, strategy, match, valid: false, errors: errs, pass: false, reasons: errs }); return null; }
576
+ if (errs.length) { attempts.push({ n: attempts.length + 1, strategy, match: match ?? null, structure, valid: false, errors: errs, pass: false, reasons: errs }); return null; }
534
577
  const corpus = (0,_corpus_js__WEBPACK_IMPORTED_MODULE_1__/* .buildCorpus */ .F)(ir, corpusSpec);
535
578
  const ev = (0,_promotion_js__WEBPACK_IMPORTED_MODULE_2__/* .evaluateRule */ .kx)(ir, corpus, thresholds);
536
- 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 } };
579
+ 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 } };
537
580
  attempts.push(rec);
538
581
  return { rec, ev };
539
582
  };
540
583
  for (let qi = 0; qi < queue.length && attempts.length < cap; qi++) {
541
- const first = tryOne(queue[qi].strategy, queue[qi].match);
584
+ const q = queue[qi];
585
+ const first = tryOne(q.strategy, q.match, q.structure);
542
586
  if (!first) continue;
543
587
  if (first.rec.pass) break;
544
588
  // revise from the failure: a negative that fired means the safe version is not excluded yet
545
589
  const fired = first.ev.score.negatives.filter((n) => n.fired);
546
590
  const rx = safeRegex(names);
547
- 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; }
591
+ 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; }
592
+ 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; }
548
593
  }
549
594
  const valid = attempts.filter((a) => a.valid);
550
595
  const best = valid.length ? [...valid].sort((a, b) => quality({ pass: b.pass, score: b.score }) - quality({ pass: a.pass, score: a.score }))[0] : null;
551
- 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) };
596
+ 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) };
552
597
  }
553
598
 
554
599