Skip to content
Scanning

Scanning

Eon Kartis runs discovery and scanning as a pipeline across three processes, Postgres, and Redis pub/sub:

                                 Redis "discovery"               Redis "scan"
operator ────────publish────► kartis-discovery ──publish──► kartis-scan
                              │      ↓                          ↓
                     (Redis "enum") discovered_domains          scan_runs → scan_findings
                              ↓                                 scan_services
                        kartis-recon                            scan_analyzer_results
                                                                 scan_pqc_signals

Stage A — domain discovery

kartis-discovery subscribes to the Redis channel discovery and, for a target domain, runs a set of discovery sources in parallel. Every result is stored in the discovered_domains table:

SourceCodeWhat it does
WHOISinternal/discovery/whois.goRegistrant / nameserver pivots
CT logsinternal/discovery/ctlogs.goCertificate Transparency subdomain harvest
DNS reconinternal/discovery/dnsrecon.goBrute-force / DNSDB / reverse lookups
IP-ASNinternal/discovery/ipasn.goASN expansion, neighboring prefixes
Tech fingerprintinternal/discovery/techfp.goHTTP/TLS fingerprint → related hosts
AI correlationinternal/ai/Optional LLM pass correlating the harvest into same-org relations

Subdomain enumeration itself is delegated over Redis (channel enum) to a dedicated worker, kartis-recon, which runs the enumeration toolchain (subfinder + DNS brute-force, plus optional AI-suggested candidates that are DNS-validated before use) and an httpx-based liveness/certificate/IP-capture pass. It publishes completion on enum-result; if Redis or the worker is unavailable, kartis-discovery falls back to enumerating in-process so a scan degrades rather than stalling.

The orchestrator entry point is internal/discovery/orchestrator.goRunDiscovery(ctx, jobUUID).

Recursive organization discovery

Beyond a single domain’s subdomains, kartis-discovery run <domain> expands into related same-organization domains (found via WHOIS/DNS-recon/reverse-IP pivots) and enumerates each of those in turn, up to a configurable depth:

kartis-discovery run <domain> [flags]
  --depth <int>                  related-expansion hops; 0 = unlimited (default 2)
  --min-confidence <float>       minimum confidence to expand a related domain
  --max-related-per-job <int>    per-job cap on related domains expanded
  --max-jobs <int>               per-tree total job cap
  --timeout <duration>           max time to wait for the tree to finish (default 30m)
  --json                         emit JSON instead of a tree (default false)
  --judge                        final AI review pass flagging related domains that may
                                  not belong to the org — needs ai.api_key (default true)

The command prints a job tree annotated with each related domain’s discovery source and confidence, and — when --judge is enabled and an AI provider is configured — a ⚠ review flag on relations the model doesn’t think actually belong to the seed organization.

Stage B — DNS filter and scan queue

Internally to kartis-discovery (internal/discovery/scand_bridge.go), once discovery for a job finishes:

  1. Every discovered_domains row for the job is DNS-resolved (4-second timeout per host).
  2. One scan_session and one scan_run is created per resolvable host — one session per host avoids an N² blow-up in nmap targets.
  3. Each new run UUID is published on the Redis channel scan.

There is no manual trigger for this stage in isolation; it always runs immediately after Stage A completes.

Stage C — per-host scan

kartis-scan daemon subscribes to the Redis channel scan. For each run it:

  1. Runs port discovery (internal/portscan/), backed by nmap or a bare TCP-connect probe, at a chosen pacing profile.
  2. Runs every matching analyzer in parallel for each discovered service.
  3. On the DB-write path only, runs an ipleak post-pass (internal/analyzers/iplook/) over every analyzer’s raw evidence, looking for leaked internal IP addresses (RFC 1918 / CGN / link-local / ULA) and recording one scan_findings row per leak.

The orchestrator entry point is internal/scan/orchestrator.goRunSession(ctx, runUUID).

Depth and category model

Every scan chooses a max depth and a category filter. kartis runs every module at or below the chosen depth, shallow-first (internal/scan/level.go, internal/scan/registry.go):

Depth (max_level)MeaningIntrusiveness
discoverPort/service discovery onlyBenign
passiveReads what the target offers, no crafted input (TLS cert/handshake read, tech fingerprint, WAF header sniff)Benign
activeSpeaks the protocol interactively but benignly (SSH/IKE/OpenVPN/STARTTLS negotiation)Benign
vulnSafe vulnerability detection, without exploitationNon-destructive

There is deliberately no offensive/attack level: attack-shaped probing (for example WAF XSS/SQLi/LFI payloads) is not reachable through the API or the scan chain by construction. Requesting an unknown level returns an error (HTTP 400 on the API).

CategoryModules
discoveryport scan
cryptotls, starttls, ssh, ike, openvpn, plaintext, certinfo — the PQC/crypto posture core of the product
webwaf (passive only in the chain)
infoleakinternal-IP-leak detection (ipleak)

Omitting categories runs all four; passing a subset (e.g. ["crypto","web"]) scopes the scan.

CLI

kartis-scan scan — standalone, no DB, no daemon

Prints JSON to stdout. Best for fast iteration on a single host; runs nmap plus every analyzer at or below the chosen depth. The ipleak post-pass does not run in this mode (it only fires on the DB write path).

kartis-scan scan <target> [flags]
  --profile <name>        scan profile / nmap pacing (default "polite")
  --max-level <level>     discover|passive|active|vuln (default "active")
  --categories <list>     discovery,crypto,web,infoleak (default: all)
  --only <name>           run a single chain module by name
  --engine <name>         port discovery engine: nmap|connect (default "nmap")
  --json                  output JSON to stdout (default true)

--engine connect uses a single TCP connect per port — the gentlest liveness probe, with no nmap dependency.

kartis-scan scan qgf.io
kartis-scan scan qgf.io --profile normal --max-level passive --categories crypto

--profile accepts polite, normal, or aggressive (see the profiles table above); any other value silently falls back to polite rather than erroring.

kartis-scan daemon — DB-backed dispatch

kartis-scan daemon -c /etc/kartis/kartis-scan.yaml

Claims pending rows from scan_runs on a 5-second poll (the DB is the source of truth), with an optional Redis pub/sub nudge for low-latency dispatch. A bounded worker pool executes up to concurrent_runs (from the active scan profile) runs at once, and a stale-run reconciler fails runs orphaned by a previous crash.

kartis-scan serve — HTTP API

kartis-scan serve -c /etc/kartis/kartis-scan.yaml

Serves the bearer-token-authenticated /v1/... API (sessions, runs, findings, services, discovery reads) that Eon Center, Eon Aethis, and Eon Insights consume. See API below.

kartis-discovery / kartis-recon

kartis-discovery [daemon]     # subscribes to "discovery", runs the daemon (default with no subcommand)
kartis-discovery run <domain> # recursive organization discovery, see above
kartis-recon                  # subscribes to "enum", runs the enumeration worker

Both take -c/--config <path> (defaults /etc/kartis/kartis-discovery.yaml and /etc/kartis/kartis-recon.yaml) and -d/--debug.

kartis-waf — standalone WAF fingerprinting

A Go port of wafw00f, detecting around 172 named WAFs plus generic/behavioral detection. Its engine (internal/waf) is also reusable as a scan-flow analyzer; the waf module in the chain above runs it passive-only.

kartis-waf scan <host> [flags]
  --active        send attack-payload probes (requires --authorized)
  --authorized    confirm you are authorized to send active probes
  --all           report all matching WAFs, not just the first
  --json          emit JSON (default true; --json=false for human output)
  --timeout       per-request timeout (default 30s)

kartis-waf serve --addr :8091 --token <bearer-token>

Passive detection (the default) sends one benign GET / and matches header/cookie signatures. Active detection additionally sends attack-shaped payloads (XSS/SQLi/LFI) and is refused unless --authorized (CLI) or "authorized": true (the POST /waf service body) is set. Every request carries an honest scanid User-Agent.

API

kartis-scan serve exposes, under bearer-token auth:

POST   /v1/sessions                        create a session (name + targets)
GET    /v1/sessions                        list sessions
GET    /v1/sessions/{uuid}                 get a session
POST   /v1/sessions/{uuid}/runs            start a run — this is where profile/max_level/categories go
GET    /v1/sessions/{uuid}/runs            list runs for a session
GET    /v1/runs/{uuid}                     run status: pending|running|completed|failed
GET    /v1/runs/{uuid}/findings            findings for a run
GET    /v1/runs/{uuid}/services            discovered services for a run
GET    /v1/services/{uuid}/tls             TLS detail for a service
GET    /v1/discovery/trees, /trees/{root}, /trees/{root}/hosts
POST   /v1/discovery/runs, /runs/batch     trigger discovery
GET    /v1/discovery/certificates, /dns, /tech, /domains, /hosts, /aggregate
GET    /v1/healthz, /v1/readyz             unauthenticated health checks

POST /v1/sessions/{uuid}/runs accepts an optional body:

{ "profile": "normal", "max_level": "active", "categories": ["crypto", "web"] }

An empty body reproduces the historical default (profile=polite, max_level=active, all categories). The run currently executes synchronously — the POST blocks until the scan completes and returns a summary — but clients should still poll GET /v1/runs/{uuid} until a terminal status rather than assuming completion, since Kartis may move run execution to asynchronous later.

Operator scripts

Two wrapper scripts (scripts/scan.sh, scripts/full-scan.sh; deployed copies live at /opt/kartis/scripts/) drive the API for manual, one-off runs:

scripts/scan.sh qgf.io                  # one host via the API — creates a session, runs sync, prints services/findings/TLS detail
scripts/full-scan.sh qgf.io > qgf.json  # full pipeline: publish discover → poll → aggregate everything into one JSON document

full-scan.sh’s output is the consolidated JSON document that kartis-scan report consumes.