@clear-capabilities/agentic-security-scanner 0.133.0 → 0.134.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.
@@ -1 +1 @@
1
- a868fa3988608a5a5c0867774d870781a52d79924daf0956652c62fa1384d782 agentic-security.mjs
1
+ c30ab141dd65e53a762c16c7683bfc3195b677107a838a83fcfae541f0fe5e0e agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.133.0",
3
+ "version": "0.134.0",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin — SAST, SCA (function-level reachability + CISA KEV), secrets, IaC, prompt-injection, MCP/agent-tool audit, auth/authZ deep analysis, attack chains, PoC generation, business logic, toxic-combinations scoring, SBOM, SARIF ingest, pipeline integrity, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -58,17 +58,18 @@
58
58
  "build": "ncc build bin/agentic-security.js -o dist --minify -e web-tree-sitter -e tree-sitter-wasms && mv dist/index.js dist/agentic-security.mjs && rm -f dist/package.json && chmod +x dist/agentic-security.mjs && node -e \"const fs=require('fs');const p='dist/agentic-security.mjs';const c=fs.readFileSync(p,'utf8');if(!c.startsWith('#!'))fs.writeFileSync(p,'#!/usr/bin/env node\\n'+c);\" && node -e \"const fs=require('fs');const c=require('crypto');const h=c.createHash('sha256').update(fs.readFileSync('dist/agentic-security.mjs')).digest('hex');fs.writeFileSync('dist/agentic-security.mjs.sha256',h+' agentic-security.mjs\\n');\"",
59
59
  "prepare": "node ../scripts/pre-push-gate.mjs --install-hook",
60
60
  "prepublishOnly": "npm run build && node ../scripts/sync-scanner-changelog.mjs && node ../scripts/release-check.mjs",
61
- "test": "npm run test:smoke && npm run test:glob && npm run test:sast && npm run test:posture && npm run test:dataflow && npm run test:mcp && npm run test:report && npm run test:bench-modules && npm run test:lifecycle && npm run test:eval && AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js",
61
+ "test": "npm run test:smoke && npm run test:glob && npm run test:sast && npm run test:posture && npm run test:dataflow && npm run test:mcp && npm run test:report && npm run test:bench-modules && npm run test:lifecycle && npm run test:eval && npm run test:discovery && AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js",
62
62
  "test:smoke": "node --test test/smoke.test.js",
63
63
  "test:glob": "node --test test/glob-compat.test.js",
64
64
  "test:sast": "node --test test/crypto-specialist.test.js test/llm.test.js test/llm-cost-advisor.test.js test/llm-owasp.test.js test/logic.test.js test/authz.test.js test/model-load.test.js test/prompt-template.test.js test/business-logic.test.js test/python-sinks.test.js test/phase1-detectors.test.js test/phase2-detectors.test.js test/phase3-v3.test.js test/phase7-extensions.test.js test/phase8-extensions.test.js test/new-cwe-detectors.test.js test/file-upload.test.js test/llmsecops-detectors.test.js test/db-taint.test.js test/dart-swift.test.js test/redos-nfa.test.js test/weak-randomness.test.js test/csharp-pipeline.test.js test/post-quantum-crypto.test.js test/web3-advanced.test.js test/cloud-iam-k8s.test.js test/crypto-protocol.test.js test/ml-supply-chain.test.js test/wrong-context-sanitizer.test.js test/sanitizer-context.test.js test/frontend-hygiene.test.js test/csv-injection.test.js test/stored-taint.test.js test/tree-sitter-sinks.test.js test/kotlin-structural.test.js test/ruby-php-structural.test.js test/java-csharp-structural.test.js test/guard-recognition.test.js test/js-python-framework-structural.test.js test/go-structural.test.js test/secret-concat.test.js test/xss-reflected-multilang.test.js test/code-injection-multilang.test.js test/xxe-multilang.test.js test/xpath-injection-multilang.test.js test/gapfill-batch13.test.js test/agent-untrusted-flow.test.js test/api-authz.test.js test/event-entrypoint.test.js test/iac-terraform.test.js test/cross-service.test.js test/rbac-consistency.test.js",
65
- "test:posture": "node --test test/material-change.test.js test/drift.test.js test/scorecard.test.js test/accuracy-scorecard.test.js test/scorecard-gate.test.js test/release-check.test.js test/pre-push-gate.test.js test/dependency-currency.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/image-packages.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/sca-batch.test.js test/composite-risk.test.js test/sca-coverage.test.js test/gradle-deps.test.js test/sca-route-reachable.test.js test/sca-policy.test.js test/sca-verdict.test.js test/install-script.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/deterministic-fix.test.js test/falsification.test.js test/verification-separation.test.js test/attestation.test.js test/determinism-cross-machine.test.js test/fix-honesty-gate.test.js test/model-routing.test.js test/root-cause-sweep.test.js test/entrypoint-inventory.test.js test/relevance.test.js test/untrusted.test.js test/agent-hardening.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/fix-verify-tests.test.js test/fix-acceptance.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js test/license-graph.test.js test/secret-live-check.test.js test/attack-taxonomy.test.js test/triage-memory.test.js test/pr-augment.test.js test/chat-batch2.test.js test/chat-batch3.test.js test/chat-batch4.test.js test/chat-batch5.test.js test/chat-batch6.test.js test/router.test.js test/legacy-alias-redirect.test.js test/cache-economics.test.js test/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.test.js test/execution-proof.test.js test/fix-metrics.test.js test/corpus-enroll.test.js test/prove-findings.test.js test/corpus-match.test.js test/corpus-provenance.test.js test/learning-quorum.test.js test/llm-cache-integrity.test.js test/integrity-legacy-key.test.js test/suppression-visibility.test.js test/cost-ceiling.test.js test/local-endpoint.test.js test/model-trust.test.js test/vuln-archaeology.test.js test/scan-checkpoint.test.js test/llm-redact.test.js",
65
+ "test:posture": "node --test test/material-change.test.js test/drift.test.js test/scorecard.test.js test/accuracy-scorecard.test.js test/scorecard-gate.test.js test/release-check.test.js test/pre-push-gate.test.js test/dependency-currency.test.js test/package-contents-check.test.js test/gate-verdict-cache.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/image-packages.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/sca-batch.test.js test/composite-risk.test.js test/sca-coverage.test.js test/gradle-deps.test.js test/sca-route-reachable.test.js test/sca-policy.test.js test/sca-verdict.test.js test/install-script.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/deterministic-fix.test.js test/falsification.test.js test/verification-separation.test.js test/logic-claims.test.js test/attestation.test.js test/comparison.test.js test/determinism-cross-machine.test.js test/fix-honesty-gate.test.js test/model-routing.test.js test/root-cause-sweep.test.js test/entrypoint-inventory.test.js test/relevance.test.js test/untrusted.test.js test/agent-hardening.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/fix-verify-tests.test.js test/fix-acceptance.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js test/ignore-pragma.test.js test/license-graph.test.js test/secret-live-check.test.js test/attack-taxonomy.test.js test/triage-memory.test.js test/pr-augment.test.js test/chat-batch2.test.js test/chat-batch3.test.js test/chat-batch4.test.js test/chat-batch5.test.js test/chat-batch6.test.js test/router.test.js test/legacy-alias-redirect.test.js test/cache-economics.test.js test/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.test.js test/execution-proof.test.js test/fix-metrics.test.js test/corpus-enroll.test.js test/prove-findings.test.js test/corpus-match.test.js test/corpus-provenance.test.js test/learning-quorum.test.js test/llm-cache-integrity.test.js test/proof-artifact.test.js test/providers.test.js test/fleet.test.js test/autopilot.test.js test/autopilot-cli.test.js test/verifier-independence.test.js test/integrity-legacy-key.test.js test/suppression-visibility.test.js test/cost-ceiling.test.js test/local-endpoint.test.js test/model-trust.test.js test/vuln-archaeology.test.js test/scan-checkpoint.test.js test/llm-redact.test.js",
66
66
  "test:dataflow": "node --test test/fn-reach.test.js test/deep-taint.test.js test/calibration.test.js test/holdout-eval.test.js test/cross-lang-meta.test.js test/cross-lang-queues.test.js test/phase5-xlang.test.js test/phase5-coverage.test.js test/phase6-taint.test.js test/llm-validator-consistency.test.js test/llm-validator-default-on.test.js test/llm-validator-preset.test.js test/parser-py-cst.test.js test/parser-cs-kt.test.js test/parser-go.test.js test/parser-php-rb.test.js test/interproc-k2.test.js test/proven-clean.test.js test/backward-default.test.js test/incremental-cache.test.js test/string-regex-lattice.test.js test/closure-capture.test.js test/points-to.test.js test/type-stubs.test.js test/soft-taint.test.js test/ifds.test.js test/symbolic-exec-proof.test.js test/ifds-summary-edges.test.js test/stub-aware-filter.test.js test/cross-repo.test.js test/proof-gate.test.js test/proof-safe.test.js test/collection-taint.test.js test/kcfa-context.test.js test/kcfa-callstring.test.js test/flow-parity.test.js test/callgraph-resolve.test.js test/import-reachability.test.js test/ir-stats.test.js test/parser-cpp.test.js test/parser-js-decorators.test.js test/cpp-integration.test.js test/engine-reconnect.test.js test/phase2-scoping.test.js test/engine-recall.test.js",
67
67
  "test:mcp": "node --test test/mcp.test.js test/mcp-audit.test.js test/audit-cli.test.js test/mcp-scratchpad.test.js test/mcp-offload.test.js test/sca-upgrade.test.js",
68
68
  "test:report": "node --test test/sarif-ingest.test.js test/junit.test.js test/ci.test.js test/poc-generator.test.js test/verifier.test.js test/verifier-target.test.js test/annotator-errors.test.js test/grader-calibration.test.js test/pr-delta-gate.test.js test/vex.test.js test/report-render.test.js",
69
69
  "test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
70
70
  "test:lifecycle": "node --test test/dead-code.test.js test/no-dead-modules.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js test/sandbox.test.js test/sandbox-escape.test.js && node ../scripts/lint-command-descriptions.mjs",
71
71
  "test:eval": "node --test test/independent-eval.test.js test/realworld-recall.test.js",
72
+ "test:discovery": "node --test test/discovery-partition.test.js test/discovery-lenses.test.js test/discovery-hunter.test.js test/discovery-confirm.test.js test/discovery-disprove.test.js test/discovery-judge.test.js test/discovery-run.test.js test/discovery-wiring.test.js test/discovery-llm-invoke.test.js",
72
73
  "eval:independent": "node ../bench/independent-eval/runner.mjs",
73
74
  "eval:independent:gate": "node ../bench/independent-eval/runner.mjs --gate default",
74
75
  "smoke": "node bin/agentic-security.js scan test/fixtures/vulnerable-js",
@@ -99,6 +100,9 @@
99
100
  "scorecard": "node ../scripts/scorecard.mjs",
100
101
  "corpus:enroll": "node ../scripts/enroll-proven-finding.mjs",
101
102
  "corpus:provenance": "node ../scripts/corpus-provenance-check.mjs",
103
+ "fleet": "node ../scripts/fleet.mjs",
104
+ "autopilot": "node ../scripts/autopilot.mjs",
105
+ "comparison": "node ../scripts/comparison.mjs",
102
106
  "determinism:attest": "node ../scripts/attest-fixture.mjs",
103
107
  "scorecard:check": "node ../scripts/scorecard-check.mjs",
104
108
  "release:check": "node ../scripts/release-check.mjs",
@@ -0,0 +1,38 @@
1
+ # scanner/src/discovery/
2
+
3
+ LLM-driven candidate discovery, gated by the deterministic engine.
4
+
5
+ User entry point: `agentic-security hunt --root <dir>` (`cmdHunt` in `bin/agentic-security.js`),
6
+ documented in `commands/scan.md`. Advisory only — it never gates a build and its
7
+ output does not enter `last-scan.json`.
8
+
9
+ `partition.js` splits the call graph into disjoint focus areas · `lenses.js`
10
+ holds the seven hunting lenses and the prompt builder · `hunter.js` runs one
11
+ bounded (area × lens) pass · `confirm.js` routes each candidate back through
12
+ the taint engine · `disprove.js` runs the three-angle majority-vote refutation
13
+ panel · `judge.js` shapes findings and dedupes against the prior scan ·
14
+ `llm-invoke.js` is the single injected LLM endpoint caller shared by
15
+ `hunter.js` and `disprove.js` (no other module may talk to an LLM directly) ·
16
+ `index.js` composes them.
17
+
18
+ ## Rules
19
+
20
+ - **A hunter proposes; it never decides.** `confirm.js` sets a confirmation
21
+ tier and never filters — an `unconfirmed` candidate still proceeds, because
22
+ the taint engine models only a subset of the program and the absence of
23
+ corroboration must never be laundered into a false-positive verdict.
24
+ `disprove.js` is the only stage that removes a candidate: nothing reaches a
25
+ report without surviving refutation there.
26
+ - **Every LLM call is an injected `llmInvoke`.** No module may import an SDK or
27
+ hard-code an endpoint. Absence of an LLM degrades to an empty, well-formed
28
+ result — it never throws and never blocks a scan.
29
+ - **Silence never refutes.** A probe that says nothing lowers a candidate to
30
+ `unconfirmed`; only an argued majority refutes. A voter that errors is
31
+ excluded from the denominator, not counted as agreement.
32
+ - **Severity comes from evidence, not from the model.** The confirmation tier
33
+ sets it: taint-confirmed → high, sink-adjacent → medium, unconfirmed → low.
34
+ This layer never emits `critical`.
35
+ - **Ids are content digests.** No clock, no randomness, anywhere in an id, a
36
+ digest, or a sort key.
37
+ - **Coverage is reported.** Degraded runs and their reasons appear in every
38
+ report. A half-failed pass must never read as a clean one.
@@ -0,0 +1,47 @@
1
+ //
2
+ // Route every model-proposed candidate back through the deterministic layer.
3
+ //
4
+ // WHY THIS EXISTS. A hunter's output is a hypothesis. The engine underneath it
5
+ // can already decide, for a given file and line, whether tainted data reaches a
6
+ // modelled sink there. Asking it turns "the model thinks so" into "the model
7
+ // thinks so AND the taint engine agrees", which is a materially stronger claim
8
+ // than either layer makes alone.
9
+ //
10
+ // THE ASYMMETRY IS DELIBERATE. Confirmation raises standing; the absence of
11
+ // confirmation NEVER lowers it below `unconfirmed` and is never recorded as a
12
+ // refutation. The taint engine models a subset of the program — an
13
+ // unconfirmed candidate may be a real bug in a construct the engine does not
14
+ // model, and calling that a false positive would launder a coverage gap into a
15
+ // verdict. Refutation is `disprove.js`'s job, and it must be argued, not
16
+ // inferred from silence.
17
+ export const CONFIRMATION_TIERS = Object.freeze(['taint-confirmed', 'sink-adjacent', 'unconfirmed']);
18
+
19
+ function unconfirmed(probedBy, reason) {
20
+ return { tier: 'unconfirmed', evidence: null, probedBy, reason: reason || null };
21
+ }
22
+
23
+ export async function confirmCandidate(candidate, opts = {}) {
24
+ const probe = typeof opts.taintProbe === 'function' ? opts.taintProbe : null;
25
+ if (!probe) return { ...candidate, confirmation: unconfirmed(null, 'no taintProbe supplied') };
26
+
27
+ let res;
28
+ try {
29
+ res = await probe(candidate);
30
+ } catch (err) {
31
+ return { ...candidate, confirmation: unconfirmed('taintProbe', `probe failed: ${err?.message || String(err)}`) };
32
+ }
33
+ if (!res) return { ...candidate, confirmation: unconfirmed('taintProbe', null) };
34
+ if (!CONFIRMATION_TIERS.includes(res.tier)) {
35
+ return { ...candidate, confirmation: unconfirmed('taintProbe', `unknown tier: ${res.tier}`) };
36
+ }
37
+ return {
38
+ ...candidate,
39
+ confirmation: { tier: res.tier, evidence: res.evidence ?? null, probedBy: 'taintProbe', reason: null },
40
+ };
41
+ }
42
+
43
+ export async function confirmAll(candidates, opts = {}) {
44
+ const out = [];
45
+ for (const c of candidates || []) out.push(await confirmCandidate(c, opts));
46
+ return out;
47
+ }
@@ -0,0 +1,79 @@
1
+ //
2
+ // Adversarial refutation. Each voter is told to REFUTE the candidate, not to
3
+ // assess it: a model asked "is this real?" agrees with the premise far more
4
+ // often than one asked "show me why this cannot happen", and the second
5
+ // question is the one that kills plausible-but-wrong findings.
6
+ //
7
+ // THREE ANGLES, NOT THREE COPIES. A candidate can fail in more than one way,
8
+ // and three identical voters mostly measure sampling noise. Reachability,
9
+ // attacker preconditions, and sanitization are the three ways these candidates
10
+ // actually die.
11
+ //
12
+ // SILENCE NEVER REFUTES. A voter that errors or returns unparseable output did
13
+ // not vote, and is excluded from the denominator rather than counted as
14
+ // agreement. If nobody votes the panel is `undecided` and the candidate
15
+ // SURVIVES — an outage must not quietly delete findings.
16
+ import { resolveLlmInvoke } from './llm-invoke.js';
17
+
18
+ const DEFAULT_ANGLES = ['reachability', 'preconditions', 'sanitization'];
19
+ export const REFUTE_ANGLES = Object.freeze([...DEFAULT_ANGLES]);
20
+
21
+ const ANGLE_BRIEF = {
22
+ reachability: 'Can attacker-controlled data actually reach this line at runtime? Name the caller chain or show there is none.',
23
+ preconditions: 'What must the attacker already have — a session, a role, a tenant, a race window? If the prerequisites exceed the impact, it is refuted.',
24
+ sanitization: 'Is the value validated, escaped, parameterised, or type-constrained anywhere on the path? A framework default counts.',
25
+ };
26
+
27
+ export function buildRefutePrompt(candidate, angle) {
28
+ return [
29
+ `Your job is to REFUTE the security finding below. Assume it is wrong and look for the reason.`,
30
+ `Refute it on this angle only: ${angle}. ${ANGLE_BRIEF[angle] || ''}`,
31
+ ``,
32
+ `Finding: ${candidate.title}`,
33
+ `Location: ${candidate.file}:${candidate.line}`,
34
+ `Claimed reason: ${candidate.rationale || '(none given)'}`,
35
+ `Deterministic confirmation: ${candidate.confirmation?.tier || 'unknown'}`,
36
+ ``,
37
+ `If you cannot refute it on this angle, say so honestly.`,
38
+ `Return JSON: {"refuted":true|false,"reason":"..."}`,
39
+ ].join('\n');
40
+ }
41
+
42
+ function parseVote(raw) {
43
+ if (typeof raw !== 'string') return null;
44
+ const s = raw.indexOf('{'), e = raw.lastIndexOf('}');
45
+ if (s < 0 || e <= s) return null;
46
+ let p;
47
+ try { p = JSON.parse(raw.slice(s, e + 1)); } catch { return null; }
48
+ if (typeof p?.refuted !== 'boolean') return null;
49
+ return { refuted: p.refuted, reason: typeof p.reason === 'string' ? p.reason : '' };
50
+ }
51
+
52
+ export async function disproveCandidate(candidate, opts = {}) {
53
+ const angles = Array.isArray(opts.angles) && opts.angles.length ? opts.angles : DEFAULT_ANGLES;
54
+ const llmInvoke = resolveLlmInvoke(opts);
55
+
56
+ const votes = [];
57
+ if (typeof llmInvoke === 'function') {
58
+ for (const angle of angles) {
59
+ let vote = null;
60
+ try { vote = parseVote(await llmInvoke(buildRefutePrompt(candidate, angle))); } catch { vote = null; }
61
+ if (vote) votes.push({ angle, ...vote });
62
+ }
63
+ }
64
+
65
+ const voterCount = votes.length;
66
+ const refuteCount = votes.filter(v => v.refuted).length;
67
+ const undecided = voterCount === 0;
68
+ const refuted = !undecided && refuteCount * 2 > voterCount;
69
+ return { ...candidate, refutation: { votes, voterCount, refuteCount, refuted, undecided } };
70
+ }
71
+
72
+ export async function disprovePanel(candidates, opts = {}) {
73
+ const survivors = [], refuted = [];
74
+ for (const c of candidates || []) {
75
+ const judged = await disproveCandidate(c, opts);
76
+ (judged.refutation.refuted ? refuted : survivors).push(judged);
77
+ }
78
+ return { survivors, refuted };
79
+ }
@@ -0,0 +1,116 @@
1
+ //
2
+ // One bounded hunter run = one focus area seen through one lens.
3
+ //
4
+ // A hunter PROPOSES. Nothing here is a finding: every candidate must survive
5
+ // `confirm.js` and `disprove.js` first. That separation is the whole design —
6
+ // the model is allowed to be imaginative precisely because something
7
+ // deterministic checks it afterwards.
8
+ //
9
+ // FAILURE IS ALWAYS DEGRADATION, NEVER AN EXCEPTION. A missing endpoint, a
10
+ // rate limit, or unparseable output yields `degraded:true` with a reason. A
11
+ // discovery pass that cannot run must leave the rest of the scan intact.
12
+ import * as crypto from 'node:crypto';
13
+ import { buildHunterPrompt } from './lenses.js';
14
+ import { resolveLlmInvoke } from './llm-invoke.js';
15
+
16
+ function appendEntry(transcript, entry) {
17
+ const prev = transcript.length ? transcript[transcript.length - 1].hash : null;
18
+ // Named `serialized`, not `body`: this is the canonical serialisation of a
19
+ // transcript entry being fed to a hash, and calling it `body` made the
20
+ // mass-assignment detector read it as a request payload. The name was simply
21
+ // wrong for what it holds, so this is a fix at the source rather than a
22
+ // suppression — and that detector's finding carries no line number, so a
23
+ // line-scoped ignore pragma could not have matched it anyway.
24
+ const serialized = JSON.stringify({ ...entry, prev });
25
+ const hash = crypto.createHash('sha256').update(serialized).digest('hex');
26
+ transcript.push({ ...entry, prev, hash });
27
+ return transcript;
28
+ }
29
+
30
+ function extractJsonWithFlag(raw) {
31
+ if (typeof raw !== 'string') return { parsed: null, found: false };
32
+ const start = raw.indexOf('{');
33
+ const end = raw.lastIndexOf('}');
34
+ if (start < 0 || end <= start) return { parsed: null, found: false };
35
+ try { return { parsed: JSON.parse(raw.slice(start, end + 1)), found: true }; } catch { return { parsed: null, found: false }; }
36
+ }
37
+
38
+ function candidateId(focusAreaId, lensKey, file, line, title) {
39
+ return crypto.createHash('sha256')
40
+ .update(`${focusAreaId}|${lensKey}|${file}|${line}|${title}`)
41
+ .digest('hex').slice(0, 12);
42
+ }
43
+
44
+ function candidatesFromParsed(parsed, focusArea, lens) {
45
+ const list = Array.isArray(parsed?.candidates) ? parsed.candidates : [];
46
+ const out = [];
47
+ for (const c of list) {
48
+ const file = typeof c?.file === 'string' ? c.file : null;
49
+ const line = Number.isInteger(c?.line) ? c.line : Number.parseInt(c?.line, 10);
50
+ if (!file || !Number.isInteger(line)) continue; // no location, no candidate
51
+ const title = typeof c?.title === 'string' && c.title ? c.title : `${lens.title} candidate`;
52
+ out.push({
53
+ id: candidateId(focusArea.id, lens.key, file, line, title),
54
+ focusAreaId: focusArea.id,
55
+ lens: lens.key,
56
+ title,
57
+ file,
58
+ line,
59
+ family: lens.family,
60
+ cwe: lens.cwe,
61
+ rationale: typeof c?.rationale === 'string' ? c.rationale : '',
62
+ entryPoint: typeof c?.entryPoint === 'string' ? c.entryPoint : '',
63
+ sink: typeof c?.sink === 'string' ? c.sink : '',
64
+ });
65
+ }
66
+ return out;
67
+ }
68
+
69
+ export function parseCandidates(raw, focusArea, lens) {
70
+ const { parsed } = extractJsonWithFlag(raw);
71
+ return candidatesFromParsed(parsed, focusArea, lens);
72
+ }
73
+
74
+ export async function runHunter(focusArea, lens, ctx = {}, opts = {}) {
75
+ const transcript = [];
76
+ const lensKey = lens?.key || 'unknown';
77
+ const base = { focusAreaId: focusArea.id, lens: lensKey, transcript };
78
+ const llmInvoke = resolveLlmInvoke(opts);
79
+
80
+ if (typeof llmInvoke !== 'function') {
81
+ const reason = 'no llmInvoke supplied and AGENTIC_SECURITY_LLM_ENDPOINT not set';
82
+ appendEntry(transcript, { phase: 'init', reason });
83
+ return { ...base, candidates: [], degraded: true, reason };
84
+ }
85
+
86
+ let prompt;
87
+ try {
88
+ prompt = buildHunterPrompt(focusArea, lens, ctx);
89
+ } catch (err) {
90
+ const reason = `failed to build hunter prompt: ${err?.message || String(err)}`;
91
+ appendEntry(transcript, { phase: 'prompt_error', reason });
92
+ return { ...base, candidates: [], degraded: true, reason };
93
+ }
94
+
95
+ appendEntry(transcript, { phase: 'prompt', promptChars: prompt.length, files: focusArea.files.length });
96
+
97
+ let raw;
98
+ try {
99
+ raw = await llmInvoke(prompt);
100
+ } catch (err) {
101
+ const reason = `hunter llm call failed: ${err?.message || String(err)}`;
102
+ appendEntry(transcript, { phase: 'error', reason });
103
+ return { ...base, candidates: [], degraded: true, reason };
104
+ }
105
+
106
+ const { parsed, found } = extractJsonWithFlag(raw);
107
+ if (!found) {
108
+ const reason = 'hunter output was not parseable JSON';
109
+ appendEntry(transcript, { phase: 'parse_error', reason });
110
+ return { ...base, candidates: [], degraded: true, reason };
111
+ }
112
+
113
+ const candidates = candidatesFromParsed(parsed, focusArea, lens);
114
+ appendEntry(transcript, { phase: 'result', candidateCount: candidates.length });
115
+ return { ...base, candidates, degraded: false, reason: null };
116
+ }
@@ -0,0 +1,159 @@
1
+ //
2
+ // Compose the discovery pipeline:
3
+ //
4
+ // partition → (area × lens) hunters → confirm → disprove → judge
5
+ //
6
+ // COVERAGE IS PART OF THE OUTPUT. Every report states how many areas were
7
+ // planned versus hunted and how many runs degraded, with reasons. A discovery
8
+ // pass that half failed and reports "no findings" is indistinguishable from a
9
+ // clean codebase unless it says so.
10
+ import { partitionCallGraph } from './partition.js';
11
+ import { LENSES, lensByKey } from './lenses.js';
12
+ import { runHunter } from './hunter.js';
13
+ import { confirmAll } from './confirm.js';
14
+ import { disprovePanel } from './disprove.js';
15
+ import { judgeCandidates } from './judge.js';
16
+
17
+ // Bridge a candidate to the deterministic layer. A taint finding at or within
18
+ // two lines of the candidate corroborates it; a modelled sink on the line
19
+ // without a full path is weaker corroboration ("sink-adjacent").
20
+ //
21
+ // NOTE: `runDeepAnalysis(perFileIR, callGraph, opts)` returns a BARE ARRAY of
22
+ // findings (see scanner/src/dataflow/index.js), not an object with a
23
+ // `.findings` property. Treat anything else defensively.
24
+ export function makeTaintProbe(perFileIR, callGraph) {
25
+ let cache = null;
26
+ return async (candidate) => {
27
+ if (!callGraph || !perFileIR) return null;
28
+ try {
29
+ if (!cache) cache = runDeepAnalysisSafe(perFileIR, callGraph);
30
+ const deep = await cache;
31
+ if (!Array.isArray(deep)) return null;
32
+ const hits = deep.filter(f => f.file === candidate.file);
33
+ const exact = hits.find(f => Math.abs((f.line ?? -1) - candidate.line) <= 2);
34
+ if (exact) {
35
+ return { tier: 'taint-confirmed', evidence: { matchedFinding: exact.id ?? null, line: exact.line, vuln: exact.vuln ?? null } };
36
+ }
37
+ return hits.length ? { tier: 'sink-adjacent', evidence: { sameFileFindings: hits.length } } : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ };
42
+ }
43
+
44
+ async function runDeepAnalysisSafe(perFileIR, callGraph) {
45
+ try {
46
+ const { runDeepAnalysis } = await import('../dataflow/index.js');
47
+ return runDeepAnalysis(perFileIR, callGraph, {});
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ // ctx = { perFileIR, callGraph, fileContents, priorScan, triageFeedback }
54
+ // where { perFileIR, callGraph } come from buildProjectIR(fileContents),
55
+ // which returns { perFile, callGraph } — callers must pass perFile as
56
+ // perFileIR (see scanner/src/ir/index.js).
57
+ export async function runDiscovery(ctx = {}, opts = {}) {
58
+ const areas = partitionCallGraph(ctx.callGraph, { maxAreas: opts.maxAreas ?? 8 });
59
+
60
+ const reasons = [];
61
+
62
+ // An explicit array (including an empty one) is honoured exactly — a caller
63
+ // narrowing a run to no lenses must get no lenses, not a silent fallback to
64
+ // all seven. Only an absent/non-array value falls back to the full set.
65
+ const lensKeys = Array.isArray(opts.lenses) ? opts.lenses : LENSES.map(l => l.key);
66
+
67
+ const lenses = [];
68
+ for (const key of lensKeys) {
69
+ const lens = lensByKey(key);
70
+ // An unknown key must degrade visibly, not vanish via a silent filter.
71
+ if (lens) lenses.push(lens);
72
+ else reasons.push(`unresolved lens key: "${key}"`);
73
+ }
74
+ if (lenses.length === 0) {
75
+ reasons.push('no lenses resolved for this run (empty or fully-unresolved lens selection); nothing was hunted');
76
+ }
77
+
78
+ const runs = [];
79
+ let candidates = [];
80
+ // areasHunted: areas where AT LEAST ONE lens run completed without degrading.
81
+ const hunted = new Set();
82
+ // areasFullyHunted: areas where EVERY lens run completed without degrading.
83
+ // Distinct from areasHunted so a partially-degraded area (e.g. 6 of 7 lenses
84
+ // failed) cannot be read as fully covered from a single number.
85
+ const fullyHunted = new Set();
86
+
87
+ for (const area of areas) {
88
+ let areaDegradedCount = 0;
89
+ for (const lens of lenses) {
90
+ const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke: opts.llmInvoke });
91
+ runs.push({ focusAreaId: run.focusAreaId, lens: run.lens, degraded: run.degraded, reason: run.reason, candidateCount: run.candidates.length });
92
+ if (run.degraded && run.reason) reasons.push(`${area.label} × ${lens.key}: ${run.reason}`);
93
+ if (run.degraded) areaDegradedCount += 1;
94
+ else hunted.add(area.id);
95
+ candidates = candidates.concat(run.candidates);
96
+ }
97
+ if (lenses.length > 0 && areaDegradedCount === 0) fullyHunted.add(area.id);
98
+ }
99
+
100
+ const taintProbe = makeTaintProbe(ctx.perFileIR, ctx.callGraph);
101
+ const confirmed = await confirmAll(candidates, { taintProbe });
102
+ const { survivors, refuted } = await disprovePanel(confirmed, { llmInvoke: opts.llmInvoke });
103
+ const { fresh, duplicates, suppressed } = judgeCandidates(survivors, ctx.priorScan, ctx.triageFeedback);
104
+
105
+ // Coverage must not stop at the hunter stage. `confirm.js` correctly never
106
+ // lowers a candidate below `unconfirmed`, and `disprove.js` correctly lets
107
+ // a candidate survive when no voter votes — each rule is right on its own,
108
+ // but composed, a run where BOTH later stages died silently would still
109
+ // report clean hunter coverage while 100% of raw, uncorroborated model
110
+ // output landed in `fresh`. These counters and reasons make that visible.
111
+ const confirmedByTier = { 'taint-confirmed': 0, 'sink-adjacent': 0, 'unconfirmed': 0 };
112
+ for (const c of confirmed) {
113
+ const tier = c?.confirmation?.tier;
114
+ if (tier && Object.prototype.hasOwnProperty.call(confirmedByTier, tier)) confirmedByTier[tier] += 1;
115
+ }
116
+ if (confirmed.length > 0 && confirmedByTier['taint-confirmed'] === 0 && confirmedByTier['sink-adjacent'] === 0) {
117
+ reasons.push(`confirmation stage corroborated nothing for ${confirmed.length} candidate(s) — all remain "unconfirmed"; the deterministic gate may not have run, and the findings below are uncorroborated, not vetted`);
118
+ }
119
+
120
+ const panelled = [...survivors, ...refuted];
121
+ const panelsRun = panelled.length;
122
+ const undecidedPanels = panelled.filter(c => c?.refutation?.undecided === true).length;
123
+ if (panelsRun > 0 && undecidedPanels === panelsRun) {
124
+ reasons.push(`refutation panel returned no votes for any of ${panelsRun} candidate(s) — every finding below survived unrefuted, not because it withstood scrutiny`);
125
+ }
126
+
127
+ return {
128
+ schema: 'agentic-security/discovery@1',
129
+ focusAreas: areas.map(a => ({ id: a.id, label: a.label, files: a.files.length, size: a.size })),
130
+ runs,
131
+ fresh,
132
+ duplicates,
133
+ suppressed,
134
+ // `refutedCandidates` holds RAW candidates straight from `disprovePanel`,
135
+ // NOT findings — no `vuln`, `severity`, `parser`, or `stableId`. It is
136
+ // deliberately not run through `toFindingShape`: a refuted candidate is
137
+ // deliberately not promoted to a finding, and giving it finding shape
138
+ // would misrepresent it as one. Unlike `fresh`/`duplicates`/`suppressed`,
139
+ // do not iterate this array as if it were finding-shaped.
140
+ refutedCandidates: refuted,
141
+ coverage: {
142
+ areasPlanned: areas.length,
143
+ // At least one lens run completed for the area. Does NOT mean every
144
+ // lens succeeded there — see areasFullyHunted for that stronger claim.
145
+ areasHunted: hunted.size,
146
+ // Every lens run for the area completed without degrading.
147
+ areasFullyHunted: fullyHunted.size,
148
+ lensesPerArea: lenses.length,
149
+ degradedRuns: runs.filter(r => r.degraded).length,
150
+ // Per-tier count of every candidate that went through confirm.js.
151
+ confirmedByTier,
152
+ // How many candidates went through the refutation panel, and how many
153
+ // of those came back with no votes at all (undecided, not refuted).
154
+ panelsRun,
155
+ undecidedPanels,
156
+ reasons,
157
+ },
158
+ };
159
+ }
@@ -0,0 +1,97 @@
1
+ //
2
+ // Turn surviving candidates into findings, then decide which are actually new.
3
+ //
4
+ // SEVERITY IS DRIVEN BY EVIDENCE, NOT BY THE MODEL'S ADJECTIVES. A hunter has
5
+ // no calibrated view of impact and will call everything critical. What we can
6
+ // defend is how well-evidenced the candidate is, so the confirmation tier sets
7
+ // the ceiling: taint-confirmed → high, sink-adjacent → medium, unconfirmed →
8
+ // low. A human or the existing triage path can raise it; the discovery layer
9
+ // never claims critical on its own.
10
+ //
11
+ // A PRIOR TRUE POSITIVE IS NOT A DUPLICATE. Triage feedback suppresses only
12
+ // `fp` verdicts. A `tp` verdict means the finding was real, and re-reporting it
13
+ // while it is still in the code is correct behaviour.
14
+ //
15
+ // STABLE IDS ARE LOCATION-FUZZY BY DESIGN, AND THAT MATTERS HERE. `stable-id.js`
16
+ // hashes ruleId, snippet, path shape, and BASENAME — deliberately not the line,
17
+ // so an id survives code moving down a file. The consequence for this layer:
18
+ // two different candidates of the same lens in the same file collide on one
19
+ // stableId. That is why file+line+family is the PRIMARY duplicate key and the
20
+ // stableId check is only a secondary net. It also means an `fp` verdict
21
+ // suppresses the whole (lens, file) pair rather than one line — the same
22
+ // breadth the rest of the engine already has, kept rather than silently
23
+ // diverged from. `ruleId` is set explicitly so ids partition by lens rather
24
+ // than falling back to the CWE.
25
+ import { computeStableId } from '../posture/stable-id.js';
26
+
27
+ const SEVERITY_BY_TIER = { 'taint-confirmed': 'high', 'sink-adjacent': 'medium', 'unconfirmed': 'low' };
28
+
29
+ // Guards against a malformed candidate producing a schema-invalid finding
30
+ // (root CLAUDE.md requires { id, severity, file, line, vuln, cwe, ... } on
31
+ // every finding). `hunter.js` already filters out candidates with no usable
32
+ // file/line before they reach here, so this should never trigger in the
33
+ // normal pipeline — but toFindingShape is exported and callable directly, and
34
+ // degrading with `null` (rather than throwing) matches this subsystem's
35
+ // degrade-don't-throw style everywhere else. Callers must skip a `null`.
36
+ export function toFindingShape(candidate) {
37
+ const file = typeof candidate?.file === 'string' && candidate.file ? candidate.file : null;
38
+ const line = Number.isInteger(candidate?.line) ? candidate.line : null;
39
+ if (!file || line === null) return null;
40
+
41
+ const tier = candidate?.confirmation?.tier || 'unconfirmed';
42
+ const lensTitle = candidate?.lens ? `${candidate.lens} candidate` : 'discovery candidate';
43
+ const title = typeof candidate?.title === 'string' && candidate.title ? candidate.title : lensTitle;
44
+ const base = {
45
+ id: `discovery-${candidate.lens}-${candidate.id}`,
46
+ severity: SEVERITY_BY_TIER[tier] || 'low',
47
+ file,
48
+ line,
49
+ vuln: title,
50
+ cwe: candidate.cwe || 'CWE-710',
51
+ description: candidate.rationale
52
+ ? `${candidate.rationale} (entry point: ${candidate.entryPoint || 'unstated'}; sink: ${candidate.sink || 'unstated'})`
53
+ : `Proposed by the ${candidate.lens} lens; no rationale supplied.`,
54
+ remediation: `Review ${candidate.file}:${candidate.line}. Confirm whether ${candidate.entryPoint || 'attacker-controlled input'} can reach ${candidate.sink || 'this operation'}, and constrain it at the boundary if so.`,
55
+ parser: 'DISCOVERY',
56
+ family: candidate.family || 'other',
57
+ ruleId: `discovery:${candidate.lens}`,
58
+ // snippet discriminates findings so computeStableId has material to hash. An empty
59
+ // snippet collapses distinct findings of the same lens in the same file onto one id.
60
+ snippet: candidate.sink || candidate.entryPoint || candidate.title || '',
61
+ };
62
+ return {
63
+ ...base,
64
+ stableId: computeStableId(base),
65
+ discovery: {
66
+ lens: candidate.lens,
67
+ focusAreaId: candidate.focusAreaId,
68
+ confirmation: candidate.confirmation || null,
69
+ refutation: candidate.refutation || null,
70
+ },
71
+ };
72
+ }
73
+
74
+ export function judgeCandidates(candidates, priorScan, triageFeedback) {
75
+ const prior = Array.isArray(priorScan?.findings) ? priorScan.findings : [];
76
+ const priorByLoc = new Map();
77
+ const priorIds = new Set();
78
+ for (const p of prior) {
79
+ if (p?.stableId) priorIds.add(p.stableId);
80
+ priorByLoc.set(`${p?.file}|${p?.line}|${p?.family}`, p?.stableId || null);
81
+ }
82
+ const feedback = triageFeedback && typeof triageFeedback === 'object' ? triageFeedback : {};
83
+
84
+ const fresh = [], duplicates = [], suppressed = [];
85
+ for (const c of candidates || []) {
86
+ const f = toFindingShape(c);
87
+ if (!f) continue; // malformed candidate (no usable file/line) — degrade by skipping, never throw
88
+ if (feedback[f.stableId] === 'fp') { suppressed.push({ ...f, suppressedBy: 'triage-fp' }); continue; }
89
+ const locKey = `${f.file}|${f.line}|${f.family}`;
90
+ // Location key is PRIMARY: same file, line, and family match existing findings.
91
+ if (priorByLoc.has(locKey)) { duplicates.push({ ...f, duplicateOf: priorByLoc.get(locKey) }); continue; }
92
+ // stableId is SECONDARY: same lens, file, and sink at a moved line is likely the same bug.
93
+ if (priorIds.has(f.stableId)) { duplicates.push({ ...f, duplicateOf: f.stableId }); continue; }
94
+ fresh.push(f);
95
+ }
96
+ return { fresh, duplicates, suppressed };
97
+ }