Graphify-Labs/graphify

MCP server

Turn any codebase, with its docs, SQL schemas, configs, and PDFs, into a queryable knowledge graph. A /graphify skill for Claude Code, Cursor, Codex, and Gemini CLI: local deterministic AST parsing, every edge explained, no vector store.

Graphify-LabsPython, YAML, Dockerfile, Markdown126,586 starsScanned Oct. 14th, 2026at 10130e7v8View source

Trust score

26/100Very low trust
Likely 13–39 · medium confidence

Scanned Oct. 14th, 2026 at 10130e7 against 26 rules · How scoring works

For developersOpen rawGET /c/Graphify-Labs/graphify/report.json
At a glance

At a glance

Contains
1 MCP server · 10 tools
Its tools can
Reach any network address 3Returns untrusted content 3Create or change data 1Read data 10
Issues flagged
1 critical9 high1 medium1 low12 of 26 rule checks, severity as assessed for this repo
Where issues are
  • Repository-wide6 of 15
  • mcp-server (MCP server)6 of 11
Trust signals
Publisher not verifiedSigned build29 known CVEs in dependenciesActively maintained

What to fix first

12 issues, most severe first. Each one points to the exact code, with the recommended fix and a prompt you can paste into your coding agent.

  1. 1CriticalOAuth 2.1 + PKCE Correctness (MCP Authorization)Declared behavior
    mcp-server (MCP server) · ATR-M0005
    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.
    class _ApiKeyMiddleware:
        """Pure-ASGI API-key gate for the HTTP transport.
        ..."""
        def __init__(self, app, api_key: str) -> None:
            self.app = app
            self._expected = api_key.encode("utf-8")
    
        async def __call__(self, scope, receive, send) -> None:
            if scope["type"] != "http":
                await self.app(scope, receive, send)
                return
            import hmac
            headers = dict(scope.get("headers") or [])
            provided = headers.get(b"x-api-key")
    Show 1 more location
    ``api_key`` (or the ``GRAPHIFY_API_KEY`` env var) enables a simple header
    check (``Authorization: Bearer <key>`` or ``X-API-Key: <key>``). OAuth is a
    deliberate follow-up. Binding ``0.0.0.0`` exposes the server beyond
    localhost — set an api_key when you do.
    Recommended fix

    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.

  2. 2HighDependency Risk & Supply Chain
    Repository-wide · ATR-C0007
    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.
    name = "mcp"
    version = "1.29.0"
    source = { registry = "https://pypi.org/simple" }
    dependencies = [
        { name = "anyio" },
        { name = "httpx" },
        { name = "httpx-sse" },
        { name = "jsonschema" },
        { name = "pydantic" },
        { name = "pydantic-settings" },
        { name = "pyjwt", extra = ["crypto"] },
        { name = "python-multipart" },
    Show 2 more locations
    name = "pyjwt"
    version = "2.13.0"
    source = { registry = "https://pypi.org/simple" }
    sdist = { 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" }
    name = "pyjwt"
    version = "2.13.0"

    +28 more locations in the finding JSON

    Recommended fix

    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.

  3. 3HighMissing or Inadequate Input Validation
    Repository-wide · ATR-C0008
    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.
                types.Tool(
                    name="query_graph",
                    description="Search the knowledge graph using BFS or DFS. Returns relevant nodes and edges as text context.",
                    inputSchema={
                        "type": "object",
                        "properties": {
                            "question": {"type": "string", "description": "Natural language question or keyword search"},
                            "mode": {"type": "string", "enum": ["bfs", "dfs"], "default": "bfs",
                                     "description": "bfs=broad context, dfs=trace a specific path"},
                            "depth": {"type": "integer", "default": 3, "description": "Traversal depth (1-6)"},
                            "token_budget": {"type": "integer", "default": 2000, "description": "Max output tokens"},
                            "context_filter": {
                                "type": "array",
                                "items": {"type": "string"},
    …
    Show 2 more locations
        def _tool_query_graph(arguments: dict) -> str:
            import time as _time
            from graphify import querylog
            question = arguments["question"]
            mode = arguments.get("mode", "bfs")
            depth = min(int(arguments.get("depth", 3)), 6)
            budget = int(arguments.get("token_budget", 2000))
            context_filter = arguments.get("context_filter")
            _t0 = _time.perf_counter()
            result = _query_graph_text(
                G,
                question,
                mode=mode,
                depth=depth,
    def _query_terms(question: str) -> list[str]:
        """Split a query into searchable terms, segmenting Chinese text, then drop
        question/filler words (`_QUERY_STOPWORDS`, English plus common German/
        Romance-language fillers) so content words drive seeding. Falls back to the
        unfiltered terms if the query is all stopwords, so a question like "how does
        it work" or "wie funktioniert das" still seeds on something."""
        terms: list[str] = []
        for raw in question.split():
            if _has_chinese(raw):
                for seg in _segment_chinese(raw.lower().strip()):
                    seg = seg.strip()
                    if seg and _is_searchable(seg):
                        terms.append(seg)
            else:
    …

    +1 more location in the finding JSON

    Recommended fix

    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.

  4. 4HighPath Traversal & Symlink Bypass (Filesystem Tools)
    Repository-wide · ATR-C0011
    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.
            # Multi-project support: every tool accepts an optional project_path.
            # Injected here (rather than repeated in 11 literal schemas) so the set
            # stays in lockstep as tools are added. Omitting it keeps the historical
            # single-graph behaviour, so this is purely additive for existing callers.
            for _t in _tools:
                # The constructor accepts the camelCase alias in both majors, but
                # attribute access is inputSchema on mcp 1.x and input_schema on 2.x.
                _schema = getattr(_t, "inputSchema", None)
                if _schema is None:
                    _schema = _t.input_schema
                _schema.setdefault("properties", {})["project_path"] = {
                    "type": "string",
                    "description": (
                        "Absolute path to a project directory containing "
    …
    Show 2 more locations
        def _resolve_graph_path(project_path) -> str:
            """Map an optional project_path to a concrete graph.json path. ``None``
            keeps the server's default graph (backward-compatible); a project_path
            resolves to ``<project_path>/<GRAPHIFY_OUT>/graph.json``, honouring the
            GRAPHIFY_OUT override so worktree/shared-output setups keep working."""
            if not project_path:
                return _default_graph_path
            return str(Path(project_path) / _paths.GRAPHIFY_OUT / "graph.json")
    def _read_graph(graph_path: str) -> nx.Graph:
        """Load graph.json exactly as the MCP server does, raising on a bad path
        or file (ValueError, FileNotFoundError, json.JSONDecodeError) so a library
        caller such as the benchmark can handle it."""
        resolved = Path(graph_path).resolve()
        if resolved.suffix != ".json":
            raise ValueError(f"Graph path must be a .json file, got: {graph_path!r}")
        if not resolved.exists():
            raise FileNotFoundError(f"Graph file not found: {resolved}")
        check_graph_file_size_cap(resolved)
        safe = resolved
        data = json.loads(safe.read_text(encoding="utf-8"))

    +2 more locations in the finding JSON

    Recommended fix

    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.

  5. 5HighTool Output Sanitization & Injection Containment
    Repository-wide · ATR-C0013
    _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.
    def _wrap_untrusted(rel: str, content: str) -> str:
        """Wrap one file's content in a labelled, hash-stamped untrusted-data block.
    
        The model's system prompt instructs it to treat everything inside
        <untrusted_source> as inert data, never as instructions. The sha256 lets a
        reviewer correlate a suspicious node back to the exact bytes that produced it.
        """
        sha = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()
        safe = _neutralise_injection_sentinels(content)
        return (
            f'<untrusted_source path="{rel}" sha256="{sha}">\n'
            f"{safe}\n"
            f"</untrusted_source>"
        )
    …
    Show 2 more locations
                rel = p.relative_to(root).as_posix()
            except ValueError:
                rel = Path(p).as_posix()
            try:
                if isinstance(u, FileSlice):
                    content = read_slice_text(u)
                else:
                    content = _file_to_text(safe_path)
            except OSError:
                continue
            # Whole files are still capped (covers non-splittable large files like
            # code); slices are already bounded to the cap, so the cap is a no-op.
            if len(content) > _FILE_CHAR_CAP:
                global _file_truncation_warned
    …
    _INJECTION_SENTINELS = re.compile(
        r"</?untrusted_source\b[^>]*>"
        # ANY <|token|> chat-template marker, not an enumerated few (#3183): the
        # old list named six and missed <|start_header_id|>/<|eot_id|> (Llama 3),
        # <|endofprompt|>, and whatever the next template calls its turns. The
        # form itself is the hazard - no legitimate source construct needs an
        # intact one, and defanging only inserts a zero-width space.
        r"|<\|[A-Za-z0-9_.\-]{1,64}\|>"
        r"|<<SYS>>|<</SYS>>"
        r"|\[/?(?:INST|SYSTEM)\]"
        r"|^\s*###?\s*(?:system|instruction)s?\s*:?\s*$",
        re.IGNORECASE | re.MULTILINE,
    )
    
    …
    Recommended fix

    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.

Show the other 7 issues
  1. 6HighUnrestricted Tool Permissions (Overprivileged Tools)
    mcp-server (MCP server) · ATR-M0002
    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.
        def _resolve_graph_path(project_path) -> str:
            """Map an optional project_path to a concrete graph.json path. ``None``
            keeps the server's default graph (backward-compatible); a project_path
            resolves to ``<project_path>/<GRAPHIFY_OUT>/graph.json``, honouring the
            GRAPHIFY_OUT override so worktree/shared-output setups keep working."""
            if not project_path:
                return _default_graph_path
            return str(Path(project_path) / _paths.GRAPHIFY_OUT / "graph.json")
    
    Show 2 more locations
        async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
            arguments = dict(arguments or {})
            project_path = arguments.pop("project_path", None)
            handler = _handlers.get(name)
            if not handler:
                return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
            try:
                _select_graph(project_path)  # bind G/communities to the target graph
                return [types.TextContent(type="text", text=handler(arguments))]
        """Load graph.json exactly as the MCP server does, raising on a bad path
        or file (ValueError, FileNotFoundError, json.JSONDecodeError) so a library
        caller such as the benchmark can handle it."""
        resolved = Path(graph_path).resolve()
        if resolved.suffix != ".json":
            raise ValueError(f"Graph path must be a .json file, got: {graph_path!r}")
        if not resolved.exists():
            raise FileNotFoundError(f"Graph file not found: {resolved}")
        check_graph_file_size_cap(resolved)
        safe = resolved
        data = json.loads(safe.read_text(encoding="utf-8"))
    Recommended fix

    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.

  2. 7HighCORS / Origin / Host Validation (DNS Rebinding Defense)
    mcp-server (MCP server) · ATR-M0003
    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.
    # DNS-rebinding protection. When the operator binds a wildcard address they
    # are intentionally exposing the server, so accept any Host header; for a
    # loopback/specific bind, restrict Host to that address (with and without
    # the port) plus the localhost aliases.
    if host in ("0.0.0.0", "::", ""):
        security = TransportSecuritySettings(enable_dns_rebinding_protection=False)
    else:
        allowed = {host, "localhost", "127.0.0.1"}
        allowed |= {f"{h}:{port}" for h in list(allowed)}
        security = TransportSecuritySettings(allowed_hosts=sorted(allowed))
    Show 2 more locations
    EXPOSE 8080
    
    ENTRYPOINT ["python", "-m", "graphify.serve"]
    CMD ["/data/graph.json", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"]
    def _tool_get_pr_impact(arguments: dict) -> str:
        from graphify.prs import fetch_pr_files, compute_pr_impact, _gh, _parse_ci
        number = int(arguments["pr_number"])
        repo = arguments.get("repo") or None
        view_args = ["pr", "view", str(number), "--json", ...]
        if repo:
            view_args += ["--repo", repo]
        pr_data = _gh(*view_args)
    Recommended fix

    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.

  3. 8HighBinding to 0.0.0.0 Without Auth/TLS
    mcp-server (MCP server) · ATR-M0004
    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.
    auth_note = "api-key required" if api_key else "no auth (set --api-key to require one)"
    print(
        f"graphify MCP server (streamable-http) on http://{host}:{port}{path} - {auth_note}",
        file=sys.stderr,
    )
    if host in ("0.0.0.0", "::", "") and not api_key:
        print(
            f"WARNING: binding {host or '0.0.0.0'} with no api-key exposes the graph "
            "unauthenticated on the network. Set --api-key (or GRAPHIFY_API_KEY).",
            file=sys.stderr,
        )
    uvicorn.run(app, host=host, port=port)
    Show 1 more location
    EXPOSE 8080
    
    ENTRYPOINT ["python", "-m", "graphify.serve"]
    CMD ["/data/graph.json", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"]
    Recommended fix

    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.

  4. 9HighTransport Security: TLS Everywhere (Remote)
    mcp-server (MCP server) · ATR-M0009
    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).
    graph_path = graph_path or _default_graph_json()
    try:
        import uvicorn
    except ImportError as e:
        raise ImportError(
            'HTTP transport needs the mcp extra (mcp + starlette + uvicorn). '
            'Run: pip install "graphifyy[mcp]"'
        ) from e
    
    api_key = (api_key or "").strip() or None
    
    app = _build_http_app(
        graph_path,
        host=host,
    …
    Show 1 more location
    # graphify MCP server as a shared HTTP service (issue #1143).
    #
    # Build:  docker build -t graphify .
    # Run:    docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data" graphify \
    #             /data/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET"
    ...
    EXPOSE 8080
    
    ENTRYPOINT ["python", "-m", "graphify.serve"]
    CMD ["/data/graph.json", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"]
    Recommended fix

    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.

  5. 10HighNo Authentication or Authorization Checks (General)
    mcp-server (MCP server) · ATR-M0010
    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.
    middleware = []
    if api_key:
        middleware.append(Middleware(_ApiKeyMiddleware, api_key=api_key))
    
    return Starlette(
        routes=[Route(path, endpoint=_MCPASGIApp(manager))],
        middleware=middleware,
        lifespan=lifespan,
    )
    Show 2 more locations
    EXPOSE 8080
    
    ENTRYPOINT ["python", "-m", "graphify.serve"]
    CMD ["/data/graph.json", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"]
    def _gh(*args: str) -> list | dict | None:
        try:
            result = subprocess.run(
                ["gh", *args],
                stdin=subprocess.DEVNULL,
                capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30
            )
            if result.returncode != 0:
                return None
            return json.loads(result.stdout)
    Recommended fix

    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.

  6. 11MediumLogging Hygiene (No Secrets/PII/Tokens) & Audit Trail
    Repository-wide · ATR-C0005
    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.
        def _tool_query_graph(arguments: dict) -> str:
            import time as _time
            from graphify import querylog
            question = arguments["question"]
            mode = arguments.get("mode", "bfs")
            depth = min(int(arguments.get("depth", 3)), 6)
            budget = int(arguments.get("token_budget", 2000))
            context_filter = arguments.get("context_filter")
            _t0 = _time.perf_counter()
            result = _query_graph_text(
                G,
                question,
                mode=mode,
                depth=depth,
    …
    Show 2 more locations
    
    def _log_path() -> Path | None:
        # Opt-in only (#1797). The log records every query/path/explain question and
        # corpus path (and full responses if GRAPHIFY_QUERY_LOG_RESPONSES) in a
        # plaintext file under ~/.cache — outside any repo's .gitignore/retention. A
        # default-on record of proprietary queries contradicts graphify's on-device,
        # no-telemetry posture, so it is OFF unless explicitly enabled:
        #   GRAPHIFY_QUERY_LOG=<path>   log to that path, or
        #   GRAPHIFY_QUERY_LOG_ENABLE=1 log to ~/.cache/graphify-queries.log.
        # GRAPHIFY_QUERY_LOG_DISABLE=1 still forces it off (back-compat, wins).
        if os.environ.get("GRAPHIFY_QUERY_LOG_DISABLE", "").lower() in ("1", "true", "yes"):
            return None
        override = os.environ.get("GRAPHIFY_QUERY_LOG", "").strip()
        if override:
    …
                return
            if nodes_returned is None and result is not None:
                nodes_returned = nodes_from_result(result)
            rec: dict[str, Any] = {
                "ts": datetime.now(timezone.utc).isoformat(),
                "kind": kind,
                "question": question,
                "corpus": corpus,
                "nodes_returned": nodes_returned,
            }
            if result is not None:
                rec["result_chars"] = len(result)
            if duration_ms is not None:
                rec["duration_ms"] = round(duration_ms, 3)
    …
    Recommended fix

    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.

  7. 12LowContainer/Runtime Hardening
    Repository-wide · ATR-C0006
    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.
    FROM python:3.12-slim
    WORKDIR /app
    COPY . /app
    RUN pip install --no-cache-dir ".[mcp]"
    RUN useradd --create-home --uid 10001 graphify
    USER graphify
    EXPOSE 8080
    ENTRYPOINT ["python", "-m", "graphify.serve"]
    CMD ["/data/graph.json", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"]
    Show 2 more locations
    ENTRYPOINT ["python", "-m", "graphify.serve"]
    CMD ["/data/graph.json", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"]
    CMD ["/data/graph.json", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"]
    Recommended fix

    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.

What’s inside

BlueRock found 1 component in this repository. Each is checked against the rules for its type.

MCP servermcp-server.27Very low trust1 critical5 high

Its tools can

Reach any network address 3Returns untrusted content 3Create or change data 1Read data 10

What stood out

  • 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.

MCP server rules 6 of 11 flagged

4 rules passed · 1 did not applyShow
PassedTool Definition Integrity & Versioning (ETDI-style)ATR-M0001Details

Why it got this result

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.

Why it matters

Prevents rug-pull tool poisoning.

Evidence

async def list_tools() -> list[types.Tool]:
    _tools = [
        types.Tool(
            name="query_graph",
            description="Search the knowledge graph using BFS or DFS. Returns relevant nodes and edges as text context.",
            inputSchema={
                "type": "object",
                "properties": {
                    "question": {"type": "string", "description": "Natural language question or keyword search"},
...
PassedCORS Misuse: Wildcard with CredentialsATR-M0008Details

Why it got this result

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.

Why it matters

Prevents credentialed cross-origin abuse.

Evidence

middleware = []
if api_key:
    middleware.append(Middleware(_ApiKeyMiddleware, api_key=api_key))

return Starlette(
    routes=[Route(path, endpoint=_MCPASGIApp(manager))],
    middleware=middleware,
    lifespan=lifespan,
)
PassedTool Name Collision / SpoofingATR-M0006Details

Why it got this result

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.

Why it matters

Prevents malicious tool shadowing.

Evidence

    async def list_tools() -> list[types.Tool]:
        _tools = [
        types.Tool(name="query_graph", ...),
        types.Tool(name="get_node", ...),
        types.Tool(name="get_neighbors", ...),
        types.Tool(name="get_community", ...),
        types.Tool(name="god_nodes", ...),
        types.Tool(name="graph_stats", ...),
        types.Tool(name="shortest_path", ...),
        types.Tool(name="list_prs", ...),
        types.Tool(name="get_pr_impact", ...),
        types.Tool(name="triage_prs", ...),
                        "token_budget": {"type": "integer", "default": 2000, "description": "Max output tokens"},
PassedPrompt/Metadata Injection in Tool DescriptionsATR-M0007Details

Why it got this result

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.

Why it matters

Prevents covert model manipulation.

Evidence

types.Tool(
    name="query_graph",
    description="Search the knowledge graph using BFS or DFS. Returns relevant nodes and edges as text context.",
Show 1 more location
def sanitize_label(text: str | None) -> str:
    """Strip control characters and cap length.

    Safe for embedding in JSON data (inside <script> tags) and plain text.
    For direct HTML injection, wrap the result with html.escape().
    """
    if text is None:
        return ""
    text = _CONTROL_CHAR_RE.sub("", str(text))
    if len(text) > _MAX_LABEL_LEN:
        text = text[:_MAX_LABEL_LEN]
    return text
Does not applyOAuth Proxy Per-Client Consent (Confused Deputy Defense)ATR-M0011Details

Why it got this result

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.

Why it 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.

Evidence

class _ApiKeyMiddleware:
    """Pure-ASGI API-key gate for the HTTP transport.
    ..."""
    Implemented as raw ASGI (not Starlette's BaseHTTPMiddleware) on purpose:
    BaseHTTPMiddleware buffers responses and breaks the Streamable HTTP SSE
    stream. This short-circuits with 401 before the request ever reaches the
    session manager, leaving the streaming path untouched for authorized calls.
    """
Show its 10 tools
ToolIt can
get_community
Get all nodes in a community by community ID.
Read data
get_neighbors
Get all direct neighbors of a node with edge details.
Read data
get_node
Get full details for a specific node by label or ID.
Read data
get_pr_impact
Get detailed graph impact for a specific PR via gh CLI (files changed, communities affected).
Read dataReach any network addressReturns untrusted content
god_nodes
Return the most connected nodes - the core abstractions of the knowledge graph.
Read data
graph_stats
Return summary statistics: node count, edge count, communities, confidence breakdown.
Read data
list_prs
List open GitHub PRs with CI status, review state, and graph impact, by shelling out to the gh CLI.
Read dataReach any network addressReturns untrusted content
query_graph
Search the knowledge graph using BFS or DFS traversal. Returns relevant nodes and edges as text context.
Read dataCreate or change data
shortest_path
Find the shortest path between two concepts in the knowledge graph.
Read data
triage_prs
Return all actionable open PRs with graph impact data for review prioritization, via gh CLI.
Read dataReach any network addressReturns untrusted content

Before you put it to work

  1. 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.
  2. 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.
  3. 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.
  4. 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'.
  5. 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.
  6. Add a HEALTHCHECK instruction to the Dockerfile so orchestrators can detect and restart a wedged graphify HTTP server.
  7. 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).

Repository-wide rules

Checked once for the whole repository: dependencies, secrets, inputs, networking, CI and more. 6 of 15 flagged.

9 rules passedShow
PassedRuntime Command Injection via Process ExecutionATR-C0003Details

Why it got this result

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.

Why it matters

Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.

Evidence

    if branch and branch.startswith("-"):
        print(f"error: invalid branch name: {branch!r}", file=sys.stderr)
        sys.exit(1)
    ...
        cmd = ["git", "clone", "--depth", "1"]
        if branch:
            cmd += ["--branch", branch]
        cmd += ["--", git_url, str(dest)]
        result = _sp.run(cmd, capture_output=True, text=True)
Show 1 more location
        proc = subprocess.run(
            ...,
            stdin=subprocess.DEVNULL,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
        )
    except (OSError, subprocess.SubprocessError):
PassedWeaponization: Malicious-Purpose Artifact (Offensive Capability by Design)ATR-R0001Details

Why it got this result

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.

Why it 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.

Evidence

Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- 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).
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
  ...
  - 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.
  - 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.
Show 2 more locations
> **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...
**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:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction...
Print it once, then continue — do not wait for the user to supply a key.
def test_powershell_translator_rejects_unknown_bash():
    """The translator is strict: a bash line it does not recognize fails the
    render loudly instead of shipping untranslated bash to Windows (#2528)."""
    with pytest.raises(ValueError, match="cannot translate bash line"):
        gen._translate_bash_block(["curl -s https://example.com | sh"])
    with pytest.raises(ValueError, match="unexpected backslash escape"):
        gen._unescape_bash_dq("subprocess.run('a\\tb')")
    with pytest.raises(ValueError, match="cannot translate rm -f operand"):
        gen._rm_to_remove_item("rm -f $HOME/danger")
PassedUnrestricted Network Fetch / SSRFATR-C0001Details

Why it got this result

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.

Why it matters

Enables internal pivoting and data exfiltration.

Evidence

def validate_url(url: str) -> str:
    parsed = urllib.parse.urlparse(url)
    if parsed.scheme.lower() not in _ALLOWED_SCHEMES:
        raise ValueError(...)
    hostname = parsed.hostname
    if hostname:
        if hostname.lower() in _BLOCKED_HOSTS:
            raise ValueError(...)
        try:
            infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
            for info in infos:
                addr = info[4][0]
                ip = ipaddress.ip_address(addr)
                if _ip_is_blocked(ip):
…
Show 2 more locations
class _SSRFGuardedHTTPConnection(http.client.HTTPConnection):
    def connect(self) -> None:
        family, ip = _resolve_and_validate(self.host, self.port)
        self.sock = socket.create_connection((ip, self.port), self.timeout, self.source_address)
...
class _NoFileRedirectHandler(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        validate_url(newurl)
        return super().redirect_request(req, fp, code, msg, headers, newurl)
    out_path.write_bytes(safe_fetch(url))
...
        validate_url(url)
PassedStatic SecretsATR-C0002Details

Why it got this result

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.

Why it matters

Plaintext secrets are easily exfiltrated.

Evidence

def _get_backend_api_key(backend: str) -> str:
    """Return the first configured API key for backend, or an empty string."""
    for env_key in _backend_env_keys(backend):
        value = os.environ.get(env_key)
        if value:
            return value
    return ""
Show 2 more locations
    def _add_doc_ref(token: str, line: int) -> None:
        # Normalize "adr 11" / "ADR-0011" spellings to a canonical "ADR-0011"
        # style label so references to the same document collapse to one node.
        kind, num = re.match(r"([A-Za-z]+)[- ]?(\d+)", token).groups()
        kind = kind.upper()
        label = f"{kind}-{num.zfill(4)}" if kind == "ADR" else f"{kind}-{num}"
        if label in seen_doc_refs:
            return
            if qualified:
                prefix, _, ref_token = [REDACTED](".")
            else:
                prefix = ""
                ref_token = base_type
            if ref_token:
                tgt_nid = _make_id(ref_token)
                if tgt_nid and tgt_nid not in seen_ids:
                    seen_ids.add(tgt_nid)
                    nodes.append({
                        "id": tgt_nid,
                        "label": ref_token,
                        "file_type": "code",
                        "source_file": "",
…

+4 more locations in the finding JSON

PassedInsecure DeserializationATR-C0009Details

Why it got this result

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.

Why it matters

Can lead to arbitrary code execution.

Evidence

    text = path.read_text(encoding="utf-8")
    data = yaml.safe_load(text)
PassedCI/CD Workflow Script InjectionATR-C0014Details

Why it got this result

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.

Why it 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.

Evidence

# pull_request_target is used so the token can label/comment on PRs from forks;
# neither job checks out or executes PR code, so this is safe.

on:
  pull_request_target:
...

permissions:
  pull-requests: write
            ...
            await github.rest.issues.addLabels({...});
Show 2 more locations
      - uses: actions/github-script@v7
        with:
          script: |
            const isPR = context.eventName === 'pull_request_target';
            const payload = isPR ? context.payload.pull_request : context.payload.issue;
            const author = payload.user.login;
            ...
            await github.rest.issues.createComment({...body});
      - uses: actions/setup-python@...
        with:
          python-version: ${{ matrix.python-version }}
PassedDoS Protections: Timeouts, Concurrency, Streaming LimitsATR-C0004Details

Why it got this result

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.

Why it matters

Resource exhaustion can crash or stall MCP.

Evidence

def safe_fetch(url: str, max_bytes: int = _MAX_FETCH_BYTES, timeout: int = 30) -> bytes:
    ...
        chunks: list[bytes] = []
        total = 0
        while True:
            chunk = resp.read(65_536)
            if not chunk:
                break
            total += len(chunk)
            if total > max_bytes:
                raise OSError(
                    f"Response from {url!r} exceeds size limit "
                    f"({max_bytes // 1_048_576} MB). Aborting download."
                )
…
Show 2 more locations
class _GraphContextCache:
    """Thread-safe graph contexts: one pinned default plus an LRU of projects."""
    def __init__(self, max_contexts: int):
        self._max_contexts = max_contexts
    ...
                while len(self._entries) > self._max_contexts:
    workers = max(1, min(max_concurrency, total))
    ...
        with ThreadPoolExecutor(max_workers=workers) as pool:
PassedLack of Output Encoding/SanitizationATR-C0010Details

Why it got this result

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.

Why it matters

Prevents UI-level injection and operator compromise.

Evidence

def _sanitize_metadata_string(value: object) -> str:
    """Return a control-character-free, HTML-escaped, bounded string."""
    text = _CONTROL_CHAR_RE.sub("", str(value))
    text = html.escape(text, quote=True)
    if len(text) > _METADATA_MAX_VALUE_LEN:
        text = text[:_METADATA_MAX_VALUE_LEN]
    return text
PassedDenial of Wallet / Cost ControlsATR-C0012Details

Why it got this result

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).

Why it matters

Prevents cost-based disruption.

Evidence

        max_concurrency = 1
    ...
    workers = max(1, min(max_concurrency, total))
    ...
        with ThreadPoolExecutor(max_workers=workers) as pool:
Show 1 more location
def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_budget: int = 2000, *, seeds: list[str] | None = None) -> str:
From build to runtime

You know what it is. Now see what it does.

A scan helps you understand what you’re building with. When your agent runs, BlueRock shows you what actually happens across tools and systems. Follow actions in context, understand behavior as it unfolds and add controls when you need them.

BUILDRegistry + scan
RUNSee actual execution
OPERATEObserve, control, optimize

Results reflect the code at the commit and date shown.