rimscan-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,302 @@
1
+ """Subdomain takeover detection using the can-i-take-over-xyz fingerprint database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import ipaddress
7
+ import json
8
+ import re
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from pathlib import Path
13
+ from typing import List, Optional, Pattern, Sequence, Tuple, Union
14
+
15
+ import requests
16
+
17
+ from rimscan_cli.recon._common import all_public
18
+ from rimscan_cli.recon._http import make_session, read_capped
19
+ from rimscan_cli.recon.resolver import ResolutionResult, ResolutionStatus
20
+
21
+ __all__ = [
22
+ "DEFAULT_FINGERPRINTS_PATH",
23
+ "TakeoverVerdict",
24
+ "TakeoverFinding",
25
+ "Fingerprint",
26
+ "FingerprintDatabase",
27
+ "TakeoverDetector",
28
+ ]
29
+
30
+ DEFAULT_FINGERPRINTS_PATH = Path(__file__).resolve().parents[1] / "data" / "fingerprints.json"
31
+ _MAX_BODY_BYTES = 256 * 1024
32
+
33
+ # The bundled upstream list leaves `cname` empty for many takeover-prone services, and
34
+ # matching is by CNAME suffix, so those services could never be detected. These suffixes
35
+ # are added to the upstream ones (matched by the entry's `service` name). Services that
36
+ # are not identified by a CNAME at all (Digital Ocean NS delegation, for one) stay out.
37
+ _SUPPLEMENTAL_CNAMES = {
38
+ "Campaign Monitor": ("createsend.com",),
39
+ "Canny": ("canny.io",),
40
+ "Cargo Collective": ("cargocollective.com",),
41
+ "Pantheon": ("pantheonsite.io",),
42
+ "Pingdom": ("stats.pingdom.com",),
43
+ "Readthedocs": ("readthedocs.io",),
44
+ "Short.io": ("short.io",),
45
+ }
46
+
47
+ # Hosting platforms whose servers belong to someone else. Many of them have an empty
48
+ # `cname` list in the upstream fingerprints (GitHub Pages, Heroku, Netlify, Shopify, ...),
49
+ # so matching by fingerprint alone would miss them when deciding what is safe to probe.
50
+ _HOSTING_PLATFORMS = (
51
+ "github.io", "githubusercontent.com", "gitlab.io", "bitbucket.io",
52
+ "herokuapp.com", "herokudns.com", "herokussl.com",
53
+ "netlify.app", "netlify.com", "netlifyglobalcdn.com",
54
+ "myshopify.com", "shopify.com", "shopifycloud.com",
55
+ "vercel.app", "vercel-dns.com", "pages.dev", "workers.dev", "fly.dev", "onrender.com",
56
+ "surge.sh", "ghost.io", "wpengine.com", "wpenginepowered.com",
57
+ "squarespace.com", "wixdns.net", "webflow.io", "tumblr.com", "wordpress.com",
58
+ "cloudfront.net", "amazonaws.com", "elasticbeanstalk.com",
59
+ "azurewebsites.net", "azureedge.net", "trafficmanager.net", "cloudapp.azure.com",
60
+ "zendesk.com", "hubspot.net", "readthedocs.io", "unbouncepages.com",
61
+ )
62
+
63
+
64
+ class TakeoverVerdict(Enum):
65
+ VULNERABLE = "vulnerable"
66
+ LIKELY = "likely"
67
+ NOT_VULNERABLE = "not_vulnerable"
68
+ UNKNOWN_SERVICE = "unknown_service"
69
+
70
+
71
+ @dataclass
72
+ class TakeoverFinding:
73
+ hostname: str
74
+ verdict: TakeoverVerdict
75
+ service: Optional[str] = None
76
+ cname_chain: List[str] = field(default_factory=list)
77
+ evidence: str = ""
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Fingerprint:
82
+ service: str
83
+ cnames: Tuple[str, ...]
84
+ vulnerable: bool
85
+ nxdomain: bool
86
+ pattern: Optional[Pattern[str]]
87
+ http_status: Optional[int]
88
+
89
+ @classmethod
90
+ def from_entry(cls, entry: dict) -> "Fingerprint":
91
+ text = (entry.get("fingerprint") or "").strip()
92
+ status = entry.get("http_status")
93
+ if text.startswith("HTTP_STATUS="):
94
+ status, text = int(text.split("=", 1)[1]), ""
95
+ service = entry.get("service", "unknown")
96
+ cnames = _usable_cnames(entry.get("cname") or []) + list(_SUPPLEMENTAL_CNAMES.get(service, ()))
97
+ return cls(
98
+ service=service,
99
+ cnames=tuple(dict.fromkeys(cnames)),
100
+ vulnerable=bool(entry.get("vulnerable")),
101
+ nxdomain=bool(entry.get("nxdomain")),
102
+ pattern=_compile_fingerprint(text),
103
+ http_status=status,
104
+ )
105
+
106
+ def matches_body(self, body: str) -> bool:
107
+ """True if the page text matches. Tried on the raw HTML and on its entity-decoded form.
108
+
109
+ The pattern is built from the entity-decoded fingerprint (``’`` becomes a
110
+ curly apostrophe), but a real page often carries the entity itself in its source.
111
+ """
112
+ if self.pattern is None:
113
+ return False
114
+ return self.pattern.search(body) is not None or self.pattern.search(html.unescape(body)) is not None
115
+
116
+
117
+ def _usable_cnames(values) -> List[str]:
118
+ """Keep real domain suffixes; the upstream list also contains URLs and bare IPs."""
119
+ usable = []
120
+ for value in values:
121
+ value = value.lower().strip().lstrip(".")
122
+ if not value or "/" in value or ":" in value:
123
+ continue
124
+ try:
125
+ ipaddress.ip_address(value)
126
+ except ValueError:
127
+ usable.append(value)
128
+ return usable
129
+
130
+
131
+ def _compile_fingerprint(text: str) -> Optional[Pattern[str]]:
132
+ """Turn an upstream fingerprint string into a case-insensitive pattern.
133
+
134
+ Upstream mixes literal text with regex (`.*`), markdown-escaped pipes (`|`),
135
+ HTML entities, and two alternative phrases joined by "` `".
136
+ """
137
+ if not text or text == "NXDOMAIN":
138
+ return None
139
+ parts = []
140
+ for alternative in text.split("` `"):
141
+ alternative = html.unescape(alternative.replace("|", "|")).strip()
142
+ if not alternative:
143
+ continue
144
+ looks_like_regex = any(token in alternative for token in (".*", "\\", "|"))
145
+ parts.append(alternative if looks_like_regex else re.escape(alternative))
146
+ try:
147
+ return re.compile("|".join(parts), re.IGNORECASE)
148
+ except re.error:
149
+ return re.compile(re.escape(text), re.IGNORECASE)
150
+
151
+
152
+ class FingerprintDatabase:
153
+ def __init__(self, path: Union[str, Path, None] = None):
154
+ source = Path(path) if path else DEFAULT_FINGERPRINTS_PATH
155
+ entries = json.loads(source.read_text(encoding="utf-8"))
156
+ self.fingerprints = [Fingerprint.from_entry(entry) for entry in entries]
157
+ self._suffixes: List[Tuple[str, Fingerprint]] = sorted(
158
+ ((cname, fp) for fp in self.fingerprints for cname in fp.cnames),
159
+ key=lambda pair: len(pair[0]),
160
+ reverse=True,
161
+ )
162
+
163
+ def match(self, hostname: str) -> Optional[Fingerprint]:
164
+ hostname = hostname.lower().rstrip(".")
165
+ for suffix, fp in self._suffixes:
166
+ if hostname == suffix or hostname.endswith("." + suffix):
167
+ return fp
168
+ return None
169
+
170
+ def is_third_party(self, hostname: str) -> bool:
171
+ """True if `hostname` belongs to a service that is not the scan target's own.
172
+
173
+ Covers every fingerprint entry (vulnerable or not) and the built-in list of hosting
174
+ platforms, including those whose upstream entry has no CNAME suffix.
175
+ """
176
+ name = hostname.lower().rstrip(".")
177
+ if self.match(name) is not None:
178
+ return True
179
+ return any(name == suffix or name.endswith("." + suffix) for suffix in _HOSTING_PLATFORMS)
180
+
181
+
182
+ class TakeoverDetector:
183
+ """Flag CNAMEs that point at third-party services with an unclaimed resource.
184
+
185
+ Only hosts whose CNAME chain matches a known service are checked. Each such host
186
+ receives at most one HTTPS and one HTTP GET request: the session does not retry and
187
+ redirects are not followed, so the response is the host's own. Hosts that resolve to
188
+ a non-public address are never contacted.
189
+ """
190
+
191
+ def __init__(self, database: FingerprintDatabase, timeout: float = 10.0, max_workers: int = 8):
192
+ self.db = database
193
+ self.timeout = timeout
194
+ self.max_workers = max_workers
195
+ self._session = make_session(retries=0)
196
+
197
+ def check_many(self, results: Sequence[ResolutionResult]) -> List[Optional[TakeoverFinding]]:
198
+ """Check hosts concurrently; findings follow the order of `results`."""
199
+ if not results:
200
+ return []
201
+ with ThreadPoolExecutor(max_workers=min(self.max_workers, len(results))) as pool:
202
+ return list(pool.map(self.check, results))
203
+
204
+ def check(self, result: ResolutionResult) -> Optional[TakeoverFinding]:
205
+ chain = result.cname_chain
206
+ if result.status not in (ResolutionStatus.CNAME, ResolutionStatus.DANGLING_CNAME) or not chain:
207
+ return None
208
+ dangling = result.status is ResolutionStatus.DANGLING_CNAME
209
+
210
+ matched = next(
211
+ ((hop, fp) for hop in reversed(chain) if (fp := self.db.match(hop)) is not None), None
212
+ )
213
+ if matched is None:
214
+ if dangling:
215
+ return self._finding(
216
+ result, TakeoverVerdict.LIKELY, None,
217
+ "dangling CNAME: the target does not exist and is not a known service",
218
+ )
219
+ return None
220
+ hop, fp = matched
221
+
222
+ if not fp.vulnerable:
223
+ return self._finding(
224
+ result, TakeoverVerdict.NOT_VULNERABLE, fp.service,
225
+ f"matched {fp.service}, which is not takeover-prone",
226
+ )
227
+ if fp.nxdomain:
228
+ if dangling:
229
+ return self._finding(
230
+ result, TakeoverVerdict.VULNERABLE, fp.service,
231
+ f"CNAME target {hop} does not resolve and {fp.service} allows claiming it",
232
+ )
233
+ return self._finding(
234
+ result, TakeoverVerdict.NOT_VULNERABLE, fp.service,
235
+ f"{fp.service} target {hop} still resolves",
236
+ )
237
+ if dangling:
238
+ return self._finding(
239
+ result, TakeoverVerdict.LIKELY, fp.service,
240
+ f"CNAME target {hop} does not resolve; {fp.service} is takeover-prone",
241
+ )
242
+ if fp.pattern is None and fp.http_status is None:
243
+ return self._finding(
244
+ result, TakeoverVerdict.LIKELY, fp.service,
245
+ f"points at takeover-prone {fp.service}; no automatic fingerprint, verify manually",
246
+ )
247
+ if result.a_records and not all_public(result.a_records):
248
+ return self._finding(
249
+ result, TakeoverVerdict.UNKNOWN_SERVICE, fp.service,
250
+ "resolves to a non-public address; not probed",
251
+ )
252
+ return self._check_http(result, fp)
253
+
254
+ def _check_http(self, result: ResolutionResult, fp: Fingerprint) -> TakeoverFinding:
255
+ response = self._fetch(result.hostname)
256
+ if response is None:
257
+ return self._finding(
258
+ result, TakeoverVerdict.UNKNOWN_SERVICE, fp.service,
259
+ "could not reach the host over HTTP or HTTPS",
260
+ )
261
+ status, body = response
262
+ body_match = fp.matches_body(body)
263
+ status_match = fp.http_status is None or status == fp.http_status
264
+
265
+ if fp.pattern is not None and body_match and status_match:
266
+ return self._finding(
267
+ result, TakeoverVerdict.VULNERABLE, fp.service,
268
+ f"HTTP {status} body matches the {fp.service} unclaimed-resource page",
269
+ )
270
+ if fp.pattern is not None and body_match:
271
+ return self._finding(
272
+ result, TakeoverVerdict.LIKELY, fp.service,
273
+ f"{fp.service} page text matched but HTTP {status} != expected {fp.http_status}",
274
+ )
275
+ if fp.pattern is None and status_match:
276
+ return self._finding(
277
+ result, TakeoverVerdict.LIKELY, fp.service,
278
+ f"{fp.service} returned the status ({status}) used as its takeover signal",
279
+ )
280
+ return self._finding(
281
+ result, TakeoverVerdict.NOT_VULNERABLE, fp.service,
282
+ f"{fp.service} host responded (HTTP {status}) without the unclaimed-resource page",
283
+ )
284
+
285
+ def _fetch(self, hostname: str) -> Optional[Tuple[int, str]]:
286
+ # Redirects are not followed: status-only fingerprints (e.g. an expected 301) would
287
+ # otherwise never match, and we would read pages from hosts outside the target.
288
+ for scheme in ("https", "http"):
289
+ try:
290
+ response = self._session.get(
291
+ f"{scheme}://{hostname}", timeout=self.timeout, stream=True, allow_redirects=False
292
+ )
293
+ return response.status_code, read_capped(response, _MAX_BODY_BYTES)
294
+ except requests.RequestException:
295
+ continue
296
+ return None
297
+
298
+ @staticmethod
299
+ def _finding(
300
+ result: ResolutionResult, verdict: TakeoverVerdict, service: Optional[str], evidence: str
301
+ ) -> TakeoverFinding:
302
+ return TakeoverFinding(result.hostname, verdict, service, list(result.cname_chain), evidence)
rimscan_cli/scanner.py ADDED
@@ -0,0 +1,305 @@
1
+ """Scan orchestration: enumerate, resolve and audit a domain. The CLI is a thin layer over this."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import json
7
+ import logging
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ from dataclasses import fields, is_dataclass
12
+ from datetime import datetime, timezone
13
+ from enum import Enum
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence
16
+
17
+ from rimscan_cli import __version__
18
+ from rimscan_cli.recon._common import all_public, normalize_domain
19
+ from rimscan_cli.recon.cert_audit import MAX_HOSTS as MAX_CERT_HOSTS
20
+ from rimscan_cli.recon.cert_audit import CertAuditor
21
+ from rimscan_cli.recon.dns_audit import DNSAuditor
22
+ from rimscan_cli.recon.enumeration import DEFAULT_SOURCES, SubdomainEnumerator
23
+ from rimscan_cli.recon.resolver import DNSResolver, ResolutionStatus
24
+ from rimscan_cli.recon.secret_scan import MAX_HOSTS as MAX_SECRET_HOSTS
25
+ from rimscan_cli.recon.secret_scan import SecretScanner
26
+ from rimscan_cli.recon.takeover import FingerprintDatabase, TakeoverDetector, TakeoverVerdict
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ SCHEMA_VERSION = 1
31
+ NUCLEI_EXCLUDED_TAGS = ("dos", "fuzz", "intrusive")
32
+ SEVERITY_RANK = {"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
33
+ # When at least this share of lookups time out or error, "no findings" cannot be trusted.
34
+ RESOLUTION_FAILURE_THRESHOLD = 0.5
35
+ _TAKEOVER_SEVERITY = {
36
+ TakeoverVerdict.VULNERABLE.value: "high",
37
+ TakeoverVerdict.LIKELY.value: "medium",
38
+ TakeoverVerdict.UNKNOWN_SERVICE.value: "info",
39
+ }
40
+
41
+ Progress = Callable[[str], None]
42
+
43
+
44
+ class InvalidArguments(Exception):
45
+ """The caller passed arguments that cannot be used (reported as a usage error)."""
46
+
47
+
48
+ class AuthorizationError(InvalidArguments):
49
+ """An option that probes the target was requested without --authorized."""
50
+
51
+
52
+ def to_jsonable(value: Any) -> Any:
53
+ if isinstance(value, Enum):
54
+ return value.value
55
+ if hasattr(value, "to_dict"):
56
+ return value.to_dict()
57
+ if is_dataclass(value) and not isinstance(value, type):
58
+ return {f.name: to_jsonable(getattr(value, f.name)) for f in fields(value)}
59
+ if isinstance(value, (list, tuple, set)):
60
+ return [to_jsonable(item) for item in value]
61
+ if isinstance(value, dict):
62
+ return {key: to_jsonable(item) for key, item in value.items()}
63
+ return value
64
+
65
+
66
+ def _parse_nuclei_output(text: str) -> List[Dict[str, Any]]:
67
+ findings = []
68
+ for line in text.splitlines():
69
+ try:
70
+ raw = json.loads(line)
71
+ except ValueError:
72
+ continue
73
+ info = raw.get("info", {})
74
+ findings.append({
75
+ "template_id": raw.get("template-id"),
76
+ "name": info.get("name"),
77
+ "severity": info.get("severity", "info"),
78
+ "host": raw.get("host"),
79
+ "matched_at": raw.get("matched-at"),
80
+ "tags": info.get("tags", []),
81
+ "cve": (info.get("classification") or {}).get("cve-id") or [],
82
+ })
83
+ return findings
84
+
85
+
86
+ def run_nuclei(hosts: Iterable[str], *, rate_limit: int = 75, timeout: float = 1800.0) -> Dict[str, Any]:
87
+ """Run Nuclei against `hosts` with dos, fuzz and intrusive templates excluded."""
88
+ binary = shutil.which("nuclei")
89
+ if not binary:
90
+ return {"status": "skipped", "reason": "nuclei executable not found in PATH"}
91
+ host_list = list(dict.fromkeys(hosts))
92
+ if not host_list:
93
+ return {"status": "skipped", "reason": "no live hosts"}
94
+
95
+ with tempfile.TemporaryDirectory(prefix="rimscan-cli-") as temp_dir:
96
+ targets = Path(temp_dir) / "targets.txt"
97
+ targets.write_text("\n".join(host_list) + "\n", encoding="utf-8")
98
+ command = [
99
+ binary, "-list", str(targets),
100
+ "-etags", ",".join(NUCLEI_EXCLUDED_TAGS),
101
+ "-rate-limit", str(rate_limit),
102
+ "-jsonl", "-silent", "-no-color", "-disable-update-check",
103
+ ]
104
+ try:
105
+ completed = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False)
106
+ except subprocess.TimeoutExpired as exc:
107
+ partial = exc.stdout.decode("utf-8", "replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
108
+ return {"status": "timeout", "timeout_seconds": timeout, "findings": _parse_nuclei_output(partial)}
109
+
110
+ result: Dict[str, Any] = {
111
+ "status": "completed" if completed.returncode == 0 else "failed",
112
+ "returncode": completed.returncode,
113
+ "findings": _parse_nuclei_output(completed.stdout),
114
+ }
115
+ if completed.returncode != 0:
116
+ result["stderr"] = completed.stderr[-500:]
117
+ return result
118
+
119
+
120
+ def _validate(
121
+ domain: str,
122
+ *,
123
+ authorized: bool,
124
+ include_zone_transfer: bool,
125
+ include_secrets: bool,
126
+ include_nuclei: bool,
127
+ sources: Optional[Sequence[str]],
128
+ nameservers: Optional[Sequence[str]],
129
+ ) -> str:
130
+ """Return the normalized domain, or raise InvalidArguments / AuthorizationError."""
131
+ try:
132
+ domain = normalize_domain(domain)
133
+ except ValueError as exc:
134
+ raise InvalidArguments(str(exc)) from None
135
+ for flag, enabled in (
136
+ ("--secrets", include_secrets),
137
+ ("--nuclei", include_nuclei),
138
+ ("--zone-transfer", include_zone_transfer),
139
+ ):
140
+ if enabled and not authorized:
141
+ raise AuthorizationError(f"{flag} sends probes to the target's own servers and requires --authorized")
142
+ unknown = [name for name in sources or () if name not in DEFAULT_SOURCES]
143
+ if unknown:
144
+ raise InvalidArguments(f"unknown source(s): {', '.join(unknown)} (choose from {', '.join(DEFAULT_SOURCES)})")
145
+ for address in nameservers or ():
146
+ try:
147
+ ipaddress.ip_address(address)
148
+ except ValueError as exc:
149
+ raise InvalidArguments(str(exc)) from None
150
+ return domain
151
+
152
+
153
+ def scan_domain(
154
+ domain: str,
155
+ *,
156
+ authorized: bool = False,
157
+ include_zone_transfer: bool = False,
158
+ include_secrets: bool = False,
159
+ include_certs: bool = True,
160
+ include_nuclei: bool = False,
161
+ include_third_party: bool = False,
162
+ bruteforce: bool = False,
163
+ sources: Optional[Sequence[str]] = None,
164
+ timeout: float = 10.0,
165
+ nameservers: Optional[Sequence[str]] = None,
166
+ nuclei_rate_limit: int = 75,
167
+ progress: Progress = lambda message: None,
168
+ ) -> Dict[str, Any]:
169
+ """Run a standalone scan and return a JSON-serializable result.
170
+
171
+ Raises InvalidArguments (AuthorizationError for a missing --authorized) before any
172
+ network activity if the arguments are unusable.
173
+
174
+ `nameservers` applies to DNS lookups only; TLS and HTTP probes use the system resolver.
175
+ Hosts that resolve to a non-public address are never probed. With `include_secrets` or
176
+ `include_nuclei`, hosts that CNAME to a known third-party service are skipped unless
177
+ `include_third_party` is set, because those servers belong to someone else.
178
+ """
179
+ domain = _validate(
180
+ domain,
181
+ authorized=authorized,
182
+ include_zone_transfer=include_zone_transfer,
183
+ include_secrets=include_secrets,
184
+ include_nuclei=include_nuclei,
185
+ sources=sources,
186
+ nameservers=nameservers,
187
+ )
188
+ warnings: List[str] = []
189
+ incomplete = False
190
+
191
+ progress(f"enumerating subdomains of {domain}")
192
+ # Repeated names are dropped so each source is queried, reported and warned about once.
193
+ chosen_sources = list(dict.fromkeys(sources)) if sources else list(DEFAULT_SOURCES)
194
+ enumerator = SubdomainEnumerator(domain, timeout=timeout)
195
+ subdomains = enumerator.run(
196
+ chosen_sources,
197
+ on_source_done=lambda name, count, status: progress(f" {name}: {count} found ({status})"),
198
+ )
199
+ failed_sources = [name for name in chosen_sources if enumerator.source_status.get(name) != "ok"]
200
+ if failed_sources and len(failed_sources) == len(chosen_sources):
201
+ warnings.append("every enumeration source failed or timed out; the subdomain list is not reliable")
202
+ incomplete = not bruteforce
203
+ elif failed_sources:
204
+ warnings.append(f"enumeration sources failed or timed out: {', '.join(failed_sources)}")
205
+
206
+ resolver = DNSResolver(timeout=min(timeout, 5.0), nameservers=nameservers)
207
+ wildcard = resolver.detect_wildcard(domain)
208
+ if wildcard:
209
+ progress("wildcard DNS detected; wildcard answers are filtered out")
210
+ if bruteforce:
211
+ progress("brute-forcing common subdomain names")
212
+ subdomains |= enumerator.from_bruteforce(resolver)
213
+
214
+ hosts = sorted({domain, *subdomains})
215
+ progress(f"resolving {len(hosts)} hosts")
216
+ resolutions = resolver.resolve_many(hosts)
217
+ failed = [r for r in resolutions if r.status in (ResolutionStatus.TIMEOUT, ResolutionStatus.ERROR)]
218
+ if failed:
219
+ warnings.append(f"DNS resolution failed (timeout or error) for {len(failed)} of {len(resolutions)} hosts")
220
+ if len(failed) >= RESOLUTION_FAILURE_THRESHOLD * len(resolutions):
221
+ incomplete = True
222
+ live = [r for r in resolutions if r.is_live]
223
+ public_hosts = [r.hostname for r in live if all_public(r.a_records)]
224
+ non_public = [r.hostname for r in live if not all_public(r.a_records)]
225
+ if non_public:
226
+ progress(f"{len(non_public)} host(s) resolve to non-public addresses and will not be probed")
227
+
228
+ progress("checking for subdomain takeovers")
229
+ database = FingerprintDatabase()
230
+ detector = TakeoverDetector(database, timeout=timeout)
231
+ takeovers = [f for f in detector.check_many(resolutions) if f is not None and f.verdict is not TakeoverVerdict.NOT_VULNERABLE]
232
+
233
+ progress("auditing DNS records")
234
+ auditor = DNSAuditor(timeout=min(timeout, 5.0), nameservers=nameservers)
235
+ dns_findings = auditor.audit(domain, include_zone_transfer=include_zone_transfer)
236
+ if auditor.errors:
237
+ warnings.append(f"DNS audit incomplete: {'; '.join(auditor.errors)}")
238
+ incomplete = True
239
+
240
+ result: Dict[str, Any] = {
241
+ "schema_version": SCHEMA_VERSION,
242
+ "tool": {"name": "rimscan-cli", "version": __version__},
243
+ "scanned_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
244
+ "domain": domain,
245
+ "wildcard_dns": wildcard,
246
+ "wildcard_filtered": sum(1 for r in resolutions if r.status is ResolutionStatus.WILDCARD),
247
+ "sources": dict(enumerator.source_status),
248
+ "subdomains": sorted(subdomains),
249
+ "resolutions": resolutions,
250
+ "takeover_findings": takeovers,
251
+ "dns_findings": dns_findings,
252
+ }
253
+ if include_certs:
254
+ progress(f"auditing TLS certificates on {len(public_hosts)} live hosts")
255
+ if len(public_hosts) > MAX_CERT_HOSTS:
256
+ warnings.append(
257
+ f"TLS audit limited to {MAX_CERT_HOSTS} of {len(public_hosts)} live hosts; the rest were not checked"
258
+ )
259
+ result["certificate_findings"] = CertAuditor(timeout=timeout).audit_many(public_hosts)
260
+
261
+ if include_secrets or include_nuclei:
262
+ third_party = {
263
+ r.hostname for r in resolutions if any(database.is_third_party(hop) for hop in r.cname_chain)
264
+ }
265
+ active_hosts = public_hosts if include_third_party else [h for h in public_hosts if h not in third_party]
266
+ skipped_third_party = [h for h in public_hosts if h not in active_hosts]
267
+ if skipped_third_party:
268
+ progress(
269
+ f"skipping {len(skipped_third_party)} host(s) that CNAME to third-party services "
270
+ "(use --include-third-party to probe them)"
271
+ )
272
+ result["active_targets"] = active_hosts
273
+ result["skipped_hosts"] = {"non_public": non_public, "third_party": skipped_third_party}
274
+ if include_secrets:
275
+ progress(f"scanning well-known paths on {len(active_hosts)} hosts for exposed secrets")
276
+ if len(active_hosts) > MAX_SECRET_HOSTS:
277
+ warnings.append(
278
+ f"secret scan limited to {MAX_SECRET_HOSTS} of {len(active_hosts)} hosts; the rest were not checked"
279
+ )
280
+ result["secret_findings"] = SecretScanner(timeout=(min(timeout, 5.0), timeout)).scan_domain(active_hosts)
281
+ if include_nuclei:
282
+ progress("running nuclei")
283
+ result["nuclei"] = run_nuclei(active_hosts, rate_limit=nuclei_rate_limit)
284
+
285
+ result["warnings"] = warnings
286
+ result["incomplete"] = incomplete
287
+ return to_jsonable(result)
288
+
289
+
290
+ def severity_counts(result: Dict[str, Any]) -> Dict[str, int]:
291
+ counts: Dict[str, int] = {}
292
+
293
+ def add(level: str) -> None:
294
+ counts[level] = counts.get(level, 0) + 1
295
+
296
+ for finding in result.get("takeover_findings", []):
297
+ add(_TAKEOVER_SEVERITY.get(finding["verdict"], "info"))
298
+ for key in ("dns_findings", "certificate_findings"):
299
+ for finding in result.get(key, []):
300
+ add(finding["severity"])
301
+ for _ in result.get("secret_findings", []):
302
+ add("high")
303
+ for finding in result.get("nuclei", {}).get("findings", []):
304
+ add(finding["severity"] if finding["severity"] in SEVERITY_RANK else "info")
305
+ return counts