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_signalsStage 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:
| Source | Code | What it does |
|---|---|---|
| WHOIS | internal/discovery/whois.go | Registrant / nameserver pivots |
| CT logs | internal/discovery/ctlogs.go | Certificate Transparency subdomain harvest |
| DNS recon | internal/discovery/dnsrecon.go | Brute-force / DNSDB / reverse lookups |
| IP-ASN | internal/discovery/ipasn.go | ASN expansion, neighboring prefixes |
| Tech fingerprint | internal/discovery/techfp.go | HTTP/TLS fingerprint → related hosts |
| AI correlation | internal/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.go →
RunDiscovery(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:
- Every
discovered_domainsrow for the job is DNS-resolved (4-second timeout per host). - One
scan_sessionand onescan_runis created per resolvable host — one session per host avoids an N² blow-up in nmap targets. - 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:
- Runs port discovery (
internal/portscan/), backed by nmap or a bare TCP-connect probe, at a chosen pacing profile. - Runs every matching analyzer in parallel for each discovered service.
- On the DB-write path only, runs an
ipleakpost-pass (internal/analyzers/iplook/) over every analyzer’s raw evidence, looking for leaked internal IP addresses (RFC 1918 / CGN / link-local / ULA) and recording onescan_findingsrow per leak.
The orchestrator entry point is internal/scan/orchestrator.go →
RunSession(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) | Meaning | Intrusiveness |
|---|---|---|
discover | Port/service discovery only | Benign |
passive | Reads what the target offers, no crafted input (TLS cert/handshake read, tech fingerprint, WAF header sniff) | Benign |
active | Speaks the protocol interactively but benignly (SSH/IKE/OpenVPN/STARTTLS negotiation) | Benign |
vuln | Safe vulnerability detection, without exploitation | Non-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).
| Category | Modules |
|---|---|
discovery | port scan |
crypto | tls, starttls, ssh, ike, openvpn, plaintext, certinfo — the PQC/crypto posture core of the product |
web | waf (passive only in the chain) |
infoleak | internal-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.yamlClaims 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.yamlServes 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 workerBoth 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 checksPOST /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 documentfull-scan.sh’s output is the consolidated JSON document that
kartis-scan report consumes.