@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.
- package/CHANGELOG.md +9 -0
- package/bin/agentic-security.js +9 -2
- package/dist/1919.index.js +1 -1
- package/dist/2066.index.js +2 -1
- package/dist/2584.index.js +19 -1
- package/dist/{4978bec8fdf5c733dc85.json → 37d64df006576248d0d8.json} +5 -2
- package/dist/5456.index.js +291 -0
- package/dist/5620.index.js +147 -23
- package/dist/5937.index.js +672 -0
- package/dist/7838.index.js +70 -25
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{df1e8683e7eda7b2b79c.js → b4289e728659450b5e19.js} +4 -1
- package/package.json +5 -2
- package/src/posture/custom-rules.js +54 -8
- package/src/posture/dnr/exploit-correlation.js +25 -0
- package/src/posture/evaluation/lab/dataset.js +1 -1
- package/src/posture/evaluation/lab/report.js +19 -1
- package/src/posture/fleet/cli.js +22 -10
- package/src/posture/fleet/enterprise-graph.js +44 -3
- package/src/posture/fleet/job-runner.js +7 -7
- package/src/posture/fleet/job-store.js +3 -1
- package/src/posture/fleet/lineage-summary.js +55 -0
- package/src/posture/fleet/scan-child.js +4 -1
- package/src/posture/proof-fabric/generate.js +2 -1
- package/src/posture/proof-fabric/runner.js +24 -2
- package/src/posture/rule-compiler/cli.js +40 -6
- package/src/posture/rule-compiler/corpus.js +3 -7
- package/src/posture/rule-compiler/ir.js +52 -9
- package/src/posture/rule-compiler/proposal.js +14 -9
- package/src/posture/rule-compiler/regression.js +95 -0
- package/src/posture/rule-compiler/store.js +29 -1
- package/src/posture/rule-pack-signing.js +4 -2
package/dist/7838.index.js
CHANGED
|
@@ -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__(
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
141
|
-
// never
|
|
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]
|
|
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: {
|
|
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
|
|
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 && !
|
|
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
|
|