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.
- rimscan_cli/__init__.py +3 -0
- rimscan_cli/__main__.py +4 -0
- rimscan_cli/banner.py +48 -0
- rimscan_cli/cli.py +158 -0
- rimscan_cli/data/fingerprints.json +932 -0
- rimscan_cli/recon/__init__.py +0 -0
- rimscan_cli/recon/_common.py +62 -0
- rimscan_cli/recon/_http.py +65 -0
- rimscan_cli/recon/cert_audit.py +131 -0
- rimscan_cli/recon/dns_audit.py +196 -0
- rimscan_cli/recon/enumeration.py +192 -0
- rimscan_cli/recon/resolver.py +232 -0
- rimscan_cli/recon/secret_scan.py +241 -0
- rimscan_cli/recon/severity.py +15 -0
- rimscan_cli/recon/takeover.py +302 -0
- rimscan_cli/scanner.py +305 -0
- rimscan_cli-1.0.0.dist-info/METADATA +250 -0
- rimscan_cli-1.0.0.dist-info/RECORD +23 -0
- rimscan_cli-1.0.0.dist-info/WHEEL +5 -0
- rimscan_cli-1.0.0.dist-info/entry_points.txt +2 -0
- rimscan_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
- rimscan_cli-1.0.0.dist-info/licenses/NOTICE +8 -0
- rimscan_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|