@clear-capabilities/agentic-security-scanner 0.195.21 → 0.195.23
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 +26 -0
- package/bin/agentic-security.js +5 -2
- package/dist/{7feb08356355c30181a1.json → 01087ded263953062654.json} +3 -3
- package/dist/550.index.js +9 -8
- package/dist/5620.index.js +59 -10
- package/dist/5937.index.js +88 -16
- package/dist/7838.index.js +32 -5
- package/dist/9441.index.js +50 -7
- package/dist/9824.index.js +19 -13
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/discovery/CLAUDE.md +4 -0
- package/src/discovery/detector-spec-synthesis.js +203 -0
- package/src/discovery/llm-invoke.js +3 -3
- package/src/egress/transport.js +148 -0
- package/src/engine.js +9 -0
- package/src/integrations/index.js +18 -9
- package/src/llm-validator/index.js +4 -2
- package/src/llm-validator/local-endpoint.js +4 -1
- package/src/llm-validator/ollama-provider.js +8 -6
- package/src/pipeline/scan-health.js +15 -2
- package/src/posture/CLAUDE.md +1 -0
- package/src/posture/adversary-agent.js +27 -10
- package/src/posture/assurance/config.js +2 -0
- package/src/posture/assurance/decision.js +45 -0
- package/src/posture/assurance/rollout.js +3 -0
- package/src/posture/custom-rules.js +13 -1
- package/src/posture/fleet/cli.js +2 -4
- package/src/posture/fleet/fleet-manifest.js +4 -1
- package/src/posture/fleet/job-runner.js +28 -4
- package/src/posture/fleet/org-health.js +23 -0
- package/src/posture/fleet/scan-cache.js +1 -1
- package/src/posture/flow-narration.js +10 -8
- package/src/posture/llm-redteam.js +3 -1
- package/src/posture/provenance/coordinator.js +8 -0
- package/src/posture/provenance/origin-resolver.js +17 -4
- package/src/posture/rule-compiler/cli.js +56 -8
- package/src/posture/rule-compiler/corpus.js +10 -2
- package/src/posture/rule-compiler/ir.js +10 -1
- package/src/posture/rule-compiler/promotion.js +12 -2
- package/src/posture/rule-compiler/regression.js +1 -1
- package/src/posture/rule-compiler/store.js +27 -7
- package/src/posture/strategic-metrics.js +50 -7
- package/src/report/index.js +7 -6
- package/src/sca/llm-function-extract.js +17 -13
package/dist/7838.index.js
CHANGED
|
@@ -34,11 +34,15 @@ function toRuntimeRule(ir) {
|
|
|
34
34
|
|
|
35
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' };
|
|
36
36
|
|
|
37
|
-
function buildCorpus(ir, { originalCode, patchedCode = null, nearbyCleanCode = [], protectedVariantsCode = [] } = {}) {
|
|
37
|
+
function buildCorpus(ir, { originalCode, patchedCode = null, nearbyCleanCode = [], protectedVariantsCode = [], independentVulnerableCode = [], independentCleanCode = [] } = {}) {
|
|
38
38
|
const positives = [{ label: 'original', code: originalCode }];
|
|
39
39
|
for (const v of (0,_mutation_js__WEBPACK_IMPORTED_MODULE_0__/* .generateVariants */ .G4)(originalCode)) positives.push({ label: `sibling:${v.mutator}`, code: v.code });
|
|
40
|
+
// Independent examples (GA-07): vulnerable and safe code that is NOT the source finding and NOT a mutation of it, supplied by the
|
|
41
|
+
// reviewer from other places. They are never mutated, so they score as what they are: evidence from outside the rule's own material.
|
|
42
|
+
independentVulnerableCode.forEach((code, i) => positives.push({ label: `independent-vulnerable-${i}`, code }));
|
|
40
43
|
|
|
41
44
|
const negatives = [];
|
|
45
|
+
independentCleanCode.forEach((code, i) => negatives.push({ label: `independent-clean-${i}`, code }));
|
|
42
46
|
if (patchedCode) {
|
|
43
47
|
negatives.push({ label: 'patched', code: patchedCode });
|
|
44
48
|
for (const v of (0,_mutation_js__WEBPACK_IMPORTED_MODULE_0__/* .generateVariants */ .G4)(patchedCode)) negatives.push({ label: `patched-sibling:${v.mutator}`, code: v.code });
|
|
@@ -74,8 +78,12 @@ function scoreRule(ir, corpus) {
|
|
|
74
78
|
const recall = corpus.positives.length === 0 ? null : tp / corpus.positives.length;
|
|
75
79
|
// "Protected negative failures" per PRD 3: a rule firing on a sanitizer/guard-carrying variant specifically.
|
|
76
80
|
const protectedFailures = negResults.filter((n) => n.label.startsWith('protected-') && n.fired).length;
|
|
81
|
+
// Independent evidence, counted apart: how many examples from outside the finding's own material there were, and how they scored.
|
|
82
|
+
const indPos = posResults.filter((p) => p.label.startsWith('independent-vulnerable-'));
|
|
83
|
+
const indNeg = negResults.filter((n) => n.label.startsWith('independent-clean-'));
|
|
84
|
+
const independent = { positives: indPos.length, negatives: indNeg.length, detected: indPos.filter((p) => p.fired).length, falseAlarms: indNeg.filter((n) => n.fired).length };
|
|
77
85
|
|
|
78
|
-
return { precision, recall, tp, fp, tn, fn, protectedFailures, positives: posResults, negatives: negResults };
|
|
86
|
+
return { precision, recall, tp, fp, tn, fn, protectedFailures, independent, positives: posResults, negatives: negResults };
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
|
|
@@ -169,9 +177,18 @@ function validateRuleIR(ir) {
|
|
|
169
177
|
return errs;
|
|
170
178
|
}
|
|
171
179
|
|
|
180
|
+
/** JSON with every object's keys sorted at every depth, so the digest does not depend on insertion order. */
|
|
181
|
+
function stableStringify(v) {
|
|
182
|
+
if (Array.isArray(v)) return `[${v.map(stableStringify).join(',')}]`;
|
|
183
|
+
if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(v[k])}`).join(',')}}`;
|
|
184
|
+
return JSON.stringify(v);
|
|
185
|
+
}
|
|
186
|
+
|
|
172
187
|
function ruleIRDigest(ir) {
|
|
173
188
|
const { state: _s, ...rest } = ir;
|
|
174
|
-
|
|
189
|
+
// Until 0.195.23 this passed the TOP-LEVEL key list as the JSON replacer, which also filters nested objects: `match.pattern` and
|
|
190
|
+
// every structural regex were serialized as `{}`, so the digest never covered the one thing a rule is. Sorted at every depth now.
|
|
191
|
+
return (0,_proof_fabric_reproducer_js__WEBPACK_IMPORTED_MODULE_0__/* .sha256 */ .sc)(stableStringify(rest));
|
|
175
192
|
}
|
|
176
193
|
|
|
177
194
|
/** Build a DRAFT rule IR. Caller supplies the candidate pattern — this module never invents one. */
|
|
@@ -386,7 +403,11 @@ function mutatorCoverage(code) {
|
|
|
386
403
|
// actually recorded, and nothing in this module writes to it.
|
|
387
404
|
|
|
388
405
|
|
|
389
|
-
|
|
406
|
+
// Since 0.195.23 (GA-07) promotion also needs independent evidence: at least one vulnerable and one safe example that are not the
|
|
407
|
+
// source finding or a mutation of it, and the rule must detect every independent vulnerable example and stay quiet on every
|
|
408
|
+
// independent safe one. The synthesis loop still scores attempts without them (`evaluateRule`); only the promotion decision
|
|
409
|
+
// (`gatePromotion`) requires them, so a candidate can be proposed before a reviewer has gathered the outside examples.
|
|
410
|
+
const PROMOTION_DEFAULTS = Object.freeze({ minPrecision: 0.95, minRecall: 0.80, maxProtectedFailures: 0, minIndependentVulnerable: 1, minIndependentClean: 1 });
|
|
390
411
|
|
|
391
412
|
// Deliberately independent of posture/evaluation/custody.js: that module's term list and guard are reserved for
|
|
392
413
|
// the evaluation custodian's sealed-label boundary (QA-002.AC03), and a test enforces that nothing outside
|
|
@@ -441,7 +462,13 @@ function gatePromotion({ ir, corpus, leakageCheck, thresholds } = {}) {
|
|
|
441
462
|
const evalResult = evaluateRule(ir, corpus, thresholds);
|
|
442
463
|
const reasons = [...evalResult.reasons];
|
|
443
464
|
if (!leakageCheck || leakageCheck.ok !== true) reasons.push(leakageCheck ? `leakage check failed: ${leakageCheck.offendingTerms.join(', ')}` : 'leakage check was not run');
|
|
444
|
-
|
|
465
|
+
const t = evalResult.thresholds;
|
|
466
|
+
const ind = evalResult.score.independent ?? { positives: 0, negatives: 0, detected: 0, falseAlarms: 0 };
|
|
467
|
+
if (ind.positives < t.minIndependentVulnerable) reasons.push(`${ind.positives} independent vulnerable example(s) beyond the source finding and its mutations (min ${t.minIndependentVulnerable}): supply independentVulnerableCode`);
|
|
468
|
+
else if (ind.detected < ind.positives) reasons.push(`misses ${ind.positives - ind.detected} of ${ind.positives} independent vulnerable example(s)`);
|
|
469
|
+
if (ind.negatives < t.minIndependentClean) reasons.push(`${ind.negatives} independent safe example(s) (min ${t.minIndependentClean}): supply independentCleanCode`);
|
|
470
|
+
else if (ind.falseAlarms > 0) reasons.push(`fires on ${ind.falseAlarms} of ${ind.negatives} independent safe example(s)`);
|
|
471
|
+
return { pass: reasons.length === 0, reasons, evaluation: evalResult, independent: ind };
|
|
445
472
|
}
|
|
446
473
|
|
|
447
474
|
|
package/dist/9441.index.js
CHANGED
|
@@ -89,7 +89,7 @@ function evidenceConversion(findings) {
|
|
|
89
89
|
const byState = Object.fromEntries(EVIDENCE_STATES.map((s) => [s, 0]));
|
|
90
90
|
for (const f of list) byState[evidenceStateOf(f)]++;
|
|
91
91
|
const converted = list.length - byState['single-signal'];
|
|
92
|
-
return { ...rate(converted, list.length), byState, basis: 'findings that reached corroborated or stronger, over all findings' };
|
|
92
|
+
return { status: list.length ? 'measured' : 'unmeasured', ...rate(converted, list.length), byState, basis: 'findings that reached corroborated or stronger, over all findings of the last scan' };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
const strongest = (a) => {
|
|
@@ -98,21 +98,52 @@ const strongest = (a) => {
|
|
|
98
98
|
return disappeared && a.testsRan === true && a.testsOk !== false && a.pocOk === true;
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
+
const isoOrNull = (v) => (typeof v === 'string' && !Number.isNaN(Date.parse(v)) ? v : null);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One attempt per finding (GA-11): a fix tried five times is one remediation, not five, so the LATEST recorded attempt for each
|
|
105
|
+
* finding id is the one counted and the earlier ones are reported as `repeated`. An attempt with no finding id cannot be matched to
|
|
106
|
+
* another and counts on its own. Order is by `at` where every record has one, else by position in the log.
|
|
107
|
+
*/
|
|
108
|
+
function dedupeAttempts(attempts) {
|
|
109
|
+
const list = Array.isArray(attempts) ? attempts.filter((a) => a && typeof a === 'object') : [];
|
|
110
|
+
const latest = new Map();
|
|
111
|
+
const loose = [];
|
|
112
|
+
list.forEach((a, i) => {
|
|
113
|
+
const key = typeof a.stableId === 'string' && a.stableId ? a.stableId : (typeof a.findingId === 'string' && a.findingId ? a.findingId : null);
|
|
114
|
+
if (!key) { loose.push(a); return; }
|
|
115
|
+
const prev = latest.get(key);
|
|
116
|
+
const newer = !prev || (isoOrNull(a.at) && isoOrNull(prev.at) ? Date.parse(a.at) >= Date.parse(prev.at) : i > prev._i);
|
|
117
|
+
if (newer) latest.set(key, Object.assign(Object.create(null), a, { _i: i }));
|
|
118
|
+
});
|
|
119
|
+
const kept = [...latest.values()].map((a) => { const { _i, ...rest } = a; return rest; }).concat(loose);
|
|
120
|
+
return { attempts: kept, recorded: list.length, repeated: list.length - kept.length };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The time span the recorded attempts cover: `from` and `to` are the earliest and latest `at`, or `null` when none carries one. */
|
|
124
|
+
function attemptsWindow(attempts) {
|
|
125
|
+
const times = (Array.isArray(attempts) ? attempts : []).map((a) => a && isoOrNull(a.at)).filter(Boolean).sort();
|
|
126
|
+
return { from: times[0] ?? null, to: times[times.length - 1] ?? null };
|
|
127
|
+
}
|
|
128
|
+
|
|
101
129
|
/** Of the fix attempts, how many reached the strongest verification tier. A lower bound: some classes have no independent proof to run. */
|
|
102
130
|
function verifiedRemediationRate(attempts) {
|
|
103
|
-
const
|
|
131
|
+
const { attempts: list, recorded, repeated } = dedupeAttempts(attempts);
|
|
104
132
|
const full = list.filter(strongest).length;
|
|
105
133
|
const disappearedOnly = list.filter((a) => typeof a.verifiedFull !== 'boolean' && (a.rescanOk === true || (a.ok === true && a.rescanOk !== false)) && !(a.testsRan === true && a.testsOk !== false) && a.pocOk !== true).length;
|
|
134
|
+
const failed = list.filter((a) => a.ok === false || a.verifiedFull === false).length;
|
|
106
135
|
return {
|
|
136
|
+
status: list.length ? 'measured' : 'unmeasured',
|
|
107
137
|
...rate(full, list.length),
|
|
108
|
-
disappearedOnly,
|
|
109
|
-
|
|
138
|
+
recorded, repeated, failed, disappearedOnly,
|
|
139
|
+
window: attemptsWindow(attempts),
|
|
140
|
+
basis: 'the latest attempt per finding that passed the re-scan, the project tests and the independent attack replay, over the latest attempt per finding. A lower bound where a class has no replay',
|
|
110
141
|
};
|
|
111
142
|
}
|
|
112
143
|
|
|
113
|
-
/** Nothing measures it independently of the rule compiler's own corpus, so it is reported as
|
|
144
|
+
/** Nothing measures it independently of the rule compiler's own corpus, so it is reported as unmeasured, with the reason. */
|
|
114
145
|
function securityLearningRate() {
|
|
115
|
-
return { status: '
|
|
146
|
+
return { status: 'unmeasured', reason: 'the rule compiler grades its rules against a corpus built from the same finding and the reviewer-supplied independent examples; no independent longitudinal measurement of novel weaknesses converted into reusable knowledge exists' };
|
|
116
147
|
}
|
|
117
148
|
|
|
118
149
|
/**
|
|
@@ -121,11 +152,21 @@ function securityLearningRate() {
|
|
|
121
152
|
* @param {object[]} [o.fixAttempts] fix verification attempts
|
|
122
153
|
*/
|
|
123
154
|
function computeStrategicMetrics({ scan = null, fixAttempts = [] } = {}) {
|
|
155
|
+
const attempts = Array.isArray(fixAttempts) ? fixAttempts : [];
|
|
124
156
|
return {
|
|
125
157
|
schema: STRATEGIC_METRICS_SCHEMA, version: STRATEGIC_METRICS_VERSION,
|
|
158
|
+
// The scope and time basis (GA-11): these are per-scan figures for ONE scan plus the attempts recorded on this machine, never an
|
|
159
|
+
// organisation trend. The scan is named by its id and start time; the attempts by the span their timestamps cover.
|
|
160
|
+
scope: {
|
|
161
|
+
kind: 'per-scan',
|
|
162
|
+
scanId: scan && typeof scan.scanId === 'string' ? scan.scanId : null,
|
|
163
|
+
scanStartedAt: scan ? isoOrNull(scan.startedAt) : null,
|
|
164
|
+
attempts: { ...attemptsWindow(attempts), recorded: attempts.filter((a) => a && typeof a === 'object').length },
|
|
165
|
+
trend: 'not a trend: one scan and this machine\'s recorded attempts; compare scans by scanId to see movement',
|
|
166
|
+
},
|
|
126
167
|
assuranceCoverage: assuranceCoverage(scan && scan.scanHealth),
|
|
127
168
|
evidenceConversion: evidenceConversion(scan && scan.findings),
|
|
128
|
-
verifiedRemediationRate: verifiedRemediationRate(
|
|
169
|
+
verifiedRemediationRate: verifiedRemediationRate(attempts),
|
|
129
170
|
securityLearningRate: securityLearningRate(),
|
|
130
171
|
};
|
|
131
172
|
}
|
|
@@ -135,6 +176,7 @@ const pc = (r) => (r && r.percent !== null && r.percent !== undefined ? `${r.per
|
|
|
135
176
|
function renderStrategicMetrics(m) {
|
|
136
177
|
const ac = m.assuranceCoverage; const ec = m.evidenceConversion; const vr = m.verifiedRemediationRate;
|
|
137
178
|
const L = [];
|
|
179
|
+
if (m.scope) L.push(`Scope: one scan${m.scope.scanId ? ` (${m.scope.scanId}${m.scope.scanStartedAt ? `, started ${m.scope.scanStartedAt}` : ''})` : ''} and ${m.scope.attempts.recorded} fix attempt(s) recorded on this machine${m.scope.attempts.from ? ` between ${m.scope.attempts.from} and ${m.scope.attempts.to}` : ''}; not a trend`);
|
|
138
180
|
L.push(`Assurance Coverage: ${ac.percent === null ? 'not measurable' : `${ac.percent}% (the lower of the two components)`}${ac.scanStatus ? `; scan ${ac.scanStatus}` : ''}`);
|
|
139
181
|
if (ac.files) L.push(` files: ${ac.files.numerator} of ${ac.files.denominator} completed (${pc(ac.files)}); ${ac.files.skipped} skipped, ${ac.files.unanalysed} not analysed, ${ac.files.timedOut} timed out`);
|
|
140
182
|
if (ac.analyzers) L.push(` analyzers: ${ac.analyzers.numerator} of ${ac.analyzers.denominator} completed (${pc(ac.analyzers)}); ${ac.analyzers.failed} failed, ${ac.analyzers.timedOut} timed out`);
|
|
@@ -143,6 +185,7 @@ function renderStrategicMetrics(m) {
|
|
|
143
185
|
L.push(` ${EVIDENCE_STATES.map((s) => `${s} ${ec.byState[s]}`).join(', ')}`);
|
|
144
186
|
L.push(`Verified Remediation Rate: ${vr.numerator} of ${vr.denominator} fix attempts reached the strongest verification tier (${pc(vr)})${vr.denominator ? '; a lower bound where a class has no attack replay' : ''}`);
|
|
145
187
|
if (vr.disappearedOnly) L.push(` ${vr.disappearedOnly} satisfied only the re-scan, which is also what deleting the code produces`);
|
|
188
|
+
if (vr.repeated) L.push(` ${vr.recorded} attempt(s) recorded; ${vr.repeated} earlier attempt(s) on the same finding counted once, by the latest`);
|
|
146
189
|
L.push(`Security Learning Rate: not measured. ${m.securityLearningRate.reason}`);
|
|
147
190
|
return L.join('\n');
|
|
148
191
|
}
|
package/dist/9824.index.js
CHANGED
|
@@ -13,6 +13,8 @@ export const modules = {
|
|
|
13
13
|
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(73024);
|
|
14
14
|
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(76760);
|
|
15
15
|
/* harmony import */ var _egress_policy_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(45712);
|
|
16
|
+
/* harmony import */ var _egress_transport_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(73785);
|
|
17
|
+
/* harmony import */ var _llm_validator_providers_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(38947);
|
|
16
18
|
// LLM-assisted vulnerable function extraction for SCA findings.
|
|
17
19
|
//
|
|
18
20
|
// For CVEs without OSV ecosystem_specific data or GHSA fix commits,
|
|
@@ -26,6 +28,8 @@ export const modules = {
|
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
|
|
31
|
+
|
|
32
|
+
|
|
29
33
|
const CACHE_DIR = process.env.XDG_CONFIG_HOME
|
|
30
34
|
? node_path__WEBPACK_IMPORTED_MODULE_2__.join(process.env.XDG_CONFIG_HOME, 'agentic-security', 'llm-sca-cache')
|
|
31
35
|
: node_path__WEBPACK_IMPORTED_MODULE_2__.join(process.env.HOME || '/tmp', '.config', 'agentic-security', 'llm-sca-cache');
|
|
@@ -52,24 +56,26 @@ function isLlmScaEnabled() {
|
|
|
52
56
|
return process.env.AGENTIC_SECURITY_LLM_SCA === '1';
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
// The provider is resolved the same way the validator resolves it (GA-06.3): the `local` preset refuses a non-loopback endpoint here
|
|
60
|
+
// too, instead of this module reading the raw endpoint variable and calling it regardless. The Ollama provider speaks a different
|
|
61
|
+
// protocol and this extractor does not implement it, so it is treated as not configured.
|
|
62
|
+
function _endpointConfig(env = process.env) {
|
|
63
|
+
const r = (0,_llm_validator_providers_js__WEBPACK_IMPORTED_MODULE_5__.resolveProvider)({ role: 'validate', env });
|
|
64
|
+
if (!r.ok || r.config.provider === 'ollama') return null;
|
|
65
|
+
return { endpoint: r.config.endpoint, apiKey: r.config.apiKey, model: r.config.model || 'unknown', shape: r.config.shape };
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
async function _askLlm(prompt, config) {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const resp = await fetch(config.endpoint, {
|
|
69
|
+
const req = (0,_llm_validator_providers_js__WEBPACK_IMPORTED_MODULE_5__/* .buildProviderRequest */ .Z$)(config, prompt, 256);
|
|
70
|
+
const resp = await (0,_egress_transport_js__WEBPACK_IMPORTED_MODULE_4__/* .modelFetch */ .Ye)(config.endpoint, {
|
|
66
71
|
method: 'POST',
|
|
67
|
-
headers,
|
|
68
|
-
body: JSON.stringify(
|
|
69
|
-
|
|
70
|
-
});
|
|
72
|
+
headers: req.headers,
|
|
73
|
+
body: JSON.stringify(req.body),
|
|
74
|
+
}, { timeoutMs: 15000 });
|
|
71
75
|
if (!resp.ok) return null;
|
|
72
|
-
|
|
76
|
+
let text = await resp.text();
|
|
77
|
+
// a vendor shape wraps the model's text in its own envelope; the generic shape's reply is searched as it came
|
|
78
|
+
try { const t = req.extractText(JSON.parse(text)); if (typeof t === 'string' && t) text = t; } catch { /* not an envelope */ }
|
|
73
79
|
const jsonMatch = text.match(/\{[^{}]*"functions"\s*:\s*\[[^\]]*\][^{}]*\}/);
|
|
74
80
|
if (!jsonMatch) return null;
|
|
75
81
|
try { return JSON.parse(jsonMatch[0]); } catch { return null; }
|