@clear-capabilities/agentic-security-scanner 0.134.0 → 0.136.9
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 +432 -0
- package/bin/agentic-security-audit.js +2 -1
- package/bin/agentic-security-consistency.js +2 -1
- package/bin/agentic-security.js +448 -74
- package/dist/113.index.js +16 -7
- package/dist/117.index.js +3 -1
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/220.index.js +5 -3
- package/dist/238.index.js +4 -4
- package/dist/317.index.js +300 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +196 -21
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/513.index.js +7 -3
- package/dist/526.index.js +6 -6
- package/dist/637.index.js +1 -1
- package/dist/675.index.js +7 -5
- package/dist/839.index.js +4 -3
- package/dist/905.index.js +1173 -0
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +32 -0
- package/dist/compliance-frameworks/eu-ai-act.json +51 -0
- package/dist/compliance-frameworks/gdpr.json +45 -0
- package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/dist/compliance-frameworks/nist-csf-2.json +73 -0
- package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/package.json +24 -12
- package/src/badge.js +2 -1
- package/src/dataflow/CLAUDE.md +10 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/cross-service-taint.js +2 -1
- package/src/dataflow/engine.js +324 -60
- package/src/dataflow/ifds-precise.js +6 -4
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +25 -8
- package/src/dataflow/index.js +2 -1
- 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/discovery/CLAUDE.md +10 -0
- package/src/discovery/index.js +175 -3
- package/src/discovery/llm-invoke.js +90 -1
- package/src/discovery/memory.js +163 -0
- package/src/engine.js +247 -50
- package/src/integrations/tickets.js +7 -6
- package/src/ir/CLAUDE.md +4 -1
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/ir-stats.js +1 -1
- package/src/ir/parser-cpp.js +1 -1
- package/src/ir/parser-cs.js +62 -9
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +96 -19
- package/src/ir/parser-js.js +151 -20
- package/src/ir/parser-php.js +44 -9
- package/src/ir/parser-rb.js +37 -7
- package/src/ir/ssa.js +6 -1
- package/src/leaderboard.js +3 -2
- package/src/llm-validator/consistency.js +6 -2
- package/src/llm-validator/index.js +1 -2
- package/src/lsp/server.js +28 -2
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/audit.js +2 -1
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +159 -17
- package/src/posture/CLAUDE.md +45 -8
- package/src/posture/accuracy-scorecard.js +67 -1
- package/src/posture/agents-memory.js +5 -3
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +111 -10
- package/src/posture/auth-posture-import.js +5 -4
- 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/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +40 -10
- 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/custom-rules.js +7 -5
- package/src/posture/cve-alert-daemon.js +6 -5
- package/src/posture/dep-add-guard.js +2 -1
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/deterministic.js +3 -2
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +276 -0
- package/src/posture/exploitability-probability.js +15 -2
- package/src/posture/falsification.js +23 -2
- package/src/posture/feature-flags.js +3 -2
- package/src/posture/findings-memory.js +3 -3
- package/src/posture/fix-history.js +5 -2
- package/src/posture/fix-metrics.js +5 -5
- package/src/posture/fix-plan.js +2 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/grader-calibration.js +3 -4
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/intent-context.js +2 -1
- package/src/posture/learning.js +4 -3
- package/src/posture/license-attributions.js +5 -7
- package/src/posture/license-graph.js +2 -1
- package/src/posture/license-policy.js +2 -1
- package/src/posture/model-rescan.js +69 -3
- package/src/posture/mttr.js +5 -0
- package/src/posture/network-policy-import.js +3 -2
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/pqc-migration-plan.js +7 -5
- package/src/posture/pr-augment.js +8 -5
- package/src/posture/privacy-framework.js +262 -0
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +20 -3
- package/src/posture/router.js +5 -4
- package/src/posture/ruleset-version.js +2 -2
- package/src/posture/runtime-correlation.js +2 -1
- package/src/posture/sbom-diff.js +12 -3
- package/src/posture/sca-policy.js +7 -4
- package/src/posture/scan-checkpoint.js +15 -0
- 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/state-dir.js +34 -0
- package/src/posture/telemetry-ingest.js +4 -3
- package/src/posture/threat-model-auto.js +4 -1
- package/src/posture/threat-model-grounding.js +13 -3
- package/src/posture/time-to-fix.js +3 -2
- package/src/posture/triage-memory.js +3 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/posture/waf-ingest.js +6 -5
- package/src/posture/watch-mode.js +4 -3
- 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/code-injection-multilang.js +29 -0
- 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 +9 -1
- package/src/sca/sarif-ingest.js +0 -187
package/src/mcp/redact.js
CHANGED
|
@@ -27,6 +27,32 @@ const PATTERNS = [
|
|
|
27
27
|
[/rk_(?:live|test)_[A-Za-z0-9]{20,}/g, 'stripe-restricted-key'],
|
|
28
28
|
[/SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g, 'sendgrid-key'],
|
|
29
29
|
[/AIza[0-9A-Za-z_-]{35}/g, 'google-api-key'],
|
|
30
|
+
// Stage 4 correctness audit (coverage breadth, AI security): this list
|
|
31
|
+
// only covered a small subset of what the scanner's OWN credential
|
|
32
|
+
// detector (engine.js's CREDENTIAL_PATTERNS, 40+ provider shapes) finds
|
|
33
|
+
// — a Shopify/Telegram/Twilio/Discord-webhook/Square/Google-OAuth/JDBC
|
|
34
|
+
// secret detected and reported by a scan reached explain_finding's
|
|
35
|
+
// output completely unredacted, because none of those shapes were in
|
|
36
|
+
// THIS separate, narrower list. Reusing the same regex bodies as
|
|
37
|
+
// engine.js's CREDENTIAL_PATTERNS for the shapes verified to leak
|
|
38
|
+
// (rather than importing engine.js itself, which would pull its entire
|
|
39
|
+
// multi-thousand-line module graph into the MCP server's dependency
|
|
40
|
+
// surface for a handful of consts).
|
|
41
|
+
[/ya29\.[0-9A-Za-z_-]{20,}/g, 'google-oauth-token'],
|
|
42
|
+
[/shp(?:at|ss|ca|pa)_[a-fA-F0-9]{32}/g, 'shopify-token'],
|
|
43
|
+
[/(?<![0-9])[0-9]{8,10}:AA[0-9A-Za-z_-]{33}(?![A-Za-z0-9_])/g, 'telegram-bot-token'],
|
|
44
|
+
[/twilio.{0,20}SK[0-9a-fA-F]{32}/gi, 'twilio-api-key'],
|
|
45
|
+
[/sq0atp-[0-9A-Za-z_-]{22}/g, 'square-access-token'],
|
|
46
|
+
[/sq0csp-[0-9A-Za-z_-]{43}/g, 'square-oauth-secret'],
|
|
47
|
+
[/access_token\$production\$[0-9a-z]{16}\$[0-9a-f]{32}/g, 'paypal-braintree-token'],
|
|
48
|
+
[/https:\/\/(?:discordapp|discord)\.com\/api\/webhooks\/[0-9]+\/[A-Za-z0-9_-]+/g, 'discord-webhook'],
|
|
49
|
+
[/https:\/\/hooks\.slack\.com\/services\/T[a-zA-Z0-9_]{8}\/B[a-zA-Z0-9_]{8,12}\/[a-zA-Z0-9_]{24}/g, 'slack-webhook'],
|
|
50
|
+
[/https:\/\/outlook\.office\.com\/webhook\/[A-Za-z0-9\-@]+\/IncomingWebhook\/[A-Za-z0-9-]+\/[A-Za-z0-9-]+/g, 'teams-webhook'],
|
|
51
|
+
[/https:\/\/(?:www\.)?hooks\.zapier\.com\/hooks\/catch\/[A-Za-z0-9]+\/[A-Za-z0-9]+\//g, 'zapier-webhook'],
|
|
52
|
+
// JDBC connection string carrying a password: only redact when password
|
|
53
|
+
// evidence is actually on the line (matches engine.js's own ctx gate),
|
|
54
|
+
// so a credential-free JDBC URL in docs isn't needlessly mangled.
|
|
55
|
+
[/jdbc:[a-z:]+:\/\/[A-Za-z0-9.\-_:;=/@?,&]*(?:@|password=|passwd=|pwd=)[A-Za-z0-9.\-_:;=/@?,&]*/gi, 'jdbc-connection-string'],
|
|
30
56
|
// JWT — three dot-separated b64url segments starting with eyJ
|
|
31
57
|
[/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, 'jwt'],
|
|
32
58
|
// PEM-encoded private keys
|
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,12 +153,17 @@ 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) {
|
|
160
|
-
const base =
|
|
166
|
+
const base = statePath(sessionRoot, 'agent-scratchpad');
|
|
161
167
|
if (!fs.existsSync(base)) return 0;
|
|
162
168
|
let total = 0;
|
|
163
169
|
const walk = (dir) => {
|
|
@@ -222,8 +228,8 @@ function _confine(sessionRoot, candidate, label) {
|
|
|
222
228
|
}
|
|
223
229
|
|
|
224
230
|
function _readLastScanVerified(sessionRoot, { allowUnsigned = false } = {}) {
|
|
225
|
-
const
|
|
226
|
-
const scanFile = path.join(
|
|
231
|
+
const stateDirPath = stateDir(sessionRoot);
|
|
232
|
+
const scanFile = path.join(stateDirPath, 'last-scan.json');
|
|
227
233
|
const sigFile = scanFile + '.sig';
|
|
228
234
|
if (!fs.existsSync(scanFile)) return { scan: null, status: 'missing' };
|
|
229
235
|
const body = fs.readFileSync(scanFile, 'utf8');
|
|
@@ -337,12 +343,22 @@ export const scan_diff = {
|
|
|
337
343
|
const wantSet = new Set(Object.keys(fileContents));
|
|
338
344
|
const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
339
345
|
const min = sevRank[severity] ?? 0;
|
|
340
|
-
|
|
346
|
+
// Stage 6 correctness audit: this only ever read result.scan.findings
|
|
347
|
+
// (the SAST channel) — scan.secrets and scan.logicVulns are separate
|
|
348
|
+
// arrays on the raw runScan() result (report/index.js's normalizeFindings
|
|
349
|
+
// is what merges all four channels, and that merge hasn't run yet here).
|
|
350
|
+
// A file containing a bare hardcoded credential reported findingCount: 0
|
|
351
|
+
// through a tool whose own description promises "Use BEFORE writing a
|
|
352
|
+
// Write/Edit to disk so the agent can self-correct". Also reused
|
|
353
|
+
// _remediationOf so a fix-string detector (the majority of engine.js's
|
|
354
|
+
// own, ~127 call sites) doesn't silently report an empty `description`
|
|
355
|
+
// the way reading only `.remediation` did.
|
|
356
|
+
const findings = [...(result.scan.findings || []), ...(result.scan.secrets || []), ...(result.scan.logicVulns || [])]
|
|
341
357
|
.filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
|
|
342
358
|
.map(f => redactFinding({
|
|
343
359
|
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
344
360
|
title: f.title || f.vuln, cwe: f.cwe,
|
|
345
|
-
description: f.description, remediation: f
|
|
361
|
+
description: f.description, remediation: _remediationOf(f),
|
|
346
362
|
}));
|
|
347
363
|
// Harness-anatomy #1: offload when the result exceeds OFFLOAD_THRESHOLD.
|
|
348
364
|
// The agent gets a head+tail preview plus a path it can page through;
|
|
@@ -505,10 +521,40 @@ export const apply_fix = {
|
|
|
505
521
|
additionalProperties: { type: 'string', maxLength: 500_000 },
|
|
506
522
|
minProperties: 1, maxProperties: 8,
|
|
507
523
|
},
|
|
524
|
+
// Stage 6 correctness audit: same gap and same fix as verify_fix — the
|
|
525
|
+
// honesty gate is reachable but was never wired to any real caller.
|
|
526
|
+
// Here it's stronger than advisory: the inline re-verify below already
|
|
527
|
+
// gates the WRITE on `verdict.ok`, and verifyFixCore's own `ok`
|
|
528
|
+
// formula already folds in `honesty.ok` when fixMeta is supplied — so
|
|
529
|
+
// passing it through here makes a dishonest fixMeta (hand-wave
|
|
530
|
+
// residual, uncited false-positive verdict) block the write itself,
|
|
531
|
+
// not just report a verdict.
|
|
532
|
+
fixMeta: {
|
|
533
|
+
type: 'object',
|
|
534
|
+
additionalProperties: false,
|
|
535
|
+
properties: {
|
|
536
|
+
residual: { type: 'string', maxLength: 2000 },
|
|
537
|
+
verdict: { type: 'string', maxLength: 64 },
|
|
538
|
+
evidence: { type: 'array', items: { type: 'string', maxLength: 500 }, maxItems: 20 },
|
|
539
|
+
signals: {
|
|
540
|
+
type: 'object',
|
|
541
|
+
additionalProperties: false,
|
|
542
|
+
properties: {
|
|
543
|
+
sinkSignatureChanged: { type: 'boolean' },
|
|
544
|
+
allCallersRouted: { type: 'boolean' },
|
|
545
|
+
testDiscriminates: { type: 'boolean' },
|
|
546
|
+
rateLimitOnly: { type: 'boolean' },
|
|
547
|
+
docsOnly: { type: 'boolean' },
|
|
548
|
+
logOnlyNoReject: { type: 'boolean' },
|
|
549
|
+
partialSanitization: { type: 'boolean' },
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
},
|
|
508
554
|
},
|
|
509
555
|
required: ['finding_id', 'confirm'],
|
|
510
556
|
},
|
|
511
|
-
async handler({ finding_id, confirm, dry_run = false, patch = null }, ctx) {
|
|
557
|
+
async handler({ finding_id, confirm, dry_run = false, patch = null, fixMeta = null }, ctx) {
|
|
512
558
|
if (confirm !== true) {
|
|
513
559
|
return { _meta: META, applied: false, reason: 'apply_fix requires confirm: true.' };
|
|
514
560
|
}
|
|
@@ -564,6 +610,7 @@ export const apply_fix = {
|
|
|
564
610
|
scanRoot: ctx.sessionRoot,
|
|
565
611
|
originalFindingStableId: f.stableId,
|
|
566
612
|
files: _files,
|
|
613
|
+
fixMeta,
|
|
567
614
|
});
|
|
568
615
|
}
|
|
569
616
|
} catch (e) {
|
|
@@ -573,7 +620,7 @@ export const apply_fix = {
|
|
|
573
620
|
return {
|
|
574
621
|
_meta: META, applied: false,
|
|
575
622
|
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 } },
|
|
623
|
+
verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok }, honesty: verdict.honesty || null },
|
|
577
624
|
};
|
|
578
625
|
}
|
|
579
626
|
if (dry_run) {
|
|
@@ -585,7 +632,7 @@ export const apply_fix = {
|
|
|
585
632
|
const originalContent = fs.existsSync(v.abs) ? await fsp.readFile(v.abs, 'utf8') : '';
|
|
586
633
|
const entry = await applyFixHistory({
|
|
587
634
|
scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content,
|
|
588
|
-
findingId: f.id, stableId: f.stableId, ruleId: f.
|
|
635
|
+
findingId: f.id, stableId: f.stableId, ruleId: f.ruleId || f.cwe || f.family || null, vuln: f.vuln || f.title || null,
|
|
589
636
|
});
|
|
590
637
|
written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath });
|
|
591
638
|
}
|
|
@@ -643,7 +690,7 @@ export const apply_fix = {
|
|
|
643
690
|
newContent: f.fix.replacement,
|
|
644
691
|
findingId: f.id,
|
|
645
692
|
stableId: f.stableId || null, // premortem 4R-8
|
|
646
|
-
ruleId: f.
|
|
693
|
+
ruleId: f.ruleId || f.cwe || f.family || null,
|
|
647
694
|
vuln: f.vuln || f.title || null,
|
|
648
695
|
});
|
|
649
696
|
} catch (e) {
|
|
@@ -680,7 +727,7 @@ export const apply_fix = {
|
|
|
680
727
|
// proceed with apply_fix.
|
|
681
728
|
export const verify_fix = {
|
|
682
729
|
name: 'verify_fix',
|
|
683
|
-
description: 'Verify a proposed patch before applying. Re-scans the patched files in memory
|
|
730
|
+
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
731
|
inputSchema: {
|
|
685
732
|
type: 'object',
|
|
686
733
|
additionalProperties: false,
|
|
@@ -692,10 +739,41 @@ export const verify_fix = {
|
|
|
692
739
|
minProperties: 1,
|
|
693
740
|
maxProperties: 8,
|
|
694
741
|
},
|
|
742
|
+
// Stage 6 correctness audit: posture/fix-honesty-gate.js's deterministic
|
|
743
|
+
// honesty checks (vague-assurance residual prose, unbacked false-
|
|
744
|
+
// positive verdicts, tier/residual consistency) were fully built and
|
|
745
|
+
// fix-verify.js already consulted them when given a `fixMeta` — but
|
|
746
|
+
// this schema never had a `fixMeta` property, so no call through the
|
|
747
|
+
// MCP surface could ever supply one. The gate can only run against
|
|
748
|
+
// claims the AGENT self-reports (residual risk, verdict, evidence,
|
|
749
|
+
// completeness signals) — nothing here is server-computable — so
|
|
750
|
+
// fixing this meant exposing the property, not inventing a lookup.
|
|
751
|
+
fixMeta: {
|
|
752
|
+
type: 'object',
|
|
753
|
+
additionalProperties: false,
|
|
754
|
+
properties: {
|
|
755
|
+
residual: { type: 'string', maxLength: 2000 },
|
|
756
|
+
verdict: { type: 'string', maxLength: 64 },
|
|
757
|
+
evidence: { type: 'array', items: { type: 'string', maxLength: 500 }, maxItems: 20 },
|
|
758
|
+
signals: {
|
|
759
|
+
type: 'object',
|
|
760
|
+
additionalProperties: false,
|
|
761
|
+
properties: {
|
|
762
|
+
sinkSignatureChanged: { type: 'boolean' },
|
|
763
|
+
allCallersRouted: { type: 'boolean' },
|
|
764
|
+
testDiscriminates: { type: 'boolean' },
|
|
765
|
+
rateLimitOnly: { type: 'boolean' },
|
|
766
|
+
docsOnly: { type: 'boolean' },
|
|
767
|
+
logOnlyNoReject: { type: 'boolean' },
|
|
768
|
+
partialSanitization: { type: 'boolean' },
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
},
|
|
772
|
+
},
|
|
695
773
|
},
|
|
696
774
|
required: ['stable_id', 'files'],
|
|
697
775
|
},
|
|
698
|
-
async handler({ stable_id, files }, ctx) {
|
|
776
|
+
async handler({ stable_id, files, fixMeta }, ctx) {
|
|
699
777
|
// Confine every file path before passing to the verifier.
|
|
700
778
|
const confined = {};
|
|
701
779
|
for (const [relPath, content] of Object.entries(files || {})) {
|
|
@@ -707,17 +785,48 @@ export const verify_fix = {
|
|
|
707
785
|
confined[relPath] = String(content);
|
|
708
786
|
}
|
|
709
787
|
try {
|
|
788
|
+
// The PoC-re-check leg (verifyFixCore's `pocLeg`) needs a `poc` param
|
|
789
|
+
// to do anything — until now nothing supplied one, so it always
|
|
790
|
+
// reported {status:'not-requested'} through this surface (see
|
|
791
|
+
// posture/CLAUDE.md's disclosure). Rather than widening inputSchema
|
|
792
|
+
// to make the CALLER pass PoC data back, look it up server-side: the
|
|
793
|
+
// scan pipeline already attaches an HTTP-shaped f.poc to matching
|
|
794
|
+
// findings by default (engine.js's annotatePocs), and last-scan.json
|
|
795
|
+
// already carries it under the same stableId this handler receives.
|
|
796
|
+
// Best-effort: a missing/unsigned/tampered scan just means no PoC is
|
|
797
|
+
// available to re-check, not a verify_fix failure — the rescan/lint/
|
|
798
|
+
// tests legs below are independent of this and still apply.
|
|
799
|
+
let poc = null;
|
|
800
|
+
try {
|
|
801
|
+
const { scan: lastScan } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
|
|
802
|
+
const orig = lastScan && (lastScan.findings || []).find(f => f.stableId === stable_id);
|
|
803
|
+
if (orig && orig.poc && orig.poc.code) poc = { ...orig.poc, finding: orig };
|
|
804
|
+
} catch { /* best-effort lookup; poc stays null */ }
|
|
805
|
+
|
|
710
806
|
const verifyFixCore = await getVerifyFixCore();
|
|
711
807
|
const r = await verifyFixCore({
|
|
712
808
|
scanRoot: ctx.sessionRoot,
|
|
713
809
|
originalFindingStableId: stable_id,
|
|
714
810
|
files: confined,
|
|
811
|
+
poc,
|
|
812
|
+
fixMeta,
|
|
715
813
|
});
|
|
716
814
|
return {
|
|
717
815
|
_meta: META,
|
|
718
816
|
ok: r.ok,
|
|
719
817
|
rescan: { ok: r.rescan.ok, reason: r.rescan.reason, introduced: r.rescan.introduced || [] },
|
|
720
818
|
lint: { runner: r.lint.runner, ok: r.lint.ok, skipped: r.lint.skipped || false, output: redactString(r.lint.output || '').slice(0, 1500) },
|
|
819
|
+
// verifyFix computes five legs, not two — tests/honesty/poc were
|
|
820
|
+
// being silently dropped here, leaving an agent with no structured
|
|
821
|
+
// way to see WHY verification failed when the failure was in one
|
|
822
|
+
// of those three (only the free-text summary carried it).
|
|
823
|
+
// test-runner.js's runProjectTests never returns raw stdout/stderr,
|
|
824
|
+
// so no redaction is needed there; honesty.violations are static,
|
|
825
|
+
// code-generated strings; poc.reason is redacted defensively since
|
|
826
|
+
// it can echo proof-harness detail derived from scanned source.
|
|
827
|
+
tests: r.tests,
|
|
828
|
+
honesty: r.honesty,
|
|
829
|
+
poc: r.poc ? { ...r.poc, reason: r.poc.reason ? redactString(r.poc.reason) : r.poc.reason } : r.poc,
|
|
721
830
|
summary: r.summary,
|
|
722
831
|
};
|
|
723
832
|
} catch (e) {
|
|
@@ -801,7 +910,13 @@ export const synthesize_fix = {
|
|
|
801
910
|
regression_test: f.regression_test || null,
|
|
802
911
|
remediation: typeof fix.description === 'string' ? fix.description : (typeof fix === 'string' ? fix : null),
|
|
803
912
|
patchBounds: { touchedFiles, locDelta, oversized },
|
|
804
|
-
|
|
913
|
+
// oversized can only be true when hasReplacement is true (locDelta is
|
|
914
|
+
// only computed in that branch, and touchedFiles never varies) — a
|
|
915
|
+
// `!hasReplacement` conjunct here was a structural contradiction that
|
|
916
|
+
// made this permanently false. The correct signal: the stored
|
|
917
|
+
// replacement itself is too big to trust auto-applying, and there's
|
|
918
|
+
// no safer deterministic alternative.
|
|
919
|
+
recommendsFixPlan: oversized && !autofix,
|
|
805
920
|
};
|
|
806
921
|
},
|
|
807
922
|
};
|
|
@@ -933,7 +1048,9 @@ export const append_scratchpad = {
|
|
|
933
1048
|
async handler({ path: relPath, content }, ctx) {
|
|
934
1049
|
const v = _validateScratchpadPath(relPath);
|
|
935
1050
|
if (!v.ok) return { _meta: META, ok: false, reason: v.reason };
|
|
936
|
-
|
|
1051
|
+
let abs;
|
|
1052
|
+
try { abs = _scratchpadAbs(ctx.sessionRoot, relPath); }
|
|
1053
|
+
catch (e) { return { _meta: META, ok: false, reason: `path-escape refused: ${e.message}` }; }
|
|
937
1054
|
const total = _scratchpadTotalBytes(ctx.sessionRoot);
|
|
938
1055
|
if (total + content.length > SCRATCHPAD_MAX_TOTAL_BYTES) {
|
|
939
1056
|
return {
|
|
@@ -979,7 +1096,9 @@ export const read_scratchpad = {
|
|
|
979
1096
|
async handler({ path: relPath, offset, limit }, ctx) {
|
|
980
1097
|
const v = _validateScratchpadPath(relPath);
|
|
981
1098
|
if (!v.ok) return { _meta: META, ok: false, reason: v.reason };
|
|
982
|
-
|
|
1099
|
+
let abs;
|
|
1100
|
+
try { abs = _scratchpadAbs(ctx.sessionRoot, relPath); }
|
|
1101
|
+
catch (e) { return { _meta: META, ok: false, reason: `path-escape refused: ${e.message}` }; }
|
|
983
1102
|
if (!fs.existsSync(abs)) return { _meta: META, ok: false, reason: 'not-found' };
|
|
984
1103
|
let stat;
|
|
985
1104
|
try { stat = fs.statSync(abs); } catch (e) { return { _meta: META, ok: false, reason: `stat-failed: ${e.message}` }; }
|
|
@@ -1013,6 +1132,7 @@ export const read_scratchpad = {
|
|
|
1013
1132
|
import { appendAgentsMemory as _appendAgentsMemory, readAgentsMemory as _readAgentsMemory } from '../posture/agents-memory.js';
|
|
1014
1133
|
import { lookupCve as _lookupCve } from '../posture/cve-lookup.js';
|
|
1015
1134
|
|
|
1135
|
+
import { stateDir, statePath } from '../posture/state-dir.js';
|
|
1016
1136
|
export const append_agents_memory = {
|
|
1017
1137
|
name: 'append_agents_memory',
|
|
1018
1138
|
description: 'Append a short narrative entry to AGENTS.md — agent-authored continual-learning notes. Use at session end to record "what worked / what didn\'t / what I\'d try differently next time" so the next agent can pick up the lesson. Bounded: 2 KB per entry, 20 KB total before rotation to AGENTS.md.archive. Use sparingly — narrative, not structured data.',
|
|
@@ -1074,7 +1194,20 @@ export const query_triage_memory = {
|
|
|
1074
1194
|
},
|
|
1075
1195
|
async handler({ query }, ctx) {
|
|
1076
1196
|
const { queryMemory } = await import('../posture/triage-memory.js');
|
|
1077
|
-
const
|
|
1197
|
+
const raw = queryMemory(ctx.sessionRoot, query || '');
|
|
1198
|
+
// Stage 6 correctness audit: this returned queryMemory's output
|
|
1199
|
+
// verbatim, with no redaction pass — every other tool that echoes
|
|
1200
|
+
// scanned-source-derived text redacts it (mcp/CLAUDE.md's "Adding a new
|
|
1201
|
+
// tool" step 3). Round-trip through redactString the same way
|
|
1202
|
+
// redactFinding already does for its own opaque `.trace` field: results
|
|
1203
|
+
// here mix shapes (a triage decision's free-text `reason`, a finding's
|
|
1204
|
+
// `vuln`/`family`/file path), so scrubbing the whole serialized
|
|
1205
|
+
// structure catches secret-shaped substrings regardless of which field
|
|
1206
|
+
// they landed in, rather than hardcoding a field allowlist that could
|
|
1207
|
+
// miss one.
|
|
1208
|
+
let results;
|
|
1209
|
+
try { results = JSON.parse(redactString(JSON.stringify(raw))); }
|
|
1210
|
+
catch { results = raw; }
|
|
1078
1211
|
return {
|
|
1079
1212
|
_meta: META,
|
|
1080
1213
|
count: results.length,
|
|
@@ -1102,7 +1235,16 @@ export const query_findings_memory = {
|
|
|
1102
1235
|
},
|
|
1103
1236
|
async handler({ query }, ctx) {
|
|
1104
1237
|
const { queryFindingsMemory } = await import('../posture/findings-memory.js');
|
|
1105
|
-
|
|
1238
|
+
const raw = queryFindingsMemory(ctx.sessionRoot, query || '');
|
|
1239
|
+
// Stage 6 correctness audit — same redaction gap and same fix as
|
|
1240
|
+
// query_triage_memory just above: this mixes four differently-shaped
|
|
1241
|
+
// result kinds (finding / triage / history / AGENTS.md text), so a
|
|
1242
|
+
// whole-structure redactString round-trip is applied rather than a
|
|
1243
|
+
// per-field allowlist that could miss one of the four shapes.
|
|
1244
|
+
let body;
|
|
1245
|
+
try { body = JSON.parse(redactString(JSON.stringify(raw))); }
|
|
1246
|
+
catch { body = raw; }
|
|
1247
|
+
return { _meta: META, ...body };
|
|
1106
1248
|
},
|
|
1107
1249
|
};
|
|
1108
1250
|
|
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
|
|
@@ -44,12 +44,13 @@ rather than creating a stray state dir outside a project.
|
|
|
44
44
|
|
|
45
45
|
**Agentic verification** — `verifier.js`, `verifier-target.js`, `verifier-ephemeral.js`, `harness-discovery.js`, `adversary-agent.js`, `defender-agent.js`, `auditor-agent.js`, `three-agent-pipeline.js`.
|
|
46
46
|
|
|
47
|
-
**Methodology additions (
|
|
47
|
+
**Methodology additions (Agentic Methodology PRD, removed post-implementation)** — default-on annotators/artifacts that layer the agentic-hunter methodology on the deterministic engine:
|
|
48
48
|
- `falsification.js` — default falsification pass. For each taint-style finding, tries to DISPROVE it (locate a context-matched control on the path, reusing `dataflow/sanitizer-proof.js`'s shape rules read-only); a blocked finding is demoted + `quarantined`, never removed and never severity-touched (recall-preserving, like `proof-gate`). Wired after `annotateProofGate`. Opt out: `AGENTIC_SECURITY_NO_FALSIFICATION=1`. Optional LLM tier over survivors when an endpoint is configured.
|
|
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,11 +77,38 @@ 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`.
|
|
82
85
|
|
|
83
|
-
**
|
|
86
|
+
**NIST Privacy Framework 1.1 (`privacy-framework.js`)** — assessment + remediation
|
|
87
|
+
over the bundled `compliance-frameworks/nist-privacy-1-1.json` (all 104 controls).
|
|
88
|
+
Sits on top of `auditor-walkthrough.js`'s evaluator and adds the half a narrative
|
|
89
|
+
cannot give you: a gap becomes a FINDING (`family: privacy-compliance`,
|
|
90
|
+
`CWE-359`) carrying an actionable remediation, so it flows through triage and
|
|
91
|
+
`/fix`.
|
|
92
|
+
|
|
93
|
+
Four buckets, and the bucket is always stated: `gap` (mapped signal failing —
|
|
94
|
+
the ONLY bucket that emits a finding), `engine-gap` (NIST rates it code-testable
|
|
95
|
+
but this engine has no signal — disclosed by name, never a pass), `manual` (NIST
|
|
96
|
+
rates it not code-testable), `satisfied`. NIST's own `codeTestable` rating is
|
|
97
|
+
carried per control and is what separates "nobody checked" from "we checked and
|
|
98
|
+
it is fine" — 48 of 104 are governance controls no scanner can assess, and
|
|
99
|
+
reporting those as passed is the failure mode the module exists to prevent.
|
|
100
|
+
|
|
101
|
+
Two guards are load-bearing. **Findings are opt-in**
|
|
102
|
+
(`AGENTIC_SECURITY_PRIVACY_FRAMEWORK=1`); the assessment always lands on
|
|
103
|
+
`scan.privacyFramework` and at `.agentic-security/privacy-framework.{json,md}`,
|
|
104
|
+
but appending to `scan.findings` by default would change every severity count
|
|
105
|
+
and gate verdict downstream. And the **vacuous-satisfaction guard**: a
|
|
106
|
+
`family:`-mapped control clears when no findings of that family are open, which
|
|
107
|
+
is also true of a scan that read zero files — so when nothing was examined every
|
|
108
|
+
mapped control degrades to `engine-gap` instead of reporting as satisfied. That
|
|
109
|
+
one was caught by the module's own test, not in review.
|
|
110
|
+
|
|
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`.
|
|
84
112
|
|
|
85
113
|
**Why this fired** — `why-fired.js`. Runs LAST so it reflects every annotation. Customer-facing provenance.
|
|
86
114
|
|
|
@@ -209,10 +237,19 @@ the CI-gated tier and graduation into it is a human decision with a stated
|
|
|
209
237
|
policy; an automated writer must not decide what blocks everyone's build.
|
|
210
238
|
|
|
211
239
|
**Operator entry point:** `scripts/enroll-proven-finding.mjs <project>`
|
|
212
|
-
(`--dry-run` scores without writing). It proves findings itself — the
|
|
213
|
-
pipeline
|
|
214
|
-
`
|
|
215
|
-
|
|
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
|
|
216
253
|
(`finding.fix.patch`) for `post/`; a proven finding with no fix is reported as
|
|
217
254
|
skipped, not dropped. After enrolling, refresh the baseline
|
|
218
255
|
(`npm run bench:cve-replay:update-baseline`) and commit it.
|
|
@@ -121,7 +121,14 @@ export function buildScorecard(inputs) {
|
|
|
121
121
|
notGeneralRecall: true,
|
|
122
122
|
notGeneralFalsePositiveRate: true,
|
|
123
123
|
f1Emitted: false,
|
|
124
|
-
|
|
124
|
+
// Was: "no labelled real-world population is available". That stopped
|
|
125
|
+
// being true when bench/independent/ was built, and a stale justification
|
|
126
|
+
// is worse than none — it argues against a measurement that now exists.
|
|
127
|
+
// No F1 is emitted FOR THE CURATED CORPUS, and the reason is narrower:
|
|
128
|
+
// its `post/` fixtures are authored here to be silent, so its precision
|
|
129
|
+
// denominator describes fixture design rather than engine behaviour.
|
|
130
|
+
// F1 over the independent population IS reported, in its own section.
|
|
131
|
+
f1OmissionReason: 'the curated corpus authors both its vulnerable and its fixed fixtures, so an F1 over it would measure fixture design, not accuracy; F1 over the labelled third-party population is reported separately from bench/independent/RESULT.json',
|
|
125
132
|
},
|
|
126
133
|
corpus: {
|
|
127
134
|
measuredThisRun: true,
|
|
@@ -138,6 +145,17 @@ export function buildScorecard(inputs) {
|
|
|
138
145
|
corpusBaseline: committed.corpusBaseline
|
|
139
146
|
? { source: 'bench/cve-replay/corpus-baseline.json', generatedAt: committed.corpusBaseline.generatedAt, total: committed.corpusBaseline.total, passing: committed.corpusBaseline.passing }
|
|
140
147
|
: null,
|
|
148
|
+
independent: committed.independent
|
|
149
|
+
? {
|
|
150
|
+
source: 'bench/independent/RESULT.json',
|
|
151
|
+
measuredAt: committed.independent.measuredAt || null,
|
|
152
|
+
engineVersion: committed.independent.engineVersion || null,
|
|
153
|
+
population: committed.independent.population || null,
|
|
154
|
+
overall: committed.independent.overall || null,
|
|
155
|
+
wide: committed.independent.wide || null,
|
|
156
|
+
byLanguage: committed.independent.byLanguage || null,
|
|
157
|
+
}
|
|
158
|
+
: null,
|
|
141
159
|
proofCorpus: proof
|
|
142
160
|
? {
|
|
143
161
|
source: 'bench/proof-corpus/results/summary.json',
|
|
@@ -307,6 +325,54 @@ export function renderScorecardMarkdown(m) {
|
|
|
307
325
|
}
|
|
308
326
|
L.push('Per-file counts are in `docs/scorecard.json`.');
|
|
309
327
|
L.push('');
|
|
328
|
+
const ind = m.committedInputs.independent;
|
|
329
|
+
if (ind && ind.overall) {
|
|
330
|
+
L.push('## Independent evaluation population — the number that matters');
|
|
331
|
+
L.push('');
|
|
332
|
+
L.push('Everything above is a **regression net**: its fixtures and its labels are both');
|
|
333
|
+
L.push('written here, which is why its detection rate sits at the ceiling by');
|
|
334
|
+
L.push('construction. `bench/independent/` is the other instrument — real upstream code');
|
|
335
|
+
L.push('at the commit where a vulnerability really existed, with the CWE assigned by a');
|
|
336
|
+
L.push('public advisory database rather than by this project.');
|
|
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);
|
|
346
|
+
L.push(`**Measured ${ind.measuredAt} on engine ${ind.engineVersion}, ` +
|
|
347
|
+
`n=${ind.population?.scoredEntries}, ${unscoredCount} unscored** ` +
|
|
348
|
+
'(*committed artifact*, `' + ind.source + '` — read, not re-run: scoring takes ~32 minutes).');
|
|
349
|
+
L.push('');
|
|
350
|
+
L.push('| | Advisory-local (**the claim**) | Wide (diagnostic) |');
|
|
351
|
+
L.push('| --- | --- | --- |');
|
|
352
|
+
L.push(`| Precision | **${formatRate(ind.overall.precision?.n, ind.overall.precision?.d)}** | ${formatRate(ind.wide?.precision?.n, ind.wide?.precision?.d)} |`);
|
|
353
|
+
L.push(`| Recall | **${formatRate(ind.overall.recall?.n, ind.overall.recall?.d)}** | ${formatRate(ind.wide?.recall?.n, ind.wide?.recall?.d)} |`);
|
|
354
|
+
L.push(`| F1 | **${ind.overall.f1 === null || ind.overall.f1 === undefined ? 'n/a' : ind.overall.f1.toFixed(3)}** | ${ind.wide?.f1 === null || ind.wide?.f1 === undefined ? 'n/a' : ind.wide.f1.toFixed(3)} |`);
|
|
355
|
+
L.push('');
|
|
356
|
+
if (ind.byLanguage) {
|
|
357
|
+
L.push('| Language | n | Recall | Precision |');
|
|
358
|
+
L.push('| --- | --- | --- | --- |');
|
|
359
|
+
for (const [k, v] of Object.entries(ind.byLanguage)) {
|
|
360
|
+
L.push(`| ${k} | ${v.entries} | ${formatRate(v.recall?.n, v.recall?.d)} | ${formatRate(v.precision?.n, v.precision?.d)} |`);
|
|
361
|
+
}
|
|
362
|
+
L.push('');
|
|
363
|
+
}
|
|
364
|
+
L.push('**Quote the advisory-local column.** "Wide" scores the same scans without');
|
|
365
|
+
L.push('restricting findings to the files the advisory\'s fix commit touched — it asks');
|
|
366
|
+
L.push('only whether the CWE appeared *anywhere* in the package. Over scopes holding up');
|
|
367
|
+
L.push('to 1740 findings that is close to asking whether the codebase contains the bug');
|
|
368
|
+
L.push('class at all, a question with a much easier yes. It is kept because it is the');
|
|
369
|
+
L.push('only way to tell whether a change moved the engine or moved the benchmark.');
|
|
370
|
+
L.push('');
|
|
371
|
+
L.push('Against ~100% on the curated corpus above. **That gap is the most useful number');
|
|
372
|
+
L.push('in this document**, and publishing it is the point of the exercise. The figure');
|
|
373
|
+
L.push('went DOWN when the benchmark was corrected, and is published that way.');
|
|
374
|
+
L.push('');
|
|
375
|
+
}
|
|
310
376
|
L.push('## Committed artifacts referenced (not re-run by this command)');
|
|
311
377
|
L.push('');
|
|
312
378
|
const cb = m.committedInputs.corpusBaseline;
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import * as fs from 'node:fs';
|
|
30
30
|
import * as path from 'node:path';
|
|
31
31
|
|
|
32
|
+
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
32
33
|
const MEMORY_FILE = '.agentic-security/AGENTS.md';
|
|
33
34
|
const ARCHIVE_FILE = '.agentic-security/AGENTS.md.archive';
|
|
34
35
|
const MAX_BYTES = 20 * 1024;
|
|
@@ -36,8 +37,8 @@ const MAX_ENTRY_BYTES = 2 * 1024;
|
|
|
36
37
|
const ARCHIVE_MAX_BYTES = 200 * 1024;
|
|
37
38
|
const HEADER = '# AGENTS.md\n\nAgent-authored continual-learning notes. Each entry: timestamp + agent name + one short paragraph. New entries appended at the bottom; oldest entries rotate to AGENTS.md.archive when this file exceeds 20 KB.\n\n';
|
|
38
39
|
|
|
39
|
-
function _resolve(scanRoot) { return
|
|
40
|
-
function _archivePath(scanRoot) { return
|
|
40
|
+
function _resolve(scanRoot) { return statePath(scanRoot, 'AGENTS.md'); }
|
|
41
|
+
function _archivePath(scanRoot) { return statePath(scanRoot, 'AGENTS.md.archive'); }
|
|
41
42
|
|
|
42
43
|
export function readAgentsMemory(scanRoot) {
|
|
43
44
|
const fp = _resolve(scanRoot);
|
|
@@ -65,7 +66,8 @@ export function appendAgentsMemory(scanRoot, { agent, body }) {
|
|
|
65
66
|
const entry = `\n## ${ts} agent: ${agent}\n\n${snippet}\n`;
|
|
66
67
|
try {
|
|
67
68
|
const fp = _resolve(scanRoot);
|
|
68
|
-
|
|
69
|
+
if (!stateWritesEnabled()) return;
|
|
70
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
69
71
|
if (!fs.existsSync(fp)) fs.writeFileSync(fp, HEADER);
|
|
70
72
|
fs.appendFileSync(fp, entry);
|
|
71
73
|
_maybeRotate(scanRoot);
|
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',
|