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,232 @@
1
+ """DNS resolution with CNAME chain tracking and wildcard detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import random
7
+ import string
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum
11
+ from typing import Dict, List, Optional, Sequence, Set, Tuple
12
+
13
+ import dns.exception
14
+ import dns.resolver
15
+
16
+ from rimscan_cli.recon._common import normalize_name
17
+
18
+ __all__ = ["ResolutionStatus", "ResolutionResult", "DNSResolver"]
19
+
20
+
21
+ class ResolutionStatus(Enum):
22
+ A_RECORD = "a_record"
23
+ CNAME = "cname"
24
+ DANGLING_CNAME = "dangling_cname"
25
+ NXDOMAIN = "nxdomain"
26
+ NO_RECORD = "no_record"
27
+ WILDCARD = "wildcard"
28
+ TIMEOUT = "timeout"
29
+ ERROR = "error"
30
+
31
+
32
+ # Transient failures are not cached so a flaky lookup can be retried later.
33
+ _CACHEABLE = {
34
+ ResolutionStatus.A_RECORD,
35
+ ResolutionStatus.CNAME,
36
+ ResolutionStatus.DANGLING_CNAME,
37
+ ResolutionStatus.NXDOMAIN,
38
+ ResolutionStatus.NO_RECORD,
39
+ ResolutionStatus.WILDCARD,
40
+ }
41
+
42
+
43
+ @dataclass
44
+ class ResolutionResult:
45
+ hostname: str
46
+ status: ResolutionStatus
47
+ a_records: List[str] = field(default_factory=list)
48
+ cname_target: Optional[str] = None
49
+ cname_chain: List[str] = field(default_factory=list)
50
+ error_detail: Optional[str] = None
51
+
52
+ def __repr__(self) -> str:
53
+ if self.status in (ResolutionStatus.CNAME, ResolutionStatus.DANGLING_CNAME):
54
+ return f"<{self.hostname} {self.status.value} via {' -> '.join(self.cname_chain)}>"
55
+ if self.status is ResolutionStatus.A_RECORD:
56
+ return f"<{self.hostname} A {', '.join(self.a_records)}>"
57
+ return f"<{self.hostname} {self.status.value}>"
58
+
59
+ @property
60
+ def is_live(self) -> bool:
61
+ """True when the name resolves to at least one address and is not wildcard noise."""
62
+ return bool(self.a_records) and self.status in (
63
+ ResolutionStatus.A_RECORD,
64
+ ResolutionStatus.CNAME,
65
+ )
66
+
67
+
68
+ class DNSResolver:
69
+ def __init__(
70
+ self,
71
+ timeout: float = 5.0,
72
+ max_workers: int = 20,
73
+ nameservers: Optional[Sequence[str]] = None,
74
+ ):
75
+ self.max_workers = max_workers
76
+ self._resolver = dns.resolver.Resolver()
77
+ self._resolver.timeout = timeout
78
+ self._resolver.lifetime = timeout
79
+ if nameservers:
80
+ self._resolver.nameservers = list(nameservers)
81
+ self._cache: Dict[str, ResolutionResult] = {}
82
+ self._wildcard_domain: Optional[str] = None
83
+ self._wildcard_ips: Set[str] = set()
84
+ self._wildcard_cnames: Set[str] = set()
85
+
86
+ # -- wildcard handling -------------------------------------------------
87
+
88
+ def detect_wildcard(self, domain: str, probes: int = 3, min_hits: int = 2) -> bool:
89
+ """Resolve random labels under `domain`; if several resolve, it is a wildcard zone.
90
+
91
+ CDN-backed wildcards can answer with different addresses for every label, so
92
+ the probes only have to resolve, not agree. Call this before resolving hosts.
93
+
94
+ Limitation: a real host that shares its addresses or CNAME target with the
95
+ wildcard cannot be told apart from wildcard noise and is reported as WILDCARD.
96
+ """
97
+ domain = normalize_name(domain)
98
+ self._wildcard_domain = None
99
+ self._wildcard_ips = set()
100
+ self._wildcard_cnames = set()
101
+ self._cache.clear()
102
+
103
+ hits: List[ResolutionResult] = []
104
+ for _ in range(probes):
105
+ label = "".join(random.choices(string.ascii_lowercase + string.digits, k=16))
106
+ result = self._resolve_uncached(f"{label}.{domain}")
107
+ if result.is_live:
108
+ hits.append(result)
109
+
110
+ if len(hits) < min(min_hits, probes):
111
+ return False
112
+ self._wildcard_domain = domain
113
+ for hit in hits:
114
+ self._wildcard_ips.update(hit.a_records)
115
+ if hit.cname_chain:
116
+ self._wildcard_cnames.add(hit.cname_chain[-1])
117
+ return True
118
+
119
+ def _is_wildcard_answer(self, hostname: str, ips: List[str], chain: List[str]) -> bool:
120
+ domain = self._wildcard_domain
121
+ if not domain or not hostname.endswith("." + domain):
122
+ return False
123
+ if chain and chain[-1] in self._wildcard_cnames:
124
+ return True
125
+ return bool(ips) and set(ips) <= self._wildcard_ips
126
+
127
+ # -- lookups -----------------------------------------------------------
128
+
129
+ def _lookup(self, name: str, rdtype: str) -> Tuple[list, str]:
130
+ """Return (records, state); state is ok, nodata, nxdomain, timeout or error."""
131
+ for attempt in range(2):
132
+ try:
133
+ return list(self._resolver.resolve(name, rdtype)), "ok"
134
+ except dns.resolver.NoAnswer:
135
+ return [], "nodata"
136
+ except dns.resolver.NXDOMAIN:
137
+ return [], "nxdomain"
138
+ except dns.exception.Timeout:
139
+ if attempt == 1:
140
+ return [], "timeout"
141
+ except dns.exception.DNSException:
142
+ return [], "error"
143
+ return [], "timeout"
144
+
145
+ @staticmethod
146
+ def _failure(hostname: str, chain: List[str], state: str) -> ResolutionResult:
147
+ if state == "nxdomain":
148
+ if chain:
149
+ return ResolutionResult(
150
+ hostname, ResolutionStatus.DANGLING_CNAME, cname_chain=chain, cname_target=chain[-1]
151
+ )
152
+ return ResolutionResult(hostname, ResolutionStatus.NXDOMAIN)
153
+ if state == "timeout":
154
+ return ResolutionResult(hostname, ResolutionStatus.TIMEOUT, cname_chain=chain)
155
+ return ResolutionResult(
156
+ hostname, ResolutionStatus.ERROR, cname_chain=chain, error_detail="DNS lookup failed"
157
+ )
158
+
159
+ def _resolve_uncached(self, hostname: str, max_chain_depth: int = 10) -> ResolutionResult:
160
+ chain: List[str] = []
161
+ current = normalize_name(hostname)
162
+ visited = {current}
163
+
164
+ while True:
165
+ records, state = self._lookup(current, "CNAME")
166
+ if state == "nodata":
167
+ break
168
+ if state != "ok":
169
+ return self._failure(hostname, chain, state)
170
+ target = normalize_name(str(records[0].target))
171
+ if target in visited:
172
+ return ResolutionResult(
173
+ hostname, ResolutionStatus.ERROR, cname_chain=chain,
174
+ error_detail=f"CNAME loop at {target}",
175
+ )
176
+ if len(chain) >= max_chain_depth:
177
+ return ResolutionResult(
178
+ hostname, ResolutionStatus.ERROR, cname_chain=chain,
179
+ error_detail=f"CNAME chain longer than {max_chain_depth} hops",
180
+ )
181
+ visited.add(target)
182
+ chain.append(target)
183
+ current = target
184
+
185
+ ips: List[str] = []
186
+ for rdtype in ("A", "AAAA"):
187
+ records, state = self._lookup(current, rdtype)
188
+ if state == "ok":
189
+ ips = [str(r) for r in records]
190
+ break
191
+ if state != "nodata":
192
+ return self._failure(hostname, chain, state)
193
+
194
+ if not ips:
195
+ if chain:
196
+ return ResolutionResult(
197
+ hostname, ResolutionStatus.CNAME, cname_chain=chain, cname_target=chain[-1]
198
+ )
199
+ return ResolutionResult(hostname, ResolutionStatus.NO_RECORD)
200
+
201
+ if self._is_wildcard_answer(normalize_name(hostname), ips, chain):
202
+ return ResolutionResult(
203
+ hostname, ResolutionStatus.WILDCARD, a_records=ips, cname_chain=chain
204
+ )
205
+ if chain:
206
+ return ResolutionResult(
207
+ hostname, ResolutionStatus.CNAME, a_records=ips,
208
+ cname_chain=chain, cname_target=chain[-1],
209
+ )
210
+ return ResolutionResult(hostname, ResolutionStatus.A_RECORD, a_records=ips)
211
+
212
+ # -- public API --------------------------------------------------------
213
+
214
+ def resolve_one(self, hostname: str, max_chain_depth: int = 10) -> ResolutionResult:
215
+ key = normalize_name(hostname)
216
+ cached = self._cache.get(key)
217
+ if cached is not None:
218
+ result = copy.deepcopy(cached)
219
+ result.hostname = hostname
220
+ return result
221
+ result = self._resolve_uncached(hostname, max_chain_depth)
222
+ if result.status in _CACHEABLE:
223
+ self._cache[key] = copy.deepcopy(result)
224
+ return result
225
+
226
+ def resolve_many(self, hostnames: Sequence[str]) -> List[ResolutionResult]:
227
+ """Resolve hosts concurrently; results follow the order of `hostnames`."""
228
+ names = list(dict.fromkeys(hostnames))
229
+ if not names:
230
+ return []
231
+ with ThreadPoolExecutor(max_workers=min(self.max_workers, len(names))) as pool:
232
+ return list(pool.map(self.resolve_one, names))
@@ -0,0 +1,241 @@
1
+ """Detect credentials exposed in publicly served files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import logging
7
+ import math
8
+ import re
9
+ from collections import Counter
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from dataclasses import dataclass, field
12
+ from typing import Dict, Iterable, List, Optional, Pattern, Set, Tuple
13
+
14
+ import requests
15
+
16
+ from rimscan_cli.recon._http import make_session, read_capped
17
+
18
+ __all__ = ["SecretFinding", "SecretScanner", "SECRET_PATTERNS", "COMMON_PATHS", "MAX_HOSTS"]
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ COMMON_PATHS = (
23
+ "/.env",
24
+ "/.env.local",
25
+ "/.env.production",
26
+ "/config.js",
27
+ "/app.js",
28
+ "/main.js",
29
+ "/static/js/main.js",
30
+ "/assets/env.js",
31
+ "/api/config",
32
+ )
33
+
34
+ # Config-style files are never legitimately served as HTML; an HTML answer is a soft 404.
35
+ _NON_HTML_PATHS = {"/.env", "/.env.local", "/.env.production"}
36
+ MAX_HOSTS = 50
37
+ _MAX_BODY_BYTES = 1024 * 1024
38
+ _CONTEXT_RADIUS = 40
39
+ _AWS_CONTEXT_RADIUS = 80
40
+
41
+ _PRIVATE_KEY_HEADER = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")
42
+
43
+ SECRET_PATTERNS: Dict[str, Pattern[str]] = {
44
+ "aws_access_key": re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
45
+ "aws_secret_key": re.compile(r"(?<![A-Za-z0-9/+])[A-Za-z0-9/+]{40}(?![A-Za-z0-9/+=])"),
46
+ "github_token": re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"),
47
+ "slack_token": re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"),
48
+ "stripe_live_key": re.compile(r"\b[sr]k_live_[0-9a-zA-Z]{24,}"),
49
+ "jwt": re.compile(r"\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]*"),
50
+ "private_key": _PRIVATE_KEY_HEADER,
51
+ }
52
+
53
+ # A bare 40-character base64 string is too common (hashes, build ids) to report alone.
54
+ _AWS_SECRET_CONTEXT = re.compile(r"aws|secret", re.IGNORECASE)
55
+
56
+ Span = Tuple[int, int]
57
+
58
+
59
+ def _redact(value: str) -> str:
60
+ """Mask a credential, keeping only its first and last four characters.
61
+
62
+ A private-key header is a public marker, not a secret, and it tells a reader what kind
63
+ of key was exposed (RSA, EC, OpenSSH, encrypted, ...), so it is kept whole. Anything
64
+ that follows it inside the same span is key material and is replaced by "[...]".
65
+ """
66
+ header = _PRIVATE_KEY_HEADER.match(value)
67
+ if header:
68
+ return header.group(0) + ("[...]" if len(value) > header.end() else "")
69
+ return "***" if len(value) <= 8 else f"{value[:4]}...{value[-4:]}"
70
+
71
+
72
+ def _merge_spans(spans: Iterable[Span]) -> List[Span]:
73
+ merged: List[Span] = []
74
+ for start, end in sorted(spans):
75
+ if merged and start < merged[-1][1]:
76
+ merged[-1] = (merged[-1][0], max(merged[-1][1], end))
77
+ else:
78
+ merged.append((start, end))
79
+ return merged
80
+
81
+
82
+ def _redacted_window(line: str, start: int, end: int, spans: List[Span]) -> str:
83
+ """Text around a match with every credential on the line redacted.
84
+
85
+ The window never cuts through a credential: if an edge lands inside one, the window
86
+ grows to include it whole, so no fragment of a secret is left unredacted.
87
+ """
88
+ low = max(0, start - _CONTEXT_RADIUS)
89
+ high = min(len(line), end + _CONTEXT_RADIUS)
90
+ for span_start, span_end in spans:
91
+ if span_start < low < span_end:
92
+ low = span_start
93
+ if span_start < high < span_end:
94
+ high = span_end
95
+ parts: List[str] = []
96
+ position = low
97
+ for span_start, span_end in spans:
98
+ if span_end <= low or span_start >= high:
99
+ continue
100
+ parts.append(line[position:span_start])
101
+ parts.append(_redact(line[span_start:span_end]))
102
+ position = span_end
103
+ parts.append(line[position:high])
104
+ return "".join(parts).strip()
105
+
106
+
107
+ @dataclass
108
+ class SecretFinding:
109
+ secret_type: str
110
+ source: str
111
+ line_number: Optional[int] = None
112
+ entropy: float = 0.0
113
+ # The raw value is kept out of repr() and to_dict() on purpose; `context` is built
114
+ # already redacted by the scanner and is redacted again on output as a second layer.
115
+ match: str = field(default="", repr=False)
116
+ context: str = field(default="", repr=False)
117
+
118
+ def redacted_match(self) -> str:
119
+ return _redact(self.match)
120
+
121
+ def redacted_context(self) -> str:
122
+ text = self.context
123
+ for pattern in SECRET_PATTERNS.values():
124
+ text = pattern.sub(lambda m: _redact(m.group()), text)
125
+ return text
126
+
127
+ def to_dict(self) -> dict:
128
+ return {
129
+ "secret_type": self.secret_type,
130
+ "match": self.redacted_match(),
131
+ "source": self.source,
132
+ "line_number": self.line_number,
133
+ "entropy": self.entropy,
134
+ "context": self.redacted_context(),
135
+ }
136
+
137
+
138
+ def shannon_entropy(data: str) -> float:
139
+ if not data:
140
+ return 0.0
141
+ total = len(data)
142
+ return -sum((n / total) * math.log2(n / total) for n in Counter(data).values())
143
+
144
+
145
+ class SecretScanner:
146
+ def __init__(
147
+ self,
148
+ min_entropy: float = 4.5,
149
+ timeout: Tuple[float, float] = (5.0, 10.0),
150
+ max_workers: int = 8,
151
+ ):
152
+ self.min_entropy = min_entropy
153
+ self.timeout = timeout
154
+ self.max_workers = max_workers
155
+ self._session = make_session(retries=1)
156
+
157
+ def scan_text(self, text: str, source: str = "unknown") -> List[SecretFinding]:
158
+ findings: List[SecretFinding] = []
159
+ for line_no, line in enumerate(text.splitlines(), start=1):
160
+ hits: List[Tuple[int, int, str, str, float]] = []
161
+ for secret_type, pattern in SECRET_PATTERNS.items():
162
+ for match in pattern.finditer(line):
163
+ value = match.group(0)
164
+ entropy = shannon_entropy(value)
165
+ if secret_type == "aws_secret_key":
166
+ nearby = line[max(0, match.start() - _AWS_CONTEXT_RADIUS):match.end() + _AWS_CONTEXT_RADIUS]
167
+ if entropy < self.min_entropy or not _AWS_SECRET_CONTEXT.search(nearby):
168
+ continue
169
+ # A key embedded in JSON or JS keeps its body on the same line as the
170
+ # header. Everything after the header on that line counts as part of the
171
+ # secret, so the context shown for the finding never contains key material.
172
+ end = len(line) if secret_type == "private_key" else match.end()
173
+ hits.append((match.start(), end, secret_type, value, entropy))
174
+ if not hits:
175
+ continue
176
+ spans = _merge_spans((start, end) for start, end, *_ in hits)
177
+ for start, end, secret_type, value, entropy in hits:
178
+ findings.append(
179
+ SecretFinding(
180
+ secret_type, source, line_no, round(entropy, 2),
181
+ match=value, context=_redacted_window(line, start, end, spans),
182
+ )
183
+ )
184
+ return findings
185
+
186
+ def scan_domain(self, hosts: Iterable[str], max_hosts: int = MAX_HOSTS) -> List[SecretFinding]:
187
+ """Fetch a fixed list of well-known paths on each host and scan the responses.
188
+
189
+ Sends one GET per path in COMMON_PATHS per host, plus one more over HTTP when the
190
+ HTTPS request fails to connect. The session may also retry once on a 429 or 5xx
191
+ answer (it never waits for a server-supplied Retry-After). Redirects are not
192
+ followed. A request that fails, including one that breaks while its body is being
193
+ read, only skips that path. At most `max_hosts` hosts are scanned; a warning is
194
+ logged when the list is longer. A response body is reported once per host, so
195
+ soft-404 pages do not repeat, but the same leak on two hosts is reported for both.
196
+ Callers must have permission to test every host passed in.
197
+ """
198
+ host_list = list(dict.fromkeys(hosts))
199
+ if len(host_list) > max_hosts:
200
+ logger.warning("Secret scan limited to %d of %d hosts", max_hosts, len(host_list))
201
+ host_list = host_list[:max_hosts]
202
+ urls = [(host, path) for host in host_list for path in COMMON_PATHS]
203
+ seen_bodies: Set[Tuple[str, str]] = set()
204
+ findings: List[SecretFinding] = []
205
+
206
+ with ThreadPoolExecutor(max_workers=self.max_workers) as pool:
207
+ for host, url, body in pool.map(lambda hp: (hp[0], *self._fetch(*hp)), urls):
208
+ if body is None:
209
+ continue
210
+ digest = hashlib.sha256(body.encode("utf-8", "replace")).hexdigest()
211
+ if (host, digest) in seen_bodies:
212
+ continue
213
+ seen_bodies.add((host, digest))
214
+ findings.extend(self.scan_text(body, source=url))
215
+ return findings
216
+
217
+ def _fetch(self, host: str, path: str) -> Tuple[str, Optional[str]]:
218
+ for scheme in ("https", "http"):
219
+ url = f"{scheme}://{host}{path}"
220
+ try:
221
+ response = self._session.get(
222
+ url, timeout=self.timeout, stream=True, allow_redirects=False
223
+ )
224
+ except requests.RequestException:
225
+ continue
226
+ if response.status_code >= 300:
227
+ # Errors and redirects (redirects are not followed) carry no file content.
228
+ response.close()
229
+ return url, None
230
+ if path in _NON_HTML_PATHS and "html" in response.headers.get("Content-Type", "").lower():
231
+ response.close()
232
+ return url, None
233
+ try:
234
+ return url, read_capped(response, _MAX_BODY_BYTES)
235
+ except requests.RequestException:
236
+ # The body is streamed, so a reset or read timeout can surface here. It must
237
+ # not escape the worker (pool.map would re-raise it and the scan would lose
238
+ # every result), and the host did answer, so there is no HTTP fallback.
239
+ response.close()
240
+ return url, None
241
+ return f"https://{host}{path}", None
@@ -0,0 +1,15 @@
1
+ """Severity levels shared by every audit module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+ __all__ = ["Severity"]
8
+
9
+
10
+ class Severity(Enum):
11
+ CRITICAL = "critical"
12
+ HIGH = "high"
13
+ MEDIUM = "medium"
14
+ LOW = "low"
15
+ INFO = "info"