@blamejs/exceptd-skills 0.18.6 → 0.18.8
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 +42 -0
- package/bin/exceptd.js +364 -119
- package/data/_indexes/_meta.json +22 -3
- package/data/cve-catalog.json +25 -0
- package/data/playbooks/framework.json +2 -2
- package/data/playbooks/post-quantum-migration.json +1 -1
- package/lib/auto-discovery.js +30 -10
- package/lib/collectors/ai-api.js +9 -2
- package/lib/collectors/cicd-pipeline-compromise.js +24 -5
- package/lib/collectors/cred-stores.js +17 -4
- package/lib/collectors/crypto.js +9 -2
- package/lib/collectors/hardening.js +9 -2
- package/lib/collectors/library-author.js +29 -5
- package/lib/collectors/mcp.js +9 -2
- package/lib/collectors/runtime.js +9 -2
- package/lib/collectors/sbom.js +28 -15
- package/lib/collectors/scan-excludes.js +25 -0
- package/lib/collectors/secrets.js +40 -4
- package/lib/cve-curation.js +84 -8
- package/lib/lint-skills.js +75 -3
- package/lib/playbook-runner.js +443 -50
- package/lib/prefetch.js +22 -2
- package/lib/refresh-external.js +32 -1
- package/lib/refresh-network.js +235 -26
- package/lib/schemas/cve-catalog.schema.json +5 -0
- package/lib/scoring.js +141 -21
- package/lib/sign.js +107 -29
- package/lib/source-advisories.js +23 -5
- package/lib/source-ghsa.js +25 -1
- package/lib/source-osv.js +26 -1
- package/lib/upstream-check.js +1 -1
- package/lib/validate-cve-catalog.js +30 -4
- package/lib/validate-indexes.js +135 -29
- package/lib/validate-playbooks.js +19 -7
- package/lib/validate-vendor.js +69 -8
- package/lib/verify.js +23 -6
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +14 -3
- package/orchestrator/index.js +100 -20
- package/orchestrator/scanner.js +8 -0
- package/package.json +1 -1
- package/sbom.cdx.json +130 -130
- package/scripts/audit-cross-skill.js +1 -1
- package/scripts/bootstrap.js +1 -0
- package/scripts/build-indexes.js +84 -11
- package/scripts/check-agents-md-collectors.js +41 -13
- package/scripts/check-changelog-extract.js +4 -4
- package/scripts/check-codebase-patterns.js +19 -5
- package/scripts/check-manifest-snapshot.js +74 -30
- package/scripts/check-sbom-currency.js +25 -5
- package/scripts/check-test-count.js +26 -7
- package/scripts/check-test-coverage.js +44 -4
- package/scripts/check-version-tags.js +27 -8
- package/scripts/predeploy.js +1 -1
- package/scripts/refresh-manifest-snapshot.js +14 -4
- package/scripts/refresh-reverse-refs.js +7 -1
- package/scripts/refresh-sbom.js +1 -1
- package/scripts/release.js +3 -3
- package/scripts/run-e2e-scenarios.js +18 -8
- package/scripts/validate-vendor-online.js +28 -2
- package/scripts/verify-shipped-tarball.js +65 -6
- package/sources/validators/cve-validator.js +17 -1
- package/vendor/blamejs/_PROVENANCE.json +4 -2
package/lib/playbook-runner.js
CHANGED
|
@@ -249,15 +249,22 @@ function preflight(playbook, runOpts = {}) {
|
|
|
249
249
|
|
|
250
250
|
// 1. Currency gate
|
|
251
251
|
const score = meta.threat_currency_score;
|
|
252
|
-
|
|
252
|
+
// A non-numeric score (absent / null / NaN from a malformed _meta) must HARD
|
|
253
|
+
// BLOCK, not slip through: `undefined < 50` is false, which would silently
|
|
254
|
+
// bypass the staleness gate on exactly the playbooks whose currency metadata
|
|
255
|
+
// is broken. Treat "no usable score" as the most-stale state.
|
|
256
|
+
const scoreUsable = typeof score === 'number' && !Number.isNaN(score);
|
|
257
|
+
if ((!scoreUsable || score < 50) && !runOpts.forceStale) {
|
|
253
258
|
return {
|
|
254
259
|
ok: false,
|
|
255
260
|
blocked_by: 'currency',
|
|
256
|
-
reason:
|
|
261
|
+
reason: scoreUsable
|
|
262
|
+
? `threat_currency_score = ${score} (< 50). Hard-blocked. Pass forceStale=true to override.`
|
|
263
|
+
: `threat_currency_score is absent or non-numeric (${JSON.stringify(score)}). Hard-blocked — a playbook without a usable currency score is treated as stale. Fix _meta.threat_currency_score, or pass forceStale=true to override.`,
|
|
257
264
|
issues
|
|
258
265
|
};
|
|
259
266
|
}
|
|
260
|
-
if (score < 70) {
|
|
267
|
+
if (scoreUsable && score < 70) {
|
|
261
268
|
issues.push({ kind: 'currency_warn', message: `threat_currency_score = ${score} (< 70). Threat model is stale — recommend running the skill-update-loop before relying on findings.` });
|
|
262
269
|
}
|
|
263
270
|
|
|
@@ -357,19 +364,53 @@ function preflight(playbook, runOpts = {}) {
|
|
|
357
364
|
return { ok: true, issues };
|
|
358
365
|
}
|
|
359
366
|
|
|
360
|
-
// lockDir lives at a stable
|
|
367
|
+
// lockDir lives at a stable per-user path so two CLI invocations from
|
|
361
368
|
// different working directories still share lock state for cross-process
|
|
362
369
|
// mutex enforcement. A process.cwd()-relative dir would let invocations
|
|
363
370
|
// from /tmp and from /home/user/project simultaneously each see an empty
|
|
364
|
-
// locks dir and both run unchallenged.
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
371
|
+
// locks dir and both run unchallenged.
|
|
372
|
+
//
|
|
373
|
+
// Resolution order (most-specific first):
|
|
374
|
+
// 1. EXCEPTD_LOCK_DIR explicit container/CI override
|
|
375
|
+
// 2. EXCEPTD_HOME || ~/.exceptd + /locks/<platform> per-user default
|
|
376
|
+
// 3. os.tmpdir()/exceptd-locks-<platform> last-resort fallback
|
|
377
|
+
// when the per-user home is non-writable (read-only home, restricted
|
|
378
|
+
// sandbox/CI runner).
|
|
379
|
+
//
|
|
380
|
+
// The per-user home (mirroring the orchestrator's watch.lock + attestation
|
|
381
|
+
// roots) is both the safer location — a per-user dir is not the
|
|
382
|
+
// world-writable shared OS tempdir a preplant/symlink attack targets — and
|
|
383
|
+
// the better fit for lockDir's stated goal: ~/.exceptd is per-user-stable
|
|
384
|
+
// across working directories, whereas the shared tmpdir is the weaker
|
|
385
|
+
// choice. The path keys on os.platform() so Windows/macOS/Linux locks live
|
|
386
|
+
// under separate directories (avoids cross-platform stale-PID confusion when
|
|
387
|
+
// a host is shared across OSes via networked FS).
|
|
388
|
+
function resolveLockDir() {
|
|
389
|
+
if (process.env.EXCEPTD_LOCK_DIR) return process.env.EXCEPTD_LOCK_DIR;
|
|
390
|
+
const home = process.env.EXCEPTD_HOME || (os.homedir() && path.join(os.homedir(), '.exceptd'));
|
|
391
|
+
if (home) {
|
|
392
|
+
const dir = path.join(home, 'locks', process.platform);
|
|
393
|
+
try {
|
|
394
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
395
|
+
// Probe writability with a marker; remove on success. A home that
|
|
396
|
+
// mkdirs but can't be written (read-only mount, restrictive ACL) must
|
|
397
|
+
// fall through to the tmpdir fallback rather than silently no-op every
|
|
398
|
+
// lock write.
|
|
399
|
+
const probe = path.join(dir, `.write-probe-${process.pid}`);
|
|
400
|
+
fs.writeFileSync(probe, '');
|
|
401
|
+
fs.unlinkSync(probe);
|
|
402
|
+
return dir;
|
|
403
|
+
} catch { /* home non-writable — fall through to tmpdir */ }
|
|
404
|
+
}
|
|
405
|
+
return path.join(os.tmpdir(), `exceptd-locks-${process.platform}`);
|
|
406
|
+
}
|
|
407
|
+
|
|
369
408
|
function lockDir() {
|
|
370
|
-
const dir =
|
|
371
|
-
|
|
372
|
-
|
|
409
|
+
const dir = resolveLockDir();
|
|
410
|
+
// Owner-only (0700): the mutex lock files inside use O_EXCL ('wx'), but
|
|
411
|
+
// tightening the parent directory keeps another local user from listing or
|
|
412
|
+
// tampering with this user's lock set.
|
|
413
|
+
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* exists / EACCES — non-fatal */ }
|
|
373
414
|
return dir;
|
|
374
415
|
}
|
|
375
416
|
|
|
@@ -388,14 +429,27 @@ function lockFilePath(playbookId) {
|
|
|
388
429
|
// lifetime.
|
|
389
430
|
const STALE_LOCK_MS = 30_000;
|
|
390
431
|
|
|
432
|
+
// Create the mutex lock file with an exclusive, owner-only descriptor
|
|
433
|
+
// (O_CREAT | O_EXCL | 0o600 via openSync 'wx'). The lock-file NAME is
|
|
434
|
+
// deliberately predictable — it IS the cross-process mutex, so every contender
|
|
435
|
+
// must agree on it; security comes from the exclusive create (a preplanted file
|
|
436
|
+
// or symlink makes the open throw EEXIST/EPERM, never a silent follow), the
|
|
437
|
+
// 0o700 lock directory, and the 0o600 mode — written through openSync so the
|
|
438
|
+
// secure-create primitive is explicit rather than a bare writeFileSync. Throws
|
|
439
|
+
// EEXIST when the lock is already held, which every caller already handles.
|
|
440
|
+
function writeLockFile(p, playbookId) {
|
|
441
|
+
const fd = fs.openSync(p, 'wx', 0o600);
|
|
442
|
+
try {
|
|
443
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2));
|
|
444
|
+
} finally {
|
|
445
|
+
fs.closeSync(fd);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
391
449
|
function acquireLock(playbookId) {
|
|
392
450
|
const p = lockFilePath(playbookId);
|
|
393
451
|
if (!p) return null;
|
|
394
|
-
const writePayload = () =>
|
|
395
|
-
p,
|
|
396
|
-
JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
|
|
397
|
-
{ flag: 'wx' }
|
|
398
|
-
);
|
|
452
|
+
const writePayload = () => writeLockFile(p, playbookId);
|
|
399
453
|
try {
|
|
400
454
|
writePayload();
|
|
401
455
|
return p;
|
|
@@ -458,9 +512,7 @@ function acquireLockDiagnostic(playbookId) {
|
|
|
458
512
|
const p = lockFilePath(playbookId);
|
|
459
513
|
if (!p) return { ok: false, reason: 'no_lock_path' };
|
|
460
514
|
try {
|
|
461
|
-
|
|
462
|
-
JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
|
|
463
|
-
{ flag: 'wx' });
|
|
515
|
+
writeLockFile(p, playbookId);
|
|
464
516
|
return { ok: true, path: p };
|
|
465
517
|
} catch (e) {
|
|
466
518
|
if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
|
|
@@ -476,9 +528,7 @@ function acquireLockDiagnostic(playbookId) {
|
|
|
476
528
|
if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && !pidAlive(pid)) {
|
|
477
529
|
try { fs.unlinkSync(p); } catch {}
|
|
478
530
|
try {
|
|
479
|
-
|
|
480
|
-
JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
|
|
481
|
-
{ flag: 'wx' });
|
|
531
|
+
writeLockFile(p, playbookId);
|
|
482
532
|
return { ok: true, path: p, reclaimed_from_pid: pid };
|
|
483
533
|
} catch (e2) {
|
|
484
534
|
return { ok: false, reason: 'reclaim_failed', error: e2.message, lock_path: p, holder_pid: pid };
|
|
@@ -494,9 +544,7 @@ function acquireLockDiagnostic(playbookId) {
|
|
|
494
544
|
if (mtimeMs !== null && (Date.now() - mtimeMs) > STALE_LOCK_MS) {
|
|
495
545
|
try { fs.unlinkSync(p); } catch {}
|
|
496
546
|
try {
|
|
497
|
-
|
|
498
|
-
JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
|
|
499
|
-
{ flag: 'wx' });
|
|
547
|
+
writeLockFile(p, playbookId);
|
|
500
548
|
return { ok: true, path: p, reclaimed_self_stale_pid: true, prior_mtime_ms: mtimeMs };
|
|
501
549
|
} catch (e3) {
|
|
502
550
|
return { ok: false, reason: 'reclaim_failed', error: e3.message, lock_path: p, holder_pid: pid };
|
|
@@ -608,7 +656,12 @@ function look(playbookId, directiveId, runOpts = {}) {
|
|
|
608
656
|
// Surface the air-gap alternative as the primary source when air_gap_mode
|
|
609
657
|
// is active, so the agent doesn't accidentally hit the network.
|
|
610
658
|
source: airGap && a.air_gap_alternative ? a.air_gap_alternative : a.source,
|
|
611
|
-
_original_source: a.source
|
|
659
|
+
_original_source: a.source,
|
|
660
|
+
// In air-gap mode an artifact with NO air_gap_alternative silently keeps
|
|
661
|
+
// its original (possibly network-bound) source — defeating air-gap with no
|
|
662
|
+
// signal. Flag it so the agent treats the source as not-offline-verified
|
|
663
|
+
// and the gap is observable instead of a silent network fallback.
|
|
664
|
+
...(airGap && !a.air_gap_alternative ? { air_gap_alternative_missing: true } : {}),
|
|
612
665
|
})),
|
|
613
666
|
collection_scope: l.collection_scope,
|
|
614
667
|
environment_assumptions: l.environment_assumptions || [],
|
|
@@ -978,8 +1031,12 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
978
1031
|
: [];
|
|
979
1032
|
// VEX-fixed CVEs remain in matched/catalog arrays but get annotated
|
|
980
1033
|
// with vex_status:'fixed' downstream so consumers see them as resolved.
|
|
1034
|
+
// Source from catalogBaselineCves (post-vexFilter survivors), NOT allCves: a
|
|
1035
|
+
// CVE the operator marked BOTH not_affected (dropped by vexFilter) AND fixed
|
|
1036
|
+
// is contradictory, and listing it as fixed while it's absent from
|
|
1037
|
+
// matched/baseline would have it appear in fixed_cves but nowhere else.
|
|
981
1038
|
const vexFixedIds = vexFixed
|
|
982
|
-
?
|
|
1039
|
+
? catalogBaselineCves.filter(c => vexFixed.has(c.cve_id)).map(c => c.cve_id)
|
|
983
1040
|
: [];
|
|
984
1041
|
|
|
985
1042
|
// Build correlation map: cve_id -> array of "indicator_hit:<id>" / "signal:<id>" reasons.
|
|
@@ -1058,6 +1115,14 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1058
1115
|
const vexStatus = (vexFixed && vexFixed.has(c.cve_id)) ? 'fixed' : null;
|
|
1059
1116
|
return {
|
|
1060
1117
|
cve_id: c.cve_id,
|
|
1118
|
+
// attack_class is the coarse chainable taxonomy (kernel-lpe /
|
|
1119
|
+
// mcp-supply-chain / ai-c2 / prompt-injection / container-escape / …)
|
|
1120
|
+
// the sbom -> deep-dive feeds_into rules quantify over
|
|
1121
|
+
// (`any matched_cve.attack_class == 'kernel-lpe'`). It comes from the
|
|
1122
|
+
// catalog entry's explicit attack_class only — no fuzzy inference from
|
|
1123
|
+
// the free-form `type`, so an unclassified CVE stays null and the chain
|
|
1124
|
+
// correctly does not fire (no false escalation) rather than misrouting.
|
|
1125
|
+
attack_class: c.entry?.attack_class ?? null,
|
|
1061
1126
|
rwep: c.rwep_score,
|
|
1062
1127
|
cvss_score: c.entry?.cvss_score ?? null,
|
|
1063
1128
|
cvss_vector: c.entry?.cvss_vector ?? null,
|
|
@@ -1408,7 +1473,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1408
1473
|
findingShape = {};
|
|
1409
1474
|
}
|
|
1410
1475
|
for (const ec of an.escalation_criteria || []) {
|
|
1411
|
-
if (evalCondition(ec.condition, { ...agentSignals, ...evalCtxRoot, rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, analyze: result, finding: findingShape }, playbook)) {
|
|
1476
|
+
if (evalCondition(ec.condition, { ...agentSignals, ...evalCtxRoot, rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, compliance_theater_check: result.compliance_theater_check, jurisdiction_obligations: (playbook.phases && playbook.phases.govern && playbook.phases.govern.jurisdiction_obligations) || [], analyze: result, matched_cve: result.matched_cves || [], finding: findingShape }, playbook)) {
|
|
1412
1477
|
escalations.push({ condition: ec.condition, action: ec.action, target_playbook: ec.target_playbook || null });
|
|
1413
1478
|
}
|
|
1414
1479
|
}
|
|
@@ -1695,8 +1760,16 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1695
1760
|
};
|
|
1696
1761
|
const enrichNotification = (na) => {
|
|
1697
1762
|
const obligation = (g.jurisdiction_obligations || []).find(o =>
|
|
1763
|
+
o && typeof o === 'object' &&
|
|
1698
1764
|
`${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
|
|
1699
1765
|
);
|
|
1766
|
+
// A non-empty obligation_ref that resolves to nothing leaves the
|
|
1767
|
+
// jurisdiction / regulation / deadline fields null below — surface it as a
|
|
1768
|
+
// runtime_error so the unmatched ref is observable, not a silent null record.
|
|
1769
|
+
if (!obligation && na && typeof na.obligation_ref === 'string' && na.obligation_ref &&
|
|
1770
|
+
Array.isArray(runOpts && runOpts._runErrors)) {
|
|
1771
|
+
pushRunError(runOpts._runErrors, { kind: 'unresolved_obligation_ref', obligation_ref: na.obligation_ref }, { dedupeKey: x => x.obligation_ref || '' });
|
|
1772
|
+
}
|
|
1700
1773
|
// Thread runOpts + the engine-computed classification through so
|
|
1701
1774
|
// computeClockStart can check operator_consent.explicit before
|
|
1702
1775
|
// auto-stamping detect_confirmed, and so an engine-confirmed detection
|
|
@@ -1727,9 +1800,23 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1727
1800
|
&& autoStartEvent
|
|
1728
1801
|
&& eventReady
|
|
1729
1802
|
&& !(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
|
|
1730
|
-
|
|
1803
|
+
// window_hours must be a finite number before it enters the deadline
|
|
1804
|
+
// arithmetic — a malformed obligation with an undefined/null/non-number
|
|
1805
|
+
// window_hours would otherwise compute `getTime() + NaN` and crash the
|
|
1806
|
+
// close phase at `new Date(NaN).toISOString()`. Runtime validation of the
|
|
1807
|
+
// playbook is not enforced, so guard here independently of the schema.
|
|
1808
|
+
const windowValid = obligation && typeof obligation.window_hours === 'number' && Number.isFinite(obligation.window_hours);
|
|
1809
|
+
const deadline = obligation && clockValid && windowValid
|
|
1731
1810
|
? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
|
|
1732
1811
|
: 'pending_clock_start_event';
|
|
1812
|
+
// A notification_action whose obligation_ref was specified but resolves to
|
|
1813
|
+
// no obligation is an internally-inconsistent playbook (the schema requires
|
|
1814
|
+
// obligation_ref to name a real govern obligation). The unmatched ref is
|
|
1815
|
+
// already surfaced as a runtime_error above; drop the record rather than
|
|
1816
|
+
// emit a null-jurisdiction/regulation entry that pollutes the deadline list.
|
|
1817
|
+
if (!obligation && na && typeof na.obligation_ref === 'string' && na.obligation_ref) {
|
|
1818
|
+
return null;
|
|
1819
|
+
}
|
|
1733
1820
|
return {
|
|
1734
1821
|
...na,
|
|
1735
1822
|
// Carry obligation metadata forward so each notification entry is
|
|
@@ -1778,7 +1865,10 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1778
1865
|
})(),
|
|
1779
1866
|
};
|
|
1780
1867
|
};
|
|
1781
|
-
|
|
1868
|
+
// enrichNotification returns null for an unresolved specified obligation_ref
|
|
1869
|
+
// (already surfaced as a runtime_error); filter those out so the notification
|
|
1870
|
+
// list never carries a null-jurisdiction record.
|
|
1871
|
+
const notificationActions = (c.notification_actions || []).map(enrichNotification).filter(Boolean);
|
|
1782
1872
|
|
|
1783
1873
|
// A govern obligation that declares a notification duty but has no
|
|
1784
1874
|
// matching close.notification_actions entry would otherwise never surface
|
|
@@ -1790,15 +1880,27 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1790
1880
|
// can tell it apart from a playbook-authored action.
|
|
1791
1881
|
const coveredObligationRefs = new Set((c.notification_actions || []).map(na => na.obligation_ref));
|
|
1792
1882
|
for (const o of (g.jurisdiction_obligations || [])) {
|
|
1883
|
+
if (!o || typeof o !== 'object') continue; // skip a null/malformed obligation rather than crash close() during synthesis
|
|
1793
1884
|
if (!String(o.obligation || '').startsWith('notify')) continue;
|
|
1885
|
+
// A notify obligation with a non-number window_hours is malformed: it would
|
|
1886
|
+
// synthesize a "…/… undefinedh" ref and could not produce a real deadline.
|
|
1887
|
+
// Surface it as a runtime_error and skip synthesis rather than emit a bogus
|
|
1888
|
+
// record (the deadline guard in enrichNotification also prevents the crash).
|
|
1889
|
+
if (typeof o.window_hours !== 'number' || !Number.isFinite(o.window_hours)) {
|
|
1890
|
+
if (Array.isArray(runOpts && runOpts._runErrors)) {
|
|
1891
|
+
pushRunError(runOpts._runErrors, { kind: 'malformed_obligation_window_hours', obligation: `${o.jurisdiction}/${o.regulation}` }, { dedupeKey: x => x.obligation || '' });
|
|
1892
|
+
}
|
|
1893
|
+
continue;
|
|
1894
|
+
}
|
|
1794
1895
|
const ref = `${o.jurisdiction}/${o.regulation} ${o.window_hours}h`;
|
|
1795
1896
|
if (coveredObligationRefs.has(ref)) continue;
|
|
1796
|
-
|
|
1897
|
+
const synthesized = enrichNotification({
|
|
1797
1898
|
obligation_ref: ref,
|
|
1798
1899
|
recipient: null,
|
|
1799
1900
|
draft_notification: null,
|
|
1800
1901
|
synthesized_from_obligation: true,
|
|
1801
|
-
})
|
|
1902
|
+
});
|
|
1903
|
+
if (synthesized) notificationActions.push(synthesized);
|
|
1802
1904
|
}
|
|
1803
1905
|
|
|
1804
1906
|
// exception_generation — evaluate trigger.
|
|
@@ -1932,7 +2034,37 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1932
2034
|
// and could suppress a legitimate downstream chain.
|
|
1933
2035
|
...agentSignals,
|
|
1934
2036
|
rwep: analyzeResult.rwep?.adjusted,
|
|
1935
|
-
|
|
2037
|
+
// Bare-token parity with the escalation context (analyze()): catalog
|
|
2038
|
+
// feeds_into conditions reference the unqualified tokens `blast_radius_score`
|
|
2039
|
+
// and `theater_verdict` (e.g. framework.json's feeds_into into sbom). Without
|
|
2040
|
+
// these top-level keys resolvePath returns null and `null >= 4` is false
|
|
2041
|
+
// regardless of the engine-computed blast radius, so the chain is dead. They
|
|
2042
|
+
// are spread AFTER ...agentSignals so the engine value wins over any
|
|
2043
|
+
// operator-submitted signals.blast_radius_score / signals.theater_verdict.
|
|
2044
|
+
blast_radius_score: analyzeResult.blast_radius_score,
|
|
2045
|
+
theater_verdict: analyzeResult.compliance_theater_check?.verdict,
|
|
2046
|
+
// Bare top-level `compliance_theater_check` so catalog conditions that
|
|
2047
|
+
// reference `compliance_theater_check.verdict` unqualified (framework.json's
|
|
2048
|
+
// feeds_into into sbom) resolve — without it resolvePath returns null and the
|
|
2049
|
+
// clause is dead regardless of the engine verdict. The `analyze.*` alias
|
|
2050
|
+
// below stays for conditions that use the qualified path.
|
|
2051
|
+
compliance_theater_check: analyzeResult.compliance_theater_check,
|
|
2052
|
+
// Govern-phase jurisdiction obligations so feeds_into conditions like
|
|
2053
|
+
// `… AND jurisdiction_obligations contains 'EU'` (framework → sbom) resolve.
|
|
2054
|
+
jurisdiction_obligations: (g && g.jurisdiction_obligations) || (playbook.phases && playbook.phases.govern && playbook.phases.govern.jurisdiction_obligations) || [],
|
|
2055
|
+
// theater_score follows lib/framework-gap.js's convention: HIGH = more
|
|
2056
|
+
// theater detected (worse). A 'theater' verdict (a gap exists) is the
|
|
2057
|
+
// concerning case, so it scores 100; a clear verdict scores 0. (Earlier
|
|
2058
|
+
// this was inverted, so a feeds_into condition like `theater_score >= 50`
|
|
2059
|
+
// would have failed to fire exactly when a gap was found.)
|
|
2060
|
+
theater_score: analyzeResult.compliance_theater_check?.verdict === 'theater' ? 100 : 0,
|
|
2061
|
+
// Top-level matched_cve array so the shipped sbom feeds_into quantifiers
|
|
2062
|
+
// (`any matched_cve.attack_class == 'kernel-lpe'`, … IN ['ai-c2', …]) re-root
|
|
2063
|
+
// at each matched CVE. Without it the quantifier head resolves null and the
|
|
2064
|
+
// sbom -> kernel/mcp/ai-api deep-dive chains stay dead even when a matched
|
|
2065
|
+
// CVE carries the attack_class. The `analyze.matched_cves` alias stays for
|
|
2066
|
+
// conditions that use the qualified path.
|
|
2067
|
+
matched_cve: analyzeResult.matched_cves || [],
|
|
1936
2068
|
analyze: analyzeResult,
|
|
1937
2069
|
validate: validateResult,
|
|
1938
2070
|
finding: analyzeFindingShape(analyzeResult),
|
|
@@ -2397,11 +2529,12 @@ function sanitizeOperatorText(s) {
|
|
|
2397
2529
|
// U+007F, U+0080-009F, private-use, unassigned), so the family pass is a
|
|
2398
2530
|
// documented-intent superset removal and the result is identical to the
|
|
2399
2531
|
// single \p{C} strip.
|
|
2400
|
-
const familyStripped = normalised
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2532
|
+
const familyStripped = codepointClass.applyCharStripPolicies(normalised, {
|
|
2533
|
+
bidiPolicy: 'strip',
|
|
2534
|
+
controlPolicy: 'strip',
|
|
2535
|
+
zeroWidthPolicy: 'strip',
|
|
2536
|
+
nullBytePolicy: 'strip',
|
|
2537
|
+
});
|
|
2405
2538
|
const stripped = familyStripped.replace(/\p{C}/gu, '');
|
|
2406
2539
|
const trimmed = stripped.trim();
|
|
2407
2540
|
if (trimmed.length === 0) return null;
|
|
@@ -3309,7 +3442,23 @@ function normalizeSubmission(submission, playbook) {
|
|
|
3309
3442
|
}
|
|
3310
3443
|
if (typeof val === "object" && val !== null) {
|
|
3311
3444
|
const aid = knownArtifacts.has(key) ? key : (val.artifact || key);
|
|
3312
|
-
|
|
3445
|
+
// Preserve every evidence-bearing key the observation carried, not just
|
|
3446
|
+
// `value`. An observation whose secret/path lives under a non-`value`
|
|
3447
|
+
// key (e.g. { path, matched, reason } — a natural collector shape) was
|
|
3448
|
+
// previously collapsed to { value: undefined, captured } here, silently
|
|
3449
|
+
// discarding the actual evidence. Two observations capturing DIFFERENT
|
|
3450
|
+
// secrets under path/matched then hashed and diffed byte-identical, so
|
|
3451
|
+
// `attest diff` reported a false "unchanged" and masked real drift.
|
|
3452
|
+
// The reserved control keys (artifact/indicator/result) drive
|
|
3453
|
+
// signal_overrides below and are intentionally not echoed as evidence.
|
|
3454
|
+
const { artifact: _a, indicator: _i, result: _r, captured: _c, value: _v, ...evidence } = val;
|
|
3455
|
+
const normalizedArtifact = { captured: val.captured !== false };
|
|
3456
|
+
if (val.value !== undefined) normalizedArtifact.value = val.value;
|
|
3457
|
+
for (const [ek, ev] of Object.entries(evidence)) {
|
|
3458
|
+
if (ek === "__proto__" || ek === "constructor" || ek === "prototype") continue;
|
|
3459
|
+
normalizedArtifact[ek] = ev;
|
|
3460
|
+
}
|
|
3461
|
+
out.artifacts[aid] = normalizedArtifact;
|
|
3313
3462
|
if (val.indicator && val.result !== undefined) {
|
|
3314
3463
|
const newVerdict = canonicalizeOutcome(val.result);
|
|
3315
3464
|
if (out.signal_overrides[val.indicator] !== undefined && out._signal_origins[val.indicator] !== undefined) {
|
|
@@ -3861,6 +4010,15 @@ function extractSubmissionForHash(sub) {
|
|
|
3861
4010
|
return pick;
|
|
3862
4011
|
}
|
|
3863
4012
|
|
|
4013
|
+
// Identity fields a `contains`/`includes` membership test targets when the
|
|
4014
|
+
// array holds objects (e.g. jurisdiction_obligations records). Membership is
|
|
4015
|
+
// field-scoped to these so a non-identity field (clock_starts, obligation,
|
|
4016
|
+
// regulation, a free-form tag) that happens to equal the member cannot satisfy
|
|
4017
|
+
// the predicate. `jurisdiction` is the only field the catalog's object-array
|
|
4018
|
+
// `contains` conditions target today; the allowlist is the seam to extend if a
|
|
4019
|
+
// future condition deliberately matches a different identity field.
|
|
4020
|
+
const OBJECT_MEMBERSHIP_FIELDS = ['jurisdiction'];
|
|
4021
|
+
|
|
3864
4022
|
function evalCondition(expr, ctx, playbook) {
|
|
3865
4023
|
if (!expr) return false;
|
|
3866
4024
|
expr = expr.trim();
|
|
@@ -3879,6 +4037,91 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
3879
4037
|
const andParts = splitAtTopLevel(expr, 'AND');
|
|
3880
4038
|
if (andParts.length > 1) return andParts.every(s => evalCondition(s, ctx, playbook));
|
|
3881
4039
|
|
|
4040
|
+
// Quantifier prefix: catalog conditions write `any <path> <op> <value>`
|
|
4041
|
+
// (e.g. framework.json's feeds_into `any compliance_theater_check.verdict ==
|
|
4042
|
+
// 'theater' …`). An unhandled `any `/`all ` prefix is unparseable and falls
|
|
4043
|
+
// through to `false`, silently disabling the clause it gates. Strip the
|
|
4044
|
+
// quantifier and re-evaluate the inner comparison: when the LHS path resolves
|
|
4045
|
+
// to a scalar (the framework theater_verdict case) the quantifier is prose
|
|
4046
|
+
// emphasis and the scalar comparison is the intended test; when the LHS first
|
|
4047
|
+
// segment resolves to an array, apply the predicate existentially (`any`) /
|
|
4048
|
+
// universally (`all`) across its elements. The inner clause is only the leaf
|
|
4049
|
+
// comparison (the surrounding AND/OR has already been split off above).
|
|
4050
|
+
const quant = expr.match(/^(any|all)\s+(.+)$/);
|
|
4051
|
+
if (quant) {
|
|
4052
|
+
const [, kind, inner] = quant;
|
|
4053
|
+
// `any head.rest <op|keyword> …` → re-root the predicate at each element of
|
|
4054
|
+
// `head` and apply it existentially (`any`) / universally (`all`). The head
|
|
4055
|
+
// is the FIRST dotted-path token of the inner clause; what follows it (`==`,
|
|
4056
|
+
// `IN [...]`, `contains`, `matches /…/`, `>=`, …) is operator-agnostic — the
|
|
4057
|
+
// inner clause is re-evaluated whole against `{ …ctx, [head]: el }`, so every
|
|
4058
|
+
// operator the leaf parser already understands works under a quantifier.
|
|
4059
|
+
// Earlier this branch only re-rooted clauses whose operator was in the
|
|
4060
|
+
// comparison set (`>=|<=|==|=|<|>|!=`); `IN`/`contains`/`matches` were
|
|
4061
|
+
// omitted, so e.g. sbom.json's `any matched_cve.attack_class IN
|
|
4062
|
+
// ['ai-c2','prompt-injection']` (the sbom→ai-api feeds_into trigger) fell
|
|
4063
|
+
// through to a whole-ctx eval where `matched_cve.attack_class` resolved to
|
|
4064
|
+
// undefined on the array and the clause was permanently false, while the
|
|
4065
|
+
// sibling `== 'kernel-lpe'` quantifiers fired. Requiring a `.`-qualified head
|
|
4066
|
+
// keeps the bare-token form (`any <path>`) routed to the non-emptiness branch
|
|
4067
|
+
// below.
|
|
4068
|
+
const headMatch = inner.match(/^([A-Za-z_][\w-]*)(?:\.[A-Za-z_][\w-]*)+(?:[\s.[]|$)/);
|
|
4069
|
+
if (headMatch) {
|
|
4070
|
+
const head = headMatch[1];
|
|
4071
|
+
const arr = resolvePath(ctx, head);
|
|
4072
|
+
if (Array.isArray(arr)) {
|
|
4073
|
+
const test = el => evalCondition(inner, { ...ctx, [head]: el }, playbook);
|
|
4074
|
+
return kind === 'all' ? arr.length > 0 && arr.every(test) : arr.some(test);
|
|
4075
|
+
}
|
|
4076
|
+
}
|
|
4077
|
+
// Bare-quantifier non-emptiness form: `any <path>` / `all <path>` with no
|
|
4078
|
+
// comparison operator is a quantifier OVER the resolved collection itself —
|
|
4079
|
+
// the author's intent is "at least one X exists" (`any`) / "every X is
|
|
4080
|
+
// truthy" (`all`), i.e. a non-emptiness / existence test. sbom.json's
|
|
4081
|
+
// `any actively_exploited_match …` EU CRA Art.14 (24h) notify_legal
|
|
4082
|
+
// escalation is the canonical case. Without this, the inner bare token has
|
|
4083
|
+
// no operator, so no comparison branch parses it and it falls through to
|
|
4084
|
+
// condition_unparsed → false, silently disabling the escalation even when
|
|
4085
|
+
// the array holds entries. Match ONLY a pure dotted-path token (no operator,
|
|
4086
|
+
// keyword, bracket, or space); anything with a comparison/keyword still
|
|
4087
|
+
// routes to the prose fall-through below so a genuinely malformed inner
|
|
4088
|
+
// clause stays observable.
|
|
4089
|
+
if (/^[A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*$/.test(inner)) {
|
|
4090
|
+
const v = resolvePath(ctx, inner);
|
|
4091
|
+
if (Array.isArray(v)) {
|
|
4092
|
+
return kind === 'all' ? v.length > 0 && v.every(Boolean) : v.length > 0;
|
|
4093
|
+
}
|
|
4094
|
+
// Non-array scalar: existence/truthiness (null/undefined/0/'' → false).
|
|
4095
|
+
return !!v;
|
|
4096
|
+
}
|
|
4097
|
+
// Scalar (or unresolved-array) LHS: the quantifier is prose; evaluate the
|
|
4098
|
+
// bare inner comparison. If it still doesn't parse it falls through to the
|
|
4099
|
+
// condition_unparsed diagnostic below, so a genuinely malformed clause stays
|
|
4100
|
+
// observable rather than silently passing.
|
|
4101
|
+
return evalCondition(inner, ctx, playbook);
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4104
|
+
// Membership clauses (`contains`/`includes`/`IN [...]`) parse fine but resolve
|
|
4105
|
+
// their LHS path to a collection. When that path does NOT exist in ctx (the
|
|
4106
|
+
// resolved value is null/undefined — an authoring typo in the LHS token, or a
|
|
4107
|
+
// wrong-shape ctx that never populated the collection), the branch returns a
|
|
4108
|
+
// silent `false` that disables whatever escalation / feeds_into the clause
|
|
4109
|
+
// gates with no signal. The clause PARSED, so condition_unparsed (a
|
|
4110
|
+
// parse-failure diagnostic) never fires. Surface a distinct, low-severity
|
|
4111
|
+
// condition_path_unresolved so an LHS-path typo is observable — deduped on the
|
|
4112
|
+
// condition string like condition_unparsed. A present-but-empty array (or any
|
|
4113
|
+
// present value that simply isn't a collection) is a LEGITIMATE false, not an
|
|
4114
|
+
// unresolved path, so it does NOT push: only a literally-absent path does.
|
|
4115
|
+
const pushPathUnresolved = () => {
|
|
4116
|
+
const target = (ctx && Array.isArray(ctx._runErrors)) ? ctx._runErrors
|
|
4117
|
+
: (playbook && Array.isArray(playbook._runErrors)) ? playbook._runErrors
|
|
4118
|
+
: null;
|
|
4119
|
+
if (target) {
|
|
4120
|
+
pushRunError(target, { kind: 'condition_path_unresolved', condition: String(expr).slice(0, 200) },
|
|
4121
|
+
{ dedupeKey: x => x.condition || '' });
|
|
4122
|
+
}
|
|
4123
|
+
};
|
|
4124
|
+
|
|
3882
4125
|
// "rwep >= 90". The LHS/RHS path tokens admit hyphens because signal and
|
|
3883
4126
|
// indicator IDs are canonically hyphenated across the catalog (e.g.
|
|
3884
4127
|
// `no-security-md`, `kver-in-affected-range`); a `\w`-only token silently
|
|
@@ -3921,15 +4164,79 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
3921
4164
|
// "scope.targets includes named_remote" — `contains` is accepted as a
|
|
3922
4165
|
// synonym for `includes` (the catalog uses both); hyphenated paths + members
|
|
3923
4166
|
// are admitted for the same reason as the comparison branch above.
|
|
3924
|
-
|
|
4167
|
+
// The member may be a bare token OR a quoted string (the catalog's
|
|
4168
|
+
// `jurisdiction_obligations contains 'EU'` / `contains 'EU/EU CRA Art.14 24h'`
|
|
4169
|
+
// forms). When the array holds objects (jurisdiction_obligations are records
|
|
4170
|
+
// like `{ jurisdiction:'EU', regulation:'NIS2 Art.21', … }`), membership is
|
|
4171
|
+
// field-TARGETED: it matches an identity field of the obligation, not ANY
|
|
4172
|
+
// field value. An unscoped `Object.values(el).includes(member)` over-matched
|
|
4173
|
+
// — `contains 'EU'` would be satisfied by a non-jurisdiction field that
|
|
4174
|
+
// happened to equal 'EU' (e.g. a tag), and `contains 'detect_confirmed'`
|
|
4175
|
+
// would match the unrelated `clock_starts` field that holds that exact value
|
|
4176
|
+
// in the shipped obligations. Scoping to OBJECT_MEMBERSHIP_FIELDS keeps the
|
|
4177
|
+
// intended "the obligation is for jurisdiction X" semantic and prevents a
|
|
4178
|
+
// future field collision (or an operator-/agent-supplied obligations array)
|
|
4179
|
+
// from forcing a notify_legal escalation via a non-jurisdiction field.
|
|
4180
|
+
m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s+(?:includes|contains)\s+(?:'([^']+)'|"([^"]+)"|([\w-]+))$/);
|
|
3925
4181
|
if (m) {
|
|
4182
|
+
const member = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]);
|
|
3926
4183
|
const arr = resolvePath(ctx, m[1]);
|
|
3927
|
-
|
|
4184
|
+
if (!Array.isArray(arr)) {
|
|
4185
|
+
if (arr == null) pushPathUnresolved();
|
|
4186
|
+
return false;
|
|
4187
|
+
}
|
|
4188
|
+
return arr.some((el) =>
|
|
4189
|
+
el === member ||
|
|
4190
|
+
(el && typeof el === 'object' &&
|
|
4191
|
+
OBJECT_MEMBERSHIP_FIELDS.some((f) => el[f] === member))
|
|
4192
|
+
);
|
|
3928
4193
|
}
|
|
3929
4194
|
|
|
3930
|
-
// "matched_cve.
|
|
3931
|
-
|
|
4195
|
+
// "matched_cve.attack_class IN ['kernel-lpe', 'rce']" — membership against a
|
|
4196
|
+
// bracketed literal list; members may be quoted or bare. A scalar LHS must be
|
|
4197
|
+
// in the list; an array LHS must intersect it. Unhandled before, so every
|
|
4198
|
+
// `IN [...]` escalation/feeds_into atom fell through to a silent false.
|
|
4199
|
+
// The member list is split quote-aware: a naive `split(',')` is unaware of
|
|
4200
|
+
// quotes, so a quoted member that itself contains a comma (`'EU, US'`) would
|
|
4201
|
+
// be broken into two members (`EU` and `US`), neither of which equals the
|
|
4202
|
+
// author's intended whole member — the clause then evaluated false with no
|
|
4203
|
+
// diagnostic (the regex still matched the bracket, so condition_unparsed never
|
|
4204
|
+
// fired). splitInMembers walks the list tracking single/double quote state and
|
|
4205
|
+
// only splits on commas at quote-depth 0, then strips the surrounding quotes.
|
|
4206
|
+
// The CLOSING `]` is located quote-aware too: a `[^\]]*` capture stops at the
|
|
4207
|
+
// FIRST `]`, so a quoted member containing a literal `]` (`'a]b'`) truncated
|
|
4208
|
+
// the list early and left trailing text the `$` anchor couldn't match — the
|
|
4209
|
+
// whole clause then fell through to condition_unparsed for every input. The
|
|
4210
|
+
// matching bracket is now found at quote-depth 0 (an unquoted `]` is still the
|
|
4211
|
+
// terminator; a `]` inside a quoted member is part of that member). Trailing
|
|
4212
|
+
// text after the closing bracket, or an unterminated bracket, still fails to
|
|
4213
|
+
// match and surfaces as condition_unparsed.
|
|
4214
|
+
m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s+IN\s+\[/);
|
|
3932
4215
|
if (m) {
|
|
4216
|
+
const body = sliceInBracketBody(expr.slice(m[0].length));
|
|
4217
|
+
if (body !== null) {
|
|
4218
|
+
const members = splitInMembers(body);
|
|
4219
|
+
const lv = resolvePath(ctx, m[1]);
|
|
4220
|
+
if (Array.isArray(lv)) return lv.some((x) => members.includes(String(x)));
|
|
4221
|
+
// lv == null means the LHS path doesn't exist in ctx (typo / wrong-shape) —
|
|
4222
|
+
// surface it as condition_path_unresolved so the dead clause is observable,
|
|
4223
|
+
// mirroring the contains/includes branch. A present scalar that's simply not
|
|
4224
|
+
// in the member list is a legitimate false and pushes nothing.
|
|
4225
|
+
if (lv == null) { pushPathUnresolved(); return false; }
|
|
4226
|
+
return members.includes(String(lv));
|
|
4227
|
+
}
|
|
4228
|
+
// body === null → no quote-depth-0 closing `]` ends the string; fall through
|
|
4229
|
+
// to the condition_unparsed diagnostic so the malformed clause stays visible.
|
|
4230
|
+
}
|
|
4231
|
+
|
|
4232
|
+
// "matched_cve.vector matches /regex/" — both delimiters are accepted: the
|
|
4233
|
+
// slash form `matches /re/` and the quote form `matches 're'` / `matches "re"`.
|
|
4234
|
+
// The catalog authors both (mcp.json's feeds_into uses the quoted form), and a
|
|
4235
|
+
// delimiter-specific parser silently disabled whichever form it didn't match —
|
|
4236
|
+
// the same class as the hyphenated-token gap above.
|
|
4237
|
+
m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s+matches\s+(?:\/(.+)\/|'([^']+)'|"([^"]+)")$/);
|
|
4238
|
+
if (m) {
|
|
4239
|
+
const pattern = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]);
|
|
3933
4240
|
const val = resolvePath(ctx, m[1]);
|
|
3934
4241
|
if (typeof val !== 'string') return false;
|
|
3935
4242
|
// An operator-supplied or playbook-supplied regex with a syntax bug
|
|
@@ -3939,9 +4246,9 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
3939
4246
|
// analyze() can surface analyze.runtime_errors[] without losing the
|
|
3940
4247
|
// diagnostic.
|
|
3941
4248
|
try {
|
|
3942
|
-
return new RegExp(
|
|
4249
|
+
return new RegExp(pattern, 'i').test(val); // allow:dynamic-regex — pattern comes from an Ed25519-signed catalog playbook condition (/…/ or '…'), so it cannot be attacker-controlled without breaking the signature; the try/catch covers construction-time syntax errors only (it does NOT defend against catastrophic backtracking — do not reuse this shape for operator-supplied patterns)
|
|
3943
4250
|
} catch (e) {
|
|
3944
|
-
const errorRec = { _regex_eval_error: { source: m[1], expr:
|
|
4251
|
+
const errorRec = { _regex_eval_error: { source: m[1], expr: pattern, message: e && e.message ? String(e.message) : String(e) } };
|
|
3945
4252
|
// Two sites where ctx may carry an accumulator: runOpts._runErrors
|
|
3946
4253
|
// (threaded from run()) or ctx._runErrors directly. Prefer the runOpts
|
|
3947
4254
|
// form; fall back to ctx.
|
|
@@ -3993,9 +4300,23 @@ function resolvePath(obj, dot) {
|
|
|
3993
4300
|
function splitAtTopLevel(expr, sep) {
|
|
3994
4301
|
const parts = [];
|
|
3995
4302
|
const needle = ' ' + sep + ' ';
|
|
3996
|
-
let depth = 0, buf = '', i = 0;
|
|
4303
|
+
let depth = 0, buf = '', i = 0, quote = null;
|
|
3997
4304
|
while (i < expr.length) {
|
|
3998
4305
|
const ch = expr[i];
|
|
4306
|
+
// Inside a quoted string literal, parens and the ` AND `/` OR ` needle are
|
|
4307
|
+
// LITERAL text, not boolean structure. A regex member like `matches 'foo('`
|
|
4308
|
+
// carries an unbalanced `(` that — counted blindly — would leave depth=1 so
|
|
4309
|
+
// a real top-level OR/AND would never split at depth 0 (silently disabling
|
|
4310
|
+
// the disjunct/conjunct), and a member like `contains 'EU AND US'` would be
|
|
4311
|
+
// torn at the inner ` AND ` as if it were an operator. Track quote state and
|
|
4312
|
+
// skip both while inside a quote. An unescaped matching quote closes the
|
|
4313
|
+
// literal; `\'`/`\"` stay in. Mirrors splitInMembers' quote-aware walk.
|
|
4314
|
+
if (quote) {
|
|
4315
|
+
if (ch === '\\' && i + 1 < expr.length) { buf += ch + expr[i + 1]; i += 2; continue; }
|
|
4316
|
+
if (ch === quote) quote = null;
|
|
4317
|
+
buf += ch; i++; continue;
|
|
4318
|
+
}
|
|
4319
|
+
if (ch === "'" || ch === '"') { quote = ch; buf += ch; i++; continue; }
|
|
3999
4320
|
if (ch === '(') { depth++; buf += ch; i++; continue; }
|
|
4000
4321
|
if (ch === ')') { depth--; buf += ch; i++; continue; }
|
|
4001
4322
|
if (depth === 0 && expr.startsWith(needle, i)) {
|
|
@@ -4011,18 +4332,90 @@ function splitAtTopLevel(expr, sep) {
|
|
|
4011
4332
|
return parts;
|
|
4012
4333
|
}
|
|
4013
4334
|
|
|
4335
|
+
/**
|
|
4336
|
+
* Quote-aware splitter for the body of an `IN [...]` member list. Splits on
|
|
4337
|
+
* commas that are OUTSIDE any quoted run, then strips the surrounding quotes and
|
|
4338
|
+
* trims each member. A naive `.split(',')` is quote-unaware, so a quoted member
|
|
4339
|
+
* that contains a comma (`'EU, US'`) would be torn into two members; this walks
|
|
4340
|
+
* the string tracking single/double quote state so a comma inside a quoted run
|
|
4341
|
+
* is treated as a literal part of that member. Bare (unquoted) members are
|
|
4342
|
+
* supported too — the catalog authors both forms (`'ai-c2', 'prompt-injection'`
|
|
4343
|
+
* and bare `kernel-lpe`). Empty members (e.g. a trailing comma) are dropped.
|
|
4344
|
+
*/
|
|
4345
|
+
function splitInMembers(listStr) {
|
|
4346
|
+
const out = [];
|
|
4347
|
+
let buf = '';
|
|
4348
|
+
let quote = null;
|
|
4349
|
+
for (let i = 0; i < listStr.length; i++) {
|
|
4350
|
+
const ch = listStr[i];
|
|
4351
|
+
if (quote) {
|
|
4352
|
+
if (ch === quote) quote = null;
|
|
4353
|
+
else buf += ch;
|
|
4354
|
+
continue;
|
|
4355
|
+
}
|
|
4356
|
+
if (ch === "'" || ch === '"') { quote = ch; continue; }
|
|
4357
|
+
if (ch === ',') { out.push(buf.trim()); buf = ''; continue; }
|
|
4358
|
+
buf += ch;
|
|
4359
|
+
}
|
|
4360
|
+
out.push(buf.trim());
|
|
4361
|
+
return out.filter((s) => s.length);
|
|
4362
|
+
}
|
|
4363
|
+
|
|
4364
|
+
/**
|
|
4365
|
+
* Locate the closing `]` of an `IN [...]` list quote-aware and return the body
|
|
4366
|
+
* between the (already-consumed) `[` and that `]`. `rest` is the text after the
|
|
4367
|
+
* opening bracket. The terminator is the first `]` encountered at quote-depth 0;
|
|
4368
|
+
* a `]` inside a single/double-quoted member (`'a]b'`) is part of the member, not
|
|
4369
|
+
* the terminator. A `[^\]]*` regex capture instead stops at the FIRST `]`, so a
|
|
4370
|
+
* quoted `]` truncated the list and left trailing text the `$` anchor rejected —
|
|
4371
|
+
* the clause then fell through to condition_unparsed for every input.
|
|
4372
|
+
*
|
|
4373
|
+
* Returns the body string on success, or `null` when the list is malformed:
|
|
4374
|
+
* unterminated (no quote-depth-0 `]`) or carrying non-whitespace text after the
|
|
4375
|
+
* closing bracket. `null` lets the caller fall through to the condition_unparsed
|
|
4376
|
+
* diagnostic so a malformed clause stays observable rather than passing silently.
|
|
4377
|
+
*/
|
|
4378
|
+
function sliceInBracketBody(rest) {
|
|
4379
|
+
let quote = null;
|
|
4380
|
+
for (let i = 0; i < rest.length; i++) {
|
|
4381
|
+
const ch = rest[i];
|
|
4382
|
+
if (quote) { if (ch === quote) quote = null; continue; }
|
|
4383
|
+
if (ch === "'" || ch === '"') { quote = ch; continue; }
|
|
4384
|
+
if (ch === ']') {
|
|
4385
|
+
// The bracket must be the final structural token: only whitespace may
|
|
4386
|
+
// follow. Anything else (e.g. `IN ['a'] AND …` reaching here, or stray
|
|
4387
|
+
// trailing text) is malformed for this leaf parser.
|
|
4388
|
+
return rest.slice(i + 1).trim() === '' ? rest.slice(0, i) : null;
|
|
4389
|
+
}
|
|
4390
|
+
}
|
|
4391
|
+
return null; // no quote-depth-0 closing bracket → unterminated list
|
|
4392
|
+
}
|
|
4393
|
+
|
|
4014
4394
|
/**
|
|
4015
4395
|
* Strip a balanced pair of outer parens, if and only if the very first and last
|
|
4016
4396
|
* characters are matching parens at the same depth boundary. `(A) AND (B)` keeps
|
|
4017
|
-
* its parens; `((A AND B))` peels one layer.
|
|
4397
|
+
* its parens; `((A AND B))` peels one layer. The depth scan is quote-aware: a
|
|
4398
|
+
* paren inside a quoted string literal (e.g. a regex member `matches '(a|b)'` or
|
|
4399
|
+
* an unbalanced `matches 'foo('`) is literal text, not grouping structure, so it
|
|
4400
|
+
* must not move the depth counter — otherwise a quoted `)` could make the outer
|
|
4401
|
+
* pair look unbalanced (skipping a legitimate strip) or an unbalanced quoted `(`
|
|
4402
|
+
* could make a non-wrapping pair look outer-spanning (stripping wrongly).
|
|
4018
4403
|
*/
|
|
4019
4404
|
function stripOuterParens(expr) {
|
|
4020
4405
|
while (expr.length >= 2 && expr[0] === '(' && expr[expr.length - 1] === ')') {
|
|
4021
4406
|
let depth = 0;
|
|
4022
4407
|
let outerMatches = true;
|
|
4408
|
+
let quote = null;
|
|
4023
4409
|
for (let i = 0; i < expr.length - 1; i++) {
|
|
4024
|
-
|
|
4025
|
-
|
|
4410
|
+
const ch = expr[i];
|
|
4411
|
+
if (quote) {
|
|
4412
|
+
if (ch === '\\') { i++; continue; }
|
|
4413
|
+
if (ch === quote) quote = null;
|
|
4414
|
+
continue;
|
|
4415
|
+
}
|
|
4416
|
+
if (ch === "'" || ch === '"') { quote = ch; continue; }
|
|
4417
|
+
if (ch === '(') depth++;
|
|
4418
|
+
else if (ch === ')') depth--;
|
|
4026
4419
|
if (depth === 0 && i < expr.length - 1) { outerMatches = false; break; }
|
|
4027
4420
|
}
|
|
4028
4421
|
if (outerMatches) expr = expr.slice(1, -1).trim();
|