A personal bug-bounty recon tool: it watches domains you add and continuously pulls in subdomain/IP intel from several passive, legal sources (see below), flags likely subdomain takeovers, automatically checks which subdomains are actually alive and what status code they return (httpx-style), and — only when you explicitly opt a domain in, optionally down to specific subdomains — port-scans, HTTP-fingerprints, screenshots, and runs real nuclei vulnerability templates against what it finds. A web dashboard (meant to be reached over your own Tailscale network) shows the results, a priority score and an evidence-based vulnerability gauge per subdomain, and lets you triage it all from your phone — plus an on-demand toolbox (curl/dig/whois/banner-grab) for one-off lookups against anything — with a push notification when something's actually worth a look.
subcred deliberately does not crawl or scan the internet itself. Instead it
queries services that already did their own (authorized) scanning/crawling,
for domains you explicitly watch. All of these run automatically, on their
own cadence, for every watched domain regardless of that domain's
active_scan flag — none of them send traffic to the target itself:
| Source | What it does | Default cadence |
|---|---|---|
| crt.sh | New subdomains from public Certificate Transparency logs | every 30 min |
| DNS brute-force | Checks ~5,000 common subdomain names against public DNS (SecLists wordlist) | weekly |
| Wayback Machine + Common Crawl | Subdomains/paths found in existing web-archive/crawl datasets | daily |
| GitHub code search | Domain mentions in public repos (leaked subdomains, keys, config) | daily, opt-in |
| ASN / IP-range (BGPView) | The watched company's whole announced IP block, not just DNS names | weekly |
| Shodan InternetDB | Per-IP open ports/CPEs/known CVEs/tags — data Shodan already collected | daily per IP |
| Subdomain takeover (passive tier) | Walks the CNAME chain (dnspython); flags a dangling target (matches a known vulnerable-service suffix, or just fails to resolve at all) | daily per subdomain |
One deliberate exception to "none of them send traffic to the target":
automatic reachability checking (httpx-style) — a single HEAD request
per (subdomain, scheme), reading no body/title/server/tech, just "is
something answering, what status code." This runs unconditionally for
every subdomain of every watched domain, not gated behind active_scan
or restricted by scan_scope — an explicit, discussed decision that a
bare status-code check is a materially lighter touch than deep
fingerprinting or port scanning. It's the primary at-a-glance triage view:
reconctl reachable <domain> or the dashboard's Reachable column show
every subdomain's https/http status side by side. Still gated by
scanner.is_scannable_ip (won't touch a private/internal address
regardless). Default cadence: daily per (subdomain, scheme).
One real bug found while dogfooding this, worth knowing about if your own results ever look wrong: the daemon picks one resolved IP per subdomain to check (not every IP a dual-stack host might have) — the original pick was arbitrary and briefly landed on an IPv6 address in an environment with no working outbound IPv6 route, reporting a domain that was genuinely up over IPv4 as "unreachable." Fixed to prefer IPv4 when both are available.
Takeover detection has a second, active tier (fetches the actual HTTP
response body to confirm a match against a known "unclaimed page" fingerprint)
that only runs when active_scan is on for that domain — see below.
GitHub search only runs if you set a token (it's free, public-repo scope is
enough): generate one at https://github.com/settings/tokens, then either
export SUBCRED_GITHUB_TOKEN=... before starting the service or add
Environment=SUBCRED_GITHUB_TOKEN=... via systemctl --user edit subcred-daemon.service. Without it, GitHub search is silently skipped —
that's expected, not a misconfiguration.
ASN/IP-range discovery is capped at ASN_MAX_IPS_PER_DOMAIN (default 500)
total per domain, spent across however many prefixes that company
announces — every IP found this way only ever goes through the passive
Shodan lookup above, never through subcred's own port scanner. That
boundary (ASN-derived IPs can't reach active scanning under any code path)
was the single most scrutinized part of this feature during review.
DNS brute-force needs a wordlist on disk — it defaults to SecLists'
subdomains-top1million-5000.txt (/usr/share/seclists/Discovery/DNS/...),
which needs apt install seclists (or the Kali/parrot equivalent) if it
isn't already present; if the file's missing, that source just logs a
warning and skips itself rather than failing the whole sweep.
Everything by default is passive: crt.sh lookups are public Certificate
Transparency data, and DNS resolution is a normal lookup — neither sends
traffic to the target. Active scanning (port scan + HTTP fingerprint)
does send traffic directly to a host, and is off by default for every
domain you add. Only flip active_scan on (reconctl enable-scan <domain>,
or the confirm-gated toggle in the /watchlist page) for domains you own
or have explicit written authorization to test — e.g. an in-scope target
in a bug bounty program you're enrolled in. This is on you; the tool gates
the switch, it doesn't police what you point it at. Every scan (daemon
cycle, manual poll, on-demand rescan) re-validates each resolved IP against
scanner.is_scannable_ip immediately before touching it, rejecting private/
loopback/link-local/reserved ranges regardless of what a domain's DNS
claims — so a misconfigured or rebound DNS answer can't trick it into
scanning your own LAN.
Narrowing scope within a domain. active_scan is a whole-domain switch,
but your actual bug bounty authorization is very often a specific list of
assets, not "every subdomain that happens to resolve under this parent
domain." Use reconctl scope add <subdomain> to restrict active scanning to
only specific subdomains you've explicitly listed — leave it unrestricted
(the default) and the whole domain stays in scope, exactly as before.
reconctl scope add app.example.com # narrow to just this one (and any others you add)
reconctl scope list example.com # see current restriction, or "whole domain in scope"
reconctl scope remove app.example.com # drop one from the restricted list
reconctl scope clear example.com # remove all restrictions, back to whole-domainThe same toggle exists per-subdomain on the /domain/<id> dashboard page.
This restriction applies identically to all five active-touch behaviors
(port scan, HTTP fingerprint, takeover confirmation, screenshots, nuclei) —
a review pass caught one real bypass here worth knowing about: the
dashboard's manual "Rescan" button initially ignored scope entirely and
would actively touch a subdomain regardless of restriction; fixed and
verified (an out-of-scope subdomain's rescan now correctly skips the active
part, an in-scope one still works normally).
Four things are gated behind active_scan, all sharing that same
re-checked-live gate and is_scannable_ip discipline, each on its own
cadence (longer = heavier):
| Feature | What it does | Default cadence |
|---|---|---|
| Port scan + HTTP fingerprint | Connects to each resolved IP, checks a fixed port list, grabs title/server header | daily |
| Takeover HTTP-confirmation | Fetches the response body, matches against known "unclaimed service" fingerprints | daily |
| Screenshot triage | Headless Chromium (Playwright), full-page render, saved as a PNG per (subdomain, scheme) | weekly |
| Nuclei vulnerability scan | Real CVE/misconfiguration/exposure templates — see below | weekly |
Every one of these connects to the already-resolved, already-vetted IP
directly and only ever uses the hostname for the HTTP Host header / TLS SNI
— none of them let anything (a raw socket, Playwright's browser, or the
nuclei subprocess) re-resolve the hostname itself. That matters: a second,
independent DNS lookup could return a different address under DNS rebinding,
bypassing the is_scannable_ip check entirely. This was verified empirically
for all three mechanisms while building them (a local test server + a
deliberately wrong/nonexistent hostname, confirming the pinned IP was the one
actually contacted) — nuclei's version of this needed a second fix after
review found that template-driven redirects bypass the pin: nuclei
follows a Location header with its own HTTP client, doing real DNS
resolution and dropping the pinned Host header on the follow-up request, with
zero is_scannable_ip check on that second connection. Confirmed against 902
templates in the actually-configured scan scope that set redirects: true.
Fixed by disabling redirect-following outright (-dr) — verified after the
fix that a redirect to a second local test server received zero requests.
The one feature here that sends genuinely test-shaped requests, not just a
fingerprint GET — real CVE/misconfiguration/exposure checks via
nuclei, reusing whatever
template checkout you already have at ~/nuclei-templates. Deliberately
scoped conservative:
- Template directories:
http/cves/,http/vulnerabilities/,http/misconfiguration/,http/exposures/,http/exposed-panels/,ssl/— HTTP/TLS categories only, ~4,100+ CVE templates alone. Configurable viaSUBCRED_NUCLEI_TEMPLATE_DIRS, but any entry that would resolve outside the templates root (an absolute path, a../escape — checked after a review found the naive version of this silently vulnerable to exactly that) is rejected and logged rather than run. - Tags
dos,intrusive,fuzzexcluded by default (SUBCRED_NUCLEI_EXCLUDE_TAGS) — denial-of-service and deliberately aggressive templates stay out of scope. - Rate-limited to 30 req/s by default (
SUBCRED_NUCLEI_RATE_LIMIT, nuclei's own default is 150) and capped atNUCLEI_TIMEOUT_SEC(180s default) per subdomain-scan.
Real timing tradeoff worth knowing: 30 req/s × several thousand
templates in the default scope means a single subdomain's scan can
legitimately approach or exceed that 180s budget even under good network
conditions — confirmed directly while testing (a scan against a live target
hit the timeout on its first attempt). A scan that times out is not
recorded as clean — it just stays due and retries next cycle — but if you
want scans to reliably finish, raise SUBCRED_NUCLEI_TIMEOUT_SEC, raise the
rate limit, or narrow SUBCRED_NUCLEI_TEMPLATE_DIRS. nuclei itself must be
on PATH (or set SUBCRED_NUCLEI_BIN) — it's a separate system binary, not
a pip package; this machine already has it via the existing
~/nuclei-templates checkout.
A newly-appearing critical/high nuclei finding (a template match that wasn't there on the previous scan) triggers a push notification, same as a new takeover candidate.
Also gated behind active_scan + scan_scope, on its own weekly cadence:
fetches a subdomain's page and its same-origin <script src> JavaScript
files, then mines the JS text for two things — hardcoded API endpoints
(paths referenced in frontend code that no DNS-based enumeration would ever
surface, since they're just routes on a host you already know about) and
credential-shaped strings (AWS keys, generic API keys, JWTs, Slack tokens,
Google API keys, PEM private-key headers).
Found secrets are never stored or displayed in full, anywhere, by design — not a partial implementation. Every regex match is hashed (sha256, for dedup — "is this the same secret as last time" without retaining it) and masked (a few leading/trailing characters, fixed-length asterisks in between so even the true length isn't revealed) in the same breath it's matched; the raw value never gets assigned to a variable that outlives that single expression, so it can't leak into the database, a log line, an exception message, or a push notification even by accident. This was the single most scrutinized property of this whole feature — verified with planted fake secrets, grepping the actual database and log files afterward to confirm the raw value is genuinely absent from both. Third-party CDN-hosted scripts are skipped entirely (same-origin only) — they won't contain target-specific secrets, and fetching them would touch infrastructure that was never part of the authorization anyway. A newly- found secret (not a repeat of one already on record) triggers a push notification containing only the type and masked value.
Fully passive from the target's side — this only ever talks to
ntfy.sh (or a self-hosted instance via
SUBCRED_NTFY_SERVER), never the watched domain. One consolidated
notification per sweep tick (never one per event) whenever something notable
happened: new subdomains, a new takeover candidate, a newly-grown InternetDB
CVE list, or a new critical/high nuclei finding.
The topic name is auto-generated and persisted at
~/.local/share/subcred/ntfy_topic on first use — treat it like a secret:
on the public ntfy.sh server, anyone who knows the topic name can subscribe
and read your notifications. The dashboard's /status page shows it
partially masked; a review pass caught an earlier version of that masking
that revealed a complete short custom topic (anything ≤10 characters set
via SUBCRED_NTFY_TOPIC) verbatim behind a misleading "…" — fixed to never
reveal more than a few leading characters regardless of topic length.
Test your setup any time with:
reconctl notify-test- Priority (
priority.score()): an operator-interestingness heuristic — combines takeover status, InternetDB CVEs/tags, open ports, and your own triage tag into a high/medium/low tier. An educated guess, not a claim. - Vulnerability gauge (
priority.vuln_gauge()): evidence-based, driven by actual nuclei findings and InternetDB CVEs — critical/high/medium/low/ clean (nuclei ran, found nothing) or unscanned (never ran —active_scanis off, or it just hasn't come due yet). Deliberately not a fabricated percentage — every level names the real finding behind it, and "clean" vs. "unscanned" are shown as visually distinct states so a genuine clean scan is never mistaken for "we don't know."
A /toolbox page (and matching reconctl curl/dig/whois/banner
commands) for one-off manual lookups against any hostname or IP you
type — deliberately not restricted to the watchlist or scan_scope, since
these are ad-hoc checks you make your own authorization judgment about, the
same as running a terminal directly:
- curl — real
curlbinary, any method/custom headers, full raw response. Pinned to the resolved IP via--resolveso curl never does its own (unvetted) DNS lookup, and redirects are never followed (no-L) — the exact bypass class that hit nuclei (below) doesn't apply here because it's simply never given the chance to. - dig — real
digbinary, pick a record type, see the raw answer. - whois — no
whoisbinary is installed, so this is a small self-contained two-hop WHOIS client (query IANA for the TLD's registry referral, then query that registry directly) — no new system dependency. - Banner grab —
ncatisn't installed either, and its full feature set (listener mode, exec mode) is more than this needs; a plain Python socket connect-and-read instead, optionally sending a payload after connecting.
curl and banner-grab still refuse private/loopback/link-local/reserved
addresses (scanner.is_scannable_ip, the same gate used everywhere else) —
this page is reachable from your phone over Tailscale, and an unrestricted
"fetch any address" tool would let a leaked auth token pivot into this
machine's own local network. dig and whois need no such gate: they're
protocol lookups against public infrastructure, not a fetch of arbitrary
target content. Nothing here is persisted to the database — results are
shown once and gone, the same as running the command yourself.
Two real bugs review found and fixed while building this: a literal IPv6
URL host (e.g. http://[2606:...]/) broke curl's --resolve pinning
syntax entirely (fails closed, not a security issue, but every such request
errored out) — fixed by skipping the pin when the host is already a literal
IP, since there's nothing left to resolve; and the whois/banner-grab read
loops only bounded each individual read call, not the overall wall-clock
time, so a deliberately slow peer trickling data could hold a request open
far longer than the configured timeout — fixed with an actual deadline.
- Not a "complete hacking DB." crt.sh only knows about hostnames that got a certificate logged publicly. It misses anything HTTPS-only-internal or that never got a public cert. It's one strong, free, passive signal — not omniscient.
- crt.sh is unreliable. It's a community-run, single-operator service with no published rate-limit or uptime guarantee, and it does go down or time out (confirmed first-hand while building this — a plain fetch of crt.sh's homepage timed out completely at one point, while the rest of the internet worked fine). The client retries 3x with exponential backoff, then gives up cleanly and logs it — "nothing new this cycle" is the normal outcome, not a bug to chase.
- "24/7" depends on this machine staying up. The daemon and dashboard
only run while this host is powered on and not suspended. Both come back
automatically (systemd
Restart=on-failurehandles the daemon; the dashboard is just stateless Flask) but there's a real gap in coverage while the machine is asleep — a subdomain could theoretically appear and disappear in that window unseen. - A single sweep can take a while if third parties are slow. Every source retries with backoff before giving up (observed first-hand: crt.sh 502s, Wayback timeouts, and a transient BGPView DNS failure all happened in the same test run) — that's correct, patient behavior, not a hang, but it does mean one sweep cycle can stretch to several minutes if multiple services are having a bad day simultaneously. The daemon just moves on to the next tick after; nothing blocks on it long-term.
- DNS brute-force and GitHub search return unvalidated candidates. Every hostname either of them turns up gets DNS-resolved before it's ever trusted as a real subdomain — GitHub search in particular is just regex matching over public code/text, so plenty of its raw hits won't resolve to anything and are silently discarded, exactly as intended.
- Storage is trivial for the database, not for the tooling. DB rows are short text (hostnames, IPs, timestamps, headers) — tens of MB even under heavy long-term use, measured directly (~1.3MB per heavily-populated domain). The log self-rotates so it can't grow unbounded. The one real disk cost is Playwright's Chromium download for screenshots — ~650MB, one-time, for the screenshot feature alone. Not a lot on a modern disk, but nothing like the KB/MB scale of everything else here.
- Screenshots are transient, not a steady background cost. A capture is ~0.75s and peaks around 156MB of RAM — but that's only during the capture itself; Chromium launches fresh per screenshot and exits, it's not a resident process.
- No mass scanning. This only ever touches domains you explicitly add. There's no discovery-of-arbitrary-hosts mode and none is planned.
subcred/
config.py # paths, intervals, ports list, web token, ntfy topic
db.py # SQLite schema + shared upsert helpers
crtsh.py # crt.sh client (retry/backoff, never raises)
dnsbrute.py # DNS brute-force against a SecLists wordlist
history.py # Wayback CDX + Common Crawl index clients
github_search.py # GitHub code search client (opt-in, needs a token)
asn.py # BGPView ASN/IP-range lookup + pure CIDR math
internetdb.py # Shodan InternetDB client (passive per-IP lookup)
takeover.py # subdomain takeover: passive DNS tier + active HTTP-confirmation tier
takeover_fingerprints.py # static data extracted once from ~/nuclei-templates -- no runtime nuclei dep
screenshot.py # Playwright/Chromium screenshot capture, DNS-pinned
nuclei.py # real nuclei CVE/misconfig scan, DNS-pinned, conservative scope
ntfy.py # ntfy.sh push notification client
priority.py # priority.score() heuristic + vuln_gauge() evidence-based tier
jsintel.py # JS endpoint/secret mining -- masks+hashes secrets at the source, never stores raw
toolbox.py # on-demand curl/dig/whois/banner-grab, freeform targets, not persisted
scanner.py # DNS resolve, is_scannable_ip guard, port scan, HTTP fingerprint, reachability check
daemon.py # the 24/7 sweep loop -- schedules all of the above
cli.py # `reconctl` — watchlist management from the terminal
webapp.py # Flask dashboard + JSON API
templates/ # dashboard HTML (Jinja, autoescaped)
systemd/ # --user unit files (staged by install.sh, not enabled by it)
install.sh # venv + deps + Chromium download + systemd unit staging -- never enables/starts anything
requirements.txt
Clone this anywhere -- install.sh and the systemd units are self-locating
(no hardcoded path/username), so it works the same regardless of where you
put it or which machine it's on.
./install.shThis creates .venv, installs Flask/waitress/Playwright/dnspython into it,
downloads Chromium for Playwright (~650MB, one-time), stages the two systemd
--user unit files into ~/.config/systemd/user/, and runs
systemctl --user daemon-reload. It does not enable, start, or expose
anything — it prints the exact next commands instead. It does not
install nuclei itself (a separate system binary, not a pip package) — if
it's not already on PATH, nuclei vulnerability scanning just logs a
warning and skips itself, same graceful-degradation pattern as every other
missing-dependency case here.
systemctl --user enable --now subcred-daemon.service
systemctl --user enable --now subcred-web.serviceCheck on them with:
systemctl --user status subcred-daemon.service subcred-web.service
journalctl --user -u subcred-daemon.service -u subcred-web.service -fThe dashboard's shared-secret auth token is generated on first access and
persisted (owner-read-only, 0600) at
~/.local/share/subcred/web_token. You'll need it to log in.
The web app binds only to 127.0.0.1:5900 — it has no awareness of
Tailscale and can't be made to bind anywhere routable. Once
subcred-web.service is running, expose it to your tailnet (and only
your tailnet) with:
tailscale serve --bg 5900That reverse-proxies a tailnet-only HTTPS endpoint (with a real cert, via
Tailscale's own cert system) to the local port. Reachable from any device on
your tailnet — your phone, the Fedora box, the Windows PC — nothing else.
Do not use tailscale serve --set-path with funnel — tailscale funnel exposes a service to the public internet, which is not what you
want here. Check what's currently exposed with tailscale serve status;
tear it down with tailscale serve --https=443 off (or whatever port it
reports).
One debugging note: the auth cookie is set with the Secure flag, since the
only intended access path is HTTPS via tailscale serve. If you hit the app
directly over plain http://127.0.0.1:5900 (bypassing tailscale serve,
e.g. while poking at it locally), a real browser will silently refuse to
store/send that cookie and login will appear to loop back to /login.
That's expected friction flagging an off-path access method, not a bug.
source .venv/bin/activate # or prefix commands with .venv/bin/python3 -m
export PYTHONPATH=$(pwd)
reconctl add example.com # passive-only by default
reconctl add example.com --active-scan # opt in to active scanning immediately — only for authorized targets
reconctl enable-scan example.com # flip active_scan on for an existing entry
reconctl disable-scan example.com
reconctl list # every watched domain + counts + poll times
reconctl subdomains example.com # everything known for one domain
reconctl new --since 24h # the money command: what's new, across every watched domain
reconctl scope add app.example.com # narrow active scanning to specific subdomains (default: whole domain)
reconctl scope list example.com
reconctl takeovers # every currently-flagged subdomain takeover candidate, watchlist-wide
reconctl vulns # every subdomain with a current nuclei finding, highest severity first
reconctl reachable example.com # httpx-style: https/http status code per subdomain, at a glance
reconctl secrets # every masked-only secret found in mined JS, watchlist-wide
reconctl endpoints # every API endpoint mined from JS, watchlist-wide
reconctl notify-test # send a test ntfy push to verify your notification setup
reconctl report example.com # full dump: subdomains + IPs + ports + HTTP fingerprints + InternetDB + ASN ranges + takeover + nuclei + priority/vuln gauge
reconctl asn example.com # just the discovered ASN/IP ranges + how many IPs checked so far
reconctl export example.com --format csv # or json, to stdout
reconctl remove example.com # cascades, deletes all its recon data
reconctl set-password # change the dashboard login (prompts interactively; takes effect immediately, no restart)
# on-demand toolbox -- any hostname/IP, not tied to the watchlist
reconctl curl https://example.com --method GET --header "X-Test: 1"
reconctl dig example.com --type MX
reconctl whois example.com
reconctl banner example.com 22 # SSH banner grab, or any TCP portRun a single sweep manually (useful for testing, or as a cron fallback instead of the daemon):
python3 -m subcred.daemon --once/— the live feed: every subdomain across your whole watchlist, newest first, with a "new since you last checked" divider and filter chips./watchlist— add/remove domains, toggle active scanning (confirm-gated), force an out-of-cycle passive poll./domain/<id>— full sortable/searchable subdomain table for one domain (sortable by priority too), each row tagged with its discovery source, reachability badge (https/http status at a glance), InternetDB findings, and a vulnerability-gauge badge, plus an "ASN / IP ranges" section for that company's announced IP blocks. A takeover candidates banner appears above the table whenever one exists./subdomain/<id>— full history for one hostname (DNS, ports, HTTP fingerprints, reachability detail, InternetDB findings, takeover check, nuclei findings, screenshot, mined JS endpoints/secrets — masked values only) plus an on-demand rescan button and a triage tag/notes editor./screenshot/<id>/<scheme>— serves a captured screenshot PNG (same auth as every other route;schemechecked against a fixed allow-list before any filesystem access)./status— daemon health: is it actually running, is any domain overdue for a poll, DB size, log tail, whether notifications are enabled and a masked ntfy server/topic./toolbox— on-demand curl/dig/whois/banner-grab against any hostname/IP, not tied to the watchlist. Nothing here is persisted.
Every page and API route requires the token from web_token (cookie after
login, or an X-Auth-Token header for scripting/curl). State-changing
requests additionally require the header even with a valid cookie present —
that's the CSRF defense, since a forged cross-site form can only ever carry
the ambient cookie, never a custom header.
Every feature here went through the same pattern — design → parallel
implementation → integration → an adversarial review pass — with findings
independently verified (not just self-reported) and fixed. Baseline
coverage across every module: XSS (every scraped/user field — subdomain
names, HTTP titles, server headers, notes, InternetDB findings, ASN org
names, takeover evidence, nuclei finding names — confirmed escaped via Jinja
autoescaping, spot-checked by hand with deliberately hostile payloads), CSRF
(cookie-only mutation attempts confirmed rejected against a live running
instance), SQL parameterization across every module, and the active-scan
is_scannable_ip gate re-verified for each new active-touch feature as it
was added (port scan/fingerprint → takeover HTTP-confirmation → screenshots
→ nuclei).
Real findings from those reviews, since fixed and re-verified:
- The 5 enrichment-source clients (crt.sh/dnsbrute/history/GitHub/ASN) —
3 of 5 could crash a sweep on a malformed third-party response instead of
degrading gracefully;
ASN_MAX_IPS_PER_DOMAINwas silently multiplied per announced prefix instead of being a true per-domain budget. - Screenshots — the daemon's
active_scanre-check (present for the port scanner) was missing for screenshot triage, so a mid-sweep toggle-off wasn't honored for that feature specifically. - Notifications —
ntfy.notify()'s "never raises" contract had a gap (an unguarded disk write on first use), and the/statuspage's topic-masking revealed a complete short custom topic verbatim behind a misleading "…" instead of actually masking it. - Takeover detection — the passive DNS-only recheck could silently
downgrade an already-confirmed finding back to "possible," which then
made the active tier re-report the same already-known takeover as
"newly confirmed" every cycle, indefinitely — the exact steady-state case
a notification system most needs to stay quiet for. Also two raw-socket
file-descriptor leaks on TLS handshake failure (one in takeover.py, one
inherited from the pre-existing
scanner.py). - Nuclei — the most scrutinized addition. The DNS-pinning approach (target
the vetted IP directly, hostname only for Host header/SNI) was verified
empirically, but review found that nuclei's own redirect-following
bypasses that pin entirely — a template hitting a redirect makes nuclei
do a second, real DNS-resolved connection with the pinned Host header
dropped, zero
is_scannable_ipcheck, against 902 templates in the actually-configured scan scope. Fixed with-dr(disable redirects), re-verified with an escalating test (a redirect to a second local server received zero requests once fixed). Also found and fixed: a duplicatetemplate_idfrom a single scan could raise an uncaughtIntegrityErrorthat silently discarded a real finding and left the subdomain permanently re-crashing; and template-directory paths weren't checked against escaping the configured root via../or an absolute path. scan_scope— after adding fine-grained scope restriction, review found the dashboard's manual "Rescan" button was a complete bypass of it, active-scanning a subdomain regardless of whether it was excluded.- The on-demand toolbox — a literal IPv6 URL host broke curl's
--resolvepinning syntax outright (failed closed, not a security gap, but every such request errored); a tempdir-creation failure could escape curl's "never raises" contract; no disk-write cap during download; and the whois/banner-grab read loops only bounded individual reads, not overall wall-clock time, letting a slow-drip peer hold a connection open indefinitely. - Reachability checking — not a security bug, but a real correctness one caught by my own live testing rather than the automated review: the IP-selection logic was non-deterministic and could pick an IPv6 address in an environment with no outbound IPv6 route, reporting a genuinely-up domain as "unreachable." Fixed to prefer IPv4.
- JS endpoint/secret mining — the most heavily scrutinized addition (5 separate review passes on the raw-secret-never-leaks property alone, plus my own independent test with planted fake secrets grepping the actual database/log files afterward) came back clean on every angle.
This is a personal tool holding live bug-bounty recon data. The code is
public on GitHub (see above); the data never is — it lives entirely outside
this repo, in ~/.local/share/subcred, which is .gitignored and was
never part of the project directory to begin with.