Adversarial AI Recon
On this page
- Why we added "AI awareness" to recon
- Pipeline placement
- Domain Recon AI hints
- Port Scan AI catalog
- Nmap version regex
- HTTP Probe AI signatures
- Endpoint AI Classifier
- JS Recon AI SDK Detection
- How the channels combine on one Technology node
- AI Surface Recon (active probing)
- See also
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_KEYand 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
- 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.
- Black-box only. Detection is from outside, from headers/ports/titles/DNS that any anonymous probe sees. No API keys required, no
Authorizationheader sent to the target. - Zero new graph labels. AI findings ride on existing node labels (
Subdomain,Service,Endpoint,Technology) with new properties prefixedai_*/is_ai_*or newTechnology.categoryvalues prefixedai-. 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-*oranthropic-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 toggle | Default | What it does |
|---|---|---|
| AI TXT/SPF/DKIM Hint | On | Reads 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 Hint | On | Looks 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 observed | ai_service_hint set to |
|---|---|
TXT anthropic-domain-verification=... | anthropic |
TXT huggingface-domain-verification-... | huggingface |
NS ns1.runpod.io | runpod |
NS ns.langfuse.com | langfuse |
| NS points at a Vercel / Modal / Netlify nameserver, no TXT match | ai-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 toggle | Project Settings location | Default | What it does |
|---|---|---|---|
| AI Port Catalog | Naabu tab | On | When Naabu reports a known AI port open, creates the matching Technology node and links it to the Service and Port. |
| AI Port Catalog | Masscan tab | On | Same 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:
| Tier | Behaviour 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:
| Tier | Port | What's catalogued | Category |
|---|---|---|---|
| Auto-promote | 11434 | ollama | ai-runtime |
| 6333 | qdrant | ai-vector-db | |
| 6334 | qdrant-grpc | ai-vector-db | |
| 19530 | milvus | ai-vector-db | |
| 7860 | gradio / automatic1111 / langflow | ai-frontend | |
| 8188 | comfyui | ai-frontend | |
| 8501 | streamlit | ai-frontend | |
| 8880 | kokoro-tts | ai-runtime | |
| 30000 | sglang | ai-runtime | |
| 6900 | argilla | ai-mlops | |
| 2024 | langgraph-dev | ai-framework | |
| Disambiguate | 1234 | lm-studio (also misc dev port) | ai-runtime |
| 3000 | bentoml / langflow / openllm (also Node / Grafana / Next.js dev) | ai-runtime | |
| 3001 | anythingllm (also Node alt port) | ai-frontend | |
| 4000 | litellm (also Phoenix / Rails / Express) | ai-proxy | |
| 5000 | mlflow (also Flask default) | ai-mlops | |
| 5001 | koboldcpp (also Flask alt) | ai-runtime | |
| 6006 | phoenix-arize (also TensorBoard) | ai-mlops | |
| 7865 | fooocus | ai-frontend | |
| 8000 | vllm / chroma / langserve / NIM / mlc / faster-whisper (also Django / FastAPI) | ai-runtime | |
| 8001 | triton / vllm / redis-insight | ai-runtime | |
| 8002 | triton-metrics | ai-runtime | |
| 8080 | open-webui / weaviate / localai / whisper.cpp (also Tomcat / Jenkins) | ai-frontend | |
| 8081 | autogen-studio (also alt HTTP) | ai-mlops | |
| 8123 | langgraph-platform (also ClickHouse) | ai-framework | |
| 8265 | ray-dashboard (also Cassandra JMX) | ai-mlops | |
| 9091 | milvus-metrics / invokeai (also Prometheus pushgateway) | ai-vector-db | |
| 50051 | weaviate-grpc | ai-vector-db |
ai-mlopscategory. Covers observability / experiment-tracking surfaces — MLflow, Langfuse, Phoenix-Arize, Ray Dashboard, Argilla, AutoGen Studio. Distinct fromai-runtime(model serving),ai-vector-db(storage),ai-framework(libraries),ai-proxy(LLM-gateway middleware),ai-frontend(chat UIs), andai-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 toggle | Default | What it does |
|---|---|---|
| AI Runtime Version Regex | On | After 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 customServer:header gets ignored in favour of timing and behaviour fingerprints. In that caseai_runtime_versionstays 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
categoryinai-runtime,ai-vector-db,ai-framework,ai-proxy,ai-frontend,ai-sdk-client, linked to the Endpoint viaUSES_TECHNOLOGY. The relationship'sdetected_byvalue tells you which channel matched —httpx-ai-header,httpx-ai-title, orhttpx-ai-favicon.
Where the toggles live. Project Settings → Httpx tab → AI Surface Recon sub-section.
| UI toggle | Default | What it does |
|---|---|---|
| AI Header Signatures | On | Matches 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 Regex | On | Matches the page <title> against AI frontend product names (Open WebUI, LibreChat, Flowise, Dify, …). |
| AI Favicon Hash Lookup | On | Looks 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 Fingerprints | On | Scans 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:
| Category | Products detected via headers |
|---|---|
| Runtime | vLLM (x-vllm-*), Text Generation Inference (TGI), Text Embeddings Inference, BentoML, Baseten, Modal, Replicate, RunPod |
| Framework | LangChain, LlamaIndex, Langfuse, MCP servers |
| Proxy / gateway | LiteLLM, Helicone, Portkey, OmniRoute, Cloudflare AI Gateway, Together |
| SDK client | OpenAI (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:
| Category | Products detected by title |
|---|---|
| Chat / generic LLM frontends | Open 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 frontends | InvokeAI (Invoke - Community Edition), AUTOMATIC1111 Stable Diffusion WebUI (Stable Diffusion) |
| MLOps / observability | MLflow, 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):
| Category | Products |
|---|---|
| Chat frontends | Open 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 frontends | InvokeAI |
| MLOps & observability | MLflow, 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:
| Category | Signature pattern |
|---|---|
| Runtime | TGI /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 |
| Framework | LangChain 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) |
| Frontend | Gradio (<gradio-app> custom element or window.gradio_config global); Streamlit (data-testid="stApp", stStreamlitApp) |
| Image-gen UI | AUTOMATIC1111 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 / observability | MLflow (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 DB | Weaviate (/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:
| Node | What identifies it | What it carries |
|---|---|---|
| BaseURL | scheme + host + port (one per HTTP service) | The service location; certificate links |
| Endpoint | path + 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.
| Node | New property | Type | Possible values |
|---|---|---|---|
Endpoint | ai_interface_type | string enum | llm-chat, llm-completion, llm-embedding, llm-tool-call, sse-stream, mcp, llm-graphql, non-llm |
Endpoint | is_ai_rag_ingest | bool | true when the path looks like RAG ingestion / retrieval |
Parameter | is_ai_prompt_injectable | bool | true when the name matches the prompt-injection catalogue on an AI-classified endpoint |
Parameter | ai_tool_arg_path | string (JSON Pointer) | reserved for the future central probe module — empty today |
Where the toggles live. Project Settings → Resource Enum → AI Classifier sub-section.
| UI toggle | Default | What it does |
|---|---|---|
| Endpoint AI Classifier (Master) | On | Master gate. Off disables every sub-classifier at once. |
| AI Path Classifier | On | Stamps Endpoint.ai_interface_type by matching the URL path against the LLM / completion / embedding / tool-call / SSE / MCP / GraphQL catalogue. |
| AI RAG Path Flag | On | Stamps 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 Flag | On | Stamps 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 Resolver | On | Reserved 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:
| Category | Catalogued patterns |
|---|---|
llm-chat | OpenAI /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-completion | OpenAI legacy /v1/completions (also TGI), Mistral FIM /v1/fim/completions, Ollama /api/generate, TGI /generate, SageMaker /invocations |
llm-embedding | OpenAI /v1/embeddings (also Voyage, Mistral, Fireworks, Together), Ollama /api/embeddings + /api/embed, Cohere /v2/embed, Gemini :embedContent + :batchEmbedContents |
llm-tool-call | OpenAI Assistants /v1/threads/{id}/runs + /steps, OpenAI Responses /v1/responses/{id}/input_items, MCP tools/call (REST shim) |
sse-stream | TGI /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 bundleai-sdk-browser-allowed— the OpenAI/AnthropicdangerouslyAllowBrowser: trueopt-inai-frontend-detected— Open WebUI, Flowise, Gradio, etc. product markers in shipped JS chunksai-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 Reconnaissance → AI 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):
- Constructor-context key patterns run first. A
new OpenAI({apiKey:"sk-proj-…"})literal yields one finding withsdk_name="OpenAI SDK constructor". The matched byte range goes into a "claimed" set. - 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). - 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). - Browser-mode flags.
dangerouslyAllowBrowser: true(OpenAI/Anthropic), the terser-minified!0form, the JSON-stringified"dangerouslyAllowBrowser":true(Next.js__NEXT_DATA__). - Frontend product markers + provider URLs. Open WebUI
WEBUI_NAME, GradiocustomElements.define("gradio-app", …), StreamlitstApptestids, etc., plus 22 provider base URLs (OpenAI, Anthropic, Cohere, Groq, Together, Bedrock, Azure OpenAI, Cloudflare AI Gateway, …).
| Category | Severity range | Confidence | Typical use |
|---|---|---|---|
ai-sdk-key-literal (constructor) | critical | high | The smoking gun — fix immediately |
ai-sdk-key-literal (prefix, env-var leak, Bearer/x-api-key header) | high–critical | medium–high | Same gravity, multiple shapes |
ai-sdk-browser-allowed | critical (OpenAI/Anthropic) | high | Means the key is on the client by design |
ai-sdk-client | medium–high | high | Confirms AI integration even with no key visible |
ai-frontend-detected | medium | high | Confirms self-hosted AI product |
ai-provider-url | medium | high | Confirmatory; 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_name | severity |
|---|---|---|
| Yes | Google Gemini API Key | critical |
| No | Google 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:
- The constructor-context pattern (captures the SDK + key together, severity critical)
- 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:
| Channel | Evidence 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
| # | Workload | What it confirms / extracts |
|---|---|---|
| 1 | Chat-shape probe | Confirms 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. |
| 2 | MCP handshake + tools/list + YARA | Performs 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. |
| 3 | OpenAPI / manifest / model listing | Passive 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. |
| 4 | Julius probe pack | A 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. |
| 5 | Vector-DB confirmation reads | A 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 / 7 | Latency baseline + summary glue | p50 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 workload | Output node | Key properties / edges |
|---|---|---|
| 1, 3, 4 | Endpoint (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 |
| 2 | Endpoint (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 |
| 2 | Parameter (per tool arg) | is_ai_prompt_injectable, ai_tool_arg_path; linked (:Endpoint)-[:HAS_PARAMETER]->(:Parameter) |
| 2 | Vulnerability | MCP tool-poisoning / injection, source=ai_surface_recon, ai_owasp_llm_id, ai_atlas_technique; attached most-specific-first to Endpoint→BaseURL→Subdomain→Domain |
| 4 | Technology (ai-*) | confirmed runtime, linked (:Endpoint)-[:USES_TECHNOLOGY {confidence:100}]->(:Technology) |
| 5 | Technology (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
- AI Gauntlet — the offensive follow-up: attacks the AI endpoints this module discovers, with garak / PyRIT / Giskard / promptfoo.
- Recon Pipeline Workflow — where in the pipeline each AI hook fires.
- AI in the Recon Pipeline — the other half: the LLM helpers that sharpen the scanners themselves (ffuf extensions, nuclei tags, WAF / takeover / false-positive calls).
- Running Reconnaissance — AI Surface Recon as pipeline GROUP 5c.
- Attack Surface Graph — the graph schema the annotations write to.
- Project Settings Reference — every project toggle, organised by tab.