@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +390 -0
- package/bin/agentic-security.js +3813 -83
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +13 -4
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/2271.index.js +165 -0
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +14 -6
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +14 -6
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +67 -3
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +29 -7
- package/dist/{449.index.js → 5830.index.js} +78 -14
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +26 -8
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +580 -187
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +23 -10
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +2 -2
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +580 -30
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +22 -1
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +140 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +76 -0
- package/src/posture/auditor-walkthrough.js +192 -13
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/src/util/git-hardening.js +128 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
package/dist/609.index.js
DELETED
|
@@ -1,741 +0,0 @@
|
|
|
1
|
-
export const id = 609;
|
|
2
|
-
export const ids = [609,499];
|
|
3
|
-
export const modules = {
|
|
4
|
-
|
|
5
|
-
/***/ 4609:
|
|
6
|
-
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// EXPORTS
|
|
10
|
-
__webpack_require__.d(__webpack_exports__, {
|
|
11
|
-
runDiscovery: () => (/* binding */ runDiscovery)
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
// UNUSED EXPORTS: makeTaintProbe
|
|
15
|
-
|
|
16
|
-
// EXTERNAL MODULE: external "node:crypto"
|
|
17
|
-
var external_node_crypto_ = __webpack_require__(7598);
|
|
18
|
-
;// CONCATENATED MODULE: ./src/discovery/partition.js
|
|
19
|
-
//
|
|
20
|
-
// Split the codebase into disjoint focus areas so parallel hunters cannot
|
|
21
|
-
// converge on the same code.
|
|
22
|
-
//
|
|
23
|
-
// WHY THE CALL GRAPH AND NOT DIRECTORIES: a directory split hands one
|
|
24
|
-
// subsystem to several hunters whenever a feature spans folders, and hands
|
|
25
|
-
// unrelated code to one hunter whenever a folder is a grab bag. Weakly-
|
|
26
|
-
// connected components over call edges group code that actually talks to
|
|
27
|
-
// itself, which is the unit a hunter can reason about end to end.
|
|
28
|
-
//
|
|
29
|
-
// FILES, NOT FUNCTIONS, ARE THE ATOM. A hunter reads whole files. If two
|
|
30
|
-
// components share a file they are merged, otherwise the same source lands in
|
|
31
|
-
// two hunters' context and the convergence this module exists to prevent
|
|
32
|
-
// comes straight back.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
function focusAreaId(files) {
|
|
36
|
-
const canon = [...new Set(files || [])].sort().join('\n');
|
|
37
|
-
return external_node_crypto_.createHash('sha256').update(canon).digest('hex').slice(0, 12);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Union-find over file paths.
|
|
41
|
-
function makeDSU() {
|
|
42
|
-
const parent = new Map();
|
|
43
|
-
const find = (x) => {
|
|
44
|
-
if (!parent.has(x)) parent.set(x, x);
|
|
45
|
-
let r = x;
|
|
46
|
-
while (parent.get(r) !== r) r = parent.get(r);
|
|
47
|
-
while (parent.get(x) !== r) { const n = parent.get(x); parent.set(x, r); x = n; }
|
|
48
|
-
return r;
|
|
49
|
-
};
|
|
50
|
-
const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent.set(ra, rb); };
|
|
51
|
-
return { find, union };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function labelFor(files) {
|
|
55
|
-
if (files.length === 1) return files[0];
|
|
56
|
-
const parts = files[0].split('/');
|
|
57
|
-
for (let i = parts.length - 1; i > 0; i--) {
|
|
58
|
-
const prefix = parts.slice(0, i).join('/') + '/';
|
|
59
|
-
if (files.every(f => f.startsWith(prefix))) return prefix;
|
|
60
|
-
}
|
|
61
|
-
return files[0] + ` (+${files.length - 1})`;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function partitionCallGraph(callGraph, opts = {}) {
|
|
65
|
-
const fns = callGraph?.functions;
|
|
66
|
-
if (!fns || typeof fns.get !== 'function' || fns.size === 0) return [];
|
|
67
|
-
const maxAreas = Number.isInteger(opts.maxAreas) && opts.maxAreas > 0 ? opts.maxAreas : 8;
|
|
68
|
-
|
|
69
|
-
const dsu = makeDSU();
|
|
70
|
-
for (const fn of fns.values()) if (fn?.file) dsu.find(fn.file);
|
|
71
|
-
for (const e of callGraph.edges || []) {
|
|
72
|
-
const a = fns.get(e?.caller)?.file;
|
|
73
|
-
const b = fns.get(e?.callee)?.file;
|
|
74
|
-
if (a && b) dsu.union(a, b);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const filesByRoot = new Map();
|
|
78
|
-
for (const fn of fns.values()) {
|
|
79
|
-
if (!fn?.file) continue;
|
|
80
|
-
const root = dsu.find(fn.file);
|
|
81
|
-
if (!filesByRoot.has(root)) filesByRoot.set(root, new Set());
|
|
82
|
-
filesByRoot.get(root).add(fn.file);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const fnsByFile = new Map();
|
|
86
|
-
for (const fn of fns.values()) {
|
|
87
|
-
if (!fn?.file) continue;
|
|
88
|
-
if (!fnsByFile.has(fn.file)) fnsByFile.set(fn.file, []);
|
|
89
|
-
fnsByFile.get(fn.file).push(fn.qid);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const build = (files, label) => {
|
|
93
|
-
const sorted = [...files].sort();
|
|
94
|
-
const functions = sorted.flatMap(f => (fnsByFile.get(f) || [])).sort();
|
|
95
|
-
return { id: focusAreaId(sorted), label: label ?? labelFor(sorted), files: sorted, functions, size: functions.length };
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
let areas = [...filesByRoot.values()].map(s => build(s));
|
|
99
|
-
// Deterministic ranking: biggest first, ties broken by id so two runs on the
|
|
100
|
-
// same graph produce the same order.
|
|
101
|
-
areas.sort((a, b) => b.size - a.size || (a.id < b.id ? -1 : 1));
|
|
102
|
-
|
|
103
|
-
if (areas.length > maxAreas) {
|
|
104
|
-
const kept = areas.slice(0, maxAreas - 1);
|
|
105
|
-
const tail = areas.slice(maxAreas - 1);
|
|
106
|
-
kept.push(build(tail.flatMap(a => a.files), 'misc'));
|
|
107
|
-
areas = kept;
|
|
108
|
-
}
|
|
109
|
-
return areas;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// EXTERNAL MODULE: ./src/discovery/lenses.js
|
|
113
|
-
var discovery_lenses = __webpack_require__(3499);
|
|
114
|
-
;// CONCATENATED MODULE: ./src/discovery/llm-invoke.js
|
|
115
|
-
//
|
|
116
|
-
// Shared LLM endpoint caller. Both the hunter and the refutation panel need
|
|
117
|
-
// the same default endpoint caller when tests don't inject a mock. Two copies
|
|
118
|
-
// of a network call is one copy too many — if one path gets fixed and the
|
|
119
|
-
// other does not, the bug stays buried in one direction.
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
const DEFAULT_TIMEOUT_MS = 60000;
|
|
123
|
-
|
|
124
|
-
async function defaultLlmInvoke(prompt, opts = {}) {
|
|
125
|
-
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
126
|
-
// The URL is the operator's own configured endpoint, read from an environment
|
|
127
|
-
// variable they set. Reaching it is this module's entire purpose; no
|
|
128
|
-
// request-controlled input exists anywhere on this path, and an operator who
|
|
129
|
-
// can set this variable can already run code.
|
|
130
|
-
const res = await fetch(process.env.AGENTIC_SECURITY_LLM_ENDPOINT, { // agentic-security-ignore: CWE-918
|
|
131
|
-
method: 'POST',
|
|
132
|
-
headers: { 'content-type': 'application/json' },
|
|
133
|
-
body: JSON.stringify({ prompt }),
|
|
134
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
135
|
-
});
|
|
136
|
-
if (!res.ok) throw new Error(`llm endpoint returned ${res.status}`);
|
|
137
|
-
const body = await res.json();
|
|
138
|
-
return typeof body === 'string' ? body : (body?.text ?? JSON.stringify(body));
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function resolveLlmInvoke(opts = {}) {
|
|
142
|
-
if (opts.llmInvoke) return opts.llmInvoke;
|
|
143
|
-
if (!process.env.AGENTIC_SECURITY_LLM_ENDPOINT) return null;
|
|
144
|
-
return (prompt) => defaultLlmInvoke(prompt, { timeoutMs: opts.timeoutMs });
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
;// CONCATENATED MODULE: ./src/discovery/hunter.js
|
|
148
|
-
//
|
|
149
|
-
// One bounded hunter run = one focus area seen through one lens.
|
|
150
|
-
//
|
|
151
|
-
// A hunter PROPOSES. Nothing here is a finding: every candidate must survive
|
|
152
|
-
// `confirm.js` and `disprove.js` first. That separation is the whole design —
|
|
153
|
-
// the model is allowed to be imaginative precisely because something
|
|
154
|
-
// deterministic checks it afterwards.
|
|
155
|
-
//
|
|
156
|
-
// FAILURE IS ALWAYS DEGRADATION, NEVER AN EXCEPTION. A missing endpoint, a
|
|
157
|
-
// rate limit, or unparseable output yields `degraded:true` with a reason. A
|
|
158
|
-
// discovery pass that cannot run must leave the rest of the scan intact.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
function appendEntry(transcript, entry) {
|
|
164
|
-
const prev = transcript.length ? transcript[transcript.length - 1].hash : null;
|
|
165
|
-
// Named `serialized`, not `body`: this is the canonical serialisation of a
|
|
166
|
-
// transcript entry being fed to a hash, and calling it `body` made the
|
|
167
|
-
// mass-assignment detector read it as a request payload. The name was simply
|
|
168
|
-
// wrong for what it holds, so this is a fix at the source rather than a
|
|
169
|
-
// suppression — and that detector's finding carries no line number, so a
|
|
170
|
-
// line-scoped ignore pragma could not have matched it anyway.
|
|
171
|
-
const serialized = JSON.stringify({ ...entry, prev });
|
|
172
|
-
const hash = external_node_crypto_.createHash('sha256').update(serialized).digest('hex');
|
|
173
|
-
transcript.push({ ...entry, prev, hash });
|
|
174
|
-
return transcript;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function extractJsonWithFlag(raw) {
|
|
178
|
-
if (typeof raw !== 'string') return { parsed: null, found: false };
|
|
179
|
-
const start = raw.indexOf('{');
|
|
180
|
-
const end = raw.lastIndexOf('}');
|
|
181
|
-
if (start < 0 || end <= start) return { parsed: null, found: false };
|
|
182
|
-
try { return { parsed: JSON.parse(raw.slice(start, end + 1)), found: true }; } catch { return { parsed: null, found: false }; }
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function candidateId(focusAreaId, lensKey, file, line, title) {
|
|
186
|
-
return external_node_crypto_.createHash('sha256')
|
|
187
|
-
.update(`${focusAreaId}|${lensKey}|${file}|${line}|${title}`)
|
|
188
|
-
.digest('hex').slice(0, 12);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function candidatesFromParsed(parsed, focusArea, lens) {
|
|
192
|
-
const list = Array.isArray(parsed?.candidates) ? parsed.candidates : [];
|
|
193
|
-
const out = [];
|
|
194
|
-
for (const c of list) {
|
|
195
|
-
const file = typeof c?.file === 'string' ? c.file : null;
|
|
196
|
-
const line = Number.isInteger(c?.line) ? c.line : Number.parseInt(c?.line, 10);
|
|
197
|
-
if (!file || !Number.isInteger(line)) continue; // no location, no candidate
|
|
198
|
-
const title = typeof c?.title === 'string' && c.title ? c.title : `${lens.title} candidate`;
|
|
199
|
-
out.push({
|
|
200
|
-
id: candidateId(focusArea.id, lens.key, file, line, title),
|
|
201
|
-
focusAreaId: focusArea.id,
|
|
202
|
-
lens: lens.key,
|
|
203
|
-
title,
|
|
204
|
-
file,
|
|
205
|
-
line,
|
|
206
|
-
family: lens.family,
|
|
207
|
-
cwe: lens.cwe,
|
|
208
|
-
rationale: typeof c?.rationale === 'string' ? c.rationale : '',
|
|
209
|
-
entryPoint: typeof c?.entryPoint === 'string' ? c.entryPoint : '',
|
|
210
|
-
sink: typeof c?.sink === 'string' ? c.sink : '',
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
return out;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function parseCandidates(raw, focusArea, lens) {
|
|
217
|
-
const { parsed } = extractJsonWithFlag(raw);
|
|
218
|
-
return candidatesFromParsed(parsed, focusArea, lens);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
async function runHunter(focusArea, lens, ctx = {}, opts = {}) {
|
|
222
|
-
const transcript = [];
|
|
223
|
-
const lensKey = lens?.key || 'unknown';
|
|
224
|
-
const base = { focusAreaId: focusArea.id, lens: lensKey, transcript };
|
|
225
|
-
const llmInvoke = resolveLlmInvoke(opts);
|
|
226
|
-
|
|
227
|
-
if (typeof llmInvoke !== 'function') {
|
|
228
|
-
const reason = 'no llmInvoke supplied and AGENTIC_SECURITY_LLM_ENDPOINT not set';
|
|
229
|
-
appendEntry(transcript, { phase: 'init', reason });
|
|
230
|
-
return { ...base, candidates: [], degraded: true, reason };
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
let prompt;
|
|
234
|
-
try {
|
|
235
|
-
prompt = (0,discovery_lenses/* buildHunterPrompt */.j)(focusArea, lens, ctx);
|
|
236
|
-
} catch (err) {
|
|
237
|
-
const reason = `failed to build hunter prompt: ${err?.message || String(err)}`;
|
|
238
|
-
appendEntry(transcript, { phase: 'prompt_error', reason });
|
|
239
|
-
return { ...base, candidates: [], degraded: true, reason };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
appendEntry(transcript, { phase: 'prompt', promptChars: prompt.length, files: focusArea.files.length });
|
|
243
|
-
|
|
244
|
-
let raw;
|
|
245
|
-
try {
|
|
246
|
-
raw = await llmInvoke(prompt);
|
|
247
|
-
} catch (err) {
|
|
248
|
-
const reason = `hunter llm call failed: ${err?.message || String(err)}`;
|
|
249
|
-
appendEntry(transcript, { phase: 'error', reason });
|
|
250
|
-
return { ...base, candidates: [], degraded: true, reason };
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const { parsed, found } = extractJsonWithFlag(raw);
|
|
254
|
-
if (!found) {
|
|
255
|
-
const reason = 'hunter output was not parseable JSON';
|
|
256
|
-
appendEntry(transcript, { phase: 'parse_error', reason });
|
|
257
|
-
return { ...base, candidates: [], degraded: true, reason };
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const candidates = candidatesFromParsed(parsed, focusArea, lens);
|
|
261
|
-
appendEntry(transcript, { phase: 'result', candidateCount: candidates.length });
|
|
262
|
-
return { ...base, candidates, degraded: false, reason: null };
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
;// CONCATENATED MODULE: ./src/discovery/confirm.js
|
|
266
|
-
//
|
|
267
|
-
// Route every model-proposed candidate back through the deterministic layer.
|
|
268
|
-
//
|
|
269
|
-
// WHY THIS EXISTS. A hunter's output is a hypothesis. The engine underneath it
|
|
270
|
-
// can already decide, for a given file and line, whether tainted data reaches a
|
|
271
|
-
// modelled sink there. Asking it turns "the model thinks so" into "the model
|
|
272
|
-
// thinks so AND the taint engine agrees", which is a materially stronger claim
|
|
273
|
-
// than either layer makes alone.
|
|
274
|
-
//
|
|
275
|
-
// THE ASYMMETRY IS DELIBERATE. Confirmation raises standing; the absence of
|
|
276
|
-
// confirmation NEVER lowers it below `unconfirmed` and is never recorded as a
|
|
277
|
-
// refutation. The taint engine models a subset of the program — an
|
|
278
|
-
// unconfirmed candidate may be a real bug in a construct the engine does not
|
|
279
|
-
// model, and calling that a false positive would launder a coverage gap into a
|
|
280
|
-
// verdict. Refutation is `disprove.js`'s job, and it must be argued, not
|
|
281
|
-
// inferred from silence.
|
|
282
|
-
const CONFIRMATION_TIERS = Object.freeze(['taint-confirmed', 'sink-adjacent', 'unconfirmed']);
|
|
283
|
-
|
|
284
|
-
function unconfirmed(probedBy, reason) {
|
|
285
|
-
return { tier: 'unconfirmed', evidence: null, probedBy, reason: reason || null };
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
async function confirmCandidate(candidate, opts = {}) {
|
|
289
|
-
const probe = typeof opts.taintProbe === 'function' ? opts.taintProbe : null;
|
|
290
|
-
if (!probe) return { ...candidate, confirmation: unconfirmed(null, 'no taintProbe supplied') };
|
|
291
|
-
|
|
292
|
-
let res;
|
|
293
|
-
try {
|
|
294
|
-
res = await probe(candidate);
|
|
295
|
-
} catch (err) {
|
|
296
|
-
return { ...candidate, confirmation: unconfirmed('taintProbe', `probe failed: ${err?.message || String(err)}`) };
|
|
297
|
-
}
|
|
298
|
-
if (!res) return { ...candidate, confirmation: unconfirmed('taintProbe', null) };
|
|
299
|
-
if (!CONFIRMATION_TIERS.includes(res.tier)) {
|
|
300
|
-
return { ...candidate, confirmation: unconfirmed('taintProbe', `unknown tier: ${res.tier}`) };
|
|
301
|
-
}
|
|
302
|
-
return {
|
|
303
|
-
...candidate,
|
|
304
|
-
confirmation: { tier: res.tier, evidence: res.evidence ?? null, probedBy: 'taintProbe', reason: null },
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
async function confirmAll(candidates, opts = {}) {
|
|
309
|
-
const out = [];
|
|
310
|
-
for (const c of candidates || []) out.push(await confirmCandidate(c, opts));
|
|
311
|
-
return out;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
;// CONCATENATED MODULE: ./src/discovery/disprove.js
|
|
315
|
-
//
|
|
316
|
-
// Adversarial refutation. Each voter is told to REFUTE the candidate, not to
|
|
317
|
-
// assess it: a model asked "is this real?" agrees with the premise far more
|
|
318
|
-
// often than one asked "show me why this cannot happen", and the second
|
|
319
|
-
// question is the one that kills plausible-but-wrong findings.
|
|
320
|
-
//
|
|
321
|
-
// THREE ANGLES, NOT THREE COPIES. A candidate can fail in more than one way,
|
|
322
|
-
// and three identical voters mostly measure sampling noise. Reachability,
|
|
323
|
-
// attacker preconditions, and sanitization are the three ways these candidates
|
|
324
|
-
// actually die.
|
|
325
|
-
//
|
|
326
|
-
// SILENCE NEVER REFUTES. A voter that errors or returns unparseable output did
|
|
327
|
-
// not vote, and is excluded from the denominator rather than counted as
|
|
328
|
-
// agreement. If nobody votes the panel is `undecided` and the candidate
|
|
329
|
-
// SURVIVES — an outage must not quietly delete findings.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const DEFAULT_ANGLES = ['reachability', 'preconditions', 'sanitization'];
|
|
333
|
-
const REFUTE_ANGLES = Object.freeze([...DEFAULT_ANGLES]);
|
|
334
|
-
|
|
335
|
-
const ANGLE_BRIEF = {
|
|
336
|
-
reachability: 'Can attacker-controlled data actually reach this line at runtime? Name the caller chain or show there is none.',
|
|
337
|
-
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.',
|
|
338
|
-
sanitization: 'Is the value validated, escaped, parameterised, or type-constrained anywhere on the path? A framework default counts.',
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
function buildRefutePrompt(candidate, angle) {
|
|
342
|
-
return [
|
|
343
|
-
`Your job is to REFUTE the security finding below. Assume it is wrong and look for the reason.`,
|
|
344
|
-
`Refute it on this angle only: ${angle}. ${ANGLE_BRIEF[angle] || ''}`,
|
|
345
|
-
``,
|
|
346
|
-
`Finding: ${candidate.title}`,
|
|
347
|
-
`Location: ${candidate.file}:${candidate.line}`,
|
|
348
|
-
`Claimed reason: ${candidate.rationale || '(none given)'}`,
|
|
349
|
-
`Deterministic confirmation: ${candidate.confirmation?.tier || 'unknown'}`,
|
|
350
|
-
``,
|
|
351
|
-
`If you cannot refute it on this angle, say so honestly.`,
|
|
352
|
-
`Return JSON: {"refuted":true|false,"reason":"..."}`,
|
|
353
|
-
].join('\n');
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function parseVote(raw) {
|
|
357
|
-
if (typeof raw !== 'string') return null;
|
|
358
|
-
const s = raw.indexOf('{'), e = raw.lastIndexOf('}');
|
|
359
|
-
if (s < 0 || e <= s) return null;
|
|
360
|
-
let p;
|
|
361
|
-
try { p = JSON.parse(raw.slice(s, e + 1)); } catch { return null; }
|
|
362
|
-
if (typeof p?.refuted !== 'boolean') return null;
|
|
363
|
-
return { refuted: p.refuted, reason: typeof p.reason === 'string' ? p.reason : '' };
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
async function disproveCandidate(candidate, opts = {}) {
|
|
367
|
-
const angles = Array.isArray(opts.angles) && opts.angles.length ? opts.angles : DEFAULT_ANGLES;
|
|
368
|
-
const llmInvoke = resolveLlmInvoke(opts);
|
|
369
|
-
|
|
370
|
-
const votes = [];
|
|
371
|
-
if (typeof llmInvoke === 'function') {
|
|
372
|
-
for (const angle of angles) {
|
|
373
|
-
let vote = null;
|
|
374
|
-
try { vote = parseVote(await llmInvoke(buildRefutePrompt(candidate, angle))); } catch { vote = null; }
|
|
375
|
-
if (vote) votes.push({ angle, ...vote });
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const voterCount = votes.length;
|
|
380
|
-
const refuteCount = votes.filter(v => v.refuted).length;
|
|
381
|
-
const undecided = voterCount === 0;
|
|
382
|
-
const refuted = !undecided && refuteCount * 2 > voterCount;
|
|
383
|
-
return { ...candidate, refutation: { votes, voterCount, refuteCount, refuted, undecided } };
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
async function disprovePanel(candidates, opts = {}) {
|
|
387
|
-
const survivors = [], refuted = [];
|
|
388
|
-
for (const c of candidates || []) {
|
|
389
|
-
const judged = await disproveCandidate(c, opts);
|
|
390
|
-
(judged.refutation.refuted ? refuted : survivors).push(judged);
|
|
391
|
-
}
|
|
392
|
-
return { survivors, refuted };
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// EXTERNAL MODULE: ./src/posture/stable-id.js
|
|
396
|
-
var stable_id = __webpack_require__(838);
|
|
397
|
-
;// CONCATENATED MODULE: ./src/discovery/judge.js
|
|
398
|
-
//
|
|
399
|
-
// Turn surviving candidates into findings, then decide which are actually new.
|
|
400
|
-
//
|
|
401
|
-
// SEVERITY IS DRIVEN BY EVIDENCE, NOT BY THE MODEL'S ADJECTIVES. A hunter has
|
|
402
|
-
// no calibrated view of impact and will call everything critical. What we can
|
|
403
|
-
// defend is how well-evidenced the candidate is, so the confirmation tier sets
|
|
404
|
-
// the ceiling: taint-confirmed → high, sink-adjacent → medium, unconfirmed →
|
|
405
|
-
// low. A human or the existing triage path can raise it; the discovery layer
|
|
406
|
-
// never claims critical on its own.
|
|
407
|
-
//
|
|
408
|
-
// A PRIOR TRUE POSITIVE IS NOT A DUPLICATE. Triage feedback suppresses only
|
|
409
|
-
// `fp` verdicts. A `tp` verdict means the finding was real, and re-reporting it
|
|
410
|
-
// while it is still in the code is correct behaviour.
|
|
411
|
-
//
|
|
412
|
-
// STABLE IDS ARE LOCATION-FUZZY BY DESIGN, AND THAT MATTERS HERE. `stable-id.js`
|
|
413
|
-
// hashes ruleId, snippet, path shape, and BASENAME — deliberately not the line,
|
|
414
|
-
// so an id survives code moving down a file. The consequence for this layer:
|
|
415
|
-
// two different candidates of the same lens in the same file collide on one
|
|
416
|
-
// stableId. That is why file+line+family is the PRIMARY duplicate key and the
|
|
417
|
-
// stableId check is only a secondary net. It also means an `fp` verdict
|
|
418
|
-
// suppresses the whole (lens, file) pair rather than one line — the same
|
|
419
|
-
// breadth the rest of the engine already has, kept rather than silently
|
|
420
|
-
// diverged from. `ruleId` is set explicitly so ids partition by lens rather
|
|
421
|
-
// than falling back to the CWE.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const SEVERITY_BY_TIER = { 'taint-confirmed': 'high', 'sink-adjacent': 'medium', 'unconfirmed': 'low' };
|
|
425
|
-
|
|
426
|
-
// Guards against a malformed candidate producing a schema-invalid finding
|
|
427
|
-
// (root CLAUDE.md requires { id, severity, file, line, vuln, cwe, ... } on
|
|
428
|
-
// every finding). `hunter.js` already filters out candidates with no usable
|
|
429
|
-
// file/line before they reach here, so this should never trigger in the
|
|
430
|
-
// normal pipeline — but toFindingShape is exported and callable directly, and
|
|
431
|
-
// degrading with `null` (rather than throwing) matches this subsystem's
|
|
432
|
-
// degrade-don't-throw style everywhere else. Callers must skip a `null`.
|
|
433
|
-
function toFindingShape(candidate) {
|
|
434
|
-
const file = typeof candidate?.file === 'string' && candidate.file ? candidate.file : null;
|
|
435
|
-
const line = Number.isInteger(candidate?.line) ? candidate.line : null;
|
|
436
|
-
if (!file || line === null) return null;
|
|
437
|
-
|
|
438
|
-
const tier = candidate?.confirmation?.tier || 'unconfirmed';
|
|
439
|
-
const lensTitle = candidate?.lens ? `${candidate.lens} candidate` : 'discovery candidate';
|
|
440
|
-
const title = typeof candidate?.title === 'string' && candidate.title ? candidate.title : lensTitle;
|
|
441
|
-
const base = {
|
|
442
|
-
id: `discovery-${candidate.lens}-${candidate.id}`,
|
|
443
|
-
severity: SEVERITY_BY_TIER[tier] || 'low',
|
|
444
|
-
file,
|
|
445
|
-
line,
|
|
446
|
-
vuln: title,
|
|
447
|
-
cwe: candidate.cwe || 'CWE-710',
|
|
448
|
-
description: candidate.rationale
|
|
449
|
-
? `${candidate.rationale} (entry point: ${candidate.entryPoint || 'unstated'}; sink: ${candidate.sink || 'unstated'})`
|
|
450
|
-
: `Proposed by the ${candidate.lens} lens; no rationale supplied.`,
|
|
451
|
-
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.`,
|
|
452
|
-
parser: 'DISCOVERY',
|
|
453
|
-
family: candidate.family || 'other',
|
|
454
|
-
ruleId: `discovery:${candidate.lens}`,
|
|
455
|
-
// snippet discriminates findings so computeStableId has material to hash. An empty
|
|
456
|
-
// snippet collapses distinct findings of the same lens in the same file onto one id.
|
|
457
|
-
snippet: candidate.sink || candidate.entryPoint || candidate.title || '',
|
|
458
|
-
};
|
|
459
|
-
return {
|
|
460
|
-
...base,
|
|
461
|
-
stableId: (0,stable_id/* computeStableId */._)(base),
|
|
462
|
-
discovery: {
|
|
463
|
-
lens: candidate.lens,
|
|
464
|
-
focusAreaId: candidate.focusAreaId,
|
|
465
|
-
confirmation: candidate.confirmation || null,
|
|
466
|
-
refutation: candidate.refutation || null,
|
|
467
|
-
},
|
|
468
|
-
};
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function judgeCandidates(candidates, priorScan, triageFeedback) {
|
|
472
|
-
const prior = Array.isArray(priorScan?.findings) ? priorScan.findings : [];
|
|
473
|
-
const priorByLoc = new Map();
|
|
474
|
-
const priorIds = new Set();
|
|
475
|
-
for (const p of prior) {
|
|
476
|
-
if (p?.stableId) priorIds.add(p.stableId);
|
|
477
|
-
priorByLoc.set(`${p?.file}|${p?.line}|${p?.family}`, p?.stableId || null);
|
|
478
|
-
}
|
|
479
|
-
const feedback = triageFeedback && typeof triageFeedback === 'object' ? triageFeedback : {};
|
|
480
|
-
|
|
481
|
-
const fresh = [], duplicates = [], suppressed = [];
|
|
482
|
-
for (const c of candidates || []) {
|
|
483
|
-
const f = toFindingShape(c);
|
|
484
|
-
if (!f) continue; // malformed candidate (no usable file/line) — degrade by skipping, never throw
|
|
485
|
-
if (feedback[f.stableId] === 'fp') { suppressed.push({ ...f, suppressedBy: 'triage-fp' }); continue; }
|
|
486
|
-
const locKey = `${f.file}|${f.line}|${f.family}`;
|
|
487
|
-
// Location key is PRIMARY: same file, line, and family match existing findings.
|
|
488
|
-
if (priorByLoc.has(locKey)) { duplicates.push({ ...f, duplicateOf: priorByLoc.get(locKey) }); continue; }
|
|
489
|
-
// stableId is SECONDARY: same lens, file, and sink at a moved line is likely the same bug.
|
|
490
|
-
if (priorIds.has(f.stableId)) { duplicates.push({ ...f, duplicateOf: f.stableId }); continue; }
|
|
491
|
-
fresh.push(f);
|
|
492
|
-
}
|
|
493
|
-
return { fresh, duplicates, suppressed };
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
;// CONCATENATED MODULE: ./src/discovery/index.js
|
|
497
|
-
//
|
|
498
|
-
// Compose the discovery pipeline:
|
|
499
|
-
//
|
|
500
|
-
// partition → (area × lens) hunters → confirm → disprove → judge
|
|
501
|
-
//
|
|
502
|
-
// COVERAGE IS PART OF THE OUTPUT. Every report states how many areas were
|
|
503
|
-
// planned versus hunted and how many runs degraded, with reasons. A discovery
|
|
504
|
-
// pass that half failed and reports "no findings" is indistinguishable from a
|
|
505
|
-
// clean codebase unless it says so.
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
// Bridge a candidate to the deterministic layer. A taint finding at or within
|
|
514
|
-
// two lines of the candidate corroborates it; a modelled sink on the line
|
|
515
|
-
// without a full path is weaker corroboration ("sink-adjacent").
|
|
516
|
-
//
|
|
517
|
-
// NOTE: `runDeepAnalysis(perFileIR, callGraph, opts)` returns a BARE ARRAY of
|
|
518
|
-
// findings (see scanner/src/dataflow/index.js), not an object with a
|
|
519
|
-
// `.findings` property. Treat anything else defensively.
|
|
520
|
-
function makeTaintProbe(perFileIR, callGraph) {
|
|
521
|
-
let cache = null;
|
|
522
|
-
return async (candidate) => {
|
|
523
|
-
if (!callGraph || !perFileIR) return null;
|
|
524
|
-
try {
|
|
525
|
-
if (!cache) cache = runDeepAnalysisSafe(perFileIR, callGraph);
|
|
526
|
-
const deep = await cache;
|
|
527
|
-
if (!Array.isArray(deep)) return null;
|
|
528
|
-
const hits = deep.filter(f => f.file === candidate.file);
|
|
529
|
-
const exact = hits.find(f => Math.abs((f.line ?? -1) - candidate.line) <= 2);
|
|
530
|
-
if (exact) {
|
|
531
|
-
return { tier: 'taint-confirmed', evidence: { matchedFinding: exact.id ?? null, line: exact.line, vuln: exact.vuln ?? null } };
|
|
532
|
-
}
|
|
533
|
-
return hits.length ? { tier: 'sink-adjacent', evidence: { sameFileFindings: hits.length } } : null;
|
|
534
|
-
} catch {
|
|
535
|
-
return null;
|
|
536
|
-
}
|
|
537
|
-
};
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
async function runDeepAnalysisSafe(perFileIR, callGraph) {
|
|
541
|
-
try {
|
|
542
|
-
const { runDeepAnalysis } = await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 6732));
|
|
543
|
-
return runDeepAnalysis(perFileIR, callGraph, {});
|
|
544
|
-
} catch {
|
|
545
|
-
return null;
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// ctx = { perFileIR, callGraph, fileContents, priorScan, triageFeedback }
|
|
550
|
-
// where { perFileIR, callGraph } come from buildProjectIR(fileContents),
|
|
551
|
-
// which returns { perFile, callGraph } — callers must pass perFile as
|
|
552
|
-
// perFileIR (see scanner/src/ir/index.js).
|
|
553
|
-
async function runDiscovery(ctx = {}, opts = {}) {
|
|
554
|
-
const areas = partitionCallGraph(ctx.callGraph, { maxAreas: opts.maxAreas ?? 8 });
|
|
555
|
-
|
|
556
|
-
const reasons = [];
|
|
557
|
-
|
|
558
|
-
// An explicit array (including an empty one) is honoured exactly — a caller
|
|
559
|
-
// narrowing a run to no lenses must get no lenses, not a silent fallback to
|
|
560
|
-
// all seven. Only an absent/non-array value falls back to the full set.
|
|
561
|
-
const lensKeys = Array.isArray(opts.lenses) ? opts.lenses : discovery_lenses.LENSES.map(l => l.key);
|
|
562
|
-
|
|
563
|
-
const lenses = [];
|
|
564
|
-
for (const key of lensKeys) {
|
|
565
|
-
const lens = (0,discovery_lenses/* lensByKey */.H)(key);
|
|
566
|
-
// An unknown key must degrade visibly, not vanish via a silent filter.
|
|
567
|
-
if (lens) lenses.push(lens);
|
|
568
|
-
else reasons.push(`unresolved lens key: "${key}"`);
|
|
569
|
-
}
|
|
570
|
-
if (lenses.length === 0) {
|
|
571
|
-
reasons.push('no lenses resolved for this run (empty or fully-unresolved lens selection); nothing was hunted');
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
const runs = [];
|
|
575
|
-
let candidates = [];
|
|
576
|
-
// areasHunted: areas where AT LEAST ONE lens run completed without degrading.
|
|
577
|
-
const hunted = new Set();
|
|
578
|
-
// areasFullyHunted: areas where EVERY lens run completed without degrading.
|
|
579
|
-
// Distinct from areasHunted so a partially-degraded area (e.g. 6 of 7 lenses
|
|
580
|
-
// failed) cannot be read as fully covered from a single number.
|
|
581
|
-
const fullyHunted = new Set();
|
|
582
|
-
|
|
583
|
-
for (const area of areas) {
|
|
584
|
-
let areaDegradedCount = 0;
|
|
585
|
-
for (const lens of lenses) {
|
|
586
|
-
const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke: opts.llmInvoke });
|
|
587
|
-
runs.push({ focusAreaId: run.focusAreaId, lens: run.lens, degraded: run.degraded, reason: run.reason, candidateCount: run.candidates.length });
|
|
588
|
-
if (run.degraded && run.reason) reasons.push(`${area.label} × ${lens.key}: ${run.reason}`);
|
|
589
|
-
if (run.degraded) areaDegradedCount += 1;
|
|
590
|
-
else hunted.add(area.id);
|
|
591
|
-
candidates = candidates.concat(run.candidates);
|
|
592
|
-
}
|
|
593
|
-
if (lenses.length > 0 && areaDegradedCount === 0) fullyHunted.add(area.id);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
const taintProbe = makeTaintProbe(ctx.perFileIR, ctx.callGraph);
|
|
597
|
-
const confirmed = await confirmAll(candidates, { taintProbe });
|
|
598
|
-
const { survivors, refuted } = await disprovePanel(confirmed, { llmInvoke: opts.llmInvoke });
|
|
599
|
-
const { fresh, duplicates, suppressed } = judgeCandidates(survivors, ctx.priorScan, ctx.triageFeedback);
|
|
600
|
-
|
|
601
|
-
// Coverage must not stop at the hunter stage. `confirm.js` correctly never
|
|
602
|
-
// lowers a candidate below `unconfirmed`, and `disprove.js` correctly lets
|
|
603
|
-
// a candidate survive when no voter votes — each rule is right on its own,
|
|
604
|
-
// but composed, a run where BOTH later stages died silently would still
|
|
605
|
-
// report clean hunter coverage while 100% of raw, uncorroborated model
|
|
606
|
-
// output landed in `fresh`. These counters and reasons make that visible.
|
|
607
|
-
const confirmedByTier = { 'taint-confirmed': 0, 'sink-adjacent': 0, 'unconfirmed': 0 };
|
|
608
|
-
for (const c of confirmed) {
|
|
609
|
-
const tier = c?.confirmation?.tier;
|
|
610
|
-
if (tier && Object.prototype.hasOwnProperty.call(confirmedByTier, tier)) confirmedByTier[tier] += 1;
|
|
611
|
-
}
|
|
612
|
-
if (confirmed.length > 0 && confirmedByTier['taint-confirmed'] === 0 && confirmedByTier['sink-adjacent'] === 0) {
|
|
613
|
-
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`);
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
const panelled = [...survivors, ...refuted];
|
|
617
|
-
const panelsRun = panelled.length;
|
|
618
|
-
const undecidedPanels = panelled.filter(c => c?.refutation?.undecided === true).length;
|
|
619
|
-
if (panelsRun > 0 && undecidedPanels === panelsRun) {
|
|
620
|
-
reasons.push(`refutation panel returned no votes for any of ${panelsRun} candidate(s) — every finding below survived unrefuted, not because it withstood scrutiny`);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
return {
|
|
624
|
-
schema: 'agentic-security/discovery@1',
|
|
625
|
-
focusAreas: areas.map(a => ({ id: a.id, label: a.label, files: a.files.length, size: a.size })),
|
|
626
|
-
runs,
|
|
627
|
-
fresh,
|
|
628
|
-
duplicates,
|
|
629
|
-
suppressed,
|
|
630
|
-
// `refutedCandidates` holds RAW candidates straight from `disprovePanel`,
|
|
631
|
-
// NOT findings — no `vuln`, `severity`, `parser`, or `stableId`. It is
|
|
632
|
-
// deliberately not run through `toFindingShape`: a refuted candidate is
|
|
633
|
-
// deliberately not promoted to a finding, and giving it finding shape
|
|
634
|
-
// would misrepresent it as one. Unlike `fresh`/`duplicates`/`suppressed`,
|
|
635
|
-
// do not iterate this array as if it were finding-shaped.
|
|
636
|
-
refutedCandidates: refuted,
|
|
637
|
-
coverage: {
|
|
638
|
-
areasPlanned: areas.length,
|
|
639
|
-
// At least one lens run completed for the area. Does NOT mean every
|
|
640
|
-
// lens succeeded there — see areasFullyHunted for that stronger claim.
|
|
641
|
-
areasHunted: hunted.size,
|
|
642
|
-
// Every lens run for the area completed without degrading.
|
|
643
|
-
areasFullyHunted: fullyHunted.size,
|
|
644
|
-
lensesPerArea: lenses.length,
|
|
645
|
-
degradedRuns: runs.filter(r => r.degraded).length,
|
|
646
|
-
// Per-tier count of every candidate that went through confirm.js.
|
|
647
|
-
confirmedByTier,
|
|
648
|
-
// How many candidates went through the refutation panel, and how many
|
|
649
|
-
// of those came back with no votes at all (undecided, not refuted).
|
|
650
|
-
panelsRun,
|
|
651
|
-
undecidedPanels,
|
|
652
|
-
reasons,
|
|
653
|
-
},
|
|
654
|
-
};
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
/***/ }),
|
|
659
|
-
|
|
660
|
-
/***/ 3499:
|
|
661
|
-
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
662
|
-
|
|
663
|
-
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
664
|
-
/* harmony export */ H: () => (/* binding */ lensByKey),
|
|
665
|
-
/* harmony export */ LENSES: () => (/* binding */ LENSES),
|
|
666
|
-
/* harmony export */ j: () => (/* binding */ buildHunterPrompt)
|
|
667
|
-
/* harmony export */ });
|
|
668
|
-
//
|
|
669
|
-
// The seven hunting lenses. Each hunter run is one (focus area × lens) pair.
|
|
670
|
-
//
|
|
671
|
-
// WHY DIVERSE LENSES RATHER THAN N IDENTICAL HUNTERS: redundancy raises
|
|
672
|
-
// confidence in what was already found and adds nothing to coverage. A lens
|
|
673
|
-
// that is told to look only at authorization asks different questions of the
|
|
674
|
-
// same code than one told to look at crypto, so the union covers failure modes
|
|
675
|
-
// no single prompt reaches. `wildcard` exists because a fixed taxonomy is a
|
|
676
|
-
// ceiling, and the classes worth finding are the ones not on the list.
|
|
677
|
-
const LENSES = Object.freeze([
|
|
678
|
-
{ key: 'injection', title: 'Injection', family: 'injection', cwe: 'CWE-74',
|
|
679
|
-
brief: 'Untrusted input reaching an interpreter: SQL, shell, template, XPath, LDAP, or deserialization. Follow the value, not the function name.' },
|
|
680
|
-
{ key: 'authz', title: 'Authorization', family: 'access-control', cwe: 'CWE-285',
|
|
681
|
-
brief: 'Missing, partial, or bypassable authorization: object references not scoped to the caller, tier checks applied on one path but not another, checks performed after the effect.' },
|
|
682
|
-
{ key: 'crypto', title: 'Cryptography', family: 'crypto', cwe: 'CWE-327',
|
|
683
|
-
brief: 'Misuse rather than choice of primitive: reused nonces, unauthenticated ciphertext, comparisons that are not constant time, keys derived from guessable material.' },
|
|
684
|
-
{ key: 'business-logic', title: 'Business logic', family: 'business-logic', cwe: 'CWE-840',
|
|
685
|
-
brief: 'The code does what it says and what it says is wrong: state machines that accept out-of-order transitions, quantities that may be negative, refunds that exceed charges, limits enforced client side.' },
|
|
686
|
-
{ key: 'feature-abuse', title: 'Feature abuse', family: 'abuse', cwe: 'CWE-799',
|
|
687
|
-
brief: 'A working feature used as a weapon: unbounded fan-out, expensive endpoints with no cost to the caller, invitations or exports that leak across tenants.' },
|
|
688
|
-
{ key: 'chained', title: 'Chained', family: 'attack-chain', cwe: 'CWE-1173',
|
|
689
|
-
brief: 'Two behaviours that are each acceptable alone and unacceptable together. State the chain as an ordered sequence of steps with the attacker capability required at each.' },
|
|
690
|
-
{ key: 'wildcard', title: 'Wildcard', family: 'other', cwe: 'CWE-710',
|
|
691
|
-
brief: 'Anything the other lenses do not cover. Prefer the surprising and specific over the generic; report nothing rather than something already obvious.' },
|
|
692
|
-
]);
|
|
693
|
-
|
|
694
|
-
function lensByKey(key) {
|
|
695
|
-
if (typeof key !== 'string') return null;
|
|
696
|
-
return LENSES.find(l => l.key === key) || null;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
const DEFAULT_MAX_CHARS = 60_000;
|
|
700
|
-
|
|
701
|
-
function buildHunterPrompt(focusArea, lens, ctx = {}) {
|
|
702
|
-
const maxChars = Number.isInteger(ctx.maxChars) && ctx.maxChars > 0 ? ctx.maxChars : DEFAULT_MAX_CHARS;
|
|
703
|
-
const contents = ctx.fileContents || {};
|
|
704
|
-
const files = (focusArea?.files || []).filter(f => typeof contents[f] === 'string');
|
|
705
|
-
|
|
706
|
-
let budget = maxChars;
|
|
707
|
-
const blocks = [];
|
|
708
|
-
for (const f of files) {
|
|
709
|
-
const src = contents[f];
|
|
710
|
-
const slice = src.length > budget ? src.slice(0, Math.max(0, budget)) : src;
|
|
711
|
-
const truncated = slice.length < src.length;
|
|
712
|
-
blocks.push(`--- ${f}${truncated ? ' (truncated)' : ''} ---\n${slice}`);
|
|
713
|
-
budget -= slice.length;
|
|
714
|
-
if (budget <= 0) break;
|
|
715
|
-
}
|
|
716
|
-
const omitted = files.length - blocks.length;
|
|
717
|
-
|
|
718
|
-
return [
|
|
719
|
-
`You are hunting for security vulnerabilities in one area of a codebase.`,
|
|
720
|
-
`Area: ${focusArea?.label ?? 'unknown'} (${files.length} files)`,
|
|
721
|
-
``,
|
|
722
|
-
`Your lens is ${lens.title}. ${lens.brief}`,
|
|
723
|
-
`Report ONLY through this lens. Another hunter covers the others.`,
|
|
724
|
-
``,
|
|
725
|
-
`Rules:`,
|
|
726
|
-
`- Report a candidate only if you can name the entry point an attacker controls and the effect they achieve.`,
|
|
727
|
-
`- Do not report defence-in-depth gaps, style, or "could be hardened". Those are not candidates.`,
|
|
728
|
-
`- Cite a real file and line from the source below. A candidate with no location is discarded.`,
|
|
729
|
-
``,
|
|
730
|
-
`Return JSON: {"candidates":[{"title","file","line","rationale","entryPoint","sink"}]}`,
|
|
731
|
-
`Return {"candidates":[]} if you find nothing. An empty result is a valid and useful answer.`,
|
|
732
|
-
``,
|
|
733
|
-
omitted > 0 ? `NOTE: ${omitted} file(s) omitted, prompt budget exhausted (truncated context).\n` : ``,
|
|
734
|
-
...blocks,
|
|
735
|
-
].join('\n');
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
/***/ })
|
|
740
|
-
|
|
741
|
-
};
|