Attack surface mapping from the terminal. Point Phantom at a host you are allowed to look at and it maps what is reachable: open ports and what is behind them, DNS and subdomains, TLS and HTTP posture, registration data, and the files people leave in a web root. Point it at a domain and it does the same for every host it can find underneath.
No server, no dashboard, no account. Scans go into a SQLite file under your home directory, so you can list them, diff them and export them later.
Only scan what you are authorised to scan. An active scan prompts for confirmation and
--passivenever sends a packet to the target, but neither of those is a legal defence. Unauthorised scanning is a crime in most jurisdictions.
git clone https://github.com/Xyness/Phantom.git
cd Phantom
pipx install . # or: pip install -e ".[dev]"That gives you a working phantom with a pure Python port scanner. If you have Rust, build
the fast one too:
cd scanner && cargo build --releasePhantom picks the binary up from scanner/target/release/, from $PATH, or from
$PHANTOM_SCANNER. phantom modules tells you which engine you ended up with. The
difference matters once you go past a few thousand ports: the Rust scanner streams them
through a bounded worker pool, the Python fallback caps itself at 400 sockets so it does
not run you out of file descriptors.
phantom scan example.com # one host, standard profile
phantom sweep example.com # the host, and everything under it
phantom watch example.com --every 6h # rescan on a schedule, report the changes
phantom scan example.com --passive # nothing touches the target
phantom scan 10.0.0.5 -p 1-65535 --profile deep
phantom scan example.com -m dns,tls,http # pick your modules
phantom scan example.com -o report.html # and write a report
phantom list # what you have scanned
phantom show last --details # full findings with fixes
phantom show a3f9 --explain # how the score was reached
phantom diff a3f9 b7e1 # what changed between two runs
phantom export last -f md -o report.md
phantom rm a3f9Ids are eight hex characters and any unambiguous prefix works. last means the most recent
run. Everything that takes an id takes a scan or a sweep.
| profile | ports | modules | use it for |
|---|---|---|---|
quick |
top 100 | ports, dns, tls, http | a first look, about a minute |
standard |
1-1024 plus the top 100 | everything | the default |
deep |
1-65535 | everything, full wordlist, retries | when you have time |
passive |
none | dns, osint, takeover | before you have authorisation |
Every profile is a set of defaults and every field stays overridable, so
--profile quick -p 1-65535 does exactly what it says.
| module | mode | what it does |
|---|---|---|
ports |
active | TCP connect scan, banner grabbing, service naming |
dns |
passive | records, DNSSEC, CAA, wildcards, AXFR, SPF/DMARC/DKIM, subdomains |
tls |
active | certificate chain, expiry, key strength, protocol versions, ciphers |
http |
active | security headers, cookies, stack fingerprint, CDN and WAF, CORS, methods |
exposure |
active | sensitive paths, exposed repositories, secrets, directory listings |
scripts |
active | API endpoints and credentials in the JavaScript the page loads |
Findings then go through a verification pass (below) that confirms the real ones and flags the
false positives.
| osint | passive | RDAP, WHOIS, certificate transparency, Wayback Machine |
| takeover | passive | dangling CNAMEs pointing at services nobody owns |
A few of these are worth explaining.
Ports. Connect scanning, not SYN scanning: it completes the handshake and shows up in the target's logs, which is the right trade for authorised recon. The scanner listens before it speaks, because SSH, SMTP, FTP and most of the rest announce themselves, and writing first throws the greeting away. If a port stays quiet it gets an HTTP request, since most of what sits on an unrecognised port these days is a web service. Services are named from what they said when they said anything, and from the port number otherwise, and the report tells you which of the two it was.
Exposure. Every path goes through a soft 404 filter first. Plenty of hosts answer 200 to
everything, so Phantom asks for two paths that cannot exist, remembers what that looks like,
and discards anything matching. On top of that, most paths carry a content check: /.git/HEAD
has to start with ref: or a commit hash, /.env has to look like assignments, a .zip that
comes back as HTML is the site's error page. When a .git config is readable it gets parsed,
and a remote URL with credentials in it is reported as one. Environment files are reported by
variable name and never by value.
Takeover. For every subdomain with a CNAME, Phantom checks whether the target still resolves and whether the service behind it answers with its own "this is unclaimed" page. It knows about 40 providers. A dead CNAME to an unknown host is reported as dangling rather than as a takeover, because that is all the evidence supports.
HTTP. Along with the header grading, it computes the favicon hash the way Shodan does, so
you can pivot with http.favicon.hash:-1234567890 and find the rest of the estate running the
same application.
Scripts. Front end builds ship the map of the back end. This reads the same origin scripts the landing page loads, pulls out the API paths they reference, and flags the ones nobody meant to publish. Credential patterns run in strict mode here, since the broad ones fire on every minified bundle.
Recon produces leads. A verification pass turns the ones it can into confirmed findings, and
flags the ones that do not hold up, so you fix what is real instead of wading through maybes.
It runs after the modules, on by default for active scans, and --no-verify turns it off.
It confirms or refutes, it never exploits. The contract every check obeys:
- Benign requests only. No exploit payload, no injection, no traversal past the file the module already found.
- No sensitive data leaves the target. A check reads structure, magic bytes, a line count,
a content type. An exposed
.envis confirmed by counting assignments; the values are never read. - Nothing changes. No writes, no deletes, no login attempts.
- A prefix, not a download. A backup is confirmed from its first 512 bytes, streamed and stopped, even if the server ignores the Range request.
What it does today:
- Exposed
.gitgoes from "HEAD returned 200" to "HEAD is a valid ref, config parses, index present" (metadata only, nothing reconstructed), or gets refuted when/.git/HEADis an HTML page behind a catch-all 200. .envfiles, backups, SQL dumps, Spring Actuator are confirmed by type, or refuted.- Version-based CVE matches on distro builds are refuted.
nginx/1.18.0 (Ubuntu)gets flagged: the vendor backports security fixes without moving the upstream version, so the range match is unreliable. That is the biggest source of false positives in version-based matching, and dropping it is most of what makes the CVE output trustworthy.
A confirmed finding keeps its full weight in the score; a refuted one drops to possible, so
the number reflects what held up. The terminal marks them verified and unconfirmed, and
--details prints the evidence. It is a tool for defenders: it tells you what an attacker
would find genuinely real on your own surface. It stops at looking.
A root domain is a starting point, not a target. phantom sweep example.com scans the root,
takes the names it discovered, and scans those too.
phantom sweep example.com --max-hosts 40
phantom sweep example.com -m ports,http,tls -p 80,443,8080,8443Two things stop it from being a loop around the scanner. Names that resolve to the same
address get one port scan between them, because forty subdomains behind one load balancer
is one machine, not forty. And hosts sharing an address are scanned one after another, so a
shared origin never takes several scans at once. The zone wide modules, dns, osint and
takeover, only run against the root: asking forty subdomains about the same SPF record is
noise.
The host budget defaults to 25 and names are sorted before it applies, so admin, staging,
vpn and their neighbours go first. phantom show <sweep-id> prints the estate again later,
and phantom diff <a> <b> between two sweeps is where this earns its keep:
hosts appeared git.example.com, grafana.example.com
new names vpn-old.example.com
git.example.com +21.0 risk
+ CRITICAL exposure Git repository exposed
+ HIGH cve CVE-2021-22205: Unauthenticated RCE via image parsing
ports opened 8022
phantom watch example.com --every 6h
phantom watch example.com --sweep --every 24h --webhook https://hooks.example.com/phantomScan, diff against the previous run of the same target, print only when something changed,
sleep, repeat. The first run prints in full because there is nothing to compare against; after
that a quiet run is one line. --webhook POSTs the diff as JSON when there is one, --runs N
stops after N rounds, and Ctrl-C stops it any time.
It is a foreground loop, not a daemon. Put it in tmux, systemd or a container. A tool that forks itself is a tool you have to debug at three in the morning.
--passive means no connection is opened to the target's own infrastructure. What runs:
certificate transparency, RDAP, WHOIS over port 43, the Wayback Machine, and record lookups
through a recursive resolver. What does not: the port scan, TLS handshakes, HTTP requests,
subdomain brute forcing and AXFR attempts.
The one nuance worth stating plainly: resolving a name through a public resolver eventually reaches the target's authoritative nameservers, and the takeover check fetches pages from third party providers such as GitHub Pages. Neither touches the host you named. If your threat model is stricter than that, passive mode is not strict enough for you.
Banners and Server headers are parsed into (product, version), and the version is
compared against declared ranges. Two rules keep it honest:
- A version bounded entry only fires when a version was actually parsed.
Server: nginxwith no version matches nothing, because nothing is known. - Entries with no range are appliance products whose banners never carry a version. Those
match on identity and are labelled
possible, and their weight in the score is discounted.
The bundled database is phantom/cve/data/cve.json: around 120 hand curated entries across
66 products. The version comparator handles what real services print (9.6p1, 1.1.1w,
8.0.36-0ubuntu0.22.04.1) rather than assuming semver.
A hand curated set rots, so it can be topped up from the NVD, which publishes exactly the shape the matcher wants: a product identified by CPE, and version bounds as data rather than as a regex.
phantom cve status # what is loaded, and where it came from
phantom cve sync # pull every mapped product
phantom cve sync --products nginx,php --min-cvss 7.0
phantom cve check "SSH-2.0-OpenSSH_9.6p1" # what would this banner matchSynced entries go to ~/.local/share/phantom/cve.json and take priority over the bundled
summary for the same id. The tool stays offline otherwise: nothing else in Phantom reaches the
network for CVE data. Without an NVD API key the sync makes one request every 6 seconds, which
is slow but polite; --api-key raises the limit tenfold.
None of this replaces a vulnerability scanner. It tells you that the version on the wire falls inside a published range, which is a lead, not a finding.
Findings are weighted by severity (critical 10, high 7, medium 4, low 1, info 0), discounted
by confidence (confirmed 1.0, likely 0.7, possible 0.4), summed, and squashed onto 0-100 with
100 * (1 - exp(-w/20)). The curve is steep early and flattens out, so a handful of serious
findings lands in the eighties and the number saturates instead of running away.
The 20 is a shape constant chosen so the output reads sensibly, not a calibrated one. That is
why --explain exists:
finding severity weight share
Environment file exposed critical 10.0 13% ▪▪
Git repository exposed critical 10.0 13% ▪▪
CVE-2024-4577: CGI argument ... critical 7.0 9% ▪
total weight 74.6 = 27.0 critical + 22.8 medium + 16.8 high + 8.0 low
score 100 x (1 - e^(-74.6/20)) = 97.6
It is not CVSS and does not pretend to be. Most recon findings have no CVE to inherit a base score from.
-o report.html writes a self contained page with no external requests, which prints to PDF
from a browser. -f md gives you something to paste into a ticket, -f csv gives you rows
you can concatenate across scans (target and scan id are on every row), and -f json gives
you everything, module data included.
phantom scan example.com --json | jq '.findings[] | select(.severity == "critical")'phantom scan staging.example.com --yes --quiet --fail-on highExit codes: 0 clean, 1 error, 2 findings at or above --fail-on, 130 interrupted.
--yes skips the authorisation prompt, which is required in a non interactive session.
Interrupting a scan with Ctrl-C keeps whatever it had collected.
phantom/
cli.py command surface
engine.py resolve, run the modules in dependency waves, score
sweep.py discovery, address grouping, one scan per host
watch.py the scheduled rescan loop
modules/ one file per technique
verify.py confirm or refute findings with benign probes
cve/ product identification, version comparison, the NVD sync
store.py SQLite history
render.py everything the terminal prints
export.py json, markdown, csv, html
portscan.py Rust bridge, and the pure Python fallback
scanner/ the Rust TCP scanner
Modules are independent and run concurrently. The only declared dependency is takeover on
dns, since it needs the subdomains. There is one soft link on top of that: if nothing
answers on 80 or 443, the HTTP modules wait for the port scan and use whatever web port it
found, which is how a site on 8099 still gets checked.
Adding a module means dropping a file in phantom/modules/, exposing
async def run(ctx) -> ModuleResult, and adding a line to the registry in
phantom/modules/__init__.py. tls.py is the smallest complete example.
./scripts/run-tests.sh # everything
pytest -q # python
cd scanner && cargo test # rust338 Python tests and 8 Rust tests, all offline: HTTP is mocked with respx, DNS with a fake
resolver, and the scanner tests bind a local socket. The version comparator and the CVE
matcher get the most attention, since boundary errors there are silent.
ROADMAP.md. Short version: UDP, adaptive timing, cloud storage checks, and calibrating that scoring constant against something real.
MIT, see LICENSE.