A seed-polymorphic reconnaissance engine. Takes any of six seed types (IP, CIDR, Domain, ASN, CertFP, BannerString), runs a fixed-point iteration of probes against them, and produces a typed graph of findings with explicit provenance, drift detection, and exposure classification.
Stdlib only. No external dependencies.
Most recon tools are pipelines: seed → tool → output. The problem with
pipelines is that findings from tool A often should become inputs to
tool B, and the only way to express that today is glue scripts that are
hard to replay, hard to diff, and opaque about why something ended up
in the report.
recongraph is a graph, not a pipeline. Every finding is a typed node
with a provenance chain back to the original seed. Every relationship
is a typed edge. Two runs can be diffed to produce DRIFT_FROM edges
automatically. Every assertion in the output is explainable.
enqueue(initial_seeds)
while queue not empty and budget not exhausted:
seed = pop highest-confidence seed
for probe in passive_probes(seed):
finding = probe(seed, budget)
graph.ingest(finding, parent=seed)
promote(finding) -> new seeds (if score > θ)
if seed not passive-saturated and budget remains:
for probe in active_nonintrusive_probes(seed):
finding = probe(seed, budget)
graph.ingest(finding)
promote(finding)
classify_exposure(graph)
Three things make it interesting:
-
Passive saturation gates active probes. An active probe only fires against a seed if passive sources didn't already converge on the answer. Fewer packets, more signal.
-
Promotion is score-gated with loop guards. A finding becomes a new seed only if
confidence × specificity × freshness ≥ θ. Plus a lineage depth cap and pathological-value heuristics prevent the runaway self-similar recursion that kills naive recon loops. -
Exposure classification is a node property. Each Service node gets labeled (
public_intended | public_accidental | mgmt_exposed | legacy_drift | unknown) by explainable rules. You can query "show me everythinglegacy_driftin ASN 15169" across runs.
recongraph/
├── __init__.py # public API
├── types.py # Seed, Node, Edge, Finding, enums
├── graph.py # Graph storage, dedup, drift, JSON roundtrip
├── probes.py # Probe base class + registry + stub probes
├── probes_real/ # real probe implementations
│ └── ct_logs.py # crt.sh probe (stdlib only)
├── budget.py # hard-cap budget accounting
├── promote.py # finding → new-seed promotion rules
├── exposure.py # rule-based exposure classification
└── engine.py # fixed-point orchestrator
Tests:
smoke_test.py # full pipeline, synthetic probes
test_ct_probe.py # ct_logs probe parsing (recorded fixture)
live_test.py # live probe against crt.sh
from recongraph import Seed, SeedType, Finding, ProbeMode, Probe, Node, NodeType
def my_probe(seed: Seed, budget) -> Finding:
if seed.type != SeedType.IP:
return Finding(source="my-probe", mode=ProbeMode.PASSIVE, confidence=0)
# ... do the thing
return Finding(
source="my-probe",
mode=ProbeMode.PASSIVE,
confidence=0.8,
nodes=[Node(type=NodeType.HOST, value=seed.value, attrs={...})],
edges=[...],
)
registry.register(Probe(
name="my_probe",
accepts=(SeedType.IP,),
mode=ProbeMode.PASSIVE,
fn=my_probe,
cost=2,
))def rule_my_thing(node, graph):
if node.type != NodeType.SERVICE:
return None
if some_condition(node):
return (ExposureClass.MGMT_EXPOSED, "my_reason")
return None
# prepend so it runs before the default rules
classify_graph(graph, rules=[rule_my_thing] + DEFAULT_RULES)snapshot = Graph.from_dict(json.loads(old_json))
current.emit_drift_edges(snapshot) # adds DRIFT_FROM edges
diff = current.diff(snapshot) # returns {added_nodes, removed_nodes, ...}- No external dependencies. urllib.request, json, hashlib, dataclasses. Runs anywhere Python runs.
- All network I/O is in probes. The engine, graph, budget, and classification logic are pure. Unit-testable with fixtures.
- Hard budget caps, not soft. A recon tool that can't stop isn't a recon tool, it's a DoS.
- Every promotion is auditable. Node.provenance records the seed-chain. You can answer "why did this node end up in my graph" from the graph itself.
- Active probes are gated. By default the engine runs passive-first and only drops to active when passive signal is ambiguous.
Every probe in probes.py except the ones you register real
implementations for. The one fully-implemented real probe is
probes_real/ct_logs.py (crt.sh CT log query). The orchestration
is probe-agnostic — fill in the surfaces you care about.
The engine ships with several additional modules developed during real-world testing:
| Module | Purpose |
|---|---|
cloud_ranges.py |
Classifier over Google/AWS/Cloudflare published range files, plus rDNS pattern matching for GCP/AWS/Azure/Huawei/Alibaba/OVH/DigitalOcean/Linode/Vultr/Hetzner. Weekly on-disk cache. |
l7_fingerprint.py |
Raw HTTP probe ladder, canonical error-page signature library with fail-closed matching (unsupported match keys never silently succeed), HTTP/2 cleartext support detection. |
neighbors.py |
/24 and /20 homogeneity sweep with verdict classification (highly homogeneous → shared edge pool; highly heterogeneous → single tenant per IP). |
tenant_model.py |
TenantModel taxonomy, IdentificationConfidence levels, EnvironmentalConstraints for recording what the environment prevented observing. |
sandbox_detect.py |
Startup check: probe unrelated reference IPs with identical payloads, compare response shapes. Identical across targets → environment is intercepting. Downgrades L7-derived tenant conclusions to OPAQUE when detected. |
upgraded_runs.py is the reference pipeline that wires everything together.
This project was developed in an agent sandbox that intercepts TCP/TLS to
arbitrary IPs — which is why sandbox_detect.py exists. Running from a
clean environment (a personal machine via Claude Code, or any normal
operator workstation) unlocks the full probe surface.
git clone https://github.com/Nicholas-Kloster/recongraph
cd recongraph
# Claude Code reads CLAUDE.md on session start for project context
claudeIn a clean environment:
sandbox_detect.detect()returnshttp_intercepted=False- All L7 probes measure real targets
- TLS cert extraction returns real issuer/SAN data
- Port scans see all listening ports, not just the ones a sandbox allows
Six seeds were used to develop and validate the engine. Graph JSON artifacts
for each are in runs/:
| Seed | Outcome |
|---|---|
91.232.105.105 |
Worldstream NL — commercial aggregator (org redacted), fully identified via CT + pDNS + named HTTPS |
1.92.126.161 |
Huawei Cloud CN — academic AI research group (org/person redacted), identified via HTML content + OpenAlex author pivot on demo project names |
35.225.160.4 |
GCP us-central1 — managed-service IP, tenant correctly reported as opaque (cloud ranges + rDNS real; L7 sandbox-contaminated) |
13.83.163.63 |
Azure westus — customer IP, verified-opaque (every passive source clean, correctly declined to guess a tenant) |
134.122.9.215 |
DigitalOcean NYC3 — indie product portfolio (org/products redacted). Shodan InternetDB was the pivot unlock. |
helloworld-6zrfagk6gq-uc.a.run.app |
Cloud Run — shared edge pool across 16 GFE IPs, classified via DNS inference independent of L7 contamination |
Maintained by Nicholas Michael Kloster as part of NuClide — independent AI infrastructure security research.
CISA disclosures: CVE-2025-4364 · ICSA-25-140-11
Note: the per-seed JSON artifacts under
runs/and the reproducer scripts underseed_runs/retain third-party operator/person identification reached during development, kept as empirical evidence of what the engine resolved. The publicREADME.mdandCLAUDE.mdgeneralize that attribution.