Skip to content

Nicholas-Kloster/recongraph

Repository files navigation

Claude Code Friendly

recongraph

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.

Why it exists

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.

The algorithm

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:

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

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

  3. 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 everything legacy_drift in ASN 15169" across runs.

Layout

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

Extending

Add a probe

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

Add an exposure rule

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)

Diff two runs

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, ...}

Design constraints that are on purpose

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

What's stubbed

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.

Additional modules beyond the core

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.

Running this from Claude Code

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
claude

In a clean environment:

  • sandbox_detect.detect() returns http_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

Tested seeds

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

About

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 under seed_runs/ retain third-party operator/person identification reached during development, kept as empirical evidence of what the engine resolved. The public README.md and CLAUDE.md generalize that attribution.

Releases

No releases published

Packages

 
 
 

Contributors

Languages