{"repo_url":"https://github.com/Graphify-Labs/graphify","overall_status":"completed","scanned_at":"2026-10-14T23:09:43.829Z","scan_summary":{"total_components":1,"worst_case_severity":"CRITICAL","repo_trust_score":{"score":26,"tier":"critical","capability_band":"moderate","score_confidence":"medium","score_interval":{"low":13,"high":39},"factors":["ATR-M0002 HIGH","ATR-M0003 HIGH","ATR-M0004 HIGH","ATR-M0005 CRITICAL","ATR-M0009 HIGH","ATR-C0005 MEDIUM","ATR-C0007 HIGH","ATR-C0008 HIGH","ATR-C0011 HIGH","ATR-C0013 HIGH"]}},"components":[{"artifact_version":"2.0","component":{"component_type":"mcp-server","component_name":"mcp-server","component_path":".","metadata":{"transports":["stdio","streamable_http"],"entry_point":"graphify/serve.py"}},"scan_provenance":{"scanned_at":"2026-10-14T23:09:43.829Z","resolved_ref":"10130e79ed9caf8f60d998073e097531eaa1a395","resolved_ver":"8"},"repo_context":{"repo_url":"https://github.com/Graphify-Labs/graphify","languages":["Python","YAML","Dockerfile","Markdown"],"transports_detected":["stdio","streamable_http"],"deployment_artifacts":["Dockerfile",".github/workflows/ci.yml",".github/workflows/pr-management.yml",".github/workflows/publish.yml",".github/workflows/release-graph.yml",".github/workflows/welcome.yml"],"provenance":{"publisher":"Graphify-Labs","publisher_verified":false,"signed_build":true,"dependency_cve_count":29,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-M0001","rule_version":"1.2.0","rule_name":"Tool Definition Integrity & Versioning (ETDI-style)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"medium","rationale":"Tool manifests are defined statically in Python source (graphify/serve.py list_tools(), lines 2153-2308) as types.Tool(...) literals compiled into the package. There is no runtime fetch, hot-reload, or remote manifest URL for tool definitions, and no signature-based re-approval mechanism exists because none is needed: the tool set ships with the installed package version (pyproject.toml version=0.9.91) and changes only via a new package release (pip/uv upgrade). There is no endpoint that could rug-pull tool definitions after a client has approved them within one install.","vulnerability_details":null,"fix_guidance":"If tool manifests are ever externalized (e.g. a remote plugin registry), add signing (ed25519) and mandatory client re-approval on any change, per ETDI guidance.","why_this_matters":"Prevents rug-pull tool poisoning.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"graphify/serve.py","start_line":2153,"end_line":2175,"snippet":"async def list_tools() -> list[types.Tool]:\n    _tools = [\n        types.Tool(\n            name=\"query_graph\",\n            description=\"Search the knowledge graph using BFS or DFS. Returns relevant nodes and edges as text context.\",\n            inputSchema={\n                \"type\": \"object\",\n                \"properties\": {\n                    \"question\": {\"type\": \"string\", \"description\": \"Natural language question or keyword search\"},\n..."}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0002","rule_version":"1.1.0","rule_name":"Unrestricted Tool Permissions (Overprivileged Tools)","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"medium","rationale":"Each tool accepts a caller-controlled, potentially absolute project_path argument with no operator-approved root allowlist or containment check. graphify/serve.py's call_tool dispatch (lines 2652-2660) forwards project_path to _resolve_graph_path (lines 2120-2128), which appends the fixed graphify-out/graph.json suffix and resolves the result; that path is then read via Path.resolve()/read_text() (lines 45-55) under the server's identity. A fixed filename suffix does not constitute filesystem scoping: a caller can supply any absolute parent directory readable by the server process and retrieve that project's graph contents, enabling cross-project graph disclosure.","vulnerability_details":null,"fix_guidance":"Constrain project_path to an operator-configured allowlist of permitted project roots (e.g. validate the resolved path is a descendant of one of those roots) before resolving and reading the graph file, rather than relying on the graphify-out/graph.json filename suffix alone.","why_this_matters":"Limits blast radius and privilege escalation.","context_note":"[none|relief:none] No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"graphify/serve.py","start_line":2120,"end_line":2128,"snippet":"    def _resolve_graph_path(project_path) -> str:\n        \"\"\"Map an optional project_path to a concrete graph.json path. ``None``\n        keeps the server's default graph (backward-compatible); a project_path\n        resolves to ``<project_path>/<GRAPHIFY_OUT>/graph.json``, honouring the\n        GRAPHIFY_OUT override so worktree/shared-output setups keep working.\"\"\"\n        if not project_path:\n            return _default_graph_path\n        return str(Path(project_path) / _paths.GRAPHIFY_OUT / \"graph.json\")\n"},{"path":"graphify/serve.py","start_line":2652,"end_line":2660,"snippet":"    async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:\n        arguments = dict(arguments or {})\n        project_path = arguments.pop(\"project_path\", None)\n        handler = _handlers.get(name)\n        if not handler:\n            return [types.TextContent(type=\"text\", text=f\"Unknown tool: {name}\")]\n        try:\n            _select_graph(project_path)  # bind G/communities to the target graph\n            return [types.TextContent(type=\"text\", text=handler(arguments))]"},{"path":"graphify/serve.py","start_line":45,"end_line":55,"snippet":"    \"\"\"Load graph.json exactly as the MCP server does, raising on a bad path\n    or file (ValueError, FileNotFoundError, json.JSONDecodeError) so a library\n    caller such as the benchmark can handle it.\"\"\"\n    resolved = Path(graph_path).resolve()\n    if resolved.suffix != \".json\":\n        raise ValueError(f\"Graph path must be a .json file, got: {graph_path!r}\")\n    if not resolved.exists():\n        raise FileNotFoundError(f\"Graph file not found: {resolved}\")\n    check_graph_file_size_cap(resolved)\n    safe = resolved\n    data = json.loads(safe.read_text(encoding=\"utf-8\"))"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0003","rule_version":"1.3.1","rule_name":"CORS / Origin / Host Validation (DNS Rebinding Defense)","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"medium","rationale":"The streamable_http transport (graphify/serve.py:_build_http_app, lines 2797-2876) explicitly DISABLES DNS-rebinding/Origin protection whenever the operator binds a wildcard address: `if host in (\"0.0.0.0\", \"::\", \"\"): security = TransportSecuritySettings(enable_dns_rebinding_protection=False)`. Per the MCP SDK's TransportSecurityMiddleware (mcp/server/transport_security.py), disabling this setting means neither Host nor Origin headers are validated at all. The Dockerfile's documented default run command explicitly binds 0.0.0.0 (`CMD [...'--host','0.0.0.0'...]`), i.e. the most commonly-deployed configuration ships with Origin validation off. The exposed tools have host-side effects (file reads of project_path-controlled graph.json paths, subprocess exec of gh/git CLIs in list_prs/get_pr_impact/triage_prs), satisfying the impact condition for VULNERABLE, and the api_key is optional even in this configuration.","vulnerability_details":"When host is 0.0.0.0, the code explicitly sets enable_dns_rebinding_protection=False, which the MCP SDK's TransportSecurityMiddleware uses to skip Host AND Origin header validation entirely. A victim browser rebound to the server can issue requests that pass straight through, and if no --api-key is configured (optional, not mandatory even on 0.0.0.0), every tool is reachable.","fix_guidance":"Do not unconditionally disable DNS-rebinding protection when binding to a wildcard address. Require allowed_origins to be configured whenever host is not loopback, and fail closed (or mandate --api-key) when binding 0.0.0.0 without an explicit allowlist.","why_this_matters":"Prevents DNS rebinding/browser hijack of local or remote MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-M0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The scan reported mitigation_present but the independent adjudication did not corroborate it; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"graphify/serve.py","start_line":2839,"end_line":2848,"snippet":"# DNS-rebinding protection. When the operator binds a wildcard address they\n# are intentionally exposing the server, so accept any Host header; for a\n# loopback/specific bind, restrict Host to that address (with and without\n# the port) plus the localhost aliases.\nif host in (\"0.0.0.0\", \"::\", \"\"):\n    security = TransportSecuritySettings(enable_dns_rebinding_protection=False)\nelse:\n    allowed = {host, \"localhost\", \"127.0.0.1\"}\n    allowed |= {f\"{h}:{port}\" for h in list(allowed)}\n    security = TransportSecuritySettings(allowed_hosts=sorted(allowed))"},{"path":"Dockerfile","start_line":22,"end_line":25,"snippet":"EXPOSE 8080\n\nENTRYPOINT [\"python\", \"-m\", \"graphify.serve\"]\nCMD [\"/data/graph.json\", \"--transport\", \"http\", \"--host\", \"0.0.0.0\", \"--port\", \"8080\"]"},{"path":"graphify/serve.py","start_line":2495,"end_line":2497,"snippet":"def _tool_get_pr_impact(arguments: dict) -> str:\n    from graphify.prs import fetch_pr_files, compute_pr_impact, _gh, _parse_ci\n    number = int(arguments[\"pr_number\"])\n    repo = arguments.get(\"repo\") or None\n    view_args = [\"pr\", \"view\", str(number), \"--json\", ...]\n    if repo:\n        view_args += [\"--repo\", repo]\n    pr_data = _gh(*view_args)"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0004","rule_version":"1.2.0","rule_name":"Binding to 0.0.0.0 Without Auth/TLS","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"medium","rationale":"serve_http() allows --host 0.0.0.0 with api_key entirely optional. The Dockerfile's own ENTRYPOINT/CMD binds 0.0.0.0 with NO --api-key argument supplied, and the code path only prints a stderr warning in that case rather than refusing to start or defaulting to a safer posture. There is also no TLS anywhere in this code path (plain uvicorn.run with no ssl_keyfile/ssl_certfile) -- TLS is left entirely to an external reverse proxy that is never configured by anything in-repo.","vulnerability_details":"uvicorn.run(app, host=host, port=port) at graphify/serve.py:2934 has no TLS parameters. _ApiKeyMiddleware (lines 2756-2794) is only attached to the Starlette app 'if api_key:' (line 2869) -- i.e. entirely absent when no key is configured. The Dockerfile's baked-in default command starts the server with 0.0.0.0 binding, no API key, and no TLS.","fix_guidance":"Require --api-key (or refuse to start) whenever --host is not loopback. Document/require a TLS-terminating reverse proxy for any non-loopback deployment, and fail closed by default rather than warning-and-continuing.","why_this_matters":"LAN/Internet exposure enables remote exploitation.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-M0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The scan reported mitigation_present but the independent adjudication did not corroborate it; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"graphify/serve.py","start_line":2923,"end_line":2934,"snippet":"auth_note = \"api-key required\" if api_key else \"no auth (set --api-key to require one)\"\nprint(\n    f\"graphify MCP server (streamable-http) on http://{host}:{port}{path} - {auth_note}\",\n    file=sys.stderr,\n)\nif host in (\"0.0.0.0\", \"::\", \"\") and not api_key:\n    print(\n        f\"WARNING: binding {host or '0.0.0.0'} with no api-key exposes the graph \"\n        \"unauthenticated on the network. Set --api-key (or GRAPHIFY_API_KEY).\",\n        file=sys.stderr,\n    )\nuvicorn.run(app, host=host, port=port)"},{"path":"Dockerfile","start_line":22,"end_line":25,"snippet":"EXPOSE 8080\n\nENTRYPOINT [\"python\", \"-m\", \"graphify.serve\"]\nCMD [\"/data/graph.json\", \"--transport\", \"http\", \"--host\", \"0.0.0.0\", \"--port\", \"8080\"]"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0005","rule_version":"1.4.0","rule_name":"OAuth 2.1 + PKCE Correctness (MCP Authorization)","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"CRITICAL","confidence":"medium","rationale":"The server exposes a remote transport (streamable_http, graphify/serve.py serve_http/_build_http_app) but implements no OAuth 2.1 authorization flow of any kind -- only an optional static API-key / Bearer-token header check (_ApiKeyMiddleware, lines 2756-2794). There is no authorization-server metadata endpoint, no PKCE, no per-user token issuance. Per the MCP Authorization spec (2025-06-18), a remote MCP server is expected to use OAuth 2.1; static API-key auth with no OAuth layer is a documented gap, not grounds for NOT_APPLICABLE since the component has a genuine remote transport. The API key itself is also optional (serve_http defaults api_key=None), compounding the gap.","vulnerability_details":"No OAuth 2.1 authorization code path exists anywhere in the repository for the HTTP transport. Authn is reduced to a single shared secret compared via hmac.compare_digest in _ApiKeyMiddleware, which is itself optional (only attached 'if api_key:'). There is no token scoping, no per-client/per-user authorization, no resource indicators, and no refresh/rotation.","fix_guidance":"Implement MCP's OAuth 2.1 + PKCE authorization flow for the streamable_http transport, or clearly document the server as intended ONLY for trusted, network-isolated deployments and make the API key mandatory (not optional) whenever the transport is http.","why_this_matters":"Misconfig enables token theft and confused-deputy attacks.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-M0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals CRITICAL.","within_declared_contract":true,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"graphify/serve.py","start_line":2756,"end_line":2770,"snippet":"class _ApiKeyMiddleware:\n    \"\"\"Pure-ASGI API-key gate for the HTTP transport.\n    ...\"\"\"\n    def __init__(self, app, api_key: str) -> None:\n        self.app = app\n        self._expected = api_key.encode(\"utf-8\")\n\n    async def __call__(self, scope, receive, send) -> None:\n        if scope[\"type\"] != \"http\":\n            await self.app(scope, receive, send)\n            return\n        import hmac\n        headers = dict(scope.get(\"headers\") or [])\n        provided = headers.get(b\"x-api-key\")"},{"path":"graphify/serve.py","start_line":2896,"end_line":2900,"snippet":"``api_key`` (or the ``GRAPHIFY_API_KEY`` env var) enables a simple header\ncheck (``Authorization: Bearer <key>`` or ``X-API-Key: <key>``). OAuth is a\ndeliberate follow-up. Binding ``0.0.0.0`` exposes the server beyond\nlocalhost — set an api_key when you do."}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0006","rule_version":"1.2.0","rule_name":"Tool Name Collision / Spoofing","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The 10 registered tools (query_graph, get_node, get_neighbors, get_community, god_nodes, graph_stats, shortest_path, list_prs, get_pr_impact, triage_prs) use distinctive, domain-specific names that do not collide with well-known MCP server tool names (e.g. filesystem server's read_file/write_file, popular fetch/browser tool names). No namespace prefix is used, but none of the names are generic enough to plausibly homograph-collide with a widely-deployed server's tools.","vulnerability_details":null,"fix_guidance":"Consider a 'graphify_' prefix for defense-in-depth against future collisions as the MCP ecosystem grows.","why_this_matters":"Prevents malicious tool shadowing.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"graphify/serve.py","start_line":2153,"end_line":2289,"snippet":"    async def list_tools() -> list[types.Tool]:\n        _tools = [\n        types.Tool(name=\"query_graph\", ...),\n        types.Tool(name=\"get_node\", ...),\n        types.Tool(name=\"get_neighbors\", ...),\n        types.Tool(name=\"get_community\", ...),\n        types.Tool(name=\"god_nodes\", ...),\n        types.Tool(name=\"graph_stats\", ...),\n        types.Tool(name=\"shortest_path\", ...),\n        types.Tool(name=\"list_prs\", ...),\n        types.Tool(name=\"get_pr_impact\", ...),\n        types.Tool(name=\"triage_prs\", ...),\n                        \"token_budget\": {\"type\": \"integer\", \"default\": 2000, \"description\": \"Max output tokens\"},"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0007","rule_version":"1.2.0","rule_name":"Prompt/Metadata Injection in Tool Descriptions","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"All 10 tool descriptions in list_tools() (graphify/serve.py:2153-2289) are plain, neutral functional descriptions with no embedded directives, no zero-width/bidi control characters, and no instructions to exfiltrate data. Checked for Unicode control characters across graphify/serve.py and graphify/skill.md via direct scan: none found. Separately, tool OUTPUT (node labels/attributes sourced from a user-supplied corpus) is passed through sanitize_label()/sanitize_metadata() (graphify/security.py:390-461) which strips control characters before being echoed back to the agent, mitigating output-side injection via corpus content -- though this is more ATR-C territory than this tool-description-specific rule.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents covert model manipulation.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"graphify/serve.py","start_line":2155,"end_line":2157,"snippet":"types.Tool(\n    name=\"query_graph\",\n    description=\"Search the knowledge graph using BFS or DFS. Returns relevant nodes and edges as text context.\","},{"path":"graphify/security.py","start_line":394,"end_line":405,"snippet":"def sanitize_label(text: str | None) -> str:\n    \"\"\"Strip control characters and cap length.\n\n    Safe for embedding in JSON data (inside <script> tags) and plain text.\n    For direct HTML injection, wrap the result with html.escape().\n    \"\"\"\n    if text is None:\n        return \"\"\n    text = _CONTROL_CHAR_RE.sub(\"\", str(text))\n    if len(text) > _MAX_LABEL_LEN:\n        text = text[:_MAX_LABEL_LEN]\n    return text"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0008","rule_version":"1.2.0","rule_name":"CORS Misuse: Wildcard with Credentials","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"medium","rationale":"No CORS middleware is configured anywhere in the codebase (grepped for CORSMiddleware/Access-Control/allow_origin/cors across graphify/ -- zero hits). The Starlette app (graphify/serve.py:_build_http_app, lines 2797-2876) adds only the optional _ApiKeyMiddleware; no Access-Control-Allow-Origin header is ever emitted by this server, let alone a wildcard combined with credentials=true. The related DNS-rebinding/Origin-validation gap is a separate issue already captured under ATR-M0003.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents credentialed cross-origin abuse.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"graphify/serve.py","start_line":2868,"end_line":2876,"snippet":"middleware = []\nif api_key:\n    middleware.append(Middleware(_ApiKeyMiddleware, api_key=api_key))\n\nreturn Starlette(\n    routes=[Route(path, endpoint=_MCPASGIApp(manager))],\n    middleware=middleware,\n    lifespan=lifespan,\n)"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0009","rule_version":"1.3.0","rule_name":"Transport Security: TLS Everywhere (Remote)","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"medium","rationale":"serve_http() (graphify/serve.py:2879-2934) calls uvicorn.run(app, host=host, port=port) with no TLS configuration, and no code path in the repository sets up HTTPS, HSTS, or certificate handling for the Streamable HTTP transport. The Dockerfile's own EXPOSE 8080 and default CMD (--transport http --host 0.0.0.0 --port 8080) is the attacker trigger: any network client that can reach the published port 8080 connects over plaintext HTTP and can submit MCP tool-call requests (and, when an api_key is configured, the key itself transits in cleartext headers, defeating the one auth control present).","vulnerability_details":"uvicorn.run(app, host=host, port=port) at graphify/serve.py:2934 is the only server-start call for the HTTP transport and passes no TLS parameters. The X-API-Key/Authorization Bearer token (graphify/serve.py:2756-2794) is transmitted in plaintext over this connection unless an operator independently fronts it with a TLS-terminating proxy -- nothing in the repo enforces or even checks for that. The Dockerfile EXPOSE 8080 plus its default CMD binding 0.0.0.0:8080 is exactly the trigger: `docker run -p 8080:8080 graphify ...` makes this plaintext endpoint reachable from any client that can route to the published port.","fix_guidance":"Either support native TLS termination (ssl_keyfile/ssl_certfile passed to uvicorn.run) or document/enforce that the HTTP transport must always run behind a TLS-terminating reverse proxy, and refuse to start (or warn loudly, as already done for the no-api-key case) when serving plaintext to a non-loopback bind.","why_this_matters":"MitM can steal tokens and tamper responses.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-M0009 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"graphify/serve.py","start_line":2901,"end_line":2934,"snippet":"graph_path = graph_path or _default_graph_json()\ntry:\n    import uvicorn\nexcept ImportError as e:\n    raise ImportError(\n        'HTTP transport needs the mcp extra (mcp + starlette + uvicorn). '\n        'Run: pip install \"graphifyy[mcp]\"'\n    ) from e\n\napi_key = (api_key or \"\").strip() or None\n\napp = _build_http_app(\n    graph_path,\n    host=host,\n    port=port,\n    api_key=api_key,\n    path=path,\n    json_response=json_response,\n    stateless=stateless,\n    session_timeout=session_timeout,\n)\n...\nuvicorn.run(app, host=host, port=port)"},{"path":"Dockerfile","start_line":1,"end_line":25,"snippet":"# graphify MCP server as a shared HTTP service (issue #1143).\n#\n# Build:  docker build -t graphify .\n# Run:    docker run -p 8080:8080 -v \"$(pwd)/graphify-out:/data\" graphify \\\n#             /data/graph.json --transport http --host 0.0.0.0 --api-key \"$SECRET\"\n...\nEXPOSE 8080\n\nENTRYPOINT [\"python\", \"-m\", \"graphify.serve\"]\nCMD [\"/data/graph.json\", \"--transport\", \"http\", \"--host\", \"0.0.0.0\", \"--port\", \"8080\"]"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0010","rule_version":"1.4.0","rule_name":"No Authentication or Authorization Checks (General)","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"On the streamable_http transport, authentication is entirely OPTIONAL: _build_http_app only attaches _ApiKeyMiddleware 'if api_key:' (graphify/serve.py:2869-2870), and serve_http()'s api_key parameter defaults to None / the GRAPHIFY_API_KEY env var, which is unset by default. The Dockerfile's own baked-in CMD does not pass --api-key, so the documented default container deployment runs with host=0.0.0.0 (beyond loopback) and NO authentication gate at all -- satisfying this rule's first positive indicator directly ('No gate + beyond-loopback bind = VULNERABLE regardless of what the tools do'). Even when loopback-bound, several tools have host-side effects (gh/git subprocess execution, filesystem reads of project_path-controlled paths), so the second positive indicator (loopback + host-side effect tool with no gate) would also apply absent an api_key.","vulnerability_details":"_build_http_app (graphify/serve.py:2797-2876) builds middleware=[] and only appends _ApiKeyMiddleware when api_key is truthy (line 2869-2870). serve_http()'s default api_key is None (line 2884 signature default, and line 2910 `api_key = (api_key or \"\").strip() or None`). _main() (line 2966-2970) reads --api-key from CLI/`GRAPHIFY_API_KEY` env var but neither is required; the Dockerfile's ENTRYPOINT/CMD never supplies either. Without an api_key, call_tool/call_tool dispatch (lines 2652-2667) is reachable by anyone who can send an HTTP request to the bound host:port, invoking any of the 10 registered tools including gh/git subprocess-executing ones.","fix_guidance":"Make authentication mandatory whenever the transport is HTTP: refuse to start serve_http (raise/exit) if api_key is empty and host is not loopback, rather than only printing a warning. Prefer requiring auth unconditionally on the HTTP transport regardless of bind address.","why_this_matters":"Prevents unauthorized tool use and data access.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-M0010 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":false,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"graphify/serve.py","start_line":2868,"end_line":2876,"snippet":"middleware = []\nif api_key:\n    middleware.append(Middleware(_ApiKeyMiddleware, api_key=api_key))\n\nreturn Starlette(\n    routes=[Route(path, endpoint=_MCPASGIApp(manager))],\n    middleware=middleware,\n    lifespan=lifespan,\n)"},{"path":"Dockerfile","start_line":22,"end_line":25,"snippet":"EXPOSE 8080\n\nENTRYPOINT [\"python\", \"-m\", \"graphify.serve\"]\nCMD [\"/data/graph.json\", \"--transport\", \"http\", \"--host\", \"0.0.0.0\", \"--port\", \"8080\"]"},{"path":"graphify/prs.py","start_line":141,"end_line":152,"snippet":"def _gh(*args: str) -> list | dict | None:\n    try:\n        result = subprocess.run(\n            [\"gh\", *args],\n            stdin=subprocess.DEVNULL,\n            capture_output=True, text=True, encoding=\"utf-8\", errors=\"replace\", timeout=30\n        )\n        if result.returncode != 0:\n            return None\n        return json.loads(result.stdout)"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0011","rule_version":"1.2.0","rule_name":"OAuth Proxy Per-Client Consent (Confused Deputy Defense)","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"graphify's MCP server is not an OAuth proxy: it does not forward authorization to a distinct upstream Identity Provider on behalf of dynamically-registered MCP clients. There is no client registration endpoint, no upstream client_id/client_secret, and no bridge-code/consent flow anywhere in the codebase (grepped for oauth/client_id/authorize/register.*client across the repo -- the only matches are unrelated comments in extract.py/detect.py about credential-pattern detection, not an actual OAuth implementation). The only authentication primitive is the static shared API key/Bearer token in _ApiKeyMiddleware. This rule's threat model (confused-deputy via a shared upstream OAuth client across untrusted registered MCP clients) does not apply to this architecture.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Shared upstream client + dynamic MCP-client registration + no per-client consent enables attacker-registered MCP clients to receive bridge codes carrying victim upstream tokens — the documented MCP Confused Deputy attack.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"graphify/serve.py","start_line":2756,"end_line":2794,"snippet":"class _ApiKeyMiddleware:\n    \"\"\"Pure-ASGI API-key gate for the HTTP transport.\n    ...\"\"\"\n    Implemented as raw ASGI (not Starlette's BaseHTTPMiddleware) on purpose:\n    BaseHTTPMiddleware buffers responses and breaks the Streamable HTTP SSE\n    stream. This short-circuits with 401 before the request ever reaches the\n    session manager, leaving the streaming path untouched for authorized calls.\n    \"\"\""}],"affected_tools":null,"component_name":"mcp-server"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"graphify/security.py implements a dedicated SSRF-guard: validate_url() restricts schemes to http/https, blocks cloud-metadata hostnames, resolves DNS and rejects private/reserved/loopback/link-local/CGN ranges. safe_fetch()/safe_fetch_text() route every connection through custom _SSRFGuardedHTTPConnection/_SSRFGuardedHTTPSConnection classes that resolve DNS exactly once and connect to the validated IP (preventing DNS-rebind TOCTOU), enforce a redirect re-validation handler (_NoFileRedirectHandler) to stop open-redirect SSRF, and cap response size. All URL-fetching call sites (graphify/ingest.py's `/graphify add <url>` command and oEmbed lookups, graphify/transcribe.py's video/audio URL handling) route through safe_fetch/safe_fetch_text/validate_url -- none of them call a raw requests/urllib fetch directly.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"graphify/security.py","start_line":103,"end_line":143,"snippet":"def validate_url(url: str) -> str:\n    parsed = urllib.parse.urlparse(url)\n    if parsed.scheme.lower() not in _ALLOWED_SCHEMES:\n        raise ValueError(...)\n    hostname = parsed.hostname\n    if hostname:\n        if hostname.lower() in _BLOCKED_HOSTS:\n            raise ValueError(...)\n        try:\n            infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)\n            for info in infos:\n                addr = info[4][0]\n                ip = ipaddress.ip_address(addr)\n                if _ip_is_blocked(ip):\n                    raise ValueError(...)"},{"path":"graphify/security.py","start_line":180,"end_line":251,"snippet":"class _SSRFGuardedHTTPConnection(http.client.HTTPConnection):\n    def connect(self) -> None:\n        family, ip = _resolve_and_validate(self.host, self.port)\n        self.sock = socket.create_connection((ip, self.port), self.timeout, self.source_address)\n...\nclass _NoFileRedirectHandler(urllib.request.HTTPRedirectHandler):\n    def redirect_request(self, req, fp, code, msg, headers, newurl):\n        validate_url(newurl)\n        return super().redirect_request(req, fp, code, msg, headers, newurl)"},{"path":"graphify/ingest.py","start_line":240,"end_line":254,"snippet":"    out_path.write_bytes(safe_fetch(url))\n...\n        validate_url(url)"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Gitleaks (secret-history scanner) returned 0 findings. Manual review of API-key handling shows keys are read exclusively from os.environ at runtime with no hardcoded fallback value. The Zeus semgrep pack's 3 ATR-C0002 'credential-to-log-sink' hits were read and are false positives: (1) graphify/extract.py:2382 -- `kind, num = re.match(...).groups()` is a regex-parsing line that merely contains the word 'token' as a parameter name from an enclosing function signature (`_add_doc_ref(token: str, line: int)`); there is no print/log call on this line or sinking that value anywhere near it -- it's parsing a doc-reference string like 'ADR-0011', not a credential. (2) graphify/extractors/razor.py:94 -- `prefix, _, ref_token = [REDACTED]('.')` splits a C#/Razor *type name* (e.g. a class reference) into namespace/type parts for building an AST graph node id; 'ref_token' here denotes a type-reference token, not a secret, and is never printed/logged. (3) graphify/serve.py:2870 -- `middleware.append(Middleware(_ApiKeyMiddleware, api_key=api_key))` registers ASGI authentication middleware (wires the key into a Starlette Middleware class for per-request validation); this is not a print/logging sink -- the key is passed to a middleware constructor, never written to stdout/logs. In all three cases the scanner's generic 'credential-typed variable name' heuristic matched on identifier naming, not on an actual logging/print call.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"graphify/llm.py","start_line":1344,"end_line":1350,"snippet":"def _get_backend_api_key(backend: str) -> str:\n    \"\"\"Return the first configured API key for backend, or an empty string.\"\"\"\n    for env_key in _backend_env_keys(backend):\n        value = os.environ.get(env_key)\n        if value:\n            return value\n    return \"\""},{"path":"graphify/extract.py","start_line":2378,"end_line":2384,"snippet":"    def _add_doc_ref(token: str, line: int) -> None:\n        # Normalize \"adr 11\" / \"ADR-0011\" spellings to a canonical \"ADR-0011\"\n        # style label so references to the same document collapse to one node.\n        kind, num = re.match(r\"([A-Za-z]+)[- ]?(\\d+)\", token).groups()\n        kind = kind.upper()\n        label = f\"{kind}-{num.zfill(4)}\" if kind == \"ADR\" else f\"{kind}-{num}\"\n        if label in seen_doc_refs:\n            return"},{"path":"graphify/extractors/razor.py","start_line":88,"end_line":99,"snippet":"            if qualified:\n                prefix, _, ref_token = [REDACTED](\".\")\n            else:\n                prefix = \"\"\n                ref_token = base_type\n            if ref_token:\n                tgt_nid = _make_id(ref_token)\n                if tgt_nid and tgt_nid not in seen_ids:\n                    seen_ids.add(tgt_nid)\n                    nodes.append({\n                        \"id\": tgt_nid,\n                        \"label\": ref_token,\n                        \"file_type\": \"code\",\n                        \"source_file\": \"\",\n                        \"source_location\": \"\",\n                        \"origin_file\": str_path,"},{"path":"graphify/serve.py","start_line":2867,"end_line":2871,"snippet":"    middleware = []\n    if api_key:\n        middleware.append(Middleware(_ApiKeyMiddleware, api_key=api_key))\n\n    return Starlette("},{"path":"graphify/extract.py","start_line":2382,"end_line":2382,"snippet":null,"vulnerability":{"severity":"WARNING"}},{"path":"graphify/extractors/razor.py","start_line":94,"end_line":94,"snippet":null,"vulnerability":{"severity":"WARNING"}},{"path":"graphify/serve.py","start_line":2870,"end_line":2870,"snippet":null,"vulnerability":{"severity":"WARNING"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Exhaustive grep across the graphify/ package for subprocess invocation, os.system/os.popen, and shell=True found zero occurrences of shell=True anywhere in the shipped runtime. Every subprocess.run/Popen call (graphify/detect.py, graphify/llm.py, graphify/prs.py, graphify/google_workspace.py, graphify/hooks.py, graphify/install.py, graphify/cli.py) passes an argument-vector list (argv), not a shell string. The git-clone/pull helper in cli.py additionally guards against argument/flag injection by validating that a user-supplied branch name cannot start with '-' and by inserting a literal '--' separator before the repository URL. No eval()/exec()/Function()-style dynamic code execution of attacker-controlled text was found in the runtime package.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"graphify/cli.py","start_line":1358,"end_line":1378,"snippet":"    if branch and branch.startswith(\"-\"):\n        print(f\"error: invalid branch name: {branch!r}\", file=sys.stderr)\n        sys.exit(1)\n    ...\n        cmd = [\"git\", \"clone\", \"--depth\", \"1\"]\n        if branch:\n            cmd += [\"--branch\", branch]\n        cmd += [\"--\", git_url, str(dest)]\n        result = _sp.run(cmd, capture_output=True, text=True)"},{"path":"graphify/detect.py","start_line":1253,"end_line":1262,"snippet":"        proc = subprocess.run(\n            ...,\n            stdin=subprocess.DEVNULL,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.DEVNULL,\n        )\n    except (OSError, subprocess.SubprocessError):"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The MCP/HTTP server and LLM-client paths apply multiple DoS protections: (1) graphify/security.py caps HTTP fetch response size at 50MB (safe_fetch) / 10MB (safe_fetch_text) via streaming reads that abort once the cap is exceeded, and caps graph.json size at 512MiB (configurable via GRAPHIFY_MAX_GRAPH_BYTES) before JSON parsing to prevent a memory-bomb; (2) graphify/llm.py enforces a configurable API request timeout (_resolve_api_timeout, default 600s) on every LLM backend client (OpenAI/Azure/Anthropic/boto3) and caps parallel extraction concurrency via max_concurrency/ThreadPoolExecutor(max_workers=...); (3) graphify/serve.py bounds per-process project-context memory via a fixed-size LRU cache (_GraphContextCache/_max_server_contexts) and sets a session_idle_timeout on the StreamableHTTPSessionManager.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"graphify/security.py","start_line":258,"end_line":299,"snippet":"def safe_fetch(url: str, max_bytes: int = _MAX_FETCH_BYTES, timeout: int = 30) -> bytes:\n    ...\n        chunks: list[bytes] = []\n        total = 0\n        while True:\n            chunk = resp.read(65_536)\n            if not chunk:\n                break\n            total += len(chunk)\n            if total > max_bytes:\n                raise OSError(\n                    f\"Response from {url!r} exceeds size limit \"\n                    f\"({max_bytes // 1_048_576} MB). Aborting download.\"\n                )\n            chunks.append(chunk)"},{"path":"graphify/serve.py","start_line":133,"end_line":191,"snippet":"class _GraphContextCache:\n    \"\"\"Thread-safe graph contexts: one pinned default plus an LRU of projects.\"\"\"\n    def __init__(self, max_contexts: int):\n        self._max_contexts = max_contexts\n    ...\n                while len(self._entries) > self._max_contexts:"},{"path":"graphify/llm.py","start_line":2832,"end_line":2856,"snippet":"    workers = max(1, min(max_concurrency, total))\n    ...\n        with ThreadPoolExecutor(max_workers=workers) as pool:"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"medium","rationale":"graphify/serve.py forwards the caller-supplied MCP `question` directly to querylog.log_query (graphify/serve.py:2310-2329); when query logging is enabled via an explicit environment opt-in, the full question text is appended as plaintext JSONL with no redaction (graphify/querylog.py:57-76), and the optional response-logging path stores the complete graph response the same way. The opt-in requirement limits exposure, but once logging is enabled, any secrets or PII present in a question or response are persisted to disk unscrubbed, which does not satisfy this rule's scrubbing requirement for opt-in debug/audit logging.","vulnerability_details":null,"fix_guidance":"Add a redaction/scrubbing step (e.g. masking known credential/token/PII patterns, or hashing/truncating sensitive fields) before writing question and response text in graphify/querylog.py's log_query, rather than persisting raw caller-supplied text.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"graphify/serve.py","start_line":2310,"end_line":2329,"snippet":"    def _tool_query_graph(arguments: dict) -> str:\n        import time as _time\n        from graphify import querylog\n        question = arguments[\"question\"]\n        mode = arguments.get(\"mode\", \"bfs\")\n        depth = min(int(arguments.get(\"depth\", 3)), 6)\n        budget = int(arguments.get(\"token_budget\", 2000))\n        context_filter = arguments.get(\"context_filter\")\n        _t0 = _time.perf_counter()\n        result = _query_graph_text(\n            G,\n            question,\n            mode=mode,\n            depth=depth,\n            token_budget=budget,\n            context_filters=context_filter,\n            graph_path=str(active_graph_path),\n        )\n        querylog.log_query(\n            kind=\"mcp_query\","},{"path":"graphify/querylog.py","start_line":14,"end_line":31,"snippet":"\ndef _log_path() -> Path | None:\n    # Opt-in only (#1797). The log records every query/path/explain question and\n    # corpus path (and full responses if GRAPHIFY_QUERY_LOG_RESPONSES) in a\n    # plaintext file under ~/.cache — outside any repo's .gitignore/retention. A\n    # default-on record of proprietary queries contradicts graphify's on-device,\n    # no-telemetry posture, so it is OFF unless explicitly enabled:\n    #   GRAPHIFY_QUERY_LOG=<path>   log to that path, or\n    #   GRAPHIFY_QUERY_LOG_ENABLE=1 log to ~/.cache/graphify-queries.log.\n    # GRAPHIFY_QUERY_LOG_DISABLE=1 still forces it off (back-compat, wins).\n    if os.environ.get(\"GRAPHIFY_QUERY_LOG_DISABLE\", \"\").lower() in (\"1\", \"true\", \"yes\"):\n        return None\n    override = os.environ.get(\"GRAPHIFY_QUERY_LOG\", \"\").strip()\n    if override:\n        return Path(override).expanduser()\n    if os.environ.get(\"GRAPHIFY_QUERY_LOG_ENABLE\", \"\").lower() in (\"1\", \"true\", \"yes\"):\n        return Path.home() / \".cache\" / \"graphify-queries.log\"\n    return None"},{"path":"graphify/querylog.py","start_line":57,"end_line":76,"snippet":"            return\n        if nodes_returned is None and result is not None:\n            nodes_returned = nodes_from_result(result)\n        rec: dict[str, Any] = {\n            \"ts\": datetime.now(timezone.utc).isoformat(),\n            \"kind\": kind,\n            \"question\": question,\n            \"corpus\": corpus,\n            \"nodes_returned\": nodes_returned,\n        }\n        if result is not None:\n            rec[\"result_chars\"] = len(result)\n        if duration_ms is not None:\n            rec[\"duration_ms\"] = round(duration_ms, 3)\n        for k, v in extra.items():\n            if v is not None:\n                rec[k] = v\n        if result is not None and _log_responses():\n            rec[\"response\"] = result\n        path.parent.mkdir(parents=True, exist_ok=True)"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"LOW","confidence":"high","rationale":"The Dockerfile's missing HEALTHCHECK instruction is operational liveness metadata, not a security-hardening indicator under this rule. However, the documented `docker run` invocation and the repository's container configuration set no CPU/memory resource limits, no read-only root filesystem, and no capability drops. The non-root `USER graphify` directive is a partial mitigation of privilege impact, but it does not close the resource/runtime-hardening gap.","vulnerability_details":null,"fix_guidance":"Add resource limits (e.g. `--memory`, `--cpus` flags or Kubernetes resource requests/limits) to the documented deployment command, and consider adding `--read-only` root filesystem and `--cap-drop=ALL` (adding back only required capabilities) to the container run configuration.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity LOW (from MEDIUM: 40% context relief -- mitigation_present -40%).","within_declared_contract":false,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"Dockerfile","start_line":9,"end_line":23,"snippet":"FROM python:3.12-slim\nWORKDIR /app\nCOPY . /app\nRUN pip install --no-cache-dir \".[mcp]\"\nRUN useradd --create-home --uid 10001 graphify\nUSER graphify\nEXPOSE 8080\nENTRYPOINT [\"python\", \"-m\", \"graphify.serve\"]\nCMD [\"/data/graph.json\", \"--transport\", \"http\", \"--host\", \"0.0.0.0\", \"--port\", \"8080\"]"},{"path":"Dockerfile","start_line":22,"end_line":23,"snippet":"ENTRYPOINT [\"python\", \"-m\", \"graphify.serve\"]\nCMD [\"/data/graph.json\", \"--transport\", \"http\", \"--host\", \"0.0.0.0\", \"--port\", \"8080\"]"},{"path":"Dockerfile","start_line":23,"end_line":23,"snippet":"CMD [\"/data/graph.json\", \"--transport\", \"http\", \"--host\", \"0.0.0.0\", \"--port\", \"8080\"]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.2.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of uv.lock identified 2 CRITICAL and 14 HIGH known-CVE vulnerabilities in exactly-pinned dependencies, most severely pyjwt==2.13.0 (CVE-2026-102268 CRITICAL JWT authentication bypass via improper public-key validation, plus CVE-2026-102266/102267/102271/102272/102273 -- 5 more HIGH pyjwt auth-bypass/token-forgery bugs), anyio==4.13.0 (CVE-2026-63374 CRITICAL TLS cert spoofing), cryptography==49.0.0 (CVE-2026-69247 HIGH Bleichenbacher oracle), fsspec==2026.4.0 (CVE-2026-104851 HIGH RCE via crafted reference documents), pypdf==6.18.1 (3 HIGH DoS CVEs), soupsieve==2.8.3 (2 HIGH DoS CVEs), urllib3==2.7.0 (2 HIGH CVEs). All fixes are already published upstream (2.14.0/4.14.2/50.0.0/2026.6.0/6.19.0/2.8.4/2.8.0 respectively) but the lockfile still pins the vulnerable versions.","vulnerability_details":null,"fix_guidance":"Upgrade pinned versions: pyjwt>=2.14.0, anyio>=4.14.2, cryptography>=50.0.0, fsspec>=2026.6.0, pypdf>=6.19.0, soupsieve>=2.8.4, urllib3>=2.8.0, then re-run `uv lock --upgrade-package <pkg>` for each and re-scan with Trivy to confirm resolution. Consider adding `exclude-newer` dependency-cooldown guidance from the separate uv-missing-dependency-cooldown semgrep finding to reduce future typosquat/compromised-release exposure.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The scan reported mitigation_present but the independent adjudication did not corroborate it; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"uv.lock","start_line":2225,"end_line":2239,"snippet":"name = \"mcp\"\nversion = \"1.29.0\"\nsource = { registry = \"https://pypi.org/simple\" }\ndependencies = [\n    { name = \"anyio\" },\n    { name = \"httpx\" },\n    { name = \"httpx-sse\" },\n    { name = \"jsonschema\" },\n    { name = \"pydantic\" },\n    { name = \"pydantic-settings\" },\n    { name = \"pyjwt\", extra = [\"crypto\"] },\n    { name = \"python-multipart\" },"},{"path":"uv.lock","start_line":3299,"end_line":3306,"snippet":"name = \"pyjwt\"\nversion = \"2.13.0\"\nsource = { registry = \"https://pypi.org/simple\" }\nsdist = { url = \"https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz\", hash = \"sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423\", size = 107515, upload-time = \"2026-05-21T19:54:36.618Z\" }"},{"path":"uv.lock","start_line":3299,"end_line":3300,"snippet":"name = \"pyjwt\"\nversion = \"2.13.0\""},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-63374","pkg_name":"anyio","severity":"CRITICAL","installed_version":"4.13.0","fixed_version":"4.14.2"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-102268","pkg_name":"pyjwt","severity":"CRITICAL","installed_version":"2.13.0","fixed_version":"2.14.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-69247","pkg_name":"cryptography","severity":"HIGH","installed_version":"49.0.0","fixed_version":"50.0.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-104851","pkg_name":"fsspec","severity":"HIGH","installed_version":"2026.4.0","fixed_version":"2026.6.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-102266","pkg_name":"pyjwt","severity":"HIGH","installed_version":"2.13.0","fixed_version":"2.14.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-102267","pkg_name":"pyjwt","severity":"HIGH","installed_version":"2.13.0","fixed_version":"2.14.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-102271","pkg_name":"pyjwt","severity":"HIGH","installed_version":"2.13.0","fixed_version":"2.14.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-102272","pkg_name":"pyjwt","severity":"HIGH","installed_version":"2.13.0","fixed_version":"2.14.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-102273","pkg_name":"pyjwt","severity":"HIGH","installed_version":"2.13.0","fixed_version":"2.14.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-102998","pkg_name":"pypdf","severity":"HIGH","installed_version":"6.18.1","fixed_version":"6.19.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-102999","pkg_name":"pypdf","severity":"HIGH","installed_version":"6.18.1","fixed_version":"6.19.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-103000","pkg_name":"pypdf","severity":"HIGH","installed_version":"6.18.1","fixed_version":"6.19.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-49476","pkg_name":"soupsieve","severity":"HIGH","installed_version":"2.8.3","fixed_version":"2.8.4"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-49477","pkg_name":"soupsieve","severity":"HIGH","installed_version":"2.8.3","fixed_version":"2.8.4"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-97687","pkg_name":"urllib3","severity":"HIGH","installed_version":"2.7.0","fixed_version":"2.8.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-97689","pkg_name":"urllib3","severity":"HIGH","installed_version":"2.7.0","fixed_version":"2.8.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-64847","pkg_name":"anyio","severity":"MEDIUM","installed_version":"4.13.0","fixed_version":"4.14.2"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-101917","pkg_name":"pyjwt","severity":"MEDIUM","installed_version":"2.13.0","fixed_version":"2.14.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-101918","pkg_name":"pyjwt","severity":"MEDIUM","installed_version":"2.13.0","fixed_version":"2.15.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-102265","pkg_name":"pyjwt","severity":"MEDIUM","installed_version":"2.13.0","fixed_version":"2.14.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-102269","pkg_name":"pyjwt","severity":"MEDIUM","installed_version":"2.13.0","fixed_version":"2.14.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-102270","pkg_name":"pyjwt","severity":"MEDIUM","installed_version":"2.13.0","fixed_version":"2.14.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-102274","pkg_name":"pyjwt","severity":"MEDIUM","installed_version":"2.13.0","fixed_version":"2.14.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-102275","pkg_name":"pyjwt","severity":"MEDIUM","installed_version":"2.13.0","fixed_version":"2.15.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-103001","pkg_name":"pyjwt","severity":"MEDIUM","installed_version":"2.13.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-85999","pkg_name":"soupsieve","severity":"MEDIUM","installed_version":"2.8.3","fixed_version":"2.9.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-86000","pkg_name":"soupsieve","severity":"MEDIUM","installed_version":"2.8.3","fixed_version":"2.9.0"}},{"path":"uv.lock","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-97688","pkg_name":"urllib3","severity":"MEDIUM","installed_version":"2.7.0","fixed_version":"2.8.0"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"medium","rationale":"The MCP tool input schemas (query_graph, get_node, etc., graphify/serve.py:2155-2172) declare types but do not enforce strict validation: there are no length or pattern bounds on string fields such as `question`, and unknown/extra properties are not rejected (no additionalProperties:false enforcement). A caller-supplied `question` is forwarded unchanged into the query/tokenization and regex-processing pipeline (graphify/serve.py:2310-2323 through the token-budget cutting logic) with no size cap before that processing begins. The call_tool dispatcher's broad try/except only converts an exception into an error response after the unbounded work has started, so it does not prevent resource exhaustion from an oversized or adversarially crafted question.","vulnerability_details":null,"fix_guidance":"Add explicit length/pattern bounds (e.g. maxLength) to the `question` and other free-text schema properties, reject unknown properties (additionalProperties:false or equivalent validation), and enforce a size cap on `question` before it reaches tokenization/regex processing in call_tool.","why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0008 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"graphify/serve.py","start_line":2155,"end_line":2172,"snippet":"            types.Tool(\n                name=\"query_graph\",\n                description=\"Search the knowledge graph using BFS or DFS. Returns relevant nodes and edges as text context.\",\n                inputSchema={\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"question\": {\"type\": \"string\", \"description\": \"Natural language question or keyword search\"},\n                        \"mode\": {\"type\": \"string\", \"enum\": [\"bfs\", \"dfs\"], \"default\": \"bfs\",\n                                 \"description\": \"bfs=broad context, dfs=trace a specific path\"},\n                        \"depth\": {\"type\": \"integer\", \"default\": 3, \"description\": \"Traversal depth (1-6)\"},\n                        \"token_budget\": {\"type\": \"integer\", \"default\": 2000, \"description\": \"Max output tokens\"},\n                        \"context_filter\": {\n                            \"type\": \"array\",\n                            \"items\": {\"type\": \"string\"},\n                            \"description\": \"Optional explicit edge-context filter, e.g. ['call', 'field']\",\n                        },\n                    },\n                    \"required\": [\"question\"],"},{"path":"graphify/serve.py","start_line":2310,"end_line":2323,"snippet":"    def _tool_query_graph(arguments: dict) -> str:\n        import time as _time\n        from graphify import querylog\n        question = arguments[\"question\"]\n        mode = arguments.get(\"mode\", \"bfs\")\n        depth = min(int(arguments.get(\"depth\", 3)), 6)\n        budget = int(arguments.get(\"token_budget\", 2000))\n        context_filter = arguments.get(\"context_filter\")\n        _t0 = _time.perf_counter()\n        result = _query_graph_text(\n            G,\n            question,\n            mode=mode,\n            depth=depth,"},{"path":"graphify/serve.py","start_line":305,"end_line":324,"snippet":"def _query_terms(question: str) -> list[str]:\n    \"\"\"Split a query into searchable terms, segmenting Chinese text, then drop\n    question/filler words (`_QUERY_STOPWORDS`, English plus common German/\n    Romance-language fillers) so content words drive seeding. Falls back to the\n    unfiltered terms if the query is all stopwords, so a question like \"how does\n    it work\" or \"wie funktioniert das\" still seeds on something.\"\"\"\n    terms: list[str] = []\n    for raw in question.split():\n        if _has_chinese(raw):\n            for seg in _segment_chinese(raw.lower().strip()):\n                seg = seg.strip()\n                if seg and _is_searchable(seg):\n                    terms.append(seg)\n        else:\n            # Strip punctuation without touching Unicode characters (avoid NFKD mangling non-Latin scripts)\n            for tok in re.findall(r\"\\w+\", raw.lower()):\n                if _is_searchable(tok):\n                    terms.append(tok)\n    content = [t for t in terms if t not in _QUERY_STOPWORDS]\n    return content or terms"},{"path":"graphify/serve.py","start_line":2652,"end_line":2667,"snippet":"    async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:\n        arguments = dict(arguments or {})\n        project_path = arguments.pop(\"project_path\", None)\n        handler = _handlers.get(name)\n        if not handler:\n            return [types.TextContent(type=\"text\", text=f\"Unknown tool: {name}\")]\n        try:\n            _select_graph(project_path)  # bind G/communities to the target graph\n            return [types.TextContent(type=\"text\", text=handler(arguments))]\n        except ToolError:\n            # A handler-signalled error: propagate so the result is marked\n            # isError:true (the mcp 1.x decorator wraps a raised exception into\n            # an error result; the 2.x path catches it in _on_call_tool).\n            raise\n        except Exception as exc:\n            return [types.TextContent(type=\"text\", text=f\"Error executing {name}: {exc}\")]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"No use of pickle (no `import pickle` anywhere in graphify/*.py) and the sole YAML load call uses `yaml.safe_load` (graphify/manifest_ingest.py:165), not `yaml.load`/`yaml.unsafe_load`. No custom object-deserializers invoking code on untrusted input were found. All graph.json / manifest loads use `json.loads` which has no code-execution surface in Python's standard json module.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"graphify/manifest_ingest.py","start_line":160,"end_line":166,"snippet":"    text = path.read_text(encoding=\"utf-8\")\n    data = yaml.safe_load(text)"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"The HTML exporter applies sink-specific encoding rather than relying solely on the generic metadata sanitizer: _js_safe escapes '<' characters when graph data is embedded into script-context JSON, and esc() HTML-encodes graph values before they are written into the page via innerHTML. This contextual encoding at the actual rendering sinks (graphify/exporters/html.py) prevents stored node/edge label or metadata content from becoming active markup or breaking out of the script context, independent of the broader sanitize_metadata helper applied earlier in the pipeline.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"graphify/security.py","start_line":417,"end_line":424,"snippet":"def _sanitize_metadata_string(value: object) -> str:\n    \"\"\"Return a control-character-free, HTML-escaped, bounded string.\"\"\"\n    text = _CONTROL_CHAR_RE.sub(\"\", str(value))\n    text = html.escape(text, quote=True)\n    if len(text) > _METADATA_MAX_VALUE_LEN:\n        text = text[:_METADATA_MAX_VALUE_LEN]\n    return text"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"graphify/serve.py does not import or call validate_graph_path for MCP tool graph loads. Every registered tool accepts a caller-supplied project_path argument; call_tool forwards it through _resolve_graph_path, which appends graphify-out/graph.json and resolves the path, and then through _load_ctx/_read_graph to Path.resolve()/read_text() with no base-directory containment check, authorization check, or symlink restriction. A caller can therefore supply an absolute project_path pointing at another project's graphify-out/graph.json and read its contents, including node labels, source paths, and attributes.","vulnerability_details":null,"fix_guidance":"Route the project_path-derived graph path through validate_graph_path (or an equivalent allow-listed base-directory containment check) before Path.resolve()/read_text() in the MCP load path, rejecting any resolved path that escapes an operator-approved set of project roots.","why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":"[none|relief:none] No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"graphify/serve.py","start_line":2290,"end_line":2306,"snippet":"        # Multi-project support: every tool accepts an optional project_path.\n        # Injected here (rather than repeated in 11 literal schemas) so the set\n        # stays in lockstep as tools are added. Omitting it keeps the historical\n        # single-graph behaviour, so this is purely additive for existing callers.\n        for _t in _tools:\n            # The constructor accepts the camelCase alias in both majors, but\n            # attribute access is inputSchema on mcp 1.x and input_schema on 2.x.\n            _schema = getattr(_t, \"inputSchema\", None)\n            if _schema is None:\n                _schema = _t.input_schema\n            _schema.setdefault(\"properties\", {})[\"project_path\"] = {\n                \"type\": \"string\",\n                \"description\": (\n                    \"Absolute path to a project directory containing \"\n                    \"graphify-out/graph.json. Optional — defaults to the graph \"\n                    \"this server was started with.\"\n                ),"},{"path":"graphify/serve.py","start_line":2120,"end_line":2127,"snippet":"    def _resolve_graph_path(project_path) -> str:\n        \"\"\"Map an optional project_path to a concrete graph.json path. ``None``\n        keeps the server's default graph (backward-compatible); a project_path\n        resolves to ``<project_path>/<GRAPHIFY_OUT>/graph.json``, honouring the\n        GRAPHIFY_OUT override so worktree/shared-output setups keep working.\"\"\"\n        if not project_path:\n            return _default_graph_path\n        return str(Path(project_path) / _paths.GRAPHIFY_OUT / \"graph.json\")"},{"path":"graphify/serve.py","start_line":44,"end_line":55,"snippet":"def _read_graph(graph_path: str) -> nx.Graph:\n    \"\"\"Load graph.json exactly as the MCP server does, raising on a bad path\n    or file (ValueError, FileNotFoundError, json.JSONDecodeError) so a library\n    caller such as the benchmark can handle it.\"\"\"\n    resolved = Path(graph_path).resolve()\n    if resolved.suffix != \".json\":\n        raise ValueError(f\"Graph path must be a .json file, got: {graph_path!r}\")\n    if not resolved.exists():\n        raise FileNotFoundError(f\"Graph file not found: {resolved}\")\n    check_graph_file_size_cap(resolved)\n    safe = resolved\n    data = json.loads(safe.read_text(encoding=\"utf-8\"))"},{"path":"graphify/serve.py","start_line":2652,"end_line":2661,"snippet":"    async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:\n        arguments = dict(arguments or {})\n        project_path = arguments.pop(\"project_path\", None)\n        handler = _handlers.get(name)\n        if not handler:\n            return [types.TextContent(type=\"text\", text=f\"Unknown tool: {name}\")]\n        try:\n            _select_graph(project_path)  # bind G/communities to the target graph\n            return [types.TextContent(type=\"text\", text=handler(arguments))]\n        except ToolError:"},{"path":"graphify/serve.py","start_line":2350,"end_line":2360,"snippet":"        attrs_line = []\n        if isinstance(attrs, dict) and attrs:\n            raw_attrs = json.dumps(attrs, sort_keys=True)\n            if len(raw_attrs) > 1000:\n                raw_attrs = raw_attrs[:997] + \"...\"\n            attrs_line = [f\"  Attributes: {_CONTROL_CHAR_RE.sub('', raw_attrs)}\"]\n        # Sanitise every LLM-derived field before concatenation (F-010).\n        return \"\\n\".join([\n            f\"Node: {sanitize_label(d.get('label', nid))}\",\n            f\"  ID: {sanitize_label(nid)}\",\n            f\"  Source: {sanitize_label(str(d.get('source_file', '')))} {sanitize_label(str(d.get('source_location', '')))}\","}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"graphify/llm.py applies hard concurrency caps (max_concurrency, default 4, enforced via ThreadPoolExecutor(max_workers=...)) on every parallel LLM extraction call path, and backend-specific lower caps are applied for providers with strict per-org RPM limits (e.g. Moonshot/kimi forces max_concurrency=1). graphify/__init__ cost.json tracking in the skill.md workflow (Step 9) records cumulative input/output token spend per run and across all runs, surfacing usage to the user every invocation, and the skill explicitly needs no mandatory paid API key (code-only corpora run with zero LLM calls). Token budgets are also enforced for query answers via a --budget flag (token_budget parameter in graphify/serve.py's _cut_lines_to_budget / _subgraph_to_text).","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"graphify/llm.py","start_line":2782,"end_line":2856,"snippet":"        max_concurrency = 1\n    ...\n    workers = max(1, min(max_concurrency, total))\n    ...\n        with ThreadPoolExecutor(max_workers=workers) as pool:"},{"path":"graphify/serve.py","start_line":1112,"end_line":1131,"snippet":"def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_budget: int = 2000, *, seeds: list[str] | None = None) -> str:"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"_wrap_untrusted (graphify/llm.py) neutralises known chat-template/control-token sentinels within file `content` via _neutralise_injection_sentinels, but it interpolates the ingested file's relative path `rel` directly, unescaped, into the opening `<untrusted_source path=\"{rel}\" ...>` tag attribute. A hostile repository can name a source file containing a quote and a `>` character so that attacker-controlled text in the filename is emitted outside the intended opening tag, before the trusted wrapper resumes -- breaking the envelope's containment for that portion of the model-visible text. The sanitizer also does not normalize ANSI/bidi/control characters, so the delimiter is not fully host-enforced against a hostile filename.","vulnerability_details":null,"fix_guidance":"Sanitize or attribute-escape the `rel` value (the same way `content` is processed) before interpolating it into the `<untrusted_source path=\"{rel}\">` tag, and extend _neutralise_injection_sentinels (or an equivalent normalization step) to strip ANSI/bidi/control characters from both `rel` and `content`.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"graphify/llm.py","start_line":606,"end_line":620,"snippet":"def _wrap_untrusted(rel: str, content: str) -> str:\n    \"\"\"Wrap one file's content in a labelled, hash-stamped untrusted-data block.\n\n    The model's system prompt instructs it to treat everything inside\n    <untrusted_source> as inert data, never as instructions. The sha256 lets a\n    reviewer correlate a suspicious node back to the exact bytes that produced it.\n    \"\"\"\n    sha = hashlib.sha256(content.encode(\"utf-8\", errors=\"replace\")).hexdigest()\n    safe = _neutralise_injection_sentinels(content)\n    return (\n        f'<untrusted_source path=\"{rel}\" sha256=\"{sha}\">\\n'\n        f\"{safe}\\n\"\n        f\"</untrusted_source>\"\n    )\n"},{"path":"graphify/llm.py","start_line":645,"end_line":665,"snippet":"            rel = p.relative_to(root).as_posix()\n        except ValueError:\n            rel = Path(p).as_posix()\n        try:\n            if isinstance(u, FileSlice):\n                content = read_slice_text(u)\n            else:\n                content = _file_to_text(safe_path)\n        except OSError:\n            continue\n        # Whole files are still capped (covers non-splittable large files like\n        # code); slices are already bounded to the cap, so the cap is a no-op.\n        if len(content) > _FILE_CHAR_CAP:\n            global _file_truncation_warned\n            if not _file_truncation_warned:\n                _file_truncation_warned = True\n                print(\n                    f\"[graphify] WARNING: file {rel} exceeds {_FILE_CHAR_CAP} characters and was truncated\",\n                    file=sys.stderr,\n                )\n        parts.append(_wrap_untrusted(rel, content[:_FILE_CHAR_CAP]))"},{"path":"graphify/llm.py","start_line":581,"end_line":598,"snippet":"_INJECTION_SENTINELS = re.compile(\n    r\"</?untrusted_source\\b[^>]*>\"\n    # ANY <|token|> chat-template marker, not an enumerated few (#3183): the\n    # old list named six and missed <|start_header_id|>/<|eot_id|> (Llama 3),\n    # <|endofprompt|>, and whatever the next template calls its turns. The\n    # form itself is the hazard - no legitimate source construct needs an\n    # intact one, and defanging only inserts a zero-width space.\n    r\"|<\\|[A-Za-z0-9_.\\-]{1,64}\\|>\"\n    r\"|<<SYS>>|<</SYS>>\"\n    r\"|\\[/?(?:INST|SYSTEM)\\]\"\n    r\"|^\\s*###?\\s*(?:system|instruction)s?\\s*:?\\s*$\",\n    re.IGNORECASE | re.MULTILINE,\n)\n\n\ndef _neutralise_injection_sentinels(text: str) -> str:\n    \"\"\"Defang known chat-template / jailbreak control tokens in untrusted text.\n"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"All 5 GitHub Actions workflows were reviewed. Two use `pull_request_target` (pr-management.yml, welcome.yml) but neither checks out or executes PR code -- they only read `pr.title`/`pr.user.login` as JS string values inside `actions/github-script` and use them exclusively as data passed to the GitHub REST API (addLabels, createComment, etc.), never concatenated into a shell `run:` block. ci.yml's only `${{ }}` usage is `matrix.python-version`, a workflow-internal enumerated value, not attacker-controlled. No workflow interpolates github.event.*, issue/PR body/title, or other attacker-controlled fields directly into a `run:` shell block.","vulnerability_details":null,"fix_guidance":"Continue using github-script's JS-level API calls (not `run:` shell interpolation) for any untrusted event metadata; keep pull_request_target jobs free of checkout/execution of PR code.","why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":".github/workflows/pr-management.yml","start_line":10,"end_line":45,"snippet":"# pull_request_target is used so the token can label/comment on PRs from forks;\n# neither job checks out or executes PR code, so this is safe.\n\non:\n  pull_request_target:\n...\n\npermissions:\n  pull-requests: write\n            ...\n            await github.rest.issues.addLabels({...});"},{"path":".github/workflows/welcome.yml","start_line":23,"end_line":56,"snippet":"      - uses: actions/github-script@v7\n        with:\n          script: |\n            const isPR = context.eventName === 'pull_request_target';\n            const payload = isPR ? context.payload.pull_request : context.payload.issue;\n            const author = payload.user.login;\n            ...\n            await github.rest.issues.createComment({...body});"},{"path":".github/workflows/ci.yml","start_line":60,"end_line":70,"snippet":"      - uses: actions/setup-python@...\n        with:\n          python-version: ${{ matrix.python-version }}"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"graphify is a legitimate developer knowledge-graph tool (parses code/docs into a graph, with HTML/JSON/Obsidian outputs). I read the full graphify/skill.md (742 lines) that the YARA agentic_metadata pack flagged, plus the cited tests/test_skillgen.py region, and found no offensive/malicious capability and no genuine consent-suppression or concealed-secondary-objective directive. The two agentic_metadata YARA hits (Zeus_Metadata_Consent_Suppression at cluster L125-165 keyed on $privileged/$hide/$approval, and Zeus_Metadata_Tool_Poisoning_Secondary_Objective at cluster L299-304 keyed on $conceal/$secondary/$sensitive) do not correspond to any literal occurrence of 'privileged', 'approval', 'conceal', or 'secondary' anywhere in the file (verified via exhaustive case-insensitive substring search across all 742 lines: zero hits for each of those four tokens); the sole 'hide' hit is 'Never hide cohesion scores behind symbols - show the raw number' (an honesty/transparency instruction, the opposite of concealment) and the sole 'sensitive' hit is about surfacing (not hiding) files the detector skipped as sensitive so a user can rename/move them. The actual text in the flagged line ranges (Steps 2-3 of the pipeline) instructs the agent to: (a) skip a redundant 'narrow the directory' prompt when there are no subdirectories to narrow among, (b) not block/wait on an OPTIONAL Gemini API key for semantic extraction of docs/images (falling back to the host agent itself as the LLM when unset) -- a UX/availability design choice, not suppression of a security approval gate, and (c) warn loudly (not silently) when a subagent appears to have been dispatched read-only and dropped its output. None of this suppresses user-facing consent/approval for a dangerous action, hides a privileged operation, or adds an undisclosed secondary capability to the tool. The third YARA hit (tests/test_skillgen.py, Zeus_Agent_Remote_Bootstrap_Execution) is a unit test, `test_powershell_translator_rejects_unknown_bash`, that asserts the project's own bash-to-PowerShell translator RAISES a ValueError when given `curl -s https://example.com | sh` -- i.e. it is a negative test proving the translator REJECTS that dangerous pattern rather than shipping it; the string appears only as a pytest.raises() input, never executed, and never in shipped runtime code. No operational attack tooling, no 'generate <malware/exploit/credential-harvester>' directive, and no instruction to evade oversight or attribution was found anywhere in the repository.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"graphify/skill.md","start_line":34,"end_line":153,"snippet":"Then act on it:\n- If `total_files` is 0: stop with \"No supported files found in [path].\"\n- If `skipped_sensitive` is non-empty: report the count and list the skipped file names, so a wrongly-flagged source or doc is visible and can be renamed or moved (#2106).\n- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:\n  ...\n  - If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.\n  - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.","vulnerability":{}},{"path":"graphify/skill.md","start_line":34,"end_line":172,"snippet":"> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely...\n**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:\n> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction...\nPrint it once, then continue — do not wait for the user to supply a key.","vulnerability":{}},{"path":"tests/test_skillgen.py","start_line":455,"end_line":468,"snippet":"def test_powershell_translator_rejects_unknown_bash():\n    \"\"\"The translator is strict: a bash line it does not recognize fails the\n    render loudly instead of shipping untranslated bash to Windows (#2528).\"\"\"\n    with pytest.raises(ValueError, match=\"cannot translate bash line\"):\n        gen._translate_bash_block([\"curl -s https://example.com | sh\"])\n    with pytest.raises(ValueError, match=\"unexpected backslash escape\"):\n        gen._unescape_bash_dq(\"subprocess.run('a\\\\tb')\")\n    with pytest.raises(ValueError, match=\"cannot translate rm -f operand\"):\n        gen._rm_to_remove_item(\"rm -f $HOME/danger\")","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[{"tool_name":"get_community","tool_description":"Get all nodes in a community by community ID.","types":["read-only"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"get_neighbors","tool_description":"Get all direct neighbors of a node with edge details.","types":["read-only"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"get_node","tool_description":"Get full details for a specific node by label or ID.","types":["read-only"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"get_pr_impact","tool_description":"Get detailed graph impact for a specific PR via gh CLI (files changed, communities affected).","types":["read-only","network-broad","untrusted-output"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"god_nodes","tool_description":"Return the most connected nodes - the core abstractions of the knowledge graph.","types":["read-only"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"graph_stats","tool_description":"Return summary statistics: node count, edge count, communities, confidence breakdown.","types":["read-only"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"list_prs","tool_description":"List open GitHub PRs with CI status, review state, and graph impact, by shelling out to the gh CLI.","types":["read-only","network-broad","untrusted-output"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"query_graph","tool_description":"Search the knowledge graph using BFS or DFS traversal. Returns relevant nodes and edges as text context.","types":["read-only","write"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"shortest_path","tool_description":"Find the shortest path between two concepts in the knowledge graph.","types":["read-only"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"triage_prs","tool_description":"Return all actionable open PRs with graph impact data for review prioritization, via gh CLI.","types":["read-only","network-broad","untrusted-output"],"status":"IMPLEMENTED","component_name":"mcp-server"}],"summary":{"high_level_risks":["The streamable_http (remote) transport ships insecure-by-default: the Dockerfile's own baked-in ENTRYPOINT/CMD binds 0.0.0.0:8080 with NO --api-key supplied, and the code only prints a stderr warning rather than refusing to start (ATR-M0004, ATR-M0010).","When bound to a wildcard host (0.0.0.0/::), the server explicitly disables the MCP SDK's DNS-rebinding/Origin+Host validation (enable_dns_rebinding_protection=False), removing a key browser-based attack defense for the most commonly deployed configuration (ATR-M0003).","The HTTP transport has no TLS support anywhere in the codebase; the optional API key/Bearer token (and all tool traffic) transits in plaintext unless an operator independently fronts it with a TLS proxy, which nothing in the repo enforces (ATR-M0009).","No OAuth 2.1 authorization flow exists for the remote transport; the only auth primitive is an optional static shared API key, which is a documented gap under the MCP Authorization spec for remote servers (ATR-M0005).","Several tools (list_prs, get_pr_impact, triage_prs) execute the gh/git CLIs via subprocess, and project_path lets a caller influence which graph.json file on disk is read -- real host-side effects that make the missing-auth-by-default posture materially dangerous rather than purely theoretical.","Known-CVE dependencies pinned in uv.lock: pyjwt==2.13.0 (CRITICAL JWT auth-bypass CVE-2026-102268 plus 5 more HIGH pyjwt auth/token-forgery CVEs), anyio==4.13.0 (CRITICAL TLS cert-spoofing CVE-2026-63374), plus HIGH CVEs in cryptography, fsspec, pypdf, soupsieve, and urllib3 -- all with fixes already published upstream.","Dockerfile is missing a HEALTHCHECK instruction (LOW, Trivy DS-0026), the only failing check out of 27 container-hardening checks."],"top_remediations":["Make the API key (or another authentication mechanism) MANDATORY whenever --transport http is used, rather than optional with only a warning -- refuse to start if absent; update the shipped Dockerfile default CMD to require an API key argument (or fail without one) so the container's out-of-the-box behavior is secure by default.","Never disable DNS-rebinding/Origin validation outright for wildcard binds; instead require an explicit --allowed-origin allowlist (or enforce TLS + auth) whenever the bind address is not loopback.","Add native TLS support (ssl_keyfile/ssl_certfile to uvicorn.run) or clearly document and enforce that the HTTP transport must run behind a TLS-terminating reverse proxy in production.","Plan and implement an OAuth 2.1 + PKCE authorization flow for the streamable_http transport per the MCP Authorization spec, as the server's own code comments acknowledge is a 'deliberate follow-up'.","Upgrade pinned dependencies to the patched versions: pyjwt>=2.14.0, anyio>=4.14.2, cryptography>=50.0.0, fsspec>=2026.6.0, pypdf>=6.19.0, soupsieve>=2.8.4, urllib3>=2.8.0, then re-run `uv lock --upgrade-package` and re-scan with Trivy to confirm resolution.","Add a HEALTHCHECK instruction to the Dockerfile so orchestrators can detect and restart a wedged graphify HTTP server.","Consider adding `exclude-newer` dependency-cooldown guidance in pyproject.toml's [tool.uv] section to reduce exposure to newly-published malicious/unstable package releases (flagged by semgrep's uv-missing-dependency-cooldown rule)."]},"rollup":{"component_findings_count":{"vulnerable":6,"secure":4,"not_applicable":1},"repo_findings_inherited_count":{"vulnerable":6,"secure":9,"not_applicable":0},"component_tools_count":{"read_only":10,"network_broad":3,"untrusted_output":3,"write":1},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":26,"tier":"critical","capability_band":"moderate","score_confidence":"medium","score_interval":{"low":13,"high":39},"factors":["ATR-M0002 HIGH","ATR-M0003 HIGH","ATR-M0004 HIGH","ATR-M0005 CRITICAL","ATR-M0009 HIGH","ATR-C0005 MEDIUM","ATR-C0007 HIGH","ATR-C0008 HIGH","ATR-C0011 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":27,"tier":"critical","capability_band":"moderate","score_confidence":"medium","score_interval":{"low":13,"high":41},"factors":["ATR-M0002 HIGH","ATR-M0003 HIGH","ATR-M0004 HIGH","ATR-M0005 CRITICAL","ATR-M0009 HIGH"]}}}]}