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,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"
|