mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
measure(g0): fair-target deep scan -- detection 3/5 on app-shaped+sound vs ~0 library-heavy
Ran ousast scan --mode deep on the 5 app-shaped + sound pool targets (the fair change-scoped eval set from the shape classification). Detection 3/5: graphiti (hunter, Cypher injection), mcp-remote (hunter, url-scheme injection), dcmcp (ENGINE overlay -- 'outbound.request reached from parameter' on readFileFromUrl; parameter-as-source fired because an entry point is present). 2/5 missed: malla (DOM/template XSS) and dms (Jinja SSTI), both in families families.toml documents as limited. This validates the strategic pivot: the near-zero catch was substantially a TARGET MISMATCH -- on app-shaped repos the engine itself detects (0 -> 3/5). But all hits stop at suspicion/ static_corroboration; release-grade BLOCK still needs proof-arm elevation + family coverage + the DSPy hint loop. Cost ~$0.007/repo. So bet #1 (fix eval target) is necessary and material but not sufficient. Next: recompute a floor on an app-shaped sound subset / real app corpus (VAmPI, NodeGoat, DjanGoat, vibe-py); measure proof-arm suspicion->proven lift. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e15ed680a9
commit
04667c815e
@@ -417,3 +417,19 @@ scored against a ~90%-illegitimate denominator — the near-zero catch is largel
|
||||
**Next:** deep scan (regex/engine/hunter) on the 5 fair targets → per-tier catch rate on the corrected set; then
|
||||
stand up an app-shaped eval set from known-vulnerable apps (VAmPI/NodeGoat/DjanGoat/vibe-py) and recompute the
|
||||
floor. Deferred bets (advisory-with-conditions, reachability prover) revisited once the fair-target numbers land.
|
||||
|
||||
## FAIR-TARGET RESULT: detection 3/5 on app-shaped+sound (vs ~0 library-heavy) — target-fix validated (2026-10-12)
|
||||
|
||||
Deep scan on the 5 app-shaped+sound targets (`benchmarks/measurements/2026-10-12-hunter-fair-targets/`):
|
||||
- **3/5 detected**: graphiti (hunter, Cypher injection), mcp-remote (hunter, url-scheme injection), dcmcp (ENGINE
|
||||
overlay — "outbound.request reached from parameter" on readFileFromUrl; the param-as-source mode fired because an
|
||||
entry point is present). **2/5 missed**: malla (DOM/template XSS) and dms (Jinja SSTI) — both in families
|
||||
families.toml DOCUMENTS as limited (template-mediated escaping).
|
||||
- vs the library-heavy pilot's ~0 → **the target mismatch was real and material.** On an app-shaped repo the engine
|
||||
itself detects (parameter is a recognized source once the entry point is in-repo).
|
||||
- **But everything stops at `suspicion`/`static_corroboration`** — none proven. Cost ~$0.007/repo.
|
||||
|
||||
**Strategy verdict:** bet #1 (fix the eval target) is validated as NECESSARY and MATERIAL but NOT SUFFICIENT.
|
||||
Detection appears on fair targets; release-grade BLOCK still needs (a) proof-arm elevation (suspicion→proven) and
|
||||
(b) family coverage (template XSS/SSTI) + the DSPy hint loop for misses. Next: recompute a floor on an app-shaped
|
||||
sound subset / real app corpus (VAmPI, NodeGoat, DjanGoat, vibe-py), and measure the proof arm's suspicion→proven lift.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Deep scan (regex/engine/hunter) on the 5 app-shaped + sound "fair" targets — 2026-10-12
|
||||
|
||||
## Why
|
||||
Strategic pivot "fix the evaluation target first". The shape classification left 5 pool targets that are BOTH
|
||||
label-sound AND application-shaped (reachability in-repo) — the fair change-scoped eval set. Ran `ousast scan
|
||||
--mode deep` (DeepSeek, model layer + tool_hunter, 60-call bound) on all 5 to get a per-tier catch rate on the
|
||||
corrected target set (vs ~0 on the library-heavy pilot). graphiti from the earlier run; the other 4 here.
|
||||
|
||||
## Result: detection 3/5 on fair targets (vs ~0 library-heavy), all at suspicion rung
|
||||
| target | family | detected | by | rung | model calls / cost |
|
||||
|---|---|---|---|---|---|
|
||||
| getzep/graphiti | injection (Cypher) | YES | hunter (family:cypher-injection, build_fulltext_query L278) | suspicion | 64 / $0.0071 |
|
||||
| punkpeye/mcp-remote | injection (url-scheme) | YES | hunter (family:url-scheme-injection, sanitizeUrl L708) | suspicion | 65 / $0.0075 |
|
||||
| sorlen008/desktopcommandermcp | untrusted_destination (SSRF) | YES | engine overlay ("outbound.request reached from parameter", readFileFromUrl L174) | suspicion | 60 / $0.0067 |
|
||||
| zenitram/malla | output_encoding (DOM XSS) | no | — (no finding on the vuln JS file) | — | 65 / $0.0072 |
|
||||
| adfinis/document-merge-service | injection (Jinja SSTI) | no | — (no finding on jinja.py get_jinja_env) | — | 64 / $0.0075 |
|
||||
|
||||
## Reads
|
||||
1. **The target mismatch was real and material: detection 0 → 3/5 when the target is app-shaped + sound.** On
|
||||
dcmcp (an MCP server) the ENGINE's parameter-as-source mode fired (entry point present) and flagged the SSRF —
|
||||
the exact thing it could NOT do on library code. On graphiti/mcpremote the hunter caught injection with a
|
||||
family tag. The near-zero pilot catch was substantially a target artifact.
|
||||
2. **The 2 misses are in families the tool DOCUMENTS as limited** — malla is DOM/template XSS (output_encoding's
|
||||
`limits` in families.toml: cannot decide template-mediated escaping) and dms is Jinja SSTI/autoescape-config.
|
||||
Expected coverage gaps, not reachability failures.
|
||||
3. **Everything still stops at `suspicion`/`static_corroboration`** — none reached corroborated/entailed/
|
||||
execution_confirmed. Release-grade BLOCK needs the proof arm to elevate these.
|
||||
4. Cost remains a non-issue: ~$0.007/repo, ~60-65 model calls for a whole-repo deep scan.
|
||||
|
||||
## Conclusion for the strategy
|
||||
Fixing the evaluation target (bet #1) is validated as necessary and material but NOT sufficient: on fair targets
|
||||
detection appears (3/5) but stays at suspicion. The remaining work is (a) proof-arm elevation (suspicion→proven)
|
||||
for BLOCK, and (b) family coverage for the documented-limited families — and the DSPy hint loop for the misses.
|
||||
This is also the honest basis to recompute record-07 on a sound+app-shaped subset / a real app corpus (VAmPI,
|
||||
NodeGoat, DjanGoat, vibe-py).
|
||||
@@ -0,0 +1,490 @@
|
||||
{
|
||||
"findings": [
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "js-command-exec:setup-claude-server.js:36",
|
||||
"function_name": null,
|
||||
"line": 36,
|
||||
"path": "setup-claude-server.js",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 1.5,
|
||||
"rationale": "Static pattern js-command-exec (CWE-78) matched exec on line 36; manual verification still required.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "critical",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"auth_boundary",
|
||||
"deserialization",
|
||||
"filesystem_entry",
|
||||
"network_entry",
|
||||
"parser",
|
||||
"syscall_entry"
|
||||
],
|
||||
"title": "Node child_process command execution needs review"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "js-command-exec:setup-claude-server.js:216",
|
||||
"function_name": null,
|
||||
"line": 216,
|
||||
"path": "setup-claude-server.js",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 1.5,
|
||||
"rationale": "Static pattern js-command-exec (CWE-78) matched exec on line 216; manual verification still required.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "critical",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"auth_boundary",
|
||||
"deserialization",
|
||||
"filesystem_entry",
|
||||
"network_entry",
|
||||
"parser",
|
||||
"syscall_entry"
|
||||
],
|
||||
"title": "Node child_process command execution needs review"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:src/terminal-manager.ts:57:prototype.merge",
|
||||
"function_name": null,
|
||||
"line": 57,
|
||||
"path": "src/terminal-manager.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink prototype.merge reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "info",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: prototype.merge"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:src/terminal-manager.ts:84:prototype.merge",
|
||||
"function_name": null,
|
||||
"line": 84,
|
||||
"path": "src/terminal-manager.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink prototype.merge reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "info",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: prototype.merge"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:src/tools/edit.ts:11:path.open",
|
||||
"function_name": null,
|
||||
"line": 11,
|
||||
"path": "src/tools/edit.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink path.open reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: path.open"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:src/tools/filesystem.ts:174:outbound.request",
|
||||
"function_name": null,
|
||||
"line": 174,
|
||||
"path": "src/tools/filesystem.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink outbound.request reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: outbound.request"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:src/tools/filesystem.ts:321:path.open",
|
||||
"function_name": null,
|
||||
"line": 321,
|
||||
"path": "src/tools/filesystem.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink path.open reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: path.open"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:src/tools/search.ts:176:path.open",
|
||||
"function_name": null,
|
||||
"line": 176,
|
||||
"path": "src/tools/search.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink path.open reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: path.open"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:scripts/sync-version.js:24",
|
||||
"function_name": null,
|
||||
"line": 24,
|
||||
"path": "scripts/sync-version.js",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 15.1974,
|
||||
"rationale": "sync-version.js reads package.json, optionally bumps the version, and writes src/version.ts via template literal `export const VERSION = '${version}';`. The version value is taken from package.json without validation; a crafted version string containing a quote could break out of the string literal and inject code into the generated TypeScript source. This is the deserialization/parser/filesystem_entry hotspot.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:code-injection"
|
||||
],
|
||||
"title": "Version string interpolated into generated source file without escaping"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:setup-claude-server.js:14",
|
||||
"function_name": null,
|
||||
"line": 14,
|
||||
"path": "setup-claude-server.js",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 22.5805,
|
||||
"rationale": "The setup script embeds a literal PostHog project API key and imports node-machine-id to derive a stable uniqueUserId, then sends analytics with flushAt:1. This is a hardcoded credential plus device fingerprinting in an install/setup entrypoint (network_entry, syscall_entry tags). The key is committed to the repository and cannot be rotated without a code change.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:hardcoded-secret"
|
||||
],
|
||||
"title": "Hardcoded PostHog API key and machine-id telemetry on startup"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:setup-claude-server.js:38",
|
||||
"function_name": null,
|
||||
"line": 38,
|
||||
"path": "setup-claude-server.js",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 22.5805,
|
||||
"rationale": "getNpmVersion invokes exec('npm --version'), which runs through a shell. The command is static so not directly injectable, but it establishes a shell-execution syscall entry in the setup script that also handles filesystem writes and network telemetry, matching the syscall_entry/network_entry tags on this top-ranked hotspot.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:command-execution"
|
||||
],
|
||||
"title": "exec of npm --version in setup entrypoint"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/command-manager.ts:152",
|
||||
"function_name": null,
|
||||
"line": 152,
|
||||
"path": "src/command-manager.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "validateCommand relies on extractCommands/getBaseCommand, which split on separators and take the first whitespace-delimited token lowercased. Blocklist matching is exact-string against blockedCommands. A blocked command invoked as '/bin/rm', './rm', 'rm.exe', or via env/quoting (e.g. 'r''m', '\\rm', 'command rm') yields a base command that does not equal the blocked entry, so the blocklist is trivially evaded. This is the auth_boundary/parser hotspot flagged in src/tools/execute.ts and src/config-manager.ts.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:command-injection"
|
||||
],
|
||||
"title": "Command blocklist validation is bypassable via shell metacharacters and path-qualified binaries"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/config-manager.ts:44",
|
||||
"function_name": null,
|
||||
"line": 44,
|
||||
"path": "src/config-manager.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 19.6849,
|
||||
"rationale": "init() reads ~/.claude-server-commander/config.json and assigns JSON.parse(configData) directly to this.config with no schema or type validation. A malformed or attacker-controlled config file can set allowedDirectories to '/' or blockedCommands to [], or inject unexpected keys (the interface has an index signature [key: string]: any). This is the deserialization/filesystem_entry/auth_boundary hotspot.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:unsafe-deserialization"
|
||||
],
|
||||
"title": "Config JSON parsed from disk without schema validation"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/config-manager.ts:76",
|
||||
"function_name": null,
|
||||
"line": 76,
|
||||
"path": "src/config-manager.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 19.6849,
|
||||
"rationale": "getDefaultConfig ships a fixed denylist of dangerous commands (mkfs, dd, sudo, etc.). Denylists are inherently incomplete: commands not enumerated (e.g. 'sh', 'bash', 'python', 'curl', 'nc', 'chmod', 'kill') are permitted by default. Combined with the base-command matching weakness in command-manager.ts, the default posture is permissive. This is the auth_boundary hotspot in config-manager.ts.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:insecure-default"
|
||||
],
|
||||
"title": "Default blockedCommands is a denylist, not an allowlist"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/handlers/filesystem-handlers.ts:51",
|
||||
"function_name": null,
|
||||
"line": 51,
|
||||
"path": "src/handlers/filesystem-handlers.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 14.1621,
|
||||
"rationale": "handleReadFile calls readFile(parsed.path, parsed.isUrl). When isUrl is true, filesystem.ts routes to readFileFromUrl which performs fetch(url). The URL is not validated against any allowlist or private-IP blocklist, so the handler can be used to fetch internal/loopback/metadata endpoints (SSRF). This is the filesystem_entry/parser hotspot in filesystem-handlers.ts.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:ssrf"
|
||||
],
|
||||
"title": "read_file handler forwards isUrl flag to readFile, enabling server-side request forgery"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/tools/config.ts:65",
|
||||
"function_name": null,
|
||||
"line": 65,
|
||||
"path": "src/tools/config.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 16.2332,
|
||||
"rationale": "setConfigValue accepts arbitrary keys including allowedDirectories and blockedCommands and writes them via configManager.setValue. A caller can widen allowedDirectories to '/' or empty the blockedCommands array, disabling both the filesystem and command boundaries at runtime. The server.ts description itself warns this must be used in a separate chat, confirming it is a security-sensitive control surface reachable through the same tool interface.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:privilege-escalation"
|
||||
],
|
||||
"title": "set_config_value allows runtime mutation of allowedDirectories and blockedCommands"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/tools/execute.ts:32",
|
||||
"function_name": null,
|
||||
"line": 32,
|
||||
"path": "src/tools/execute.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 13.5639,
|
||||
"rationale": "executeCommand extracts commands for analytics, then calls commandManager.validateCommand, then terminalManager.executeCommand. The only authorization check is the blocklist-based validateCommand, which is bypassable (see command-manager finding). There is no allowlist, no shell-escape sanitization, and the raw parsed.data.command string is passed to the terminal manager. This is the auth_boundary hotspot in execute.ts.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:command-injection"
|
||||
],
|
||||
"title": "Command validation occurs after analytics extraction and is the sole gate before execution"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/tools/filesystem.ts:101",
|
||||
"function_name": null,
|
||||
"line": 101,
|
||||
"path": "src/tools/filesystem.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "When the requested path does not exist and validateParentDirectories returns false, validatePath still returns the absolute path rather than throwing. Callers (readFile, writeFile, search, move) treat the return as validated, so a non-existent path outside allowed directories can be passed through to subsequent operations. The allowlist check happens before realpath resolution, so symlink-based escapes are also possible for existing paths.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:path-traversal"
|
||||
],
|
||||
"title": "validatePath returns unvalidated absolute path when no valid parent exists"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/tools/filesystem.ts:78",
|
||||
"function_name": null,
|
||||
"line": 78,
|
||||
"path": "src/tools/filesystem.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "isPathAllowed returns true if allowedDirectories includes '/' OR allowedDirectories.length === 0. getAllowedDirs returns [] on any error (catch block), and validatePath's error path also returns the absolute path anyway. Combined with config-manager defaulting allowedDirectories to homedir, an empty/failed config silently grants access to all paths, defeating the filesystem boundary tagged on src/tools/search.ts, src/handlers/filesystem-handlers.ts, and src/config-manager.ts.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:path-traversal"
|
||||
],
|
||||
"title": "Path allowlist fails open when allowedDirectories is empty or '/'"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/tools/search.ts:36",
|
||||
"function_name": null,
|
||||
"line": 36,
|
||||
"path": "src/tools/search.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 19.995,
|
||||
"rationale": "The pattern and filePattern are pushed into the ripgrep argv array. While spawn avoids shell interpretation, a pattern beginning with '-' can be interpreted as a ripgrep flag (argument injection), and filePattern via -g can broaden or redirect the search scope. validatePath is applied to rootPath but not to the pattern/filePattern, so the deserialization/parser hotspot can be steered outside intended semantics.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:argument-injection"
|
||||
],
|
||||
"title": "searchCode passes user pattern directly to ripgrep spawn without argument hardening"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/tools/search.ts:88",
|
||||
"function_name": null,
|
||||
"line": 88,
|
||||
"path": "src/tools/search.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 19.995,
|
||||
"rationale": "stdoutBuffer accumulates all ripgrep output in memory before splitting and JSON.parse-ing each line. A large or adversarial search result set can exhaust memory, and the per-line JSON.parse is wrapped in a catch that only logs, so malformed output is silently dropped. This is the deserialization/parser tag on this high-band hotspot.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:resource-exhaustion"
|
||||
],
|
||||
"title": "Unbounded stdout buffering and JSON.parse of ripgrep output"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:test/test-blocked-commands.js:147",
|
||||
"function_name": null,
|
||||
"line": 147,
|
||||
"path": "test/test-blocked-commands.js",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 13.5864,
|
||||
"rationale": "The test sets blockedCommands to a custom list, asserts behavior, then teardown calls configManager.updateConfig(originalConfig). Because the test operates on the real config file (~/.claude-server-commander/config.json) rather than an isolated temp config, a crash between setValue and teardown leaves the persisted blocklist modified, weakening the command boundary. This is the filesystem_entry/parser hotspot in the test file.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:test-isolation"
|
||||
],
|
||||
"title": "Test mutates live blockedCommands config and restores via updateConfig"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
{
|
||||
"findings": [
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:document_merge_service/api/authentication.py:67:outbound.request",
|
||||
"function_name": null,
|
||||
"line": 67,
|
||||
"path": "document_merge_service/api/authentication.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink outbound.request reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: outbound.request"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:document_merge_service/api/tests/data/__init__.py:14:path.open",
|
||||
"function_name": null,
|
||||
"line": 14,
|
||||
"path": "document_merge_service/api/tests/data/__init__.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink path.open reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: path.open"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:document_merge_service/api/views.py:39:html.unescaped",
|
||||
"function_name": null,
|
||||
"line": 39,
|
||||
"path": "document_merge_service/api/views.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink html.unescaped reached from request",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: html.unescaped"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:document_merge_service/api/views.py:52:outbound.request",
|
||||
"function_name": null,
|
||||
"line": 52,
|
||||
"path": "document_merge_service/api/views.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink outbound.request reached from request",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: outbound.request"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:document_merge_service/api/views.py:57:html.unescaped",
|
||||
"function_name": null,
|
||||
"line": 57,
|
||||
"path": "document_merge_service/api/views.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink html.unescaped reached from request",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: html.unescaped"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:document_merge_service/api/views.py:79:html.unescaped",
|
||||
"function_name": null,
|
||||
"line": 79,
|
||||
"path": "document_merge_service/api/views.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink html.unescaped reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: html.unescaped"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "model:untrusted_destination:document_merge_service/api/views.py:52:merge",
|
||||
"function_name": "merge",
|
||||
"line": 52,
|
||||
"path": "document_merge_service/api/views.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "request.data -> requests.post(url, files = {\"file\":response.content}) in document_merge_service/api/views.py (line 52, 17 steps)",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "model_entailed",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"family:untrusted_destination",
|
||||
"rung:model_entailed"
|
||||
],
|
||||
"title": "untrusted_destination (model_entailed)"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:document_merge_service/api/authentication.py:32",
|
||||
"function_name": null,
|
||||
"line": 32,
|
||||
"path": "document_merge_service/api/authentication.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 11.7422,
|
||||
"rationale": "self.groups = userinfo.get(settings.OIDC_GROUPS_CLAIM) or []. Groups drive access control in views.py:25 (queryset.filter(group__in=self.request.user.groups)) and serializers.py:25. The claim is trusted verbatim from the userinfo response with no validation of issuer/audience, so any party able to influence that response controls group membership.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:authorization"
|
||||
],
|
||||
"title": "Authorization groups taken directly from OIDC userinfo claim"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:document_merge_service/api/authentication.py:68",
|
||||
"function_name": null,
|
||||
"line": 68,
|
||||
"path": "document_merge_service/api/authentication.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 11.7422,
|
||||
"rationale": "requests.get(settings.OIDC_USERINFO_ENDPOINT, verify=settings.OIDC_VERIFY_SSL, ...). OIDC_VERIFY_SSL is env-controlled (settings.py:193, default True). If set false, the bearer-token identity lookup accepts any TLS cert, allowing a MITM to forge userinfo (sub/groups) and thus impersonate users and gain group-based access.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:transport-security"
|
||||
],
|
||||
"title": "OIDC userinfo fetched with configurable SSL verification"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:document_merge_service/api/authentication.py:88",
|
||||
"function_name": null,
|
||||
"line": 88,
|
||||
"path": "document_merge_service/api/authentication.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 11.7422,
|
||||
"rationale": "cache.get_or_set(f\"authentication.userinfo.{hashsum_token}\", userinfo_method, timeout=settings.OIDC_BEARER_TOKEN_REVALIDATION_TIME). OIDC_BEARER_TOKEN_REVALIDATION_TIME defaults to 0 (settings.py:197-198). A 0 timeout means the userinfo is not cached, forcing a network call per request; conversely a large value keeps revoked tokens valid. The cache key is a sha256 of the token, so cache contents are keyed by token hash.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:session-management"
|
||||
],
|
||||
"title": "Bearer token cached by hash with configurable revalidation timeout"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:document_merge_service/api/engines.py:52",
|
||||
"function_name": null,
|
||||
"line": 52,
|
||||
"path": "document_merge_service/api/engines.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 10.7693,
|
||||
"rationale": "return ENGINES[engine](template) indexes the ENGINES dict with the engine value. The engine comes from the template model/serializer (serializers.py:31-34, views.py:36). A KeyError on an unknown engine would surface as a 500 rather than a validation error; the value is constrained by model choices but the serializer path passes data.get(\"engine\") before full validation.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:input-validation"
|
||||
],
|
||||
"title": "Engine dispatch via dict lookup on user-controlled engine value"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:document_merge_service/api/migrations/0001_initial.py:22",
|
||||
"function_name": null,
|
||||
"line": 22,
|
||||
"path": "document_merge_service/api/migrations/0001_initial.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 9.4474,
|
||||
"rationale": "(\"template\", models.FileField(max_length=1024, upload_to=\"\")) stores uploaded files directly in the media root with no subdirectory. Combined with the download view (views.py:66-84) which serves template.template.read() with a Content-Disposition attachment, uploaded filenames are not namespaced, raising collision/overwrite concerns.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:file-handling"
|
||||
],
|
||||
"title": "Template FileField with upload_to empty string"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:document_merge_service/api/serializers.py:25",
|
||||
"function_name": null,
|
||||
"line": 25,
|
||||
"path": "document_merge_service/api/serializers.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "if group and group not in request.user.groups: raise ValidationError. This is the write-side counterpart to the read-side filter in views.py:25. It relies on request.user.groups being trustworthy, which traces back to the unvalidated OIDC claim in authentication.py:32.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:authorization"
|
||||
],
|
||||
"title": "Group assignment validated against request user's groups"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:document_merge_service/api/tests/test_authentication.py:25",
|
||||
"function_name": null,
|
||||
"line": 25,
|
||||
"path": "document_merge_service/api/tests/test_authentication.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 11.908,
|
||||
"rationale": "userinfo = {\"sub\": \"1\", settings.OIDC_GROUPS_CLAIM: [\"test\"]} and the test asserts user.group == \"test\". The test confirms the implementation trusts the userinfo claim for group membership; no test covers issuer/audience verification or rejection of a mismatched token, so the trust boundary is untested.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:test-coverage"
|
||||
],
|
||||
"title": "Auth test asserts group from mocked userinfo without issuer validation"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:document_merge_service/api/views.py:25",
|
||||
"function_name": null,
|
||||
"line": 25,
|
||||
"path": "document_merge_service/api/views.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "queryset.filter(group__in=self.request.user.groups) only applies when settings.GROUP_ACCESS_ONLY is true (default False, settings.py:190). When disabled, all templates are listed regardless of group. When enabled, AnonymousUser.groups is [] so filtering yields nothing, but the security posture hinges entirely on this env flag.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:authorization"
|
||||
],
|
||||
"title": "Group-based queryset filtering depends on user.groups being populated"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:document_merge_service/api/views.py:47",
|
||||
"function_name": null,
|
||||
"line": 47,
|
||||
"path": "document_merge_service/api/views.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "requests.post(f\"{settings.UNOCONV_URL}/unoconv/{convert}\", files={\"file\": response.content}) sends the rendered document to an external conversion service. The convert value is validated against UNOCONV_ALLOWED_TYPES (serializers.py:52-58) and UNOCONV_URL must be configured, but the response content/status are passed straight back to the client (views.py:52-56), so the external service's output is trusted.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:ssrf"
|
||||
],
|
||||
"title": "Merge output forwarded to external UNOCONV_URL service"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:document_merge_service/conftest.py:24",
|
||||
"function_name": null,
|
||||
"line": 24,
|
||||
"path": "document_merge_service/conftest.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 10.1065,
|
||||
"rationale": "TestUser(AnonymousUser) sets is_authenticated True and admin_client uses client.force_authenticate(user=admin_user). This is test-only scaffolding, but it means the group/authorization paths exercised in test_template.py never go through BearerTokenAuthentication, leaving the real auth boundary under-tested.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:test-coverage"
|
||||
],
|
||||
"title": "TestUser bypasses real authentication for admin client"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:document_merge_service/settings.py:26",
|
||||
"function_name": null,
|
||||
"line": 26,
|
||||
"path": "document_merge_service/settings.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 15.1654,
|
||||
"rationale": "SECRET_KEY = env.str(\"SECRET_KEY\", default=default(\"uuuuuuuuuu\")). The default() helper returns default_prod when ENV == \"production\", and ENV defaults to \"production\". So if SECRET_KEY is unset in production, the app silently uses the weak literal \"uuuuuuuuuu\" instead of failing. This is a predictable signing key for Django sessions/CSRF.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:secret-management"
|
||||
],
|
||||
"title": "Hardcoded fallback SECRET_KEY in production default"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:document_merge_service/settings.py:28",
|
||||
"function_name": null,
|
||||
"line": 28,
|
||||
"path": "document_merge_service/settings.py",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 15.1654,
|
||||
"rationale": "ALLOWED_HOSTS = env.list(\"ALLOWED_HOSTS\", default=default([\"*\"])) \u2014 with ENV defaulting to production, the production default is [\"*\"], disabling Django host-header validation. Combined with DEBUG defaulting to False in prod, this permits arbitrary Host headers.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:misconfiguration"
|
||||
],
|
||||
"title": "ALLOWED_HOSTS defaults to wildcard in production"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,611 @@
|
||||
{
|
||||
"findings": [
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "js-eval:src/lib/utils.test.ts:36",
|
||||
"function_name": null,
|
||||
"line": 36,
|
||||
"path": "src/lib/utils.test.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 3.8,
|
||||
"rationale": "Static pattern js-eval (CWE-95) matched eval on line 36; manual verification still required.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [
|
||||
{
|
||||
"access_declared": false,
|
||||
"access_evidence": [
|
||||
"auth boundary file tag"
|
||||
],
|
||||
"access_level": "authenticated",
|
||||
"conditions": [],
|
||||
"defines_handler": true,
|
||||
"end_line": null,
|
||||
"function_name": null,
|
||||
"kind": "privileged_surface",
|
||||
"line": null,
|
||||
"name": "auth_boundary",
|
||||
"path": "src/lib/utils.test.ts",
|
||||
"provenance": "entrypoint:privileged_surface:authenticated",
|
||||
"rationale": "privileged_surface surface classified as authenticated at identity_boundary",
|
||||
"registered_by": "",
|
||||
"trust_boundary": "identity_boundary"
|
||||
},
|
||||
{
|
||||
"access_declared": false,
|
||||
"access_evidence": [
|
||||
"syscall/filesystem file tag"
|
||||
],
|
||||
"access_level": "local-only",
|
||||
"conditions": [],
|
||||
"defines_handler": true,
|
||||
"end_line": null,
|
||||
"function_name": null,
|
||||
"kind": "privileged_surface",
|
||||
"line": null,
|
||||
"name": "local_privileged_boundary",
|
||||
"path": "src/lib/utils.test.ts",
|
||||
"provenance": "entrypoint:privileged_surface:local-only",
|
||||
"rationale": "privileged_surface surface classified as local-only at local_process",
|
||||
"registered_by": "",
|
||||
"trust_boundary": "local_process"
|
||||
},
|
||||
{
|
||||
"access_declared": false,
|
||||
"access_evidence": [
|
||||
"network entry file tag"
|
||||
],
|
||||
"access_level": "public",
|
||||
"conditions": [],
|
||||
"defines_handler": true,
|
||||
"end_line": null,
|
||||
"function_name": null,
|
||||
"kind": "route",
|
||||
"line": null,
|
||||
"name": "network_surface",
|
||||
"path": "src/lib/utils.test.ts",
|
||||
"provenance": "entrypoint:route:public",
|
||||
"rationale": "route surface classified as public at network_request",
|
||||
"registered_by": "",
|
||||
"trust_boundary": "network_request"
|
||||
}
|
||||
],
|
||||
"reachability_status": "inferred-file-surface",
|
||||
"rung": "suspicion",
|
||||
"severity": "critical",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"auth_boundary",
|
||||
"crypto",
|
||||
"filesystem_entry",
|
||||
"network_entry",
|
||||
"syscall_entry"
|
||||
],
|
||||
"title": "Dynamic JavaScript evaluation needs review"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "js-weak-hash:src/lib/utils.ts:705",
|
||||
"function_name": null,
|
||||
"line": 705,
|
||||
"path": "src/lib/utils.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 1.5,
|
||||
"rationale": "Static pattern js-weak-hash (CWE-327) matched md5 on line 705; manual verification still required.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"auth_boundary",
|
||||
"crypto",
|
||||
"deserialization",
|
||||
"filesystem_entry",
|
||||
"network_entry",
|
||||
"parser"
|
||||
],
|
||||
"title": "JavaScript weak hash needs review"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:src/lib/coordination.ts:63:outbound.request",
|
||||
"function_name": null,
|
||||
"line": 63,
|
||||
"path": "src/lib/coordination.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink outbound.request reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: outbound.request"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:src/lib/coordination.ts:96:outbound.request",
|
||||
"function_name": null,
|
||||
"line": 96,
|
||||
"path": "src/lib/coordination.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink outbound.request reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: outbound.request"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:src/lib/mcp-auth-config.ts:122:path.open",
|
||||
"function_name": null,
|
||||
"line": 122,
|
||||
"path": "src/lib/mcp-auth-config.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink path.open reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: path.open"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:src/lib/mcp-auth-config.ts:143:path.open",
|
||||
"function_name": null,
|
||||
"line": 143,
|
||||
"path": "src/lib/mcp-auth-config.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink path.open reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: path.open"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:src/lib/mcp-auth-config.ts:185:path.open",
|
||||
"function_name": null,
|
||||
"line": 185,
|
||||
"path": "src/lib/mcp-auth-config.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink path.open reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: path.open"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:src/lib/utils.ts:202:outbound.request",
|
||||
"function_name": null,
|
||||
"line": 202,
|
||||
"path": "src/lib/utils.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink outbound.request reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: outbound.request"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:src/lib/utils.ts:588:path.open",
|
||||
"function_name": null,
|
||||
"line": 588,
|
||||
"path": "src/lib/utils.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink path.open reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: path.open"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"finding_id": "overlay-coverage:src/lib/utils.ts:604:path.open",
|
||||
"function_name": null,
|
||||
"line": 604,
|
||||
"path": "src/lib/utils.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "uninventoried sink path.open reached from parameter",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"overlay-originated"
|
||||
],
|
||||
"title": "Overlay coverage: path.open"
|
||||
},
|
||||
{
|
||||
"confidence": "medium",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "model:access_control:src/lib/mcp-auth-config.ts:122:deleteConfigFile",
|
||||
"function_name": "deleteConfigFile",
|
||||
"line": 122,
|
||||
"path": "src/lib/mcp-auth-config.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 0.0,
|
||||
"rationale": "deleteConfigFile line 122: obligated operation with no discharging guard, and no guarded sibling",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "model_corroborated",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"family:access_control",
|
||||
"rung:model_corroborated"
|
||||
],
|
||||
"title": "access_control (model_corroborated)"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/lib/coordination.ts:227",
|
||||
"function_name": null,
|
||||
"line": 227,
|
||||
"path": "src/lib/coordination.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 14.4621,
|
||||
"rationale": "coordinateAuth calls setupOAuthCallbackServerWithLongPoll({...}) using callbackPort and serverUrlHash derived from CLI input. This wires the user-controlled port into the local HTTP listener that receives the OAuth code, connecting the CLI entry point to the callback trust boundary.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:oauth-callback-csrf"
|
||||
],
|
||||
"title": "Auth coordinator starts callback server with CLI-provided port/host"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/lib/coordination.ts:63",
|
||||
"function_name": null,
|
||||
"line": 63,
|
||||
"path": "src/lib/coordination.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 14.4621,
|
||||
"rationale": "isLockValid reads lockData.port from the on-disk lockfile (written by createLockfile) and interpolates it into fetch(`http://127.0.0.1:${lockData.port}/wait-for-auth?poll=false`). The lockfile is read from the config dir via readJsonFile with only type checks (pid/port/timestamp are numbers) in mcp-auth-config.ts checkLockfile. A tampered lockfile could redirect the probe to an arbitrary local port; impact is limited to localhost but the value is attacker-influenced if the config dir is writable.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:untrusted-config-input"
|
||||
],
|
||||
"title": "Lockfile port used to build localhost fetch URL without validation"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/lib/coordination.ts:91",
|
||||
"function_name": null,
|
||||
"line": 91,
|
||||
"path": "src/lib/coordination.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 14.4621,
|
||||
"rationale": "waitForAuthentication(port) builds `http://127.0.0.1:${port}/wait-for-auth` and loops `while (true)` with no maximum attempt count, retrying every 1-2s. Combined with the lockfile-derived port, this is an unbounded polling loop that never terminates on persistent 202 responses, a potential hang/DoS in the auth coordination path.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:unbounded-loop"
|
||||
],
|
||||
"title": "waitForAuthentication polls attacker-influenced port in unbounded loop"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/lib/mcp-auth-config.ts:130",
|
||||
"function_name": null,
|
||||
"line": 130,
|
||||
"path": "src/lib/mcp-auth-config.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 19.8233,
|
||||
"rationale": "readJsonFile does JSON.parse(content) then schema.parseAsync(...). The schema is passed as `any` and callers (e.g. checkLockfile) supply ad-hoc parseAsync validators. Deserialization of on-disk config (tokens.json, client_info.json, lock.json) is a trust boundary; malformed or tampered files are parsed before validation, and errors are swallowed returning undefined.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:deserialization"
|
||||
],
|
||||
"title": "readJsonFile parses JSON from disk with caller-supplied schema"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/lib/mcp-auth-config.ts:96",
|
||||
"function_name": null,
|
||||
"line": 96,
|
||||
"path": "src/lib/mcp-auth-config.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 19.8233,
|
||||
"rationale": "getConfigFilePath joins configDir with `${serverUrlHash}_${filename}`. serverUrlHash is produced by getServerUrlHash (utils.ts:704) from the user-supplied server URL. If the hash is not strictly hex/limited charset, path traversal characters could influence the resulting path. The filename argument is internal, but the hash component flows from CLI input.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:path-construction"
|
||||
],
|
||||
"title": "Config file path built from serverUrlHash without sanitization"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/lib/node-oauth-client-provider.ts:175",
|
||||
"function_name": null,
|
||||
"line": 175,
|
||||
"path": "src/lib/node-oauth-client-provider.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 14.3531,
|
||||
"rationale": "redirectToAuthorization calls open(sanitizeUrl(authorizationUrl.toString())). sanitizeUrl (utils.ts:708) is the guard against dangerous schemes (javascript:, file:, data:) and shell metacharacters, as exercised by utils.test.ts. This is the primary sink where an attacker-controlled authorization URL could reach the OS opener; correctness depends entirely on sanitizeUrl's allowlist.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:url-scheme-injection"
|
||||
],
|
||||
"title": "Authorization URL passed to open() after sanitizeUrl"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/lib/utils.ts:364",
|
||||
"function_name": null,
|
||||
"line": 364,
|
||||
"path": "src/lib/utils.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 22.9898,
|
||||
"rationale": "setupOAuthCallbackServerWithLongPoll creates an express app serving /wait-for-auth and options.path, storing the received code in a module-level variable and resolving waitForAuthCode. The callback server is the network entry point for the OAuth redirect; its host/port come from CLI options (host, callbackPort) and it lacks visible state/origin validation on the code endpoint.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:oauth-callback-csrf"
|
||||
],
|
||||
"title": "OAuth callback server binds and serves auth code over HTTP"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/lib/utils.ts:375",
|
||||
"function_name": null,
|
||||
"line": 375,
|
||||
"path": "src/lib/utils.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 22.9898,
|
||||
"rationale": "app.get('/wait-for-auth', ...) responds based on the module-level authCode variable and req.query.poll. Any local process (or browser page via localhost) can poll this endpoint to learn whether authentication completed and trigger the long-poll resolution path. There is no origin/host check or token on the endpoint.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:local-endpoint-exposure"
|
||||
],
|
||||
"title": "Unauthenticated local /wait-for-auth endpoint leaks auth completion state"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/lib/utils.ts:415",
|
||||
"function_name": null,
|
||||
"line": 415,
|
||||
"path": "src/lib/utils.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 22.9898,
|
||||
"rationale": "setupOAuthCallbackServerWithLongPoll registers app.get(options.path, ...) which reads req.query.code and assigns it to authCode (line 422) and resolves the auth promise (line 448). No comparison of req.query.state against the provider's state() value is visible in this handler, so an attacker who can reach the local callback server could inject an authorization code (CSRF on the OAuth callback). The provider does generate state via randomUUID (node-oauth-client-provider.ts:41), but the callback handler here does not appear to verify it.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:oauth-callback-csrf"
|
||||
],
|
||||
"title": "OAuth callback endpoint accepts code without state validation"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/lib/utils.ts:426",
|
||||
"function_name": null,
|
||||
"line": 426,
|
||||
"path": "src/lib/utils.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 22.9898,
|
||||
"rationale": "The callback handler responds with res.send(`...`) (line 426). If any request-derived value (e.g. req.query.code or error params) is interpolated into this HTML response without escaping, it is a reflected-content sink in the local callback page. The exact template body was not fully read, so this is a suspicion to verify.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:reflected-content"
|
||||
],
|
||||
"title": "Callback response rendered via res.send with interpolated content"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/lib/utils.ts:530",
|
||||
"function_name": null,
|
||||
"line": 530,
|
||||
"path": "src/lib/utils.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 22.9898,
|
||||
"rationale": "parseCommandLineArgs(args, usage) is invoked with process.argv.slice(2) in both proxy.ts:144 and client.ts:161, producing serverUrl, callbackPort, headers, host, and static OAuth metadata that flow into the OAuth provider and callback server. This is the entry point for all user-controlled configuration (server URL, host, headers) that later reaches sanitizeUrl, getServerUrlHash, and the callback server bind.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:cli-input-entry"
|
||||
],
|
||||
"title": "parseCommandLineArgs consumes raw process.argv"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/lib/utils.ts:60",
|
||||
"function_name": null,
|
||||
"line": 60,
|
||||
"path": "src/lib/utils.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 22.9898,
|
||||
"rationale": "debugLog builds a log path from global.currentServerUrlHash and appends JSON.stringify(arg) of arbitrary message/params (including remote MCP messages logged in mcpProxy). Remote-controlled message content is serialized into a file under the config dir; combined with the hash-derived filename this is a filesystem write of untrusted data.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:untrusted-log-write"
|
||||
],
|
||||
"title": "debugLog writes attacker-influenced data to disk log file"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/lib/utils.ts:704",
|
||||
"function_name": null,
|
||||
"line": 704,
|
||||
"path": "src/lib/utils.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 22.9898,
|
||||
"rationale": "getServerUrlHash(serverUrl) is called with the CLI-provided server URL (proxy.ts:43, client.ts:44, node-oauth-client-provider.ts:35) and its result is used as a filename prefix in getConfigFilePath and as the debug log filename. If the hash is not a fixed-length hex digest, the user-controlled URL could influence on-disk paths.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:path-construction"
|
||||
],
|
||||
"title": "getServerUrlHash derives filesystem key from user URL"
|
||||
},
|
||||
{
|
||||
"confidence": "low",
|
||||
"evidence_level": "suspicion",
|
||||
"finding_id": "tool-hunter:src/lib/utils.ts:708",
|
||||
"function_name": null,
|
||||
"line": 708,
|
||||
"path": "src/lib/utils.ts",
|
||||
"proposed_snippet": null,
|
||||
"ranking_priority": 22.9898,
|
||||
"rationale": "sanitizeUrl is the security-critical function protecting open() in node-oauth-client-provider.ts. The test file (utils.test.ts) asserts rejection of javascript:, file:, data:, and $(...) host/path payloads, indicating this is the intended defense. Any gap in its protocol/host allowlist directly enables command/URL injection via the browser opener.",
|
||||
"reachability_conditions": [],
|
||||
"reachability_evidence": [],
|
||||
"reachability_status": "unknown",
|
||||
"rung": "suspicion",
|
||||
"severity": "medium",
|
||||
"status": "enabled",
|
||||
"tags": [
|
||||
"tool-hunter",
|
||||
"family:url-scheme-injection"
|
||||
],
|
||||
"title": "sanitizeUrl is the sole gate before open()"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user