@clear-capabilities/agentic-security-scanner 0.136.9 → 0.137.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 +644 -0
- package/bin/agentic-security.js +3 -0
- package/dist/435.index.js +9 -1
- package/dist/agentic-security.mjs +3 -3
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +8 -8
- package/src/dataflow/CLAUDE.md +3 -1
- package/src/dataflow/catalog-expanded.js +1 -0
- package/src/dataflow/catalog.js +157 -31
- package/src/dataflow/engine.js +318 -55
- package/src/dataflow/index.js +15 -0
- package/src/dataflow/points-to.js +19 -6
- package/src/engine.js +281 -207
- package/src/ir/CLAUDE.md +14 -3
- package/src/ir/class-hierarchy.js +57 -11
- package/src/ir/index.js +14 -2
- package/src/ir/parser-cs.js +451 -31
- package/src/ir/parser-java.js +205 -2
- package/src/ir/parser-js.js +149 -2
- package/src/ir/parser-kt.js +436 -18
- package/src/ir/parser-php.js +587 -29
- package/src/ir/parser-py.helper.py +32 -2
- package/src/ir/parser-py.js +31 -4
- package/src/ir/parser-rb.js +124 -19
- package/src/lsp/server.js +7 -1
- package/src/mcp/tools.js +9 -1
- package/src/posture/clustering.js +12 -1
- package/src/posture/sbom.js +2 -2
package/src/engine.js
CHANGED
|
@@ -1271,13 +1271,75 @@ function _guardWindow(ctx, before = 25, after = 5) {
|
|
|
1271
1271
|
.replace(/(^|[^\w'"`])#[^\n]*/g, '$1 ');
|
|
1272
1272
|
}
|
|
1273
1273
|
|
|
1274
|
-
|
|
1274
|
+
// PRD R15: a guard-shaped token anywhere in the -25/+5 window used to be
|
|
1275
|
+
// sufficient — an allow-list built for an unrelated purpose, or even the
|
|
1276
|
+
// tainted variable's OWN declaration line, sitting in the same screenful of
|
|
1277
|
+
// code as a genuinely-unguarded sink, silently killed a real finding. This
|
|
1278
|
+
// does not require full dataflow correlation, only a cheap positional one:
|
|
1279
|
+
// the sink's own argument identifier(s) must appear WITHIN A FEW LINES of
|
|
1280
|
+
// the specific line the guard-shaped text actually matched on — not merely
|
|
1281
|
+
// somewhere in the whole window, which is true of almost any variable used
|
|
1282
|
+
// nearby (its own declaration, an unrelated helper, the sink line itself).
|
|
1283
|
+
// Falls back to permissive (unable to correlate → don't break existing
|
|
1284
|
+
// recall protection) when the sink line yields no usable identifier.
|
|
1285
|
+
const _GUARD_STOPWORDS = new Set(['var', 'let', 'const', 'function', 'return', 'new', 'await', 'async',
|
|
1286
|
+
'if', 'else', 'for', 'while', 'require', 'import', 'from', 'true', 'false', 'null', 'undefined',
|
|
1287
|
+
'this', 'self', 'req', 'res', 'request', 'response']);
|
|
1288
|
+
function _sinkLineIdentifiers(ctx) {
|
|
1289
|
+
const line = (ctx && Array.isArray(ctx.lines) && ctx.lines[(ctx.line || 1) - 1]) || '';
|
|
1290
|
+
const ids = new Set();
|
|
1291
|
+
// Excludes call-target identifiers (name immediately followed by `(`) —
|
|
1292
|
+
// `fetch(target)` must correlate on the ARGUMENT `target`, not on `fetch`
|
|
1293
|
+
// itself, which incidentally appears anywhere the module imports fetch
|
|
1294
|
+
// (e.g. `const fetch = require('node-fetch')`) and would trivially
|
|
1295
|
+
// "correlate" with any guard window in the same file.
|
|
1296
|
+
const re = /\b[A-Za-z_$][\w$]*\b(?!\s*\()/g;
|
|
1297
|
+
let m;
|
|
1298
|
+
while ((m = re.exec(line))) {
|
|
1299
|
+
const id = m[0];
|
|
1300
|
+
// No minimum length: short variable names (`u`, `p`, `f`) are common,
|
|
1301
|
+
// legitimate taint carriers in real code — excluding them left the
|
|
1302
|
+
// ONLY correlating identifier out entirely for sinks like
|
|
1303
|
+
// `File.ReadAllText(p)` or `axios.get(u.toString())`, defeating
|
|
1304
|
+
// correlation with a guard that genuinely protects that exact
|
|
1305
|
+
// variable (a corpus regression caught this: CVE-2021-22054-ssrf-shape,
|
|
1306
|
+
// CVE-2022-26049-cs-path).
|
|
1307
|
+
if (id.length >= 1 && !_GUARD_STOPWORDS.has(id)) ids.add(id);
|
|
1308
|
+
}
|
|
1309
|
+
return ids;
|
|
1310
|
+
}
|
|
1311
|
+
// Runs guardRe against the window and, for each match, checks whether any
|
|
1312
|
+
// sink-line identifier appears within `span` lines of that match's own line
|
|
1313
|
+
// (excluding the sink line itself, which trivially contains its own
|
|
1314
|
+
// argument). Tries every match, not just the first, since a window can
|
|
1315
|
+
// contain several guard-shaped lines and only one need actually correlate.
|
|
1316
|
+
function _guardMatchNearSinkIdentifier(ctx, guardRe, span = 2) {
|
|
1317
|
+
const w = _guardWindow(ctx);
|
|
1318
|
+
const re = new RegExp(guardRe.source, guardRe.flags.includes('g') ? guardRe.flags : guardRe.flags + 'g');
|
|
1319
|
+
const ids = _sinkLineIdentifiers(ctx);
|
|
1320
|
+
if (!ids.size) return re.test(w); // can't correlate — fall back to the old shape-only check
|
|
1321
|
+
const wLines = w.split('\n');
|
|
1322
|
+
const sinkLineText = (ctx.lines && ctx.lines[(ctx.line || 1) - 1]) || '';
|
|
1323
|
+
let m;
|
|
1324
|
+
while ((m = re.exec(w))) {
|
|
1325
|
+
const guardLineIdx = w.slice(0, m.index).split('\n').length - 1; // 0-based within window
|
|
1326
|
+
const lo = Math.max(0, guardLineIdx - span);
|
|
1327
|
+
const hi = Math.min(wLines.length, guardLineIdx + span + 1);
|
|
1328
|
+
const local = wLines.slice(lo, hi).filter((l) => l !== sinkLineText).join('\n');
|
|
1329
|
+
for (const id of ids) {
|
|
1330
|
+
if (new RegExp(`\\b${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(local)) return true;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function _hasSsrfHostGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _SSRF_HOST_GUARD_RE); }
|
|
1275
1337
|
|
|
1276
1338
|
// A path-traversal containment guard near the file sink: a basename/strip
|
|
1277
1339
|
// helper that removes directory components, a framework safe-join, or a
|
|
1278
1340
|
// canonicalize-then-startsWith containment check.
|
|
1279
1341
|
const _PATH_GUARD_RE = /\b(?:basename|GetFileName|secure_filename|sanitize_filename|send_from_directory|safe_join)\s*\(|\b(?:startsWith|startswith|StartsWith|HasPrefix)\s*\(|\bgetCanonicalPath\b|\btoRealPath\b|\bfilepath\s*\.\s*(?:Clean|Base|Abs)\b/;
|
|
1280
|
-
function _hasPathGuard(ctx) { return
|
|
1342
|
+
function _hasPathGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _PATH_GUARD_RE); }
|
|
1281
1343
|
|
|
1282
1344
|
// Reflected-XSS output-encoding guard: an HTML escaper applied near the sink.
|
|
1283
1345
|
const _XSS_ESCAPER = String.raw`(?:escapeHtml|escape_html|escape-html|sanitizeHtml|sanitize_html|DOMPurify\.sanitize|he\.encode|he\.escape|_\.escape|validator\.escape|bleach\.clean|markupsafe|htmlspecialchars|htmlentities|html\.escape|escapeHTML|encodeURIComponent|escape)\s*\(`;
|
|
@@ -3008,15 +3070,17 @@ const JAVA_FAMILY_RULES = [
|
|
|
3008
3070
|
return !isWeak(resolved); // strong → suppress; weak → fire
|
|
3009
3071
|
}
|
|
3010
3072
|
// 2) OWASP Benchmark fallback — hardcoded answer-key for OWASP's own
|
|
3011
|
-
// benchmark.properties file. Pure label leakage
|
|
3012
|
-
//
|
|
3013
|
-
|
|
3014
|
-
|
|
3073
|
+
// benchmark.properties file. Pure label leakage. PRD R5: was
|
|
3074
|
+
// opt-out (disabled only under BLIND_BENCH=1) — inverted to opt-in,
|
|
3075
|
+
// enabled only under explicit BENCH_SHAPE=1, matching the
|
|
3076
|
+
// documented default every other bench-shape mechanism follows.
|
|
3077
|
+
const _benchShapeHere = process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1' && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1';
|
|
3078
|
+
const OWASP_BENCH_PROPS = _benchShapeHere ? {
|
|
3015
3079
|
cryptoAlg1: 'DES/ECB/PKCS5Padding',
|
|
3016
3080
|
cryptoAlg2: 'AES/CCM/NoPadding',
|
|
3017
3081
|
hashAlg1: 'MD5',
|
|
3018
3082
|
hashAlg2: 'SHA-256',
|
|
3019
|
-
};
|
|
3083
|
+
} : {};
|
|
3020
3084
|
if (OWASP_BENCH_PROPS[propKey]) {
|
|
3021
3085
|
return !isWeak(OWASP_BENCH_PROPS[propKey]);
|
|
3022
3086
|
}
|
|
@@ -4464,8 +4528,10 @@ function scanJavaSAST(fp, raw) {
|
|
|
4464
4528
|
// 72/73/74/.../82 where the receiving file has no local source — the
|
|
4465
4529
|
// tainted Vector/List/Map arrives via a method parameter from a sibling
|
|
4466
4530
|
// file. Gated tightly to avoid FPs on real apps.
|
|
4467
|
-
if (!hasSource && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1') {
|
|
4468
|
-
//
|
|
4531
|
+
if (!hasSource && process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1' && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1') {
|
|
4532
|
+
// PRD R5: was opt-out (disabled only under BLIND_BENCH=1) — inverted to
|
|
4533
|
+
// opt-in, matching the documented default every other bench-shape
|
|
4534
|
+
// mechanism in this file follows. Juliet-shape signal.
|
|
4469
4535
|
const _isJulietShape = /\bjuliet\.(?:testcases|support)\b/.test(cleaned)
|
|
4470
4536
|
|| /\b(?:badSink|badSource|goodG2B|goodB2G)\s*\(/.test(cleaned);
|
|
4471
4537
|
if (_isJulietShape && /\b(?:Vector|ArrayList|LinkedList|List|Set|HashSet|Map|HashMap|Hashtable|Properties|Queue|Deque|Stack|Optional)\s*<[^>]*>\s+[A-Za-z_]\w*\s*[,)]/.test(cleaned)) {
|
|
@@ -4485,15 +4551,18 @@ function scanJavaSAST(fp, raw) {
|
|
|
4485
4551
|
let pm;
|
|
4486
4552
|
// OWASP_BENCH_PROPS is the OWASP Benchmark answer-key for its own
|
|
4487
4553
|
// benchmark.properties file (hashAlg1 → MD5, cryptoAlg1 → DES/ECB). Pure
|
|
4488
|
-
// label leakage.
|
|
4489
|
-
//
|
|
4490
|
-
|
|
4491
|
-
|
|
4554
|
+
// label leakage. PRD R5: was opt-out (disabled only under
|
|
4555
|
+
// BLIND_BENCH=1) — inverted to opt-in, enabled only under explicit
|
|
4556
|
+
// BENCH_SHAPE=1; real apps use the properties index loaded from the
|
|
4557
|
+
// filesystem instead (the `resolved`/`getJavaProperty` path above this
|
|
4558
|
+
// fallback).
|
|
4559
|
+
const _benchShapeHere = process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1' && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1';
|
|
4560
|
+
const OWASP_BENCH_PROPS = _benchShapeHere ? {
|
|
4492
4561
|
cryptoAlg1: 'DES/ECB/PKCS5Padding',
|
|
4493
4562
|
cryptoAlg2: 'AES/CCM/NoPadding',
|
|
4494
4563
|
hashAlg1: 'MD5',
|
|
4495
4564
|
hashAlg2: 'SHA-256',
|
|
4496
|
-
};
|
|
4565
|
+
} : {};
|
|
4497
4566
|
const isWeak = (v) =>
|
|
4498
4567
|
/\b(?:MD2|MD4|MD5|SHA-?1|SHA1|DES|DESede|3DES|RC2|RC4|Blowfish|HmacMD5|HmacSHA1)\b|AES\s*\/\s*ECB/i.test(v || '');
|
|
4499
4568
|
while ((pm = propUseRe.exec(cleaned)) !== null) {
|
|
@@ -4824,7 +4893,13 @@ function annotateReachability(findings,routes,callGraph,fc){
|
|
|
4824
4893
|
// Within 60 lines of a route declaration we consider this source route-rooted
|
|
4825
4894
|
const routeRooted=rl.some(l=>Math.abs(l-srcLine)<60);
|
|
4826
4895
|
f.routeRooted=routeRooted;
|
|
4827
|
-
// Cheap function-of-source lookup via callGraph
|
|
4896
|
+
// Cheap function-of-source lookup via callGraph. buildCallGraph only
|
|
4897
|
+
// ever populates entries for .js/.jsx/.ts/.tsx/.mjs/.cjs files (PRD
|
|
4898
|
+
// R15) — callGraph[fp] being absent for every other language is an
|
|
4899
|
+
// ABSENCE OF EVIDENCE, not evidence the finding is unreachable, and
|
|
4900
|
+
// must not be conflated with a JS file whose call graph genuinely has
|
|
4901
|
+
// no incoming edge.
|
|
4902
|
+
const hasCallGraphData=Object.prototype.hasOwnProperty.call(callGraph,fp);
|
|
4828
4903
|
const funcs=callGraph[fp]||{};
|
|
4829
4904
|
let enclosing=null;
|
|
4830
4905
|
for(const[fn,info] of Object.entries(funcs))
|
|
@@ -4833,6 +4908,7 @@ function annotateReachability(findings,routes,callGraph,fc){
|
|
|
4833
4908
|
// Reachable when route-rooted OR enclosingFunction is called from any function
|
|
4834
4909
|
// declared near a route in the same file
|
|
4835
4910
|
if(routeRooted){f.reachable=true;continue;}
|
|
4911
|
+
if(!hasCallGraphData){f.reachable=null;continue;}
|
|
4836
4912
|
let reachable=false;
|
|
4837
4913
|
if(enclosing){
|
|
4838
4914
|
for(const rLine of rl){
|
|
@@ -5822,15 +5898,26 @@ function dedupeFindingsWithEvidence(findings){
|
|
|
5822
5898
|
const key=`${file}:${sinkLine}:${fam}`;
|
|
5823
5899
|
if(!buckets.has(key)){buckets.set(key,f);continue;}
|
|
5824
5900
|
const kept=buckets.get(key);
|
|
5825
|
-
// Winner selection: an
|
|
5826
|
-
//
|
|
5827
|
-
//
|
|
5828
|
-
//
|
|
5829
|
-
//
|
|
5830
|
-
//
|
|
5901
|
+
// Winner selection: an IR-TAINT finding (the deep engine's real
|
|
5902
|
+
// interprocedural taint walk) is the best carrier at a shared sink,
|
|
5903
|
+
// ahead of everything else — PRD R3: it carries taint-walk-only evidence
|
|
5904
|
+
// (sanitizer observations keyed off the actual value reaching the sink,
|
|
5905
|
+
// chain, LLM-validation state) that a flat pattern/AST match has no
|
|
5906
|
+
// equivalent for, and that evidence would silently vanish if a
|
|
5907
|
+
// same-severity pattern-layer duplicate won the tie instead. Below that,
|
|
5908
|
+
// an interprocedural flow finding (carries source→sink attribution) is
|
|
5909
|
+
// the better carrier than a flat structural/regex match — keep its
|
|
5910
|
+
// chain/source-line attribution. When neither or both carry flow, fall
|
|
5911
|
+
// back to severity. The winner keeps its own severity (we must not
|
|
5912
|
+
// resurrect a rating that ownership/reachability analysis deliberately
|
|
5913
|
+
// downgraded on one of the two findings).
|
|
5914
|
+
const fIsIrTaint = f.parser === 'IR-TAINT';
|
|
5915
|
+
const kIsIrTaint = kept.parser === 'IR-TAINT';
|
|
5831
5916
|
const fHasFlow = !!(f.source && f.sink);
|
|
5832
5917
|
const kHasFlow = !!(kept.source && kept.sink);
|
|
5833
|
-
const keepNew = (
|
|
5918
|
+
const keepNew = (fIsIrTaint !== kIsIrTaint)
|
|
5919
|
+
? fIsIrTaint
|
|
5920
|
+
: (fHasFlow !== kHasFlow)
|
|
5834
5921
|
? fHasFlow
|
|
5835
5922
|
: (SEV_RANK[f.severity]??9) < (SEV_RANK[kept.severity]??9);
|
|
5836
5923
|
const winner = keepNew ? f : kept;
|
|
@@ -7817,7 +7904,15 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
7817
7904
|
const c = fc[p];
|
|
7818
7905
|
if (!c) continue;
|
|
7819
7906
|
// Path-based category for Juliet test cases: `juliet-cweN/.../...java`.
|
|
7820
|
-
|
|
7907
|
+
// PRD R5: this reads a path-embedded answer key (the directory name
|
|
7908
|
+
// declares the CWE) exactly like _javaWebServletCategory's @WebServlet
|
|
7909
|
+
// annotation reading below — off by default, enabled only when
|
|
7910
|
+
// BENCH_SHAPE=1. This branch previously had no gate at all, so a real
|
|
7911
|
+
// repository with a directory that happens to be named `juliet-cweNN/`
|
|
7912
|
+
// would silently lose off-family findings in the default pipeline.
|
|
7913
|
+
const julietMatch = (process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1'
|
|
7914
|
+
&& process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1')
|
|
7915
|
+
? p.match(/(?:^|\/)juliet-cwe(\d+)\//i) : null;
|
|
7821
7916
|
if (julietMatch && _JULIET_CWE_TO_FAMILY[julietMatch[1]]) {
|
|
7822
7917
|
_benchCategoryByFile.set(p, _JULIET_CWE_TO_FAMILY[julietMatch[1]]);
|
|
7823
7918
|
continue;
|
|
@@ -8030,6 +8125,169 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8030
8125
|
// Roadmap #8 — tree-sitter sinks for long-tail languages (opt-in,
|
|
8031
8126
|
// AGENTIC_SECURITY_TREE_SITTER=1; degrades to no-op without the optional dep).
|
|
8032
8127
|
if(process.env.AGENTIC_SECURITY_TREE_SITTER==='1'){try{aF.push(...await scanTreeSitterSinks(fc));}catch(_){}}
|
|
8128
|
+
// Phase 3 (Sentinel-parity FR-L1, FR-L2) — IR + interprocedural taint.
|
|
8129
|
+
// R1 (PRD §5): the CLI entry (bin/agentic-security.js#cmdScan) now sets
|
|
8130
|
+
// AGENTIC_SECURITY_DEEP=1 by default for local/interactive scans, so deep mode
|
|
8131
|
+
// runs on the default `/scan --all` path. This gate is the enforcement +
|
|
8132
|
+
// CI-safety point: it honors an explicit opt-out (DEEP=0) and keeps deep off in
|
|
8133
|
+
// CI unless DEEP_IN_CI=1. In-process callers (tests, the cve-replay corpus) invoke
|
|
8134
|
+
// runScan()/runFullScan() directly without the CLI default, so they stay deep-off
|
|
8135
|
+
// and remain deterministic regression gates.
|
|
8136
|
+
//
|
|
8137
|
+
// PRD R3: IR-TAINT findings are appended into `aF` HERE, before dedup, so
|
|
8138
|
+
// they dedupe against a pattern-layer duplicate of the same sink and then
|
|
8139
|
+
// ride through the exact same annotator pipeline every other finding does
|
|
8140
|
+
// (stable IDs, clustering, reachability, family backfill, confidence,
|
|
8141
|
+
// calibration, exploitability, sanitizer/proof gate, mitigation, composite
|
|
8142
|
+
// risk, LLM validation...) below. Previously this block ran AFTER that
|
|
8143
|
+
// entire pipeline and pushed straight into the post-dedup `finalFindings`
|
|
8144
|
+
// array, so a sink caught by both the regex layer and deep mode produced
|
|
8145
|
+
// two findings (one with no family and no calibrated confidence) instead
|
|
8146
|
+
// of one deduped, fully-annotated finding.
|
|
8147
|
+
//
|
|
8148
|
+
// SAFETY: Deep mode is gated for CI safety:
|
|
8149
|
+
// - Global timeout via AGENTIC_SECURITY_DEEP_TIMEOUT_MS (default 300_000 = 5 min)
|
|
8150
|
+
// - Auto-disabled in CI unless AGENTIC_SECURITY_DEEP_IN_CI=1 is also set,
|
|
8151
|
+
// so a pathological file can't hang the whole pipeline.
|
|
8152
|
+
// ── IR parse-coverage sidecar (proof-corpus instrumentation, default off) ──
|
|
8153
|
+
// Built ahead of the deep-mode gate so coverage is measurable without paying
|
|
8154
|
+
// for taint analysis, and stashed in _sharedIR so the deep block below reuses
|
|
8155
|
+
// it rather than parsing the project twice.
|
|
8156
|
+
//
|
|
8157
|
+
// NOTE (affects instrumented runs only, i.e. AGENTIC_SECURITY_IR_STATS set):
|
|
8158
|
+
// when this block runs, buildProjectIR() happens here, BEFORE the deep-mode
|
|
8159
|
+
// budget timer (t0) below is started. On an uninstrumented run, IR
|
|
8160
|
+
// construction instead happens inside the timed block via the
|
|
8161
|
+
// `_sharedIR || (_sharedIR = buildProjectIR(fc))` line, so its cost counts
|
|
8162
|
+
// against AGENTIC_SECURITY_DEEP_TIMEOUT_MS. That means the deep budget does
|
|
8163
|
+
// NOT account for parse time when stats are enabled — an instrumented run
|
|
8164
|
+
// gets strictly more wall-clock for the taint analysis itself than an
|
|
8165
|
+
// uninstrumented run with the same budget.
|
|
8166
|
+
let _sharedIR = null;
|
|
8167
|
+
// Java IR requires the ASYNC builder. `parser-java.js` exports an async
|
|
8168
|
+
// `parseJavaFile` (java-parser needs a dynamic import), so the sync
|
|
8169
|
+
// `buildProjectIR` has no Java branch at all — and both deep-path call sites
|
|
8170
|
+
// used it. The result was that no .java file had ever produced an IR function
|
|
8171
|
+
// in deep mode: `bench/layer-recall` measured java at 0/25 while the catalog
|
|
8172
|
+
// carried 7 Java sources and 15 Java sinks that had nothing to run against.
|
|
8173
|
+
// `buildProjectIRAsync` is a full mirror plus Java and had zero callers.
|
|
8174
|
+
//
|
|
8175
|
+
// Gated on the presence of .java rather than always awaiting: the async
|
|
8176
|
+
// builder is a superset, but switching every scan in the product to it to fix
|
|
8177
|
+
// one language would change the execution shape (and attempt the java-parser
|
|
8178
|
+
// import) for projects that contain no Java. `runFullScan` is already async,
|
|
8179
|
+
// so the await costs nothing structurally.
|
|
8180
|
+
const _hasJava = Object.keys(fc || {}).some(f => /\.java$/i.test(f));
|
|
8181
|
+
const _buildIR = async () => (_hasJava ? await buildProjectIRAsync(fc) : buildProjectIR(fc));
|
|
8182
|
+
const _irStatsTarget = irStatsTarget();
|
|
8183
|
+
if (_irStatsTarget) {
|
|
8184
|
+
try {
|
|
8185
|
+
_sharedIR = await _buildIR();
|
|
8186
|
+
writeIrStats(_irStatsTarget, collectIrStats(fc, _sharedIR.perFile, _sharedIR.callGraph));
|
|
8187
|
+
} catch (e) {
|
|
8188
|
+
// Instrumentation must never fail a scan. Surface only when debugging.
|
|
8189
|
+
if (process.env.AGENTIC_SECURITY_IR_STATS_DEBUG === '1') {
|
|
8190
|
+
process.stderr.write(`ir-stats: ${e && e.message}\n`);
|
|
8191
|
+
}
|
|
8192
|
+
}
|
|
8193
|
+
}
|
|
8194
|
+
// `deep`/`deepInCi` come from runScan()'s options object (threaded through
|
|
8195
|
+
// unchanged from runScan.js) — an explicit-opt-in override alongside the
|
|
8196
|
+
// env vars, not a replacement for them. Added because `runScan(dir,
|
|
8197
|
+
// {deep:true})` was a silent, total no-op: this options object was
|
|
8198
|
+
// destructured for fileContents/depFileContents/scanRoot/resume only, so
|
|
8199
|
+
// `deep` was dropped on the floor and deep mode stayed off regardless.
|
|
8200
|
+
// Several interprocedural test files (interproc-k2.test.js,
|
|
8201
|
+
// parser-cs-kt.test.js, points-to.test.js) pass exactly this option
|
|
8202
|
+
// believing it enables deep mode — it never did, so those tests were
|
|
8203
|
+
// exercising whatever coincidentally fires without the deep engine, not
|
|
8204
|
+
// the interprocedural machinery they're named for.
|
|
8205
|
+
const _deepRequested = deep === true || process.env.AGENTIC_SECURITY_DEEP === '1';
|
|
8206
|
+
const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
|
|
8207
|
+
process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
|
|
8208
|
+
const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
|
|
8209
|
+
const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
|
|
8210
|
+
let _deepCallGraph = null;
|
|
8211
|
+
if (_deepEnabled) {
|
|
8212
|
+
const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
|
|
8213
|
+
const t0 = Date.now();
|
|
8214
|
+
try {
|
|
8215
|
+
const { perFile, callGraph } = _sharedIR || (_sharedIR = await _buildIR());
|
|
8216
|
+
_deepCallGraph = callGraph;
|
|
8217
|
+
// The runDeepAnalysis call is synchronous in this codebase; we can't
|
|
8218
|
+
// truly interrupt it without re-architecting the worklist. We pass a
|
|
8219
|
+
// deadlineMs hint that the inner loops check; if absent, we still cap
|
|
8220
|
+
// function count via fnLimit. Operators who suspect a hung run can
|
|
8221
|
+
// kill the process and re-run with AGENTIC_SECURITY_DEEP=0.
|
|
8222
|
+
const irFindings = runDeepAnalysis(perFile, callGraph, {
|
|
8223
|
+
fnLimit: parseInt(process.env.AGENTIC_SECURITY_DEEP_FN_LIMIT || '5000', 10),
|
|
8224
|
+
deadlineMs: t0 + budgetMs,
|
|
8225
|
+
// v0.69 — incremental cache inputs (used when AGENTIC_SECURITY_INCREMENTAL=1).
|
|
8226
|
+
scanRoot,
|
|
8227
|
+
fileContents: fc,
|
|
8228
|
+
});
|
|
8229
|
+
const elapsed = Date.now() - t0;
|
|
8230
|
+
if (elapsed > budgetMs) {
|
|
8231
|
+
// We exceeded budget — surface a single info finding so operators see it.
|
|
8232
|
+
aF.push({
|
|
8233
|
+
id: `ir-taint-timeout:${scanRoot || ''}`,
|
|
8234
|
+
file: '(deep-engine)', line: 0,
|
|
8235
|
+
vuln: `IR-TAINT deep mode exceeded ${budgetMs}ms budget (${elapsed}ms used) — results may be incomplete`,
|
|
8236
|
+
severity: 'info',
|
|
8237
|
+
parser: 'IR-TAINT',
|
|
8238
|
+
confidence: 0.5,
|
|
8239
|
+
});
|
|
8240
|
+
}
|
|
8241
|
+
for (const f of irFindings) {
|
|
8242
|
+
f.unvalidated = true;
|
|
8243
|
+
f.validator_verdict = 'unvalidated';
|
|
8244
|
+
}
|
|
8245
|
+
aF.push(...irFindings);
|
|
8246
|
+
} catch (e) {
|
|
8247
|
+
// Deep mode is best-effort. A parser blowup in one file shouldn't kill
|
|
8248
|
+
// the scan — fall back to the pattern-only result.
|
|
8249
|
+
}
|
|
8250
|
+
} else if (_deepRequested && _inCi) {
|
|
8251
|
+
// Operator asked for deep but we're in CI — emit a non-blocking notice
|
|
8252
|
+
// so they know it was skipped and how to override.
|
|
8253
|
+
aF.push({
|
|
8254
|
+
id: 'ir-taint-ci-skipped',
|
|
8255
|
+
file: '(deep-engine)', line: 0,
|
|
8256
|
+
vuln: 'IR-TAINT deep mode skipped in CI environment (set AGENTIC_SECURITY_DEEP_IN_CI=1 to opt in)',
|
|
8257
|
+
severity: 'info',
|
|
8258
|
+
parser: 'IR-TAINT',
|
|
8259
|
+
confidence: 1.0,
|
|
8260
|
+
});
|
|
8261
|
+
}
|
|
8262
|
+
// Java SCA enrichment: use deep-mode IR call graph to improve Java function reachability
|
|
8263
|
+
if (_deepCallGraph) {
|
|
8264
|
+
try {
|
|
8265
|
+
for (const sc of supplyChain) {
|
|
8266
|
+
if (sc.type !== 'vulnerable_dep' || sc.ecosystem !== 'maven') continue;
|
|
8267
|
+
if (sc.functionReachable === 'reachable') continue;
|
|
8268
|
+
const allFns = [...(sc.osvVulnFunctions || []), ...(VULN_FUNCTION_HINTS[sc.name] || [])];
|
|
8269
|
+
if (!allFns.length) continue;
|
|
8270
|
+
for (const fn of _deepCallGraph.functions ? _deepCallGraph.functions.values() : []) {
|
|
8271
|
+
if (!fn.cfg || !fn.cfg.nodes) continue;
|
|
8272
|
+
for (const node of Object.values(fn.cfg.nodes)) {
|
|
8273
|
+
if (node.kind !== 'call') continue;
|
|
8274
|
+
const callee = typeof node.callee === 'string' ? node.callee : null;
|
|
8275
|
+
if (!callee) continue;
|
|
8276
|
+
const shortCallee = callee.includes('.') ? callee.split('.').pop() : callee;
|
|
8277
|
+
if (allFns.some(f => f === shortCallee || f === callee)) {
|
|
8278
|
+
sc.functionReachable = 'reachable';
|
|
8279
|
+
sc.reachabilityTier = 'function-reachable';
|
|
8280
|
+
if (!sc.vulnerableFunctionCallSites) sc.vulnerableFunctionCallSites = [];
|
|
8281
|
+
sc.vulnerableFunctionCallSites.push({ pkg: sc.name, fn: shortCallee, file: fn.file, line: node.line });
|
|
8282
|
+
sc._javaIrEnriched = true;
|
|
8283
|
+
break;
|
|
8284
|
+
}
|
|
8285
|
+
}
|
|
8286
|
+
if (sc.functionReachable === 'reachable') break;
|
|
8287
|
+
}
|
|
8288
|
+
}
|
|
8289
|
+
} catch { /* Java SCA enrichment is best-effort */ }
|
|
8290
|
+
}
|
|
8033
8291
|
let finalFindings;try{finalFindings=dedupeFindingsWithEvidence(aF);}catch(_){finalFindings=dd(aF,f=>f.id);}
|
|
8034
8292
|
// Inline `agentic-security-ignore` pragmas, pass 1 of 2. This covers every
|
|
8035
8293
|
// finding that exists BY THIS POINT — the pattern detectors, the cross-file
|
|
@@ -8453,190 +8711,6 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8453
8711
|
// FR-LOGIC-6: LLM-driven flow narration (template fallback when no LLM endpoint).
|
|
8454
8712
|
try { await annotateNarration(finalFindings); }
|
|
8455
8713
|
catch (e) { _annotatorErrors.push({ phase: 'annotateNarration', err: String((e && e.message) || e) }); }
|
|
8456
|
-
// Phase 3 (Sentinel-parity FR-L1, FR-L2) — IR + interprocedural taint.
|
|
8457
|
-
// R1 (PRD §5): the CLI entry (bin/agentic-security.js#cmdScan) now sets
|
|
8458
|
-
// AGENTIC_SECURITY_DEEP=1 by default for local/interactive scans, so deep mode
|
|
8459
|
-
// runs on the default `/scan --all` path. This gate is the enforcement +
|
|
8460
|
-
// CI-safety point: it honors an explicit opt-out (DEEP=0) and keeps deep off in
|
|
8461
|
-
// CI unless DEEP_IN_CI=1. In-process callers (tests, the cve-replay corpus) invoke
|
|
8462
|
-
// runScan()/runFullScan() directly without the CLI default, so they stay deep-off
|
|
8463
|
-
// and remain deterministic regression gates. Findings ride through the standard
|
|
8464
|
-
// dedup/cluster/confidence pipeline below and the LLM-validator stage that follows.
|
|
8465
|
-
//
|
|
8466
|
-
// SAFETY: Deep mode is gated for CI safety:
|
|
8467
|
-
// - Global timeout via AGENTIC_SECURITY_DEEP_TIMEOUT_MS (default 300_000 = 5 min)
|
|
8468
|
-
// - Auto-disabled in CI unless AGENTIC_SECURITY_DEEP_IN_CI=1 is also set,
|
|
8469
|
-
// so a pathological file can't hang the whole pipeline.
|
|
8470
|
-
// ── IR parse-coverage sidecar (proof-corpus instrumentation, default off) ──
|
|
8471
|
-
// Built ahead of the deep-mode gate so coverage is measurable without paying
|
|
8472
|
-
// for taint analysis, and stashed in _sharedIR so the deep block below reuses
|
|
8473
|
-
// it rather than parsing the project twice.
|
|
8474
|
-
//
|
|
8475
|
-
// NOTE (affects instrumented runs only, i.e. AGENTIC_SECURITY_IR_STATS set):
|
|
8476
|
-
// when this block runs, buildProjectIR() happens here, BEFORE the deep-mode
|
|
8477
|
-
// budget timer (t0) below is started. On an uninstrumented run, IR
|
|
8478
|
-
// construction instead happens inside the timed block via the
|
|
8479
|
-
// `_sharedIR || (_sharedIR = buildProjectIR(fc))` line, so its cost counts
|
|
8480
|
-
// against AGENTIC_SECURITY_DEEP_TIMEOUT_MS. That means the deep budget does
|
|
8481
|
-
// NOT account for parse time when stats are enabled — an instrumented run
|
|
8482
|
-
// gets strictly more wall-clock for the taint analysis itself than an
|
|
8483
|
-
// uninstrumented run with the same budget.
|
|
8484
|
-
let _sharedIR = null;
|
|
8485
|
-
// Java IR requires the ASYNC builder. `parser-java.js` exports an async
|
|
8486
|
-
// `parseJavaFile` (java-parser needs a dynamic import), so the sync
|
|
8487
|
-
// `buildProjectIR` has no Java branch at all — and both deep-path call sites
|
|
8488
|
-
// used it. The result was that no .java file had ever produced an IR function
|
|
8489
|
-
// in deep mode: `bench/layer-recall` measured java at 0/25 while the catalog
|
|
8490
|
-
// carried 7 Java sources and 15 Java sinks that had nothing to run against.
|
|
8491
|
-
// `buildProjectIRAsync` is a full mirror plus Java and had zero callers.
|
|
8492
|
-
//
|
|
8493
|
-
// Gated on the presence of .java rather than always awaiting: the async
|
|
8494
|
-
// builder is a superset, but switching every scan in the product to it to fix
|
|
8495
|
-
// one language would change the execution shape (and attempt the java-parser
|
|
8496
|
-
// import) for projects that contain no Java. `runFullScan` is already async,
|
|
8497
|
-
// so the await costs nothing structurally.
|
|
8498
|
-
const _hasJava = Object.keys(fc || {}).some(f => /\.java$/i.test(f));
|
|
8499
|
-
const _buildIR = async () => (_hasJava ? await buildProjectIRAsync(fc) : buildProjectIR(fc));
|
|
8500
|
-
const _irStatsTarget = irStatsTarget();
|
|
8501
|
-
if (_irStatsTarget) {
|
|
8502
|
-
try {
|
|
8503
|
-
_sharedIR = await _buildIR();
|
|
8504
|
-
writeIrStats(_irStatsTarget, collectIrStats(fc, _sharedIR.perFile, _sharedIR.callGraph));
|
|
8505
|
-
} catch (e) {
|
|
8506
|
-
// Instrumentation must never fail a scan. Surface only when debugging.
|
|
8507
|
-
if (process.env.AGENTIC_SECURITY_IR_STATS_DEBUG === '1') {
|
|
8508
|
-
process.stderr.write(`ir-stats: ${e && e.message}\n`);
|
|
8509
|
-
}
|
|
8510
|
-
}
|
|
8511
|
-
}
|
|
8512
|
-
// `deep`/`deepInCi` come from runScan()'s options object (threaded through
|
|
8513
|
-
// unchanged from runScan.js) — an explicit-opt-in override alongside the
|
|
8514
|
-
// env vars, not a replacement for them. Added because `runScan(dir,
|
|
8515
|
-
// {deep:true})` was a silent, total no-op: this options object was
|
|
8516
|
-
// destructured for fileContents/depFileContents/scanRoot/resume only, so
|
|
8517
|
-
// `deep` was dropped on the floor and deep mode stayed off regardless.
|
|
8518
|
-
// Several interprocedural test files (interproc-k2.test.js,
|
|
8519
|
-
// parser-cs-kt.test.js, points-to.test.js) pass exactly this option
|
|
8520
|
-
// believing it enables deep mode — it never did, so those tests were
|
|
8521
|
-
// exercising whatever coincidentally fires without the deep engine, not
|
|
8522
|
-
// the interprocedural machinery they're named for.
|
|
8523
|
-
const _deepRequested = deep === true || process.env.AGENTIC_SECURITY_DEEP === '1';
|
|
8524
|
-
const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
|
|
8525
|
-
process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
|
|
8526
|
-
const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
|
|
8527
|
-
const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
|
|
8528
|
-
if (_deepEnabled) {
|
|
8529
|
-
const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
|
|
8530
|
-
const t0 = Date.now();
|
|
8531
|
-
try {
|
|
8532
|
-
const { perFile, callGraph } = _sharedIR || (_sharedIR = await _buildIR());
|
|
8533
|
-
// The runDeepAnalysis call is synchronous in this codebase; we can't
|
|
8534
|
-
// truly interrupt it without re-architecting the worklist. We pass a
|
|
8535
|
-
// deadlineMs hint that the inner loops check; if absent, we still cap
|
|
8536
|
-
// function count via fnLimit. Operators who suspect a hung run can
|
|
8537
|
-
// kill the process and re-run with AGENTIC_SECURITY_DEEP=0.
|
|
8538
|
-
const irFindings = runDeepAnalysis(perFile, callGraph, {
|
|
8539
|
-
fnLimit: parseInt(process.env.AGENTIC_SECURITY_DEEP_FN_LIMIT || '5000', 10),
|
|
8540
|
-
deadlineMs: t0 + budgetMs,
|
|
8541
|
-
// v0.69 — incremental cache inputs (used when AGENTIC_SECURITY_INCREMENTAL=1).
|
|
8542
|
-
scanRoot,
|
|
8543
|
-
fileContents: fc,
|
|
8544
|
-
});
|
|
8545
|
-
const elapsed = Date.now() - t0;
|
|
8546
|
-
if (elapsed > budgetMs) {
|
|
8547
|
-
// We exceeded budget — surface a single info finding so operators see it.
|
|
8548
|
-
finalFindings.push({
|
|
8549
|
-
id: `ir-taint-timeout:${scanRoot || ''}`,
|
|
8550
|
-
file: '(deep-engine)', line: 0,
|
|
8551
|
-
vuln: `IR-TAINT deep mode exceeded ${budgetMs}ms budget (${elapsed}ms used) — results may be incomplete`,
|
|
8552
|
-
severity: 'info',
|
|
8553
|
-
parser: 'IR-TAINT',
|
|
8554
|
-
confidence: 0.5,
|
|
8555
|
-
});
|
|
8556
|
-
}
|
|
8557
|
-
for (const f of irFindings) {
|
|
8558
|
-
f.unvalidated = true;
|
|
8559
|
-
f.validator_verdict = 'unvalidated';
|
|
8560
|
-
}
|
|
8561
|
-
finalFindings.push(...irFindings);
|
|
8562
|
-
// Sanitizer + proof gate, pass 2 of 2 — same ordering trap as the
|
|
8563
|
-
// ignore-pragma double pass below, and for the same reason: pass 1 runs
|
|
8564
|
-
// ~2300 lines above, long before deep-mode IR findings exist, so a
|
|
8565
|
-
// sanitized IR-TAINT flow was never labelled and a proven-clean one was
|
|
8566
|
-
// never demoted. Deep mode is what the CLI uses outside CI, so that was
|
|
8567
|
-
// the case that mattered most.
|
|
8568
|
-
//
|
|
8569
|
-
// Scoped to `irFindings` rather than re-running over `finalFindings`:
|
|
8570
|
-
// annotateProofGate demotes confidence, so a second pass over findings
|
|
8571
|
-
// pass 1 already handled would demote them twice.
|
|
8572
|
-
if (process.env.AGENTIC_SECURITY_NO_PROOF_GATE !== '1') {
|
|
8573
|
-
const _irSanitizers = {};
|
|
8574
|
-
for (const f of irFindings) {
|
|
8575
|
-
const names = f && f._sanitizersOnPath;
|
|
8576
|
-
if (!Array.isArray(names) || !names.length) continue;
|
|
8577
|
-
if (f.id) _irSanitizers[f.id] = names;
|
|
8578
|
-
if (f.stableId) _irSanitizers[f.stableId] = names;
|
|
8579
|
-
}
|
|
8580
|
-
_runAnnotator("applySanitizerGate:deep", () => {
|
|
8581
|
-
applySanitizerGate(irFindings, { sanitizersOnPath: _irSanitizers });
|
|
8582
|
-
});
|
|
8583
|
-
_runAnnotator("annotateProofGate:deep", () => { annotateProofGate(irFindings); });
|
|
8584
|
-
}
|
|
8585
|
-
// Pragma pass 2 of 2 — see the pass-1 comment far above. Deep-mode IR
|
|
8586
|
-
// findings land here, long after pass 1 ran, so without this an
|
|
8587
|
-
// `agentic-security-ignore` on an ir-taint finding is inert. Deep mode is
|
|
8588
|
-
// what the CLI uses outside CI and taint findings are the ones users most
|
|
8589
|
-
// want to silence, so the documented feature did nothing in the case that
|
|
8590
|
-
// mattered most.
|
|
8591
|
-
//
|
|
8592
|
-
// Re-running over the already-filtered array is safe and does not
|
|
8593
|
-
// double-log: pass 1's removals are gone from `finalFindings`, so only the
|
|
8594
|
-
// newly-appended IR findings can match here, and each suppression reaches
|
|
8595
|
-
// the ledger exactly once.
|
|
8596
|
-
try{ _applyIgnorePragmas(finalFindings, fc); }catch(_){}
|
|
8597
|
-
// Java SCA enrichment: use deep-mode IR call graph to improve Java function reachability
|
|
8598
|
-
try {
|
|
8599
|
-
for (const sc of supplyChain) {
|
|
8600
|
-
if (sc.type !== 'vulnerable_dep' || sc.ecosystem !== 'maven') continue;
|
|
8601
|
-
if (sc.functionReachable === 'reachable') continue;
|
|
8602
|
-
const allFns = [...(sc.osvVulnFunctions || []), ...(VULN_FUNCTION_HINTS[sc.name] || [])];
|
|
8603
|
-
if (!allFns.length) continue;
|
|
8604
|
-
for (const fn of callGraph.functions ? callGraph.functions.values() : []) {
|
|
8605
|
-
if (!fn.cfg || !fn.cfg.nodes) continue;
|
|
8606
|
-
for (const node of Object.values(fn.cfg.nodes)) {
|
|
8607
|
-
if (node.kind !== 'call') continue;
|
|
8608
|
-
const callee = typeof node.callee === 'string' ? node.callee : null;
|
|
8609
|
-
if (!callee) continue;
|
|
8610
|
-
const shortCallee = callee.includes('.') ? callee.split('.').pop() : callee;
|
|
8611
|
-
if (allFns.some(f => f === shortCallee || f === callee)) {
|
|
8612
|
-
sc.functionReachable = 'reachable';
|
|
8613
|
-
sc.reachabilityTier = 'function-reachable';
|
|
8614
|
-
if (!sc.vulnerableFunctionCallSites) sc.vulnerableFunctionCallSites = [];
|
|
8615
|
-
sc.vulnerableFunctionCallSites.push({ pkg: sc.name, fn: shortCallee, file: fn.file, line: node.line });
|
|
8616
|
-
sc._javaIrEnriched = true;
|
|
8617
|
-
break;
|
|
8618
|
-
}
|
|
8619
|
-
}
|
|
8620
|
-
if (sc.functionReachable === 'reachable') break;
|
|
8621
|
-
}
|
|
8622
|
-
}
|
|
8623
|
-
} catch { /* Java SCA enrichment is best-effort */ }
|
|
8624
|
-
} catch (e) {
|
|
8625
|
-
// Deep mode is best-effort. A parser blowup in one file shouldn't kill
|
|
8626
|
-
// the scan — fall back to the pattern-only result.
|
|
8627
|
-
}
|
|
8628
|
-
} else if (_deepRequested && _inCi) {
|
|
8629
|
-
// Operator asked for deep but we're in CI — emit a non-blocking notice
|
|
8630
|
-
// so they know it was skipped and how to override.
|
|
8631
|
-
finalFindings.push({
|
|
8632
|
-
id: 'ir-taint-ci-skipped',
|
|
8633
|
-
file: '(deep-engine)', line: 0,
|
|
8634
|
-
vuln: 'IR-TAINT deep mode skipped in CI environment (set AGENTIC_SECURITY_DEEP_IN_CI=1 to opt in)',
|
|
8635
|
-
severity: 'info',
|
|
8636
|
-
parser: 'IR-TAINT',
|
|
8637
|
-
confidence: 1.0,
|
|
8638
|
-
});
|
|
8639
|
-
}
|
|
8640
8714
|
// Phase 2 (Sentinel-parity): LLM validator stage. DEFAULT-ON whenever
|
|
8641
8715
|
// AGENTIC_SECURITY_LLM_ENDPOINT is configured — not gated on
|
|
8642
8716
|
// AGENTIC_SECURITY_LLM_VALIDATE=1 as this comment previously (and wrongly)
|