Adversarial AI Recon

On this page


Why we added "AI awareness" to recon

A modern attack surface no longer ends at HTTP, SSH, and a database. Targets now expose:

  • Local model runtimes — Ollama, vLLM, LM Studio, Triton, TGI, Llama.cpp serving raw model APIs on internal ports.
  • AI frontends — Open WebUI, LibreChat, AnythingLLM, Flowise, Langflow, Dify, ChatGPT clones, Streamlit/Gradio demos.
  • Vector databases — Qdrant, Milvus, Chroma, Weaviate, often left open with no auth because "it's just embeddings".
  • AI proxies & gateways — LiteLLM, Helicone, Portkey, Cloudflare AI Gateway, Together, OmniRoute that forward chats to upstream providers (often with the provider key embedded server-side).
  • SDK-talking apps — production services that hold OPENAI_API_KEY / ANTHROPIC_API_KEY and call out to provider APIs.
  • AI frameworks — LangChain, LlamaIndex, Langfuse, MCP servers exposing tool catalogs.

Each one of these is a fresh source of vulnerabilities the classic OWASP-Top-10 playbook does not cover — prompt injection, model extraction, vector-DB poisoning, RAG-corpus tampering, leaked provider keys, over-privileged tool calls. To attack them you first have to find them, and you have to find them without authenticating because in a real engagement you have no API key for the target.

That's what Adversarial AI Recon does. It teaches every standard recon tool we already run (Naabu, Nmap, httpx, the DNS resolver, the OSINT helpers) to recognise AI-shaped signals as they fly past, and to attach those signals onto the same graph the rest of the pipeline writes to. No new tool, no new container, no new container build for the operator — it's regex catalogues and dict lookups injected into existing modules.

Design philosophy in three lines

  1. Distributed signal extraction, not a separate scan. Every module that already touches the target gets a cheap AI-aware hook, gated by its own settings toggle. No central "AI scan" stage that doubles your scan time.
  2. Black-box only. Detection is from outside, from headers/ports/titles/DNS that any anonymous probe sees. No API keys required, no Authorization header sent to the target.
  3. Zero new graph labels. AI findings ride on existing node labels (Subdomain, Service, Endpoint, Technology) with new properties prefixed ai_* / is_ai_* or new Technology.category values prefixed ai-. Old queries keep working; new queries filter on the prefix.

What you get out of it

After a normal scan against an AI-bearing target you can ask the graph:

  • "Which subdomains have DNS evidence of an Anthropic / OpenAI / HuggingFace tenancy?"
  • "Which open ports look like vector databases?"
  • "Which endpoints are serving an LLM frontend like Open WebUI or LibreChat?"
  • "Which paths returned x-vllm-* or anthropic-ratelimit-* headers?"
  • "Roll up everything AI-shaped on this attack surface — runtimes, vector DBs, proxies, frontends, SDK clients — into one table."

All four come from regex catalogs against signals the rest of the pipeline was already gathering.


Pipeline placement

Phase 1  Domain Discovery     →  Subdomain.ai_service_hint  (TXT + NS regex)
Phase 2  Port Scanning        →  Technology(category=ai-*)  (port catalog auto-promote)
Phase 3  Nmap Version Detect  →  Service.ai_runtime_version (product/version regex)
Phase 4  HTTP Probing         →  Endpoint.is_ai_framework_detected
                                  Endpoint.ai_framework_name
                                  Endpoint.ai_frontend_product_guess
                                  Technology(category=ai-*) via httpx-ai-{header,title,favicon}
Phase 5  Resource Enum         →  Endpoint.ai_interface_type
                                  Endpoint.is_ai_rag_ingest
                                  Parameter.is_ai_prompt_injectable
Phase 6  JS Recon AI SDK       →  JsReconFinding(finding_type=ai-sdk-*)
                                  Secret.ai_provider (enrichment of existing Secret nodes)
Phase 7  AI Surface Recon      →  Endpoint.ai_interface_type / ai_supports_* / ai_latency_p50_ms
        (ACTIVE probing,          Endpoint(POST /mcp).ai_mcp_* + Parameter.is_ai_prompt_injectable
         display Phase 4.5)       Technology(category=ai-*) confirmed (Julius + vector-DB reads)
                                  Vulnerability(source=ai_surface_recon)  MCP tool-poisoning

Each phase reads its own settings toggle. All toggles are default-enabled so a fresh project picks up AI annotations with zero extra configuration.

Phases 1–6 are passive — they annotate what other tools already observed (DNS records, ports, headers, crawled paths, JS bundles), making zero extra requests. Phase 7 (AI Surface Recon) is the active half: it sends its own benign protocol-aware probes to confirm and characterize the surfaces the passive phases merely flagged. Everything below Phase 7 in this page documents the passive channels; the AI Surface Recon section at the end documents the active module.


Domain Recon AI hints

The earliest AI signal the pipeline can pick up is in DNS itself. When an organisation uses a third-party AI vendor — Anthropic, OpenAI, HuggingFace, Replicate, Cohere, Together, LangChain, etc. — that vendor almost always asks them to add a domain-verification TXT record, or asks them to point an NS record at a vendor-controlled nameserver. Those records leak the vendor relationship to anyone who can do a DNS lookup.

This hook reads those records (which the DNS step was already pulling for SPF / DMARC / DNSSEC) and stamps the detected vendor on the matching Subdomain node — before any port scan runs. So even when a host has no open ports, you still know who it's plugged into on the AI side.

What it adds to the graph. A new property ai_service_hint on existing Subdomain nodes. No new nodes are created. The value is a string naming the detected AI vendor.

Where the toggles live. Project Settings → Subdomain Discovery tab → DNS & WHOIS sub-section.

UI toggleDefaultWhat it does
AI TXT/SPF/DKIM HintOnReads the TXT / SPF / DKIM / DMARC records the DNS step already collected, looks for AI-vendor domain verification strings, and stamps the matching provider name on the Subdomain.
AI NS HintOnLooks at the NS (nameserver) records; if they point at an AI-friendly host (Vercel, Netlify, Replit, Modal, HuggingFace Spaces, …) the Subdomain is tagged ai-hosting-candidate (weak signal — only used when no stronger TXT hint won).

Possible values stamped on Subdomain.ai_service_hint.

anthropic, openai, huggingface, huggingface-spaces, replicate,
langchain, langfuse, langsmith, cohere, together, groq, mistral, runpod,
ai-hosting-candidate

Example matches.

DNS record observedai_service_hint set to
TXT anthropic-domain-verification=...anthropic
TXT huggingface-domain-verification-...huggingface
NS ns1.runpod.iorunpod
NS ns.langfuse.comlangfuse
NS points at a Vercel / Modal / Netlify nameserver, no TXT matchai-hosting-candidate

How to read it. This annotation lands before any port scan — as soon as DNS resolution finishes — so you can tell a host has an AI-vendor relationship from its DNS records alone, even when no ports respond.

Sample query against the graph.

MATCH (s:Subdomain)
WHERE s.ai_service_hint IS NOT NULL
RETURN s.subdomain, s.ai_service_hint
ORDER BY s.ai_service_hint

Port Scan AI catalog

Every AI tool that runs as a server has a default port. Ollama listens on 11434, Qdrant on 6333, Milvus on 19530, ComfyUI on 8188, Gradio on 7860, Streamlit on 8501, vLLM on 8000, Triton on 8001, and so on. When the port scan finds one of those open on a target, that's already a strong (sometimes definitive) AI signal — long before the HTTP probe ever runs.

This hook turns each open AI-port into a labelled AI Technology on the graph the moment Naabu or Masscan reports the port open. You don't have to remember to add the ports to your custom port list yourself — they're automatically scanned and the AI service catalog runs over the results.

Because some "AI ports" are shared with non-AI software (8080 is also Tomcat, 4000 is also Phoenix, 3001 is also a Node dev server), the catalog uses a two-tier model — high-confidence ports are auto-promoted, generic ports wait for the HTTP probe to corroborate. See the Two-tier promotion sub-section below.

What it adds to the graph. New Technology nodes whose category value starts with ai- (e.g. ai-runtime, ai-vector-db, ai-frontend, ai-proxy). Each new Technology is linked to the existing Service node and Port node via the standard USES_TECHNOLOGY / HAS_TECHNOLOGY relationships. The relationship carries a detected_by value of naabu-ai-port (or masscan-ai-port) so you can later tell port-only evidence apart from HTTP-response evidence.

Where the toggles live.

UI toggleProject Settings locationDefaultWhat it does
AI Port CatalogNaabu tabOnWhen Naabu reports a known AI port open, creates the matching Technology node and links it to the Service and Port.
AI Port CatalogMasscan tabOnSame behaviour for the Masscan code path (same port list, same Technology shape).

You don't need to add the AI ports to your custom port list yourself — they're added to the scan automatically when the toggle is on.

Two-tier promotion (the disambiguate flag)

Not every AI port is unambiguous. Port 4000 is also Phoenix LiveView, 3001 is also a Node dev server, 8080 is also Tomcat / Jenkins. To avoid false-positive AI tags on innocent corporate boxes, the catalog splits AI ports into two tiers:

TierBehaviour when the port is open
Auto-promote (vendor-specific ports)Open port alone is enough evidence. A Technology node is created and linked to the Service / Port with detected_by = naabu-ai-port.
Disambiguate (generic ports)The port is recorded as open (normal Service node) but no AI tag is added. The AI tag is only added later if the HTTP Probe step sees a matching AI header or title on that port — see HTTP Probe AI signatures.

Current split:

TierPortWhat's cataloguedCategory
Auto-promote11434ollamaai-runtime
6333qdrantai-vector-db
6334qdrant-grpcai-vector-db
19530milvusai-vector-db
7860gradio / automatic1111 / langflowai-frontend
8188comfyuiai-frontend
8501streamlitai-frontend
8880kokoro-ttsai-runtime
30000sglangai-runtime
6900argillaai-mlops
2024langgraph-devai-framework
Disambiguate1234lm-studio (also misc dev port)ai-runtime
3000bentoml / langflow / openllm (also Node / Grafana / Next.js dev)ai-runtime
3001anythingllm (also Node alt port)ai-frontend
4000litellm (also Phoenix / Rails / Express)ai-proxy
5000mlflow (also Flask default)ai-mlops
5001koboldcpp (also Flask alt)ai-runtime
6006phoenix-arize (also TensorBoard)ai-mlops
7865fooocusai-frontend
8000vllm / chroma / langserve / NIM / mlc / faster-whisper (also Django / FastAPI)ai-runtime
8001triton / vllm / redis-insightai-runtime
8002triton-metricsai-runtime
8080open-webui / weaviate / localai / whisper.cpp (also Tomcat / Jenkins)ai-frontend
8081autogen-studio (also alt HTTP)ai-mlops
8123langgraph-platform (also ClickHouse)ai-framework
8265ray-dashboard (also Cassandra JMX)ai-mlops
9091milvus-metrics / invokeai (also Prometheus pushgateway)ai-vector-db
50051weaviate-grpcai-vector-db

ai-mlops category. Covers observability / experiment-tracking surfaces — MLflow, Langfuse, Phoenix-Arize, Ray Dashboard, Argilla, AutoGen Studio. Distinct from ai-runtime (model serving), ai-vector-db (storage), ai-framework (libraries), ai-proxy (LLM-gateway middleware), ai-frontend (chat UIs), and ai-sdk-client (provider proxies).

Sample queries.

// High-confidence AI services (open port alone was enough evidence)
MATCH (svc:Service)-[r:USES_TECHNOLOGY]->(t:Technology)
WHERE r.detected_by IN ['naabu-ai-port', 'masscan-ai-port']
RETURN svc.host, svc.port_number, t.name, t.category

// Exposed vector databases on this attack surface
MATCH (svc:Service)-[:USES_TECHNOLOGY]->(t:Technology)
WHERE t.category = 'ai-vector-db'
RETURN svc.host, svc.port_number, t.name

Nmap version regex

Once the port scan has found open ports, Nmap takes over and does service-version detection (-sV) — banner-grabbing, protocol negotiation, behaviour fingerprinting. The point is to put a product/version string on every Service: nginx/1.25, OpenSSH/9.6, and so on.

When Nmap is correctly able to identify a server, AI runtimes leak their identity in those banners too. Ollama returns Ollama/0.1.32, vLLM returns its own server tag, LiteLLM identifies itself, TGI advertises itself. This hook runs a small catalogue of regexes over each Service's product/version field and stamps the matched runtime name on the Service — a "second confirmation" channel that's independent of port number and independent of HTTP responses.

The same Service might already be tagged by the Port Scan AI catalog via a Technology node; this hook is complementary — it pins the runtime identity directly on the Service, which is useful for later joins against AI-library CVE clusters.

What it adds to the graph. A new property ai_runtime_version on existing Service nodes (the ones already created by the port scan). No new nodes are created. The value names the detected runtime: ollama, vllm, litellm, tgi, triton, or llama.cpp.

Where the toggle lives. Project Settings → Nmap tab.

UI toggleDefaultWhat it does
AI Runtime Version RegexOnAfter Nmap finishes service-version detection (-sV), every Service node's product/version string is checked against a catalogue of AI-runtime patterns. On a match, the matched runtime name is stamped on Service.ai_runtime_version.

This is the second confirmation channel for AI-runtime ports. Even if a port was disambiguated and skipped by the Port Scan catalog (because it was a generic port), a positive Nmap banner — e.g. Ollama/0.1.32 in the version field — is strong enough evidence to set the runtime name on the Service directly.

Caveat — Nmap library masking. When a target uses a thin HTTP wrapper (for example, an aiohttp Python app that exposes Ollama-style endpoints), Nmap's service detection often reports the underlying library (aiohttp) rather than the wrapper. The custom Server: header gets ignored in favour of timing and behaviour fingerprints. In that case ai_runtime_version stays empty and you should rely on the HTTP Probe AI signatures below — header / title / favicon are the reliable channels when Nmap is fooled.

Sample query.

MATCH (svc:Service)
WHERE svc.ai_runtime_version IS NOT NULL
RETURN svc.host, svc.port_number, svc.ai_runtime_version

HTTP Probe AI signatures

This is the most precise AI-detection layer in the pipeline. Once httpx has probed every live HTTP service, the response itself — its headers, its <title> tag, its favicon — is full of unintentional fingerprints left behind by the AI tool serving the response. vLLM emits x-vllm-* headers, Anthropic-style proxies emit anthropic-ratelimit-*, Open WebUI puts its product name in the <title>, LibreChat serves a recognisable favicon. None of this requires authentication, no extra requests are sent — httpx was already capturing all of it.

Compared to the previous three phases this one is the highest-signal: a response that says <title>Open WebUI</title> is essentially a confession. It's also the channel that rescues the disambiguate ports — a port that the Port Scan catalog refused to auto-tag (because 8080 could be anything) gets a confident AI label here if the HTTP response carries a known AI signature.

Four independent signal channels (header, title, favicon, Wappalyzer-style body fingerprints) all feed the same target node, an Endpoint. Each channel is fully wired and shipping with its own catalogue.

What it adds to the graph.

  • New properties on each probed Endpoint node:
    • is_ai_framework_detected — boolean, true when any of the three channels matched.
    • ai_framework_name — the matched product (e.g. vllm, langchain, anythingllm).
    • ai_frontend_product_guess — for frontend products, the specific UI guess (e.g. open-webui, librechat).
  • New Technology nodes with category in ai-runtime, ai-vector-db, ai-framework, ai-proxy, ai-frontend, ai-sdk-client, linked to the Endpoint via USES_TECHNOLOGY. The relationship's detected_by value tells you which channel matched — httpx-ai-header, httpx-ai-title, or httpx-ai-favicon.

Where the toggles live. Project Settings → Httpx tab → AI Surface Recon sub-section.

UI toggleDefaultWhat it does
AI Header SignaturesOnMatches HTTP response headers against known AI runtime / framework / proxy / SDK-client patterns (x-vllm-*, anthropic-ratelimit-*, x-langchain-*, x-litellm-*, cf-aig-*, x-mcp-*, …).
AI Title RegexOnMatches the page <title> against AI frontend product names (Open WebUI, LibreChat, Flowise, Dify, …).
AI Favicon Hash LookupOnLooks up the captured favicon hash against a catalogue of known AI frontend favicons (Open WebUI, AnythingLLM, Langflow, LibreChat, LobeChat, Dify, SillyTavern…). Requires the standard "Favicon Hash" probe (above in the same section) to be enabled.
AI Wappalyzer FingerprintsOnScans the response body for AI-product signatures: LangChain globals (window.__LANGCHAIN__), LangChain/LlamaIndex JS imports, TGI's /generate_stream form, vLLM session cookie, <gradio-app> tag, Streamlit's React markers, @anthropic-ai/sdk import.

All four channels are passive — they read response data that the standard httpx probe was already capturing. No extra HTTP traffic is sent to the target.

Header signature

What this channel sets. Endpoint.is_ai_framework_detected = true, Endpoint.ai_framework_name = <product>, plus a Technology(category=ai-*) linked from the Endpoint with detected_by = httpx-ai-header.

How it works. Every response header captured by httpx is matched against a catalogue of 20 vendor-specific regex patterns. First match wins. The catalogue covers:

CategoryProducts detected via headers
RuntimevLLM (x-vllm-*), Text Generation Inference (TGI), Text Embeddings Inference, BentoML, Baseten, Modal, Replicate, RunPod
FrameworkLangChain, LlamaIndex, Langfuse, MCP servers
Proxy / gatewayLiteLLM, Helicone, Portkey, OmniRoute, Cloudflare AI Gateway, Together
SDK clientOpenAI (openai-organization, openai-version, openai-processing-ms), Anthropic (anthropic-version, anthropic-ratelimit-*, anthropic-beta), Azure OpenAI (x-ms-region, azureml-model-session), Fireworks AI (x-fireworks-account-id, x-ratelimit-limit-tokens-cache-adjusted-prompt)

Title regex

What this channel sets. Endpoint.is_ai_framework_detected = true, Endpoint.ai_frontend_product_guess = <product>, plus a Technology(category=ai-frontend) linked from the Endpoint with detected_by = httpx-ai-title.

How it works. The page's <title> tag is matched against the title-pattern catalogue. First match wins — specific clones (BetterChatGPT, Onyx) are tested before the generic ChatGPT-clone fallback so they win their products. Categories cover:

CategoryProducts detected by title
Chat / generic LLM frontendsOpen WebUI, LibreChat, AnythingLLM, Flowise, Langflow, Dify, ComfyUI, Gradio, Streamlit, BetterChatGPT, Onyx (formerly Danswer), ChatGPT-clone, HF Chat UI, LobeChat, NextChat, SillyTavern, Jan, h2oGPT, PrivateGPT, Quivr
Image-gen frontendsInvokeAI (Invoke - Community Edition), AUTOMATIC1111 Stable Diffusion WebUI (Stable Diffusion)
MLOps / observabilityMLflow, Label Studio (literal lowercase Labelstudio), Ray Dashboard, RedisInsight, AutoGen Studio, Langfuse, Phoenix Arize, Argilla, GPT Researcher

This is the channel that rescues disambiguate ports. A generic port like 8080 is skipped by the Port Scan catalog, but if the HTTP body's title is Open WebUI the title channel still fires — and the title is hard to mimic by accident, so the resulting AI tag is high-confidence.

Favicon hash

What this channel sets. Endpoint.ai_frontend_product_guess = <product> plus a Technology(category=ai-frontend) linked from the Endpoint with detected_by = httpx-ai-favicon.

How it works. httpx already computes an mmh3 hash of /favicon.ico when its favicon probe is enabled — the Shodan/FOFA-standard mmh3.hash(base64.encodebytes(favicon_bytes)). The AI hook compares that integer against a dictionary of known AI-frontend favicon hashes. Useful when the page title is generic (proxied through a custom wrapper) but the original product's favicon is still served.

Catalogued products (favicons computed from each project's upstream repo / vendor site):

CategoryProducts
Chat frontendsOpen WebUI, AnythingLLM, Langflow, Flowise (16×16 + 32×32), LibreChat (16×16 + 32×32), LobeChat, Dify, SillyTavern, PrivateGPT, Onyx, BetterChatGPT, HuggingFace Chat UI, Streamlit
Image-gen frontendsInvokeAI
MLOps & observabilityMLflow, Langfuse (favicon ico + 32×32), Phoenix Arize, Ray Dashboard, Argilla, GPT Researcher
SaaS provider login pages (for catching corporate proxies)DeepSeek, HuggingFace, Together AI, Cohere, LangSmith, Groq, OpenRouter, Perplexity, Helicone

A deployed instance may serve a re-encoded favicon (via Vite/Webpack asset bundling, CDN, or custom theming) whose hash differs from the upstream one. When operators encounter a new instance hash, they extend the catalogue (one line per entry).

Wappalyzer-style body fingerprints

What this channel sets. Endpoint.is_ai_framework_detected = true, Endpoint.ai_framework_name = <product>, and the Technology category. Only fires if no higher-priority channel (header, favicon, title) has already won — this preserves the strongest signal's provenance.

How it works. Pattern-matches the response body (HTML + shipped JS, capped at 512 KB) against a catalogue of AI-product signatures. Patterns are deliberately specific so casual product mentions in documentation pages don't trip them — they're looking for deployed-product evidence, not blog posts. The catalogue covers:

CategorySignature pattern
RuntimeTGI /generate_stream form action; vLLM vllm_session cookie literal; SGLang /get_model_info JSON (is_generation + model_path); KoboldCpp "result":"KoboldCpp"; LocalAI gallery installer (/models/apply, /models/available); OpenLLM /v1/generate route
FrameworkLangChain globals (window.__LANGCHAIN__, window.__LANGCHAIN_TRACING_V2__); @langchain/{core,community,langgraph,openai,anthropic} import strings; LlamaIndex (@llamaindex/{core,cloud,community} import or window.LlamaIndex global)
FrontendGradio (<gradio-app> custom element or window.gradio_config global); Streamlit (data-testid="stApp", stStreamlitApp)
Image-gen UIAUTOMATIC1111 Stable Diffusion WebUI (txt2img_textarea, img2img_textarea, gradioApp(), onAfterUiUpdate — disambiguates A1111 from a generic Gradio app); Fooocus (literal fooocus_v2 token); InvokeAI (invoke-favicon.svg asset or /src/main.tsx Vite shell); ComfyUI frontend (aria-label="Loading ComfyUI", localStorage comfy-splash-bg)
MLOps / observabilityMLflow (mlflow-ui-container class, /ajax-api/2.0/mlflow/ XHR pattern); Langfuse (/api/public/ingestion, /api/public/projects); Phoenix Arize (/v1/traces//v1/datasets Phoenix-specific paths); Ray Dashboard (/api/cluster_status, /logs/job/...)
Vector DBWeaviate (/v1/meta response shape with hostname + version + modules keys); Chroma (/api/v1/heartbeat "nanosecond heartbeat" key)
SDK client@anthropic-ai/sdk import string shipped to the browser; dangerouslyAllowBrowser: true (OpenAI JS SDK browser-mode flag — high-severity, provider key likely in same bundle)

Graph model — BaseURL + Endpoint

The HTTP probe splits every probed URL into two graph nodes:

NodeWhat identifies itWhat it carries
BaseURLscheme + host + port (one per HTTP service)The service location; certificate links
Endpointpath + method, under a BaseURL (one per probed path)The actual response data + all AI annotations

Linked by (BaseURL)-[:HAS_ENDPOINT]->(Endpoint).

This split matters because AI signals are per-path — the same host might serve Open WebUI on / (a frontend signal) and vLLM on /v1/chat/completions (a runtime signal). One BaseURL, two distinct Endpoints, two distinct AI tags.

Sample queries.

// Every endpoint flagged as an AI surface (any signal channel)
MATCH (b:BaseURL)-[:HAS_ENDPOINT]->(e:Endpoint)
WHERE e.is_ai_framework_detected = true
RETURN b.url AS base_url, e.path,
       e.ai_framework_name, e.ai_frontend_product_guess

// Endpoints with AI provenance broken down by signal channel
MATCH (e:Endpoint)-[r:USES_TECHNOLOGY]->(t:Technology)
WHERE t.category STARTS WITH 'ai-'
RETURN r.detected_by, t.category, t.name, count(*) AS endpoints
ORDER BY r.detected_by, endpoints DESC

Endpoint AI Classifier

Once the URL discovery tools (Katana, Hakrawler, GAU, FFuf, ParamSpider, Arjun, Kiterunner, jsluice) finish, every Endpoint and Parameter the graph has accumulated gets a final pass that tags it against an AI-shape catalogue. This step is what turns a forest of crawled URLs into a labelled map of the target's AI surface — which paths are chat APIs, which are RAG ingestion routes, and which parameters carry user-controlled text that flows to an LLM.

The classifier runs as its own node in the workflow diagram (right of the URL discovery tools, upstream of the Endpoint and Parameter data pills). It does not send any traffic to the target — it only reads the graph and writes back enrichment properties. That makes it safe to re-run whenever the catalogue is extended, without re-crawling.

What it adds to the graph.

NodeNew propertyTypePossible values
Endpointai_interface_typestring enumllm-chat, llm-completion, llm-embedding, llm-tool-call, sse-stream, mcp, llm-graphql, non-llm
Endpointis_ai_rag_ingestbooltrue when the path looks like RAG ingestion / retrieval
Parameteris_ai_prompt_injectablebooltrue when the name matches the prompt-injection catalogue on an AI-classified endpoint
Parameterai_tool_arg_pathstring (JSON Pointer)reserved for the future central probe module — empty today

Where the toggles live. Project Settings → Resource Enum → AI Classifier sub-section.

UI toggleDefaultWhat it does
Endpoint AI Classifier (Master)OnMaster gate. Off disables every sub-classifier at once.
AI Path ClassifierOnStamps Endpoint.ai_interface_type by matching the URL path against the LLM / completion / embedding / tool-call / SSE / MCP / GraphQL catalogue.
AI RAG Path FlagOnStamps Endpoint.is_ai_rag_ingest = true for known RAG paths (OpenAI Vector Stores, Pinecone /vectors/upsert, Weaviate /v1/objects, Qdrant /collections/.../points).
AI Prompt-Injectable Param FlagOnStamps Parameter.is_ai_prompt_injectable = true when the parameter name is in the prompt-injection catalogue and the parent Endpoint is AI-classified.
AI Tool-Arg Path ResolverOnReserved for the future central probe module. No-op today.

The classifier is also exposed as a partial recon entry from the workflow graph. An operator can right-click the Endpoint AI Classifier node, choose Run, and the classifier re-tags every existing Endpoint in the graph without re-running the URL discovery tools.

Path classifier

What this channel sets. Endpoint.ai_interface_type = <enum value>.

How it works. The captured URL path (query string stripped) is matched against a catalogue of vendor-specific LLM routes. First match wins. Cited vendor APIs covered:

CategoryCatalogued patterns
llm-chatOpenAI /v1/chat/completions (also Fireworks, Together, Mistral, TGI), Groq /openai/v1/chat/completions, DeepSeek /chat/completions, Anthropic /v1/messages, OpenAI Responses /v1/responses, Ollama /api/chat, Gemini /v1beta/models/*:generateContent + :streamGenerateContent, Cohere v2 /v2/chat, Perplexity Sonar /v1/sonar
llm-completionOpenAI legacy /v1/completions (also TGI), Mistral FIM /v1/fim/completions, Ollama /api/generate, TGI /generate, SageMaker /invocations
llm-embeddingOpenAI /v1/embeddings (also Voyage, Mistral, Fireworks, Together), Ollama /api/embeddings + /api/embed, Cohere /v2/embed, Gemini :embedContent + :batchEmbedContents
llm-tool-callOpenAI Assistants /v1/threads/{id}/runs + /steps, OpenAI Responses /v1/responses/{id}/input_items, MCP tools/call (REST shim)
sse-streamTGI /generate_stream, LangServe /stream + /stream_log + /astream_events (suffix-position)
mcp/mcp + /api/mcp + /sse, MCP method names tools/list / resources/list / prompts/list
llm-graphql/graphql, /api/graphql, /v1/graphql (gated on parent BaseURL being AI-tagged)

Paths that don't match any catalogue entry get ai_interface_type = "non-llm" — an explicit sentinel so Cypher queries can include negative cases.

RAG path flag

What this channel sets. Endpoint.is_ai_rag_ingest = true.

How it works. Two tiers:

  • Unambiguous vendor-specific paths fire unconditionally — OpenAI Files / Uploads / Vector Stores / Assistants / Threads, Pinecone /vectors/upsert, Weaviate /v1/objects + /v1/batch/objects, Qdrant /collections/{name}/points + /search + /query.
  • Ambiguous paths gated on parent-AI/upload, /files, /search, /query, /embed, /embeddings, /index, /retrieve, /lookup, /knn, /rag, /vectorize, /documents. These only fire when the parent BaseURL has already been tagged as AI (via header / favicon / title signal from the HTTP probe). Without that gate, every e-commerce search bar would be flagged as a RAG endpoint.

Prompt-injectable parameter flag

What this channel sets. Parameter.is_ai_prompt_injectable = true.

How it works. For each Parameter on an Endpoint that is AI-classified (ai_interface_type is not non-llm OR is_ai_rag_ingest is true), the parameter name is matched case-insensitively against a catalogue of known prompt-injection field names sourced from vendor request-body schemas:

messages, prompt, system, input, instructions, contents, systemInstruction, inputs, suffix, tools, arguments (cited from OpenAI / Anthropic / Gemini / Ollama / Cohere / Mistral / TGI / HF Inference / MCP). Soft-match names (query, question, text, q, tool_input, function_args, content, message, instruction, search_query, query_text, tool_arguments) also fire — the parent-Endpoint gate prevents over-tagging on non-AI sites.

A parameter named text on /contact is not flagged. The same parameter on /v1/chat/completions is.

Sample queries.

// Endpoints classified by interface type
MATCH (e:Endpoint) WHERE e.ai_interface_type IS NOT NULL
  AND e.ai_interface_type <> 'non-llm'
RETURN e.ai_interface_type, count(*) AS endpoints
ORDER BY endpoints DESC;

// Potentially prompt-injectable params on chat endpoints
MATCH (e:Endpoint)-[:HAS_PARAMETER]->(p:Parameter)
WHERE p.is_ai_prompt_injectable = true
  AND e.ai_interface_type STARTS WITH 'llm-'
RETURN e.baseurl, e.path, e.ai_interface_type, p.name, p.position;

// RAG ingestion endpoints
MATCH (e:Endpoint) WHERE e.is_ai_rag_ingest = true
RETURN e.baseurl, e.path, e.ai_interface_type;

JS Recon AI SDK Detection

The previous five hooks scan signals the rest of the pipeline already gathered for free — DNS records, ports, banners, headers, titles, URL paths. JS Recon AI SDK Detection extends the catalogue into a place those hooks structurally cannot reach: the JavaScript bundles that modern SPAs ship to the browser.

Bundles are a fundamentally different vantage point. The http_probe Wappalyzer pass scans the initial HTML response body (capped at ~512KB), which catches inline scripts and the first JS chunk. But on any Next.js / Vite / Webpack app the interesting AI imports — @anthropic-ai/sdk, @langchain/openai, @google/genai, @modelcontextprotocol/sdk, Pinecone/Qdrant clients — almost always live in separate async-loaded chunks the http_probe never sees. Worse, when developers hard-code API keys (it happens more often than you'd hope), the keys also end up in those chunks, base64-shaped and ready to grep.

This phase teaches js_recon to run a new analysis pass over every JavaScript file it already downloads, using the same recon/helpers/ai_signal_catalog.py source of truth. No new module, no new traffic — pure pattern matching over bytes already on disk.

What it adds to the graph. Each match becomes a new JsReconFinding node (the existing label for everything js_recon writes) with one of five new finding_type values:

  • ai-sdk-client — vendor SDK import string (import OpenAI from "openai", require("@langchain/anthropic"), …)
  • ai-sdk-key-literal — a provider API key hard-coded into the bundle
  • ai-sdk-browser-allowed — the OpenAI/Anthropic dangerouslyAllowBrowser: true opt-in
  • ai-frontend-detected — Open WebUI, Flowise, Gradio, etc. product markers in shipped JS chunks
  • ai-provider-url — provider base URLs (api.openai.com, generativelanguage.googleapis.com, …)

For ai-sdk-key-literal findings whose captured value overlaps an existing Secret node (caught earlier by the legacy JS_SECRET_PATTERNS scan), the matching Secret gets a new ai_provider property set to the AI vendor name. This is the "value-prefixed reuse" pattern: rather than build a parallel Secret taxonomy, AI context is grafted onto the existing one so generic-secret queries pivot straight to AI findings.

Where the toggle lives. Project Settings → JS ReconnaissanceAI SDK Detection (default on). Pure regex over data js_recon already harvested; sends no additional traffic to the target.

Five signal channels

The catalogue is a single ordered pipeline inside match_ai_sdk(content):

  1. Constructor-context key patterns run first. A new OpenAI({apiKey:"sk-proj-…"}) literal yields one finding with sdk_name="OpenAI SDK constructor". The matched byte range goes into a "claimed" set.
  2. Prefix-anchored key patterns run second. Any standalone sk-proj-…/sk-ant-…/hf_…/gsk_…/etc. literal is detected — except matches that fall inside a claimed range from step 1 (which would be a duplicate finding for the same constructor key).
  3. SDK imports. Every npm package name shipped as a string literal ("openai", "@anthropic-ai/sdk", "@langchain/openai", "@modelcontextprotocol/sdk", "@pinecone-database/pinecone", ~65 vendors total).
  4. Browser-mode flags. dangerouslyAllowBrowser: true (OpenAI/Anthropic), the terser-minified !0 form, the JSON-stringified "dangerouslyAllowBrowser":true (Next.js __NEXT_DATA__).
  5. Frontend product markers + provider URLs. Open WebUI WEBUI_NAME, Gradio customElements.define("gradio-app", …), Streamlit stApp testids, etc., plus 22 provider base URLs (OpenAI, Anthropic, Cohere, Groq, Together, Bedrock, Azure OpenAI, Cloudflare AI Gateway, …).
CategorySeverity rangeConfidenceTypical use
ai-sdk-key-literal (constructor)criticalhighThe smoking gun — fix immediately
ai-sdk-key-literal (prefix, env-var leak, Bearer/x-api-key header)high–criticalmedium–highSame gravity, multiple shapes
ai-sdk-browser-allowedcritical (OpenAI/Anthropic)highMeans the key is on the client by design
ai-sdk-clientmedium–highhighConfirms AI integration even with no key visible
ai-frontend-detectedmediumhighConfirms self-hosted AI product
ai-provider-urlmediumhighConfirmatory; pair with import/key to escalate

Gemini disambiguation

Google's AIzaSy[A-Za-z0-9_-]{33} key format is shared by Gemini, Maps, Firebase, YouTube Data, Translate, and every other Google service — they're all the same shape. Treating every match as a Gemini leak would flood reports with false positives.

The catalogue handles this with a post-match disambiguation rule. When the AIzaSy pattern fires, the helper scans ±2KB around the match for any of these Gemini-specific tokens:

@google/genai, @google/generative-ai, GoogleGenerativeAI, GoogleGenAI,
generativelanguage.googleapis.com, gemini-1.5, gemini-2, x-goog-api-key
Context found within ±2KB?sdk_nameseverity
YesGoogle Gemini API Keycritical
NoGoogle API Key (likely Maps/Firebase)medium

Both findings are emitted (operators can still investigate the Maps/Firebase case), but the severity-weighted risk score escalates only when there's real evidence of Gemini integration.

Constructor-context suppression

The pattern new OpenAI({apiKey: "sk-proj-…T3BlbkFJ…"}) would match TWO regexes in isolation:

  1. The constructor-context pattern (captures the SDK + key together, severity critical)
  2. The prefix-anchored pattern (captures just the sk-proj-… key, severity critical)

Without dedup, every leaked SDK key would emit two parallel findings — same value, different framing. The matcher resolves this by running constructors first, recording their (start, end) spans in a claimed_ranges list, and suppressing any subsequent prefix match whose span falls inside a claimed range. The result is exactly one finding per leaked key, attributed to its specific SDK (so Anthropic SDK constructor instead of generic Anthropic API Key).

Secret enrichment (value-prefixed reuse)

When match_ai_sdk detects a hard-coded AI key in a JS file, the legacy JS_SECRET_PATTERNS pass has almost certainly also detected it (their regex sets overlap on every common AI vendor prefix). Without coordination, the graph would carry two parallel finding nodes for one underlying problem — confusing operators, inflating counts, double-weighting the risk score.

The Phase 6 graph mixin solves this with the same "value-prefixed reuse" rule the rest of the AI rollout follows. Pseudocode:

For every JsReconFinding(finding_type = 'ai-sdk-key-literal'):
  1. Create the JsReconFinding node (the structural-context view: SDK
     constructor span, byte offset, sdk_name).
  2. MATCH any pre-existing Secret with the same source_url whose
     matched_text starts with a known AI-key prefix (sk-, hf_, lsv2_,
     gsk_, r8_, pcsk_, pplx-, xai-, csk-, tgp_, pa-, AIzaSy, co_, rpa_,
     pk-lf-, fw_) AND overlaps the captured key value.
  3. SET that Secret's ai_provider = <SDK constructor name> and
     ai_finding_id = <new JsReconFinding id>.

The AI-prefix gate is critical. Without it, a Stripe sk_live_… literal living in the same JS file as an OpenAI key could be wrongly enriched as AI (Cypher's symmetric CONTAINS is a sharp tool). The strict prefix list ensures only Secret nodes that actually start with an AI vendor's key format get the enrichment.

Why this matters for the operator. Existing secret-discovery queries — "show me every API key found in JS files" — automatically gain AI context. You don't need a new dashboard or a new query template; the same MATCH (s:Secret) clause now answers "and which of these are AI provider keys?" via WHERE s.ai_provider IS NOT NULL.

Sample queries against the graph.

// Every endpoint shipping an AI SDK to the browser (any vendor)
MATCH (b:BaseURL)-[:HAS_JS_FILE]->(file:JsReconFinding {finding_type:'js_file'})
  -[:HAS_JS_FINDING]->(jf:JsReconFinding)
WHERE jf.finding_type = 'ai-sdk-client'
RETURN b.url, file.source_url AS js_file, jf.sdk_name, jf.severity
ORDER BY b.url;

// CRITICAL: hard-coded AI provider keys in client-shipped JS
MATCH (jf:JsReconFinding {finding_type:'ai-sdk-key-literal'})
WHERE jf.severity = 'critical'
RETURN jf.source_url AS file, jf.sdk_name AS leaked_for, jf.sample
ORDER BY file;

// Sites where the developer EXPLICITLY opted into shipping the key to the
// browser via dangerouslyAllowBrowser
MATCH (jf:JsReconFinding {finding_type:'ai-sdk-browser-allowed'})
RETURN jf.source_url, jf.sdk_name;

// Pivot from generic secret → AI provider (the value-prefixed reuse view)
MATCH (s:Secret) WHERE s.ai_provider IS NOT NULL
RETURN s.source_url, s.secret_type, s.ai_provider, s.validation_status, s.sample
ORDER BY s.severity DESC;

// Rollup: AI SDK findings by category and severity
MATCH (jf:JsReconFinding)
WHERE jf.finding_type STARTS WITH 'ai-'
RETURN jf.finding_type AS category, jf.severity AS severity, count(*) AS n
ORDER BY category, severity DESC;

The catalogue ships 164 patterns across the 6 families (33 prefix-anchored keys + 18 constructor-context + 65 SDK imports + 3 browser flags + 23 frontend markers + 22 provider URLs). For end-to-end validation see the guinea pig fixture suite — 23 JS files served on port 9104, each engineered to exercise one specific detection branch including the Gemini disambiguation rule, constructor suppression, terser truthy-rewrites, and negative-case regression guards (jQuery + Stripe-only bundles that must produce zero AI findings).


How the channels combine on one Technology node

A single AI product (say anythingllm) can be detected via three different channels in the same scan:

ChannelEvidence captured
Port Scan AI catalog (naabu-ai-port)Port 3001 was open
HTTP Probe header (httpx-ai-header)Response carried an AnythingLLM-specific header
HTTP Probe title (httpx-ai-title)Body contained <title>AnythingLLM</title>

All three create / re-use the same Technology {name:'anythingllm', category:'ai-frontend'} node — Neo4j deduplicates by content. You get one Technology node with multiple incoming edges, each carrying a different detected_by. This makes provenance trivial to audit: a Technology that has only a port-catalog edge is port-only evidence; one with both header and title edges is high-confidence.


AI Surface Recon (active probing)

Everything above is passive: each phase annotates data another tool already collected. AI Surface Recon is the active half — a dedicated module (display Phase 4.5, after Resource Enumeration) that sends its own benign, protocol-aware probes to confirm and characterize the AI surfaces the passive phases only flagged. It answers one question: "what AI surface is here, and what shape is it?"

It is black-box and benign. It sends a 1-token chat "ping", a standard MCP initialize, OpenAPI GETs, declarative fingerprint probes, and vector-DB health reads. It never jailbreaks, prompt-injects, fuzzes, mutates, or presents credentials — and it treats an auth wall (401) as a positive signal, not something to defeat. (Jailbreaking and exploitation belong to later offensive containers.)

How it decides what to probe. It does not blast every host. A host is probed only if a passive phase already saw an AI signal on it — a classified chat/MCP endpoint, an AI header/title/favicon, or an AI/vector-DB port. Surviving candidates (minus RoE-excluded hosts) are fanned across a thread pool: hosts run in parallel, workloads run sequentially within a host. Vector-DB confirmation runs after the pool drains. Heavy libraries (mcp, yara, prance, jq, PyYAML) are lazy-imported, so a missing dependency degrades one workload, not the whole run.

The seven workloads

#WorkloadWhat it confirms / extracts
1Chat-shape probeConfirms an LLM chat endpoint without a key — a 401/422 with an OpenAI-style {"error":…} body proves the protocol. Records dialect, streaming, and p50 latency.
2MCP handshake + tools/list + YARAPerforms an MCP initialize, enumerates the full tool/resource/prompt surface (unauthenticated, where the server allows it), and statically scans every tool description + the server instructions with Cisco MCP YARA rules for tool-poisoning / prompt-injection. Hashes the tool set as a rug-pull pin.
3OpenAPI / manifest / model listingPassive GETs of /openapi.json, ai-plugin.json, /v1/models, /api/tags, … → tool/vision/streaming support, deployed model ids + family, and a cached spec on disk.
4Julius probe packA declarative YAML fingerprint engine (Praetorian Julius, reimplemented) that nails runtimes the others miss (vLLM, Ollama, TGI) by banner/response shape. New service = new YAML, zero code.
5Vector-DB confirmation readsA benign unauthenticated GET to a distinctive endpoint (qdrant /collections, weaviate /v1/meta, chroma /api/v2/heartbeat, milvus /healthz). A 200 confirms the DB and that its data API is anonymously readable — a real exposure.
6 / 7Latency baseline + summary gluep50 latency baseline (piggybacks on #1) and the cross-workload merge (model-family guess, summary counters).

The two highest-value security workloads are #2 (MCP tool poisoning — hidden <IMPORTANT> / "ignore previous instructions" payloads inside tool descriptions that an agent would read and obey) and #5 (exposed vector DBs — an anonymously-listable RAG corpus).

What it writes to the graph

Zero new node labels. It enriches existing nodes (all ai_* properties, COALESCE-merged so re-runs never clobber) and adds Vulnerability nodes:

Source workloadOutput nodeKey properties / edges
1, 3, 4Endpoint (GET, primary)ai_interface_type, ai_supports_streaming/tools/vision, ai_model_family_guess, ai_model_ids, ai_latency_p50_ms, ai_tool_schema_ref
2Endpoint (POST /mcp)ai_mcp_server_name/version, ai_mcp_protocol_version, ai_mcp_tool_count/resource_count/prompt_count, ai_mcp_caps, ai_mcp_auth_required, ai_mcp_tools_hash, ai_mcp_instructions_hash
2Parameter (per tool arg)is_ai_prompt_injectable, ai_tool_arg_path; linked (:Endpoint)-[:HAS_PARAMETER]->(:Parameter)
2VulnerabilityMCP tool-poisoning / injection, source=ai_surface_recon, ai_owasp_llm_id, ai_atlas_technique; attached most-specific-first to EndpointBaseURLSubdomainDomain
4Technology (ai-*)confirmed runtime, linked (:Endpoint)-[:USES_TECHNOLOGY {confidence:100}]->(:Technology)
5Technology (ai-vector-db)linked (:Port)-[:HAS_TECHNOLOGY]->(:Technology)

AI Surface Recon settings

The module exposes 15 toggles (all default-enabled) in the project settings — a master AI_SURFACE_RECON_ENABLED, per-workload toggles, and tuning knobs (_TIMEOUT, _MAX_WORKERS, _USER_AGENT, _PROBE_PACK_VERSION, _CACHE_ENABLED). Stealth mode keeps the passive probes on but throttles concurrency and drops the noisier active calls (MCP tool enumeration, vector-DB reads). See Project Settings Reference → AI Surface Recon for every parameter.


See also