@clear-capabilities/agentic-security-scanner 0.136.2 → 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 +880 -0
- package/bin/agentic-security.js +189 -37
- package/dist/113.index.js +13 -4
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/238.index.js +1 -1
- package/dist/317.index.js +36 -6
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +192 -15
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/526.index.js +3 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/package.json +21 -13
- package/src/dataflow/CLAUDE.md +12 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/catalog-expanded.js +1 -0
- package/src/dataflow/catalog.js +157 -31
- package/src/dataflow/engine.js +639 -112
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +18 -3
- package/src/dataflow/index.js +17 -1
- package/src/dataflow/points-to.js +19 -6
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/engine.js +430 -196
- package/src/ir/CLAUDE.md +16 -2
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/class-hierarchy.js +57 -11
- package/src/ir/index.js +14 -2
- package/src/ir/parser-cs.js +513 -40
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +300 -20
- package/src/ir/parser-js.js +300 -22
- package/src/ir/parser-kt.js +436 -18
- package/src/ir/parser-php.js +631 -38
- package/src/ir/parser-py.helper.py +32 -2
- package/src/ir/parser-py.js +31 -4
- package/src/ir/parser-rb.js +161 -26
- package/src/ir/ssa.js +6 -1
- package/src/lsp/server.js +35 -3
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +164 -15
- package/src/posture/CLAUDE.md +19 -7
- package/src/posture/accuracy-scorecard.js +9 -1
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +102 -3
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/clustering.js +12 -1
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +33 -1
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +36 -6
- package/src/posture/exploitability-probability.js +13 -1
- package/src/posture/falsification.js +23 -2
- package/src/posture/fix-metrics.js +1 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/model-rescan.js +65 -0
- package/src/posture/mttr.js +5 -0
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +18 -1
- package/src/posture/sbom.js +2 -2
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/threat-model-grounding.js +2 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +7 -0
- package/src/sca/sarif-ingest.js +0 -187
package/src/mcp/tools.js
CHANGED
|
@@ -21,6 +21,7 @@ import { synthesizeDeterministicPatch } from '../posture/deterministic-fix.js';
|
|
|
21
21
|
import { verifyLastScan } from '../posture/integrity.js';
|
|
22
22
|
import { analyzeTranscript, formatCacheReport, renderCacheStatusLine } from '../posture/cache-economics.js';
|
|
23
23
|
import { redactString, redactFinding } from './redact.js';
|
|
24
|
+
import { _remediationOf } from '../report/index.js';
|
|
24
25
|
|
|
25
26
|
// Lazy-loaded: these transitively pull in npm packages (@babel/core and
|
|
26
27
|
// friends) that aren't available in the plugin-cache install path
|
|
@@ -152,8 +153,13 @@ function _validateScratchpadPath(relPath) {
|
|
|
152
153
|
return { ok: true, agent, session, fileParts };
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
// Routes through the same lstat+realpath confinement every other write/
|
|
157
|
+
// path-taking tool uses (OWASP MCP05) — a lexical prefix/charset check
|
|
158
|
+
// alone doesn't stop a pre-planted symlink at any path component from
|
|
159
|
+
// relocating the write/read outside the session root. Throws on escape;
|
|
160
|
+
// callers must catch (see append_scratchpad / read_scratchpad).
|
|
155
161
|
function _scratchpadAbs(sessionRoot, relPath) {
|
|
156
|
-
return
|
|
162
|
+
return _confine(sessionRoot, relPath.replace(/\\/g, '/'), 'scratchpad path');
|
|
157
163
|
}
|
|
158
164
|
|
|
159
165
|
function _scratchpadTotalBytes(sessionRoot) {
|
|
@@ -332,17 +338,35 @@ export const scan_diff = {
|
|
|
332
338
|
fileContents[rel] = content;
|
|
333
339
|
}
|
|
334
340
|
|
|
341
|
+
// PRD R1 (docs/DETECTION_GAP_REMEDIATION_PRD.md): deep mode is default-on
|
|
342
|
+
// for the interactive CLI scan but was never requested here, so an
|
|
343
|
+
// agent's pre-write self-correction scan was regex/AST-only — blind to
|
|
344
|
+
// any bug whose source and sink are connected only through a call
|
|
345
|
+
// (`fileContents` scopes the deep engine's IR to exactly the files
|
|
346
|
+
// passed in, same bound this tool already enforces via MAX_FILES_PER_SCAN
|
|
347
|
+
// / MAX_TOTAL_SCAN_BYTES, so this does not turn scan_diff into a
|
|
348
|
+
// full-project deep scan).
|
|
335
349
|
const runScan = await getRunScan();
|
|
336
|
-
const result = await runScan(sessionRoot, { network: false, fileContents });
|
|
350
|
+
const result = await runScan(sessionRoot, { network: false, fileContents, deep: true, deepInCi: true });
|
|
337
351
|
const wantSet = new Set(Object.keys(fileContents));
|
|
338
352
|
const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
339
353
|
const min = sevRank[severity] ?? 0;
|
|
340
|
-
|
|
354
|
+
// Stage 6 correctness audit: this only ever read result.scan.findings
|
|
355
|
+
// (the SAST channel) — scan.secrets and scan.logicVulns are separate
|
|
356
|
+
// arrays on the raw runScan() result (report/index.js's normalizeFindings
|
|
357
|
+
// is what merges all four channels, and that merge hasn't run yet here).
|
|
358
|
+
// A file containing a bare hardcoded credential reported findingCount: 0
|
|
359
|
+
// through a tool whose own description promises "Use BEFORE writing a
|
|
360
|
+
// Write/Edit to disk so the agent can self-correct". Also reused
|
|
361
|
+
// _remediationOf so a fix-string detector (the majority of engine.js's
|
|
362
|
+
// own, ~127 call sites) doesn't silently report an empty `description`
|
|
363
|
+
// the way reading only `.remediation` did.
|
|
364
|
+
const findings = [...(result.scan.findings || []), ...(result.scan.secrets || []), ...(result.scan.logicVulns || [])]
|
|
341
365
|
.filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
|
|
342
366
|
.map(f => redactFinding({
|
|
343
367
|
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
344
368
|
title: f.title || f.vuln, cwe: f.cwe,
|
|
345
|
-
description: f.description, remediation: f
|
|
369
|
+
description: f.description, remediation: _remediationOf(f),
|
|
346
370
|
}));
|
|
347
371
|
// Harness-anatomy #1: offload when the result exceeds OFFLOAD_THRESHOLD.
|
|
348
372
|
// The agent gets a head+tail preview plus a path it can page through;
|
|
@@ -505,10 +529,40 @@ export const apply_fix = {
|
|
|
505
529
|
additionalProperties: { type: 'string', maxLength: 500_000 },
|
|
506
530
|
minProperties: 1, maxProperties: 8,
|
|
507
531
|
},
|
|
532
|
+
// Stage 6 correctness audit: same gap and same fix as verify_fix — the
|
|
533
|
+
// honesty gate is reachable but was never wired to any real caller.
|
|
534
|
+
// Here it's stronger than advisory: the inline re-verify below already
|
|
535
|
+
// gates the WRITE on `verdict.ok`, and verifyFixCore's own `ok`
|
|
536
|
+
// formula already folds in `honesty.ok` when fixMeta is supplied — so
|
|
537
|
+
// passing it through here makes a dishonest fixMeta (hand-wave
|
|
538
|
+
// residual, uncited false-positive verdict) block the write itself,
|
|
539
|
+
// not just report a verdict.
|
|
540
|
+
fixMeta: {
|
|
541
|
+
type: 'object',
|
|
542
|
+
additionalProperties: false,
|
|
543
|
+
properties: {
|
|
544
|
+
residual: { type: 'string', maxLength: 2000 },
|
|
545
|
+
verdict: { type: 'string', maxLength: 64 },
|
|
546
|
+
evidence: { type: 'array', items: { type: 'string', maxLength: 500 }, maxItems: 20 },
|
|
547
|
+
signals: {
|
|
548
|
+
type: 'object',
|
|
549
|
+
additionalProperties: false,
|
|
550
|
+
properties: {
|
|
551
|
+
sinkSignatureChanged: { type: 'boolean' },
|
|
552
|
+
allCallersRouted: { type: 'boolean' },
|
|
553
|
+
testDiscriminates: { type: 'boolean' },
|
|
554
|
+
rateLimitOnly: { type: 'boolean' },
|
|
555
|
+
docsOnly: { type: 'boolean' },
|
|
556
|
+
logOnlyNoReject: { type: 'boolean' },
|
|
557
|
+
partialSanitization: { type: 'boolean' },
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
},
|
|
508
562
|
},
|
|
509
563
|
required: ['finding_id', 'confirm'],
|
|
510
564
|
},
|
|
511
|
-
async handler({ finding_id, confirm, dry_run = false, patch = null }, ctx) {
|
|
565
|
+
async handler({ finding_id, confirm, dry_run = false, patch = null, fixMeta = null }, ctx) {
|
|
512
566
|
if (confirm !== true) {
|
|
513
567
|
return { _meta: META, applied: false, reason: 'apply_fix requires confirm: true.' };
|
|
514
568
|
}
|
|
@@ -564,6 +618,7 @@ export const apply_fix = {
|
|
|
564
618
|
scanRoot: ctx.sessionRoot,
|
|
565
619
|
originalFindingStableId: f.stableId,
|
|
566
620
|
files: _files,
|
|
621
|
+
fixMeta,
|
|
567
622
|
});
|
|
568
623
|
}
|
|
569
624
|
} catch (e) {
|
|
@@ -573,7 +628,7 @@ export const apply_fix = {
|
|
|
573
628
|
return {
|
|
574
629
|
_meta: META, applied: false,
|
|
575
630
|
reason: `patch rejected by verifier: ${verdict.summary || verdict.rescan?.reason || 'did not verify'}`,
|
|
576
|
-
verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok } },
|
|
631
|
+
verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok }, honesty: verdict.honesty || null },
|
|
577
632
|
};
|
|
578
633
|
}
|
|
579
634
|
if (dry_run) {
|
|
@@ -585,7 +640,7 @@ export const apply_fix = {
|
|
|
585
640
|
const originalContent = fs.existsSync(v.abs) ? await fsp.readFile(v.abs, 'utf8') : '';
|
|
586
641
|
const entry = await applyFixHistory({
|
|
587
642
|
scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content,
|
|
588
|
-
findingId: f.id, stableId: f.stableId, ruleId: f.
|
|
643
|
+
findingId: f.id, stableId: f.stableId, ruleId: f.ruleId || f.cwe || f.family || null, vuln: f.vuln || f.title || null,
|
|
589
644
|
});
|
|
590
645
|
written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath });
|
|
591
646
|
}
|
|
@@ -643,7 +698,7 @@ export const apply_fix = {
|
|
|
643
698
|
newContent: f.fix.replacement,
|
|
644
699
|
findingId: f.id,
|
|
645
700
|
stableId: f.stableId || null, // premortem 4R-8
|
|
646
|
-
ruleId: f.
|
|
701
|
+
ruleId: f.ruleId || f.cwe || f.family || null,
|
|
647
702
|
vuln: f.vuln || f.title || null,
|
|
648
703
|
});
|
|
649
704
|
} catch (e) {
|
|
@@ -680,7 +735,7 @@ export const apply_fix = {
|
|
|
680
735
|
// proceed with apply_fix.
|
|
681
736
|
export const verify_fix = {
|
|
682
737
|
name: 'verify_fix',
|
|
683
|
-
description: 'Verify a proposed patch before applying. Re-scans the patched files in memory
|
|
738
|
+
description: 'Verify a proposed patch before applying. Re-scans the patched files in memory, runs the project linter, runs the project test suite, checks fix honesty (FULL/MITIGATION/WORKAROUND) when fixMeta is supplied, and re-runs the PoC when one exists. Returns { ok, rescan, lint, tests, honesty, poc, summary }. Does not write to the target project’s own files, but DOES append one record per attempt to .agentic-security/fix-metrics.jsonl for the measured fix-loop.',
|
|
684
739
|
inputSchema: {
|
|
685
740
|
type: 'object',
|
|
686
741
|
additionalProperties: false,
|
|
@@ -692,10 +747,41 @@ export const verify_fix = {
|
|
|
692
747
|
minProperties: 1,
|
|
693
748
|
maxProperties: 8,
|
|
694
749
|
},
|
|
750
|
+
// Stage 6 correctness audit: posture/fix-honesty-gate.js's deterministic
|
|
751
|
+
// honesty checks (vague-assurance residual prose, unbacked false-
|
|
752
|
+
// positive verdicts, tier/residual consistency) were fully built and
|
|
753
|
+
// fix-verify.js already consulted them when given a `fixMeta` — but
|
|
754
|
+
// this schema never had a `fixMeta` property, so no call through the
|
|
755
|
+
// MCP surface could ever supply one. The gate can only run against
|
|
756
|
+
// claims the AGENT self-reports (residual risk, verdict, evidence,
|
|
757
|
+
// completeness signals) — nothing here is server-computable — so
|
|
758
|
+
// fixing this meant exposing the property, not inventing a lookup.
|
|
759
|
+
fixMeta: {
|
|
760
|
+
type: 'object',
|
|
761
|
+
additionalProperties: false,
|
|
762
|
+
properties: {
|
|
763
|
+
residual: { type: 'string', maxLength: 2000 },
|
|
764
|
+
verdict: { type: 'string', maxLength: 64 },
|
|
765
|
+
evidence: { type: 'array', items: { type: 'string', maxLength: 500 }, maxItems: 20 },
|
|
766
|
+
signals: {
|
|
767
|
+
type: 'object',
|
|
768
|
+
additionalProperties: false,
|
|
769
|
+
properties: {
|
|
770
|
+
sinkSignatureChanged: { type: 'boolean' },
|
|
771
|
+
allCallersRouted: { type: 'boolean' },
|
|
772
|
+
testDiscriminates: { type: 'boolean' },
|
|
773
|
+
rateLimitOnly: { type: 'boolean' },
|
|
774
|
+
docsOnly: { type: 'boolean' },
|
|
775
|
+
logOnlyNoReject: { type: 'boolean' },
|
|
776
|
+
partialSanitization: { type: 'boolean' },
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
},
|
|
695
781
|
},
|
|
696
782
|
required: ['stable_id', 'files'],
|
|
697
783
|
},
|
|
698
|
-
async handler({ stable_id, files }, ctx) {
|
|
784
|
+
async handler({ stable_id, files, fixMeta }, ctx) {
|
|
699
785
|
// Confine every file path before passing to the verifier.
|
|
700
786
|
const confined = {};
|
|
701
787
|
for (const [relPath, content] of Object.entries(files || {})) {
|
|
@@ -707,17 +793,48 @@ export const verify_fix = {
|
|
|
707
793
|
confined[relPath] = String(content);
|
|
708
794
|
}
|
|
709
795
|
try {
|
|
796
|
+
// The PoC-re-check leg (verifyFixCore's `pocLeg`) needs a `poc` param
|
|
797
|
+
// to do anything — until now nothing supplied one, so it always
|
|
798
|
+
// reported {status:'not-requested'} through this surface (see
|
|
799
|
+
// posture/CLAUDE.md's disclosure). Rather than widening inputSchema
|
|
800
|
+
// to make the CALLER pass PoC data back, look it up server-side: the
|
|
801
|
+
// scan pipeline already attaches an HTTP-shaped f.poc to matching
|
|
802
|
+
// findings by default (engine.js's annotatePocs), and last-scan.json
|
|
803
|
+
// already carries it under the same stableId this handler receives.
|
|
804
|
+
// Best-effort: a missing/unsigned/tampered scan just means no PoC is
|
|
805
|
+
// available to re-check, not a verify_fix failure — the rescan/lint/
|
|
806
|
+
// tests legs below are independent of this and still apply.
|
|
807
|
+
let poc = null;
|
|
808
|
+
try {
|
|
809
|
+
const { scan: lastScan } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
|
|
810
|
+
const orig = lastScan && (lastScan.findings || []).find(f => f.stableId === stable_id);
|
|
811
|
+
if (orig && orig.poc && orig.poc.code) poc = { ...orig.poc, finding: orig };
|
|
812
|
+
} catch { /* best-effort lookup; poc stays null */ }
|
|
813
|
+
|
|
710
814
|
const verifyFixCore = await getVerifyFixCore();
|
|
711
815
|
const r = await verifyFixCore({
|
|
712
816
|
scanRoot: ctx.sessionRoot,
|
|
713
817
|
originalFindingStableId: stable_id,
|
|
714
818
|
files: confined,
|
|
819
|
+
poc,
|
|
820
|
+
fixMeta,
|
|
715
821
|
});
|
|
716
822
|
return {
|
|
717
823
|
_meta: META,
|
|
718
824
|
ok: r.ok,
|
|
719
825
|
rescan: { ok: r.rescan.ok, reason: r.rescan.reason, introduced: r.rescan.introduced || [] },
|
|
720
826
|
lint: { runner: r.lint.runner, ok: r.lint.ok, skipped: r.lint.skipped || false, output: redactString(r.lint.output || '').slice(0, 1500) },
|
|
827
|
+
// verifyFix computes five legs, not two — tests/honesty/poc were
|
|
828
|
+
// being silently dropped here, leaving an agent with no structured
|
|
829
|
+
// way to see WHY verification failed when the failure was in one
|
|
830
|
+
// of those three (only the free-text summary carried it).
|
|
831
|
+
// test-runner.js's runProjectTests never returns raw stdout/stderr,
|
|
832
|
+
// so no redaction is needed there; honesty.violations are static,
|
|
833
|
+
// code-generated strings; poc.reason is redacted defensively since
|
|
834
|
+
// it can echo proof-harness detail derived from scanned source.
|
|
835
|
+
tests: r.tests,
|
|
836
|
+
honesty: r.honesty,
|
|
837
|
+
poc: r.poc ? { ...r.poc, reason: r.poc.reason ? redactString(r.poc.reason) : r.poc.reason } : r.poc,
|
|
721
838
|
summary: r.summary,
|
|
722
839
|
};
|
|
723
840
|
} catch (e) {
|
|
@@ -801,7 +918,13 @@ export const synthesize_fix = {
|
|
|
801
918
|
regression_test: f.regression_test || null,
|
|
802
919
|
remediation: typeof fix.description === 'string' ? fix.description : (typeof fix === 'string' ? fix : null),
|
|
803
920
|
patchBounds: { touchedFiles, locDelta, oversized },
|
|
804
|
-
|
|
921
|
+
// oversized can only be true when hasReplacement is true (locDelta is
|
|
922
|
+
// only computed in that branch, and touchedFiles never varies) — a
|
|
923
|
+
// `!hasReplacement` conjunct here was a structural contradiction that
|
|
924
|
+
// made this permanently false. The correct signal: the stored
|
|
925
|
+
// replacement itself is too big to trust auto-applying, and there's
|
|
926
|
+
// no safer deterministic alternative.
|
|
927
|
+
recommendsFixPlan: oversized && !autofix,
|
|
805
928
|
};
|
|
806
929
|
},
|
|
807
930
|
};
|
|
@@ -933,7 +1056,9 @@ export const append_scratchpad = {
|
|
|
933
1056
|
async handler({ path: relPath, content }, ctx) {
|
|
934
1057
|
const v = _validateScratchpadPath(relPath);
|
|
935
1058
|
if (!v.ok) return { _meta: META, ok: false, reason: v.reason };
|
|
936
|
-
|
|
1059
|
+
let abs;
|
|
1060
|
+
try { abs = _scratchpadAbs(ctx.sessionRoot, relPath); }
|
|
1061
|
+
catch (e) { return { _meta: META, ok: false, reason: `path-escape refused: ${e.message}` }; }
|
|
937
1062
|
const total = _scratchpadTotalBytes(ctx.sessionRoot);
|
|
938
1063
|
if (total + content.length > SCRATCHPAD_MAX_TOTAL_BYTES) {
|
|
939
1064
|
return {
|
|
@@ -979,7 +1104,9 @@ export const read_scratchpad = {
|
|
|
979
1104
|
async handler({ path: relPath, offset, limit }, ctx) {
|
|
980
1105
|
const v = _validateScratchpadPath(relPath);
|
|
981
1106
|
if (!v.ok) return { _meta: META, ok: false, reason: v.reason };
|
|
982
|
-
|
|
1107
|
+
let abs;
|
|
1108
|
+
try { abs = _scratchpadAbs(ctx.sessionRoot, relPath); }
|
|
1109
|
+
catch (e) { return { _meta: META, ok: false, reason: `path-escape refused: ${e.message}` }; }
|
|
983
1110
|
if (!fs.existsSync(abs)) return { _meta: META, ok: false, reason: 'not-found' };
|
|
984
1111
|
let stat;
|
|
985
1112
|
try { stat = fs.statSync(abs); } catch (e) { return { _meta: META, ok: false, reason: `stat-failed: ${e.message}` }; }
|
|
@@ -1075,7 +1202,20 @@ export const query_triage_memory = {
|
|
|
1075
1202
|
},
|
|
1076
1203
|
async handler({ query }, ctx) {
|
|
1077
1204
|
const { queryMemory } = await import('../posture/triage-memory.js');
|
|
1078
|
-
const
|
|
1205
|
+
const raw = queryMemory(ctx.sessionRoot, query || '');
|
|
1206
|
+
// Stage 6 correctness audit: this returned queryMemory's output
|
|
1207
|
+
// verbatim, with no redaction pass — every other tool that echoes
|
|
1208
|
+
// scanned-source-derived text redacts it (mcp/CLAUDE.md's "Adding a new
|
|
1209
|
+
// tool" step 3). Round-trip through redactString the same way
|
|
1210
|
+
// redactFinding already does for its own opaque `.trace` field: results
|
|
1211
|
+
// here mix shapes (a triage decision's free-text `reason`, a finding's
|
|
1212
|
+
// `vuln`/`family`/file path), so scrubbing the whole serialized
|
|
1213
|
+
// structure catches secret-shaped substrings regardless of which field
|
|
1214
|
+
// they landed in, rather than hardcoding a field allowlist that could
|
|
1215
|
+
// miss one.
|
|
1216
|
+
let results;
|
|
1217
|
+
try { results = JSON.parse(redactString(JSON.stringify(raw))); }
|
|
1218
|
+
catch { results = raw; }
|
|
1079
1219
|
return {
|
|
1080
1220
|
_meta: META,
|
|
1081
1221
|
count: results.length,
|
|
@@ -1103,7 +1243,16 @@ export const query_findings_memory = {
|
|
|
1103
1243
|
},
|
|
1104
1244
|
async handler({ query }, ctx) {
|
|
1105
1245
|
const { queryFindingsMemory } = await import('../posture/findings-memory.js');
|
|
1106
|
-
|
|
1246
|
+
const raw = queryFindingsMemory(ctx.sessionRoot, query || '');
|
|
1247
|
+
// Stage 6 correctness audit — same redaction gap and same fix as
|
|
1248
|
+
// query_triage_memory just above: this mixes four differently-shaped
|
|
1249
|
+
// result kinds (finding / triage / history / AGENTS.md text), so a
|
|
1250
|
+
// whole-structure redactString round-trip is applied rather than a
|
|
1251
|
+
// per-field allowlist that could miss one of the four shapes.
|
|
1252
|
+
let body;
|
|
1253
|
+
try { body = JSON.parse(redactString(JSON.stringify(raw))); }
|
|
1254
|
+
catch { body = raw; }
|
|
1255
|
+
return { _meta: META, ...body };
|
|
1107
1256
|
},
|
|
1108
1257
|
};
|
|
1109
1258
|
|
package/src/posture/CLAUDE.md
CHANGED
|
@@ -19,7 +19,7 @@ Annotators that run **after** every detector has emitted, plus state stores read
|
|
|
19
19
|
|
|
20
20
|
**Production-posture ingest** — `auth-posture-import.js`, `network-policy-import.js`, `telemetry-ingest.js`, `waf-ingest.js`, `feature-flags.js`. These read customer-side YAML and convert to mitigation flags consumed by `mitigation-composite.js`.
|
|
21
21
|
|
|
22
|
-
**Fix lifecycle** — `fix-history.js` (apply + backup + recover), `fix-verify.js` (
|
|
22
|
+
**Fix lifecycle** — `fix-history.js` (apply + backup + recover), `fix-verify.js` (**five legs, not "re-scan + lint"**: rescan + lint + the project test suite + the fix-honesty gate + a PoC re-check, and it appends one record per attempt to `.agentic-security/fix-metrics.jsonl` — see `mcp/CLAUDE.md`'s `verify_fix` row, which had the same stale "no writes" claim), `fix-plan.js` (oversized-patch fallback — **not currently wired to anything**, see the dead-module allowlist), `regression-test-gen.js`, `deterministic-fix.js` (safe context-independent literal-swap patch synthesis — md5/sha1→sha256, TLS verify-off→on — materialized on demand by `mcp/synthesize_fix`; every patch still passes through `apply_fix`'s inline verify before it lands).
|
|
23
23
|
|
|
24
24
|
**Measured fix loop (R5)** — `fix-metrics.js`. `verifyFix` times each stage
|
|
25
25
|
(`rescan`/`lint`/`tests`/`honesty`) and appends one record per attempt to
|
|
@@ -49,7 +49,8 @@ rather than creating a stray state dir outside a project.
|
|
|
49
49
|
- `entrypoint-inventory.js` — attack-surface completeness ledger. Enumerates every entry point (HTTP/queue/cron/CLI/env/upload/webhook) with a disposition each; on `scan.entrypointInventory`.
|
|
50
50
|
- `root-cause-sweep.js` — from confirmed findings, finds sibling instances detectors missed with total-count accounting (`found === candidates + mitigated`); on `scan.rootCauseSweep`. Searches the corpus **once per distinct sink pattern**, not once per finding — findings deriving the same pattern share one walk and one set of (read-only) match records. The counts are always exact; the materialised `instances` list is a bounded sample (`INSTANCE_SAMPLE_LIMIT`, 100) and says so via `instancesTruncated`. Both properties are load-bearing on large corpora: the per-finding walk was O(findings × corpus-bytes) and the instance records were O(findings × matches), which together exhausted a 6 GB heap on a 40k-file suite. If you touch this module, keep the own-site exclusion **per pattern group** — resolving it globally makes a group subtract an exclusion it never matched and drives counts negative.
|
|
51
51
|
- `model-routing.js` — capability-based CWE/severity→model policy; stamps `finding.dispatchModel` (strongest for crypto/auth/critical, mid for injection, cheapest for low-sev hardening) for cost-sensitive subagent dispatch.
|
|
52
|
-
- `fix-honesty-gate.js` — deterministic honesty gates on fix output: a residual-risk hand-wave guard, a cited-file:line requirement for any FP/safe verdict, and FULL/MITIGATION/WORKAROUND completeness tiers.
|
|
52
|
+
- `fix-honesty-gate.js` — deterministic honesty gates on fix output: a residual-risk hand-wave guard, a cited-file:line requirement for any FP/safe verdict, and FULL/MITIGATION/WORKAROUND completeness tiers. `fix-verify.js` accepts a `fixMeta` param and consults this gate when it is present (`if (fixMeta && typeof fixMeta === 'object')`). Both `mcp/apply_fix` and `mcp/verify_fix` now expose an optional `fixMeta: {residual, verdict, evidence, signals}` input property and pass it straight through — `fixMeta` is inherently agent-self-reported (only the caller claiming a fix worked knows its own residual-risk reasoning), so the fix was exposing the property, not computing anything server-side. On `apply_fix`'s patch path this is stronger than advisory: `verifyFixCore`'s own `ok` formula already folds in `honesty.ok`, and the inline re-verify already gates the write on `ok` — so a hand-wave residual or an uncited false-positive verdict in `fixMeta` blocks the write itself. The closed-loop test leg (`fix-verify-loop.js`) is separately wired into `mcp/apply_fix` behind `AGENTIC_SECURITY_FIX_RUN_TESTS=1` and does not (yet) thread `fixMeta` through — that path still bypasses the honesty gate.
|
|
53
|
+
- **The PoC-re-check leg is now genuinely reachable, without a schema change.** `verifyFixCore` accepts a `poc` param and, when given one with `poc.code` set, re-runs the proof harness against the patched files (`fix-verify.js`, the `pocLeg` block). `mcp/tools.js`'s `verify_fix` `inputSchema` still has no `poc` property — instead of widening it to make the caller resupply PoC data it never had, the handler looks up the original finding server-side from `last-scan.json` via `stable_id` (best-effort, `allowUnsigned: true` — a missing/tampered scan just means no PoC is available, not a `verify_fix` failure) and passes its `f.poc` straight through. Since `annotatePocs` attaches an HTTP-shaped `f.poc` by default on every scan (see the "Operator entry point" section below), any finding with a matching CWE template gets its PoC re-checked automatically on every `verify_fix` call — no agent-side plumbing required. `tests`/`honesty`/`poc` are all forwarded in the response (previously silently dropped).
|
|
53
54
|
|
|
54
55
|
**Relevance scoping (R6 + R9)** — `relevance.js`. Turns the two existing *inventories* into *inputs*: `entrypoint-inventory.js` supplies the attack surface, `threat-model.js` supplies assets/boundaries/STRIDE, and `annotateRelevance(findings, ctx)` scores each finding by how reachable and how threat-modelled it is. Sets `entrypointReachable: true|false|null`, `relevance` (0..1), `relevanceTier: 'direct'|'indirect'|'unreachable'|'unknown'`, `relevanceFactors[]`, and re-ranks `exploitability` (ordinal priority, ×1.15 direct / ×0.6 unreachable, floored at 0.05, tier label recomputed on the same thresholds `annotateExploitability` uses). Reachability is a forward BFS over a literal-specifier import graph (JS/TS relative + Python dotted + Java FQCN) starting at every entry-point file.
|
|
55
56
|
|
|
@@ -76,6 +77,8 @@ Canonicalisation is an **allowlist, not a denylist** — each finding reduces to
|
|
|
76
77
|
|
|
77
78
|
Wired in `bin/agentic-security.js` after every filter and after `makeDeterministic`, over `normalizeFindings(scan)` — i.e. it attests the set that actually ships — and surfaced as `attestation` in `toJSON`. `bundleSha` is read from the sidecar next to the *running* bundle and is `'unavailable'` when running from source, rather than reporting a dist hash that may not correspond to this run.
|
|
78
79
|
|
|
80
|
+
**`verifyRunAttestation` now has two real callers.** `agentic-security verify-attestation <file>` auto-detects whether the given JSON is an evidence bundle (`.finding`+`.signature`, verified via `evidence-bundle.js`'s Ed25519 path, unchanged) or a run attestation (`.digest`+`.canonicalisation`, either bare or embedded under a full `last-scan.json`'s `.attestation` field) and dispatches accordingly. A run attestation isn't self-contained the way a bundle is — verifying it means re-scanning the project (`--against <path>`, default `.`) and confirming the fresh scan reproduces the attested digest, which is the actual, meaningful claim this artifact makes ("does this codebase, scanned now, match what was attested earlier"). Separately, `scripts/release-check.mjs`'s `attestation-self-check` gate round-trips a synthetic finding set through compute→verify (and a mutated copy through verify, which must fail) on every release, catching a broken canonicalisation or signing path before it ships — independent of whether any project ever calls `verify-attestation` on a real artifact.
|
|
81
|
+
|
|
79
82
|
**Integrity + signing** — `integrity.js` (per-install HMAC for `last-scan.json`), `rule-pack-signing.js`. The HMAC key lives at `$XDG_CONFIG_HOME/agentic-security/scan-key`; override via `$AGENTIC_SECURITY_HMAC_KEY`. Premortem-derived; do not regress to hostname-derived.
|
|
80
83
|
|
|
81
84
|
**Rule lifecycle** — `custom-rules.js` (YAML pattern DSL), `rule-overrides.js` (`disable:` gated on signature), `rule-packs.js`, `rule-synthesis.js` (proposes suppressions from triage feedback), `ruleset-version.js`.
|
|
@@ -105,7 +108,7 @@ is also true of a scan that read zero files — so when nothing was examined eve
|
|
|
105
108
|
mapped control degrades to `engine-gap` instead of reporting as satisfied. That
|
|
106
109
|
one was caught by the module's own test, not in review.
|
|
107
110
|
|
|
108
|
-
**Posture artifacts** — `sbom.js`, `aibom.js`, `api-inventory.js`, `threat-model.js`, `trust-boundary-diagram.js`, `stack-playbook.js`, `deploy-platform.js`, `license-policy.js`, `material-change.js`, `mttr.js`, `streak.js`, `scorecard.js
|
|
111
|
+
**Posture artifacts** — `sbom.js`, `aibom.js`, `api-inventory.js`, `threat-model.js`, `trust-boundary-diagram.js`, `stack-playbook.js`, `deploy-platform.js`, `license-policy.js`, `material-change.js`, `mttr.js`, `streak.js`, `accuracy-scorecard.js` (see "Published accuracy scorecard (R3)" above — this line previously named a bare "scorecard" module that never existed under that filename), `security-trend.js`.
|
|
109
112
|
|
|
110
113
|
**Why this fired** — `why-fired.js`. Runs LAST so it reflects every annotation. Customer-facing provenance.
|
|
111
114
|
|
|
@@ -234,10 +237,19 @@ the CI-gated tier and graduation into it is a human decision with a stated
|
|
|
234
237
|
policy; an automated writer must not decide what blocks everyone's build.
|
|
235
238
|
|
|
236
239
|
**Operator entry point:** `scripts/enroll-proven-finding.mjs <project>`
|
|
237
|
-
(`--dry-run` scores without writing). It proves findings itself — the
|
|
238
|
-
pipeline
|
|
239
|
-
`
|
|
240
|
-
|
|
240
|
+
(`--dry-run` scores without writing). It proves findings itself — **but the
|
|
241
|
+
scan pipeline DOES attach an HTTP-shaped `f.poc` by default** (`annotatePocs`,
|
|
242
|
+
`engine.js`, unconditional — not behind a flag; findings with no matching CWE
|
|
243
|
+
template get `f.poc: null`). What the scan pipeline does NOT do by default is
|
|
244
|
+
the *sandbox execution proof* that promotes a finding to the
|
|
245
|
+
`execution-proven` tier: that pass is genuinely opt-in
|
|
246
|
+
(`AGENTIC_SECURITY_PROVE=1`), so `last-scan.json` never contains an
|
|
247
|
+
`execution-proven` finding from an ordinary scan on its own — this file
|
|
248
|
+
previously conflated "attaches a poc" with "promotes to execution-proven,"
|
|
249
|
+
which are two different passes with two different default states. Enrolment
|
|
250
|
+
still proves findings itself via its own sandboxed run, independent of
|
|
251
|
+
whichever tier `last-scan.json` shipped with. Enrolment additionally needs
|
|
252
|
+
fixed content
|
|
241
253
|
(`finding.fix.patch`) for `post/`; a proven finding with no fix is reported as
|
|
242
254
|
skipped, not dropped. After enrolling, refresh the baseline
|
|
243
255
|
(`npm run bench:cve-replay:update-baseline`) and commit it.
|
|
@@ -335,8 +335,16 @@ export function renderScorecardMarkdown(m) {
|
|
|
335
335
|
L.push('at the commit where a vulnerability really existed, with the CWE assigned by a');
|
|
336
336
|
L.push('public advisory database rather than by this project.');
|
|
337
337
|
L.push('');
|
|
338
|
+
// population.unscored is an array of {id, reason} (bench/independent/runner.mjs) —
|
|
339
|
+
// render its count, not the array itself (Array#toString would stringify to
|
|
340
|
+
// "[object Object],[object Object]" or blank for an empty array, both silently
|
|
341
|
+
// wrong on the line this section calls "the number that matters"). Tolerates a
|
|
342
|
+
// bare number too, for any already-committed artifact predating this fix.
|
|
343
|
+
const unscoredList = ind.population?.unscored;
|
|
344
|
+
const unscoredCount = Array.isArray(unscoredList) ? unscoredList.length
|
|
345
|
+
: (typeof unscoredList === 'number' ? unscoredList : 0);
|
|
338
346
|
L.push(`**Measured ${ind.measuredAt} on engine ${ind.engineVersion}, ` +
|
|
339
|
-
`n=${ind.population?.scoredEntries}, ${
|
|
347
|
+
`n=${ind.population?.scoredEntries}, ${unscoredCount} unscored** ` +
|
|
340
348
|
'(*committed artifact*, `' + ind.source + '` — read, not re-run: scoring takes ~32 minutes).');
|
|
341
349
|
L.push('');
|
|
342
350
|
L.push('| | Advisory-local (**the claim**) | Wide (diagnostic) |');
|
package/src/posture/aibom.js
CHANGED
|
@@ -156,12 +156,16 @@ function _extractPromptFile(fp, content) {
|
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// A package's role isn't mutually exclusive in reality — `openai` and
|
|
160
|
+
// `@anthropic-ai/sdk` are both a general inference framework AND an
|
|
161
|
+
// embedding-provider SDK. Returns every matching class, not just the first.
|
|
159
162
|
function _classifyFramework(c) {
|
|
160
163
|
const name = (c.name || '').toLowerCase();
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
if (
|
|
164
|
-
|
|
164
|
+
const classes = [];
|
|
165
|
+
if (FRAMEWORK_PACKAGES.has(name) || FRAMEWORK_PACKAGES.has(c.name)) classes.push('inference-framework');
|
|
166
|
+
if (VECTOR_STORE_PACKAGES.has(name) || VECTOR_STORE_PACKAGES.has(c.name)) classes.push('vector-store');
|
|
167
|
+
if (EMBEDDING_PACKAGES.has(name) || EMBEDDING_PACKAGES.has(c.name)) classes.push('embedding-provider');
|
|
168
|
+
return classes;
|
|
165
169
|
}
|
|
166
170
|
|
|
167
171
|
// Public: build the AI-BOM from already-scanned data.
|
|
@@ -189,10 +193,10 @@ export function buildAIBOM(scan, fileContents = {}, meta = {}) {
|
|
|
189
193
|
const vectorStores = [];
|
|
190
194
|
const embeddings = [];
|
|
191
195
|
for (const c of (scan.components || [])) {
|
|
192
|
-
const
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
+
const classes = _classifyFramework(c);
|
|
197
|
+
if (classes.includes('inference-framework')) frameworks.push({ ecosystem: c.ecosystem, name: c.name, version: c.version, license: c.license || null });
|
|
198
|
+
if (classes.includes('vector-store')) vectorStores.push({ ecosystem: c.ecosystem, name: c.name, version: c.version });
|
|
199
|
+
if (classes.includes('embedding-provider')) embeddings.push({ ecosystem: c.ecosystem, name: c.name, version: c.version });
|
|
196
200
|
}
|
|
197
201
|
return {
|
|
198
202
|
aibomFormat: 'agentic-security AI-BOM',
|
|
@@ -98,11 +98,69 @@ export function loadFramework(scanRoot, id) {
|
|
|
98
98
|
* 'absent' — no signal / open critical findings on every mapsTo family
|
|
99
99
|
* 'manual' — control has no mapsTo (requires manual attestation)
|
|
100
100
|
*/
|
|
101
|
+
// Stage 6 correctness audit: several compliance frameworks map controls to
|
|
102
|
+
// `family:auth-missing` / `family:authz`, but no detector in this codebase
|
|
103
|
+
// ever emits those literal family strings — the real missing-auth/authz
|
|
104
|
+
// detectors use `broken-access-control` (generic), `fastapi-missing-auth`,
|
|
105
|
+
// `springboot-missing-authz`, `laravel-missing-auth`, `quarkus-missing-authz`
|
|
106
|
+
// (framework-specific). Without this alias, a control mapped to
|
|
107
|
+
// auth-missing/authz read "present" (vacuously — the family bucket was
|
|
108
|
+
// always empty) even with a critical, unauthenticated route open. Listed
|
|
109
|
+
// under both compliance-side names since `broken-access-control` covers
|
|
110
|
+
// both "nobody checked" (missing auth) and "checked wrong" (broken authz)
|
|
111
|
+
// and it is strictly safer to over-count a real finding against both than
|
|
112
|
+
// to keep silently excluding it from either.
|
|
113
|
+
// CMP-1 (Stage 6 follow-up): grown incrementally by cross-referencing every
|
|
114
|
+
// `family:` string the bundled compliance-frameworks/*.json files reference
|
|
115
|
+
// against what src/sast + src/posture actually emit (a small, closed
|
|
116
|
+
// problem — only the strings a control actually maps to, not a universal
|
|
117
|
+
// vocabulary registry). k8s-admission.js's real rule id is
|
|
118
|
+
// `k8s-pod-privileged`; `nist-csf-2.json`/`hipaa-security-rule.json` map to
|
|
119
|
+
// the compliance-side spelling `k8s-pod-security-privileged`, which no
|
|
120
|
+
// detector ever emitted.
|
|
121
|
+
const COMPLIANCE_FAMILY_ALIAS = {
|
|
122
|
+
'auth-missing': ['broken-access-control', 'fastapi-missing-auth', 'springboot-missing-authz', 'laravel-missing-auth', 'quarkus-missing-authz'],
|
|
123
|
+
'authz': ['broken-access-control', 'idor', 'springboot-missing-authz', 'quarkus-missing-authz'],
|
|
124
|
+
'k8s-pod-security-privileged': ['k8s-pod-privileged'],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// CMP-1 audit trail: every `family:` string referenced by the bundled
|
|
128
|
+
// compliance-frameworks/*.json files was cross-checked against real
|
|
129
|
+
// detector output; entries above are the confirmed naming mismatches with a
|
|
130
|
+
// real detector to alias, and `mcp-audit.js`/`sca/dep-confusion.js` were
|
|
131
|
+
// fixed at the SOURCE (they now set `family` explicitly) rather than
|
|
132
|
+
// aliased, since the finding constructors themselves were the root cause.
|
|
133
|
+
// Four references have no matching detector at all — not a naming
|
|
134
|
+
// mismatch, a genuine coverage gap that would need a new rule, out of
|
|
135
|
+
// scope for an alias table: `crypto-tls-version` (crypto-protocol.js checks
|
|
136
|
+
// verify-disabled, not minimum-TLS-version — the file's own header comment
|
|
137
|
+
// even names the never-implemented `crypto-tls-min-version` rule id),
|
|
138
|
+
// `nosql-injection` (referenced by cross-lang-meta.js's chain-detection
|
|
139
|
+
// list, but no dedicated NoSQL-injection detector exists), `pii-exposure`
|
|
140
|
+
// and `data-exposure` (both referenced only by consumers — threat-model-
|
|
141
|
+
// auto.js's classifier and a Juliet-benchmark answer-key label
|
|
142
|
+
// respectively — with no producer). Every control mapped to one of these
|
|
143
|
+
// four reads `manual`/`engine-gap` rather than a false `present`, which is
|
|
144
|
+
// the safe failure mode this whole mechanism exists to guarantee.
|
|
101
145
|
export function evaluateFramework(scanRoot, fw, scan) {
|
|
146
|
+
// CMP-2: last-scan.json (what this is actually handed in production) carries
|
|
147
|
+
// findings across four separate channels — SAST (`findings`), secrets,
|
|
148
|
+
// business-logic, and SCA (`supplyChain`) — because report/index.js's
|
|
149
|
+
// normalizeFindings keeps them apart too. Reading only `scan.findings` made
|
|
150
|
+
// every secrets/logic/SCA finding invisible to every framework's family:
|
|
151
|
+
// mappings, e.g. a critical hardcoded secret never counted against a
|
|
152
|
+
// control mapped to family:hardcoded-secret. Each channel's family default
|
|
153
|
+
// mirrors normalizeFindings' own fallback for that channel so the two stay
|
|
154
|
+
// in agreement about what family an untagged finding belongs to.
|
|
102
155
|
const findings = (scan && Array.isArray(scan.findings)) ? scan.findings : [];
|
|
156
|
+
const secrets = ((scan && Array.isArray(scan.secrets)) ? scan.secrets : [])
|
|
157
|
+
.map(s => ({ ...s, family: s.family || 'hardcoded-secret' }));
|
|
158
|
+
const logicVulns = (scan && Array.isArray(scan.logicVulns)) ? scan.logicVulns : [];
|
|
159
|
+
const supplyChain = ((scan && Array.isArray(scan.supplyChain)) ? scan.supplyChain : [])
|
|
160
|
+
.map(sc => ({ ...sc, family: sc.family || 'vulnerable-dep' }));
|
|
103
161
|
const components = (scan && Array.isArray(scan.components)) ? scan.components : [];
|
|
104
162
|
const families = new Map();
|
|
105
|
-
for (const f of findings) {
|
|
163
|
+
for (const f of [...findings, ...secrets, ...logicVulns, ...supplyChain]) {
|
|
106
164
|
const k = f.family || 'unknown';
|
|
107
165
|
if (!families.has(k)) families.set(k, []);
|
|
108
166
|
families.get(k).push(f);
|
|
@@ -122,15 +180,43 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
122
180
|
|
|
123
181
|
let allCleared = true;
|
|
124
182
|
let anySignal = false;
|
|
183
|
+
// Tracks whether ANY family:/module: mapping in this control actually
|
|
184
|
+
// passed (distinct from allCleared, which asks whether EVERY one did).
|
|
185
|
+
// Feeds the 'absent' vs 'partial' distinction below: a control where
|
|
186
|
+
// nothing at all checks out is a materially different auditor story
|
|
187
|
+
// ("no evidence") than one that's mostly clean with one open gap
|
|
188
|
+
// ("evidence with a gap") — both used to render as the same 'partial'.
|
|
189
|
+
let anyCleared = false;
|
|
190
|
+
// CMP-2: a rule: mapping's own observation says "verify manually" — it
|
|
191
|
+
// is deliberately not code-checked. It used to set anySignal=true and
|
|
192
|
+
// leave allCleared untouched, so a control whose ONLY mapping was rule:
|
|
193
|
+
// resolved to 'present' (fully evidenced), the same status as a control
|
|
194
|
+
// with real, checked evidence. Any rule: mapping present caps the
|
|
195
|
+
// control at 'partial' — never 'present' — regardless of what the
|
|
196
|
+
// family:/module: mappings in the same control found.
|
|
197
|
+
let hasUnverifiableMapping = false;
|
|
125
198
|
for (const m of maps) {
|
|
126
199
|
if (m.startsWith('family:')) {
|
|
127
|
-
|
|
128
|
-
|
|
200
|
+
// `family:X` and the subfamily-qualified `family:X:Y` (used by
|
|
201
|
+
// owasp-llm-top-10 for LLM02/LLM06/LLM07) both used to collapse to
|
|
202
|
+
// just `X` here — the `:Y` qualifier was parsed and then silently
|
|
203
|
+
// dropped, so any finding of family X counted against every control
|
|
204
|
+
// mapped to X regardless of which subfamily the control actually
|
|
205
|
+
// named (four LLM controls flagged off one credential-in-prompt
|
|
206
|
+
// finding). Detectors that emit a subfamily always set it, so
|
|
207
|
+
// filtering is safe; findings with no subfamily set still count
|
|
208
|
+
// (recall-preserving default — same precedent as relevance.js).
|
|
209
|
+
const [fam, subfam] = m.slice('family:'.length).split(':');
|
|
210
|
+
const aliasFams = COMPLIANCE_FAMILY_ALIAS[fam] || [];
|
|
211
|
+
const candidates = [fam, ...aliasFams].flatMap(k => families.get(k) || []);
|
|
212
|
+
const scoped = subfam ? candidates.filter(f => !f.subfamily || f.subfamily === subfam) : candidates;
|
|
213
|
+
const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (f.severity === 'critical' || f.severity === 'high'));
|
|
129
214
|
if (open.length) {
|
|
130
215
|
allCleared = false;
|
|
131
216
|
obs.push(`${open.length} open ${fam} finding(s) at high/critical.`);
|
|
132
217
|
} else {
|
|
133
218
|
obs.push(`✓ ${fam}: no open critical/high findings.`);
|
|
219
|
+
anyCleared = true;
|
|
134
220
|
}
|
|
135
221
|
anySignal = true;
|
|
136
222
|
} else if (m.startsWith('module:')) {
|
|
@@ -174,6 +260,7 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
174
260
|
if (resolved && fs.existsSync(resolved)) {
|
|
175
261
|
obs.push(`✓ ${mod}: ${label} present.`);
|
|
176
262
|
anySignal = true;
|
|
263
|
+
anyCleared = true;
|
|
177
264
|
} else {
|
|
178
265
|
obs.push(`✗ ${mod}: expected ${label} not present.`);
|
|
179
266
|
allCleared = false;
|
|
@@ -182,11 +269,23 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
182
269
|
// Could check whether a custom rule fires zero — leave a hint for now.
|
|
183
270
|
obs.push(`(rule mapping) ${m} — verify manually that the bodyguard rule is enabled.`);
|
|
184
271
|
anySignal = true;
|
|
272
|
+
hasUnverifiableMapping = true;
|
|
185
273
|
}
|
|
186
274
|
}
|
|
187
275
|
|
|
276
|
+
// 'absent' was documented (see the docstring above) as a fourth,
|
|
277
|
+
// distinct status — a control where NOTHING passed at all — but the
|
|
278
|
+
// assignment below never produced it; every non-present, non-manual
|
|
279
|
+
// control rendered as 'partial' regardless of whether it was "mostly
|
|
280
|
+
// clean with one gap" or "completely unevidenced". Only introduced for
|
|
281
|
+
// the fully-automated case (no rule: mapping) to avoid reclassifying
|
|
282
|
+
// any control that already reads 'partial' because of an inherently
|
|
283
|
+
// unverifiable rule: mapping — that precedent (rule: caps at 'partial',
|
|
284
|
+
// never reaching 'present' OR 'absent') is unchanged.
|
|
188
285
|
if (!anySignal) status = 'manual';
|
|
286
|
+
else if (hasUnverifiableMapping) status = 'partial';
|
|
189
287
|
else if (allCleared) status = 'present';
|
|
288
|
+
else if (!anyCleared) status = 'absent';
|
|
190
289
|
else status = 'partial';
|
|
191
290
|
|
|
192
291
|
results.push({ control: c, status, observations: obs });
|
package/src/posture/autopilot.js
CHANGED
|
@@ -110,7 +110,14 @@ export async function runAutopilot({
|
|
|
110
110
|
for (const f of inScope) {
|
|
111
111
|
const key = f.stableId || `${f.file}:${f.line}:${f.vuln}`;
|
|
112
112
|
const prior = resume ? state.findings[key] : null;
|
|
113
|
-
|
|
113
|
+
// A cached VERIFIED_FIXED whose patch was gated (not written) on a prior
|
|
114
|
+
// apply:false run is not a terminal outcome once the caller asks for
|
|
115
|
+
// apply:true — replaying it verbatim would silently never call
|
|
116
|
+
// applyFix on the exact two-step workflow (preview, then --apply) this
|
|
117
|
+
// gate exists to support. Everything else cached IS terminal and is
|
|
118
|
+
// replayed as before.
|
|
119
|
+
const gatedOnlyByApply = prior?.outcome === 'VERIFIED_FIXED' && prior.applied !== true && apply === true;
|
|
120
|
+
if (prior?.outcome && !gatedOnlyByApply) { results.push(prior); continue; }
|
|
114
121
|
|
|
115
122
|
const rec = { key, file: f.file, line: f.line, vuln: f.vuln, severity: f.severity };
|
|
116
123
|
|