fourisnotrandom 0.1.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.
- fourisnotrandom/__init__.py +3 -0
- fourisnotrandom/__main__.py +6 -0
- fourisnotrandom/checks/__init__.py +1 -0
- fourisnotrandom/checks/apps.py +81 -0
- fourisnotrandom/checks/emudhra.py +535 -0
- fourisnotrandom/checks/gitlab.py +152 -0
- fourisnotrandom/checks/litellm.py +315 -0
- fourisnotrandom/checks/php.py +429 -0
- fourisnotrandom/cli.py +312 -0
- fourisnotrandom/config_loader.py +79 -0
- fourisnotrandom/data/__init__.py +1 -0
- fourisnotrandom/data/compromised_hashes.json +55 -0
- fourisnotrandom/data/fixed_versions.yaml +75 -0
- fourisnotrandom/data/php_apps.yaml +42 -0
- fourisnotrandom/models.py +43 -0
- fourisnotrandom/report.py +61 -0
- fourisnotrandom/targets.py +67 -0
- fourisnotrandom/versioning.py +262 -0
- fourisnotrandom-0.1.0.dist-info/METADATA +122 -0
- fourisnotrandom-0.1.0.dist-info/RECORD +24 -0
- fourisnotrandom-0.1.0.dist-info/WHEEL +5 -0
- fourisnotrandom-0.1.0.dist-info/entry_points.txt +2 -0
- fourisnotrandom-0.1.0.dist-info/licenses/LICENSE +21 -0
- fourisnotrandom-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Detection-only check modules."""
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fourisnotrandom.config_loader import load_php_apps
|
|
8
|
+
from fourisnotrandom.models import Finding, Status
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def detect_php_apps(
|
|
12
|
+
root: Path,
|
|
13
|
+
php_version: str | None = None,
|
|
14
|
+
php_status: str | None = None,
|
|
15
|
+
apps_config: dict[str, Any] | None = None,
|
|
16
|
+
) -> list[Finding]:
|
|
17
|
+
"""Detect common PHP application stacks under a webroot (read-only)."""
|
|
18
|
+
cfg = apps_config or load_php_apps()
|
|
19
|
+
findings: list[Finding] = []
|
|
20
|
+
root = Path(root)
|
|
21
|
+
|
|
22
|
+
for app in cfg.get("apps", []):
|
|
23
|
+
name = app.get("name", "unknown")
|
|
24
|
+
matched = False
|
|
25
|
+
version_hint = ""
|
|
26
|
+
for marker in app.get("markers", []):
|
|
27
|
+
rel = marker.get("path")
|
|
28
|
+
if not rel:
|
|
29
|
+
continue
|
|
30
|
+
path = root / rel
|
|
31
|
+
if not path.is_file():
|
|
32
|
+
continue
|
|
33
|
+
try:
|
|
34
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
35
|
+
except OSError:
|
|
36
|
+
continue
|
|
37
|
+
contains = marker.get("contains")
|
|
38
|
+
if contains and contains not in text:
|
|
39
|
+
continue
|
|
40
|
+
pattern = marker.get("pattern")
|
|
41
|
+
if pattern:
|
|
42
|
+
m = re.search(pattern, text)
|
|
43
|
+
if not m:
|
|
44
|
+
continue
|
|
45
|
+
version_hint = m.group(1).strip() if m.lastindex else ""
|
|
46
|
+
matched = True
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
if not matched:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
if php_status == "FAIL":
|
|
53
|
+
status = Status.FAIL
|
|
54
|
+
remediation = (
|
|
55
|
+
f"{name} detected on vulnerable PHP {php_version or '?'}; "
|
|
56
|
+
"upgrade PHP (and review app compatibility)."
|
|
57
|
+
)
|
|
58
|
+
elif php_version:
|
|
59
|
+
status = Status.PASS if php_status == "PASS" else Status.UNKNOWN
|
|
60
|
+
remediation = (
|
|
61
|
+
f"{name} detected; confirm PHP {php_version} is on a fixed branch."
|
|
62
|
+
if status != Status.PASS
|
|
63
|
+
else f"{name} detected; PHP version appears fixed."
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
status = Status.UNKNOWN
|
|
67
|
+
remediation = f"{name} detected; PHP version not determined - run a PHP check."
|
|
68
|
+
|
|
69
|
+
findings.append(
|
|
70
|
+
Finding(
|
|
71
|
+
status=status,
|
|
72
|
+
cve="CVE-2026-109618/CVE-2026-109619",
|
|
73
|
+
target=str(root),
|
|
74
|
+
installed=f"{name}" + (f" {version_hint}" if version_hint else ""),
|
|
75
|
+
fixed="PHP fixed branch (see php check)",
|
|
76
|
+
remediation=remediation,
|
|
77
|
+
detail="application_stack",
|
|
78
|
+
extra={"app": name, "app_version": version_hint, "php_version": php_version},
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
return findings
|
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import socket
|
|
7
|
+
import ssl
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Iterable
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from cryptography import x509
|
|
14
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
15
|
+
from cryptography.hazmat.primitives.serialization.pkcs12 import load_key_and_certificates
|
|
16
|
+
|
|
17
|
+
from fourisnotrandom.config_loader import compromised_hash_set, load_fixed_versions
|
|
18
|
+
from fourisnotrandom.models import Finding, Status
|
|
19
|
+
from fourisnotrandom.targets import assert_not_cidr_or_range
|
|
20
|
+
from fourisnotrandom.versioning import (
|
|
21
|
+
any_hash_match,
|
|
22
|
+
certifi_exposure,
|
|
23
|
+
go_exposure,
|
|
24
|
+
normalize_hash,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
CVE_LABEL = "CVE-2026-111943/CVE-2026-113470"
|
|
28
|
+
CERT_SUFFIXES = {".pem", ".crt", ".cer", ".der"}
|
|
29
|
+
KEYSTORE_SUFFIXES = {".p12", ".pfx", ".pkcs12"}
|
|
30
|
+
|
|
31
|
+
CA_BUNDLE_PATHS = [
|
|
32
|
+
"/etc/ssl/certs/ca-certificates.crt",
|
|
33
|
+
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
34
|
+
"/etc/ssl/cert.pem",
|
|
35
|
+
"/etc/ssl/certs",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cert_digests(cert: x509.Certificate) -> tuple[str, str]:
|
|
40
|
+
"""Return (full_cert_sha256, spki_sha256) hex digests."""
|
|
41
|
+
der = cert.public_bytes(Encoding.DER)
|
|
42
|
+
cert_fp = hashlib.sha256(der).hexdigest()
|
|
43
|
+
spki = cert.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
|
|
44
|
+
spki_fp = hashlib.sha256(spki).hexdigest()
|
|
45
|
+
return cert_fp, spki_fp
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_certs_from_pem_bytes(data: bytes) -> list[x509.Certificate]:
|
|
49
|
+
certs: list[x509.Certificate] = []
|
|
50
|
+
text = data.decode("utf-8", errors="ignore")
|
|
51
|
+
if "BEGIN CERTIFICATE" in text:
|
|
52
|
+
blocks = re.findall(
|
|
53
|
+
r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----",
|
|
54
|
+
text,
|
|
55
|
+
flags=re.S,
|
|
56
|
+
)
|
|
57
|
+
for block in blocks:
|
|
58
|
+
try:
|
|
59
|
+
certs.append(x509.load_pem_x509_certificate(block.encode()))
|
|
60
|
+
except ValueError:
|
|
61
|
+
continue
|
|
62
|
+
else:
|
|
63
|
+
try:
|
|
64
|
+
certs.append(x509.load_der_x509_certificate(data))
|
|
65
|
+
except ValueError:
|
|
66
|
+
pass
|
|
67
|
+
return certs
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _match_finding(
|
|
71
|
+
target: str,
|
|
72
|
+
cert: x509.Certificate,
|
|
73
|
+
hashes: set[str],
|
|
74
|
+
*,
|
|
75
|
+
detection_method: str = "certificate digest match",
|
|
76
|
+
) -> Finding | None:
|
|
77
|
+
cert_fp, spki_fp = cert_digests(cert)
|
|
78
|
+
hit = any_hash_match([cert_fp, spki_fp], hashes)
|
|
79
|
+
subject = cert.subject.rfc4514_string()
|
|
80
|
+
if hit:
|
|
81
|
+
nh = normalize_hash(hit)
|
|
82
|
+
if nh == normalize_hash(cert_fp):
|
|
83
|
+
match_type = "full-certificate SHA-256"
|
|
84
|
+
elif nh == normalize_hash(spki_fp):
|
|
85
|
+
match_type = "SPKI SHA-256"
|
|
86
|
+
else:
|
|
87
|
+
match_type = "unknown"
|
|
88
|
+
return Finding(
|
|
89
|
+
status=Status.FAIL,
|
|
90
|
+
cve=CVE_LABEL,
|
|
91
|
+
target=target,
|
|
92
|
+
installed=f"matched_via={match_type} matched={hit} subject={subject}",
|
|
93
|
+
fixed="distrust / remove compromised intermediate",
|
|
94
|
+
remediation="Remove or replace trust in compromised eMudhra intermediate; rotate any certs chaining through it.",
|
|
95
|
+
detail="hash_match",
|
|
96
|
+
detection_method=detection_method,
|
|
97
|
+
extra={
|
|
98
|
+
"cert_sha256": cert_fp,
|
|
99
|
+
"spki_sha256": spki_fp,
|
|
100
|
+
"matched": hit,
|
|
101
|
+
"matched_via": match_type,
|
|
102
|
+
"subject": subject,
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def scan_certificates(
|
|
109
|
+
certs: Iterable[x509.Certificate],
|
|
110
|
+
target: str,
|
|
111
|
+
hashes: set[str] | None = None,
|
|
112
|
+
) -> list[Finding]:
|
|
113
|
+
hashes = hashes or compromised_hash_set()
|
|
114
|
+
findings: list[Finding] = []
|
|
115
|
+
matched = False
|
|
116
|
+
certs_list = list(certs)
|
|
117
|
+
for cert in certs_list:
|
|
118
|
+
f = _match_finding(target, cert, hashes)
|
|
119
|
+
if f:
|
|
120
|
+
findings.append(f)
|
|
121
|
+
matched = True
|
|
122
|
+
if not matched:
|
|
123
|
+
# Still surface digests for the first cert when present (auditability).
|
|
124
|
+
extra: dict[str, Any] = {}
|
|
125
|
+
if certs_list:
|
|
126
|
+
c_fp, s_fp = cert_digests(certs_list[0])
|
|
127
|
+
extra = {
|
|
128
|
+
"cert_sha256": c_fp,
|
|
129
|
+
"spki_sha256": s_fp,
|
|
130
|
+
"matched": None,
|
|
131
|
+
"matched_via": None,
|
|
132
|
+
}
|
|
133
|
+
findings.append(
|
|
134
|
+
Finding(
|
|
135
|
+
status=Status.PASS,
|
|
136
|
+
cve=CVE_LABEL,
|
|
137
|
+
target=target,
|
|
138
|
+
installed="no compromised hash matched",
|
|
139
|
+
fixed="N/A",
|
|
140
|
+
remediation="No action required for eMudhra compromised intermediates on this target.",
|
|
141
|
+
detail="no_match",
|
|
142
|
+
detection_method="certificate digest match",
|
|
143
|
+
extra=extra,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
return findings
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def fetch_tls_chain(host: str, port: int = 443, timeout: float = 15.0) -> list[x509.Certificate]:
|
|
150
|
+
assert_not_cidr_or_range(host)
|
|
151
|
+
context = ssl.create_default_context()
|
|
152
|
+
context.check_hostname = False
|
|
153
|
+
context.verify_mode = ssl.CERT_NONE
|
|
154
|
+
with socket.create_connection((host, port), timeout=timeout) as sock:
|
|
155
|
+
with context.wrap_socket(sock, server_hostname=host) as ssock:
|
|
156
|
+
der = ssock.getpeercert(binary_form=True)
|
|
157
|
+
certs = [x509.load_der_x509_certificate(der)]
|
|
158
|
+
# Peer cert only from stdlib; try getpeercert_chain if available (3.13+)
|
|
159
|
+
chain = getattr(ssock, "getpeercert_chain", None)
|
|
160
|
+
if callable(chain):
|
|
161
|
+
for c in chain() or []:
|
|
162
|
+
try:
|
|
163
|
+
certs.append(x509.load_der_x509_certificate(c))
|
|
164
|
+
except Exception:
|
|
165
|
+
continue
|
|
166
|
+
return certs
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def check_host(host_or_url: str, hashes: set[str] | None = None) -> list[Finding]:
|
|
170
|
+
assert_not_cidr_or_range(host_or_url)
|
|
171
|
+
parsed = urlparse(host_or_url if "://" in host_or_url else f"https://{host_or_url}")
|
|
172
|
+
host = parsed.hostname or host_or_url
|
|
173
|
+
port = parsed.port or 443
|
|
174
|
+
try:
|
|
175
|
+
certs = fetch_tls_chain(host, port=port)
|
|
176
|
+
except OSError as exc:
|
|
177
|
+
return [
|
|
178
|
+
Finding(
|
|
179
|
+
status=Status.UNKNOWN,
|
|
180
|
+
cve=CVE_LABEL,
|
|
181
|
+
target=host,
|
|
182
|
+
installed="?",
|
|
183
|
+
fixed="",
|
|
184
|
+
remediation=f"TLS connection failed: {exc}",
|
|
185
|
+
detection_method="detected via TLS handshake",
|
|
186
|
+
)
|
|
187
|
+
]
|
|
188
|
+
findings = scan_certificates(certs, target=f"{host}:{port}", hashes=hashes)
|
|
189
|
+
for f in findings:
|
|
190
|
+
f.detection_method = "detected via TLS handshake"
|
|
191
|
+
return findings
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def check_path(path: str | Path, hashes: set[str] | None = None) -> list[Finding]:
|
|
195
|
+
hashes = hashes or compromised_hash_set()
|
|
196
|
+
root = Path(path)
|
|
197
|
+
findings: list[Finding] = []
|
|
198
|
+
files: list[Path] = []
|
|
199
|
+
if root.is_file():
|
|
200
|
+
files = [root]
|
|
201
|
+
elif root.is_dir():
|
|
202
|
+
for p in root.rglob("*"):
|
|
203
|
+
if p.suffix.lower() in CERT_SUFFIXES | KEYSTORE_SUFFIXES and p.is_file():
|
|
204
|
+
files.append(p)
|
|
205
|
+
else:
|
|
206
|
+
return [
|
|
207
|
+
Finding(
|
|
208
|
+
status=Status.UNKNOWN,
|
|
209
|
+
cve=CVE_LABEL,
|
|
210
|
+
target=str(root),
|
|
211
|
+
installed="?",
|
|
212
|
+
fixed="",
|
|
213
|
+
remediation=f"Path not found: {root}",
|
|
214
|
+
)
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
if not files:
|
|
218
|
+
return [
|
|
219
|
+
Finding(
|
|
220
|
+
status=Status.UNKNOWN,
|
|
221
|
+
cve=CVE_LABEL,
|
|
222
|
+
target=str(root),
|
|
223
|
+
installed="?",
|
|
224
|
+
fixed="",
|
|
225
|
+
remediation="No certificate/keystore files found.",
|
|
226
|
+
)
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
any_fail = False
|
|
230
|
+
scanned = 0
|
|
231
|
+
for file in files:
|
|
232
|
+
certs: list[x509.Certificate] = []
|
|
233
|
+
suffix = file.suffix.lower()
|
|
234
|
+
try:
|
|
235
|
+
data = file.read_bytes()
|
|
236
|
+
except OSError as exc:
|
|
237
|
+
findings.append(
|
|
238
|
+
Finding(
|
|
239
|
+
status=Status.UNKNOWN,
|
|
240
|
+
cve=CVE_LABEL,
|
|
241
|
+
target=str(file),
|
|
242
|
+
installed="?",
|
|
243
|
+
fixed="",
|
|
244
|
+
remediation=f"Unreadable: {exc}",
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
continue
|
|
248
|
+
if suffix in KEYSTORE_SUFFIXES:
|
|
249
|
+
password = os.environ.get("CVE_EXPOSURE_KEYSTORE_PASSWORD")
|
|
250
|
+
if password is None:
|
|
251
|
+
findings.append(
|
|
252
|
+
Finding(
|
|
253
|
+
status=Status.UNKNOWN,
|
|
254
|
+
cve=CVE_LABEL,
|
|
255
|
+
target=str(file),
|
|
256
|
+
installed="pkcs12",
|
|
257
|
+
fixed="",
|
|
258
|
+
remediation="Set CVE_EXPOSURE_KEYSTORE_PASSWORD to scan this keystore (read-only).",
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
continue
|
|
262
|
+
try:
|
|
263
|
+
key, cert, addl = load_key_and_certificates(data, password.encode())
|
|
264
|
+
del key
|
|
265
|
+
if cert:
|
|
266
|
+
certs.append(cert)
|
|
267
|
+
certs.extend(addl or [])
|
|
268
|
+
except ValueError as exc:
|
|
269
|
+
findings.append(
|
|
270
|
+
Finding(
|
|
271
|
+
status=Status.UNKNOWN,
|
|
272
|
+
cve=CVE_LABEL,
|
|
273
|
+
target=str(file),
|
|
274
|
+
installed="pkcs12",
|
|
275
|
+
fixed="",
|
|
276
|
+
remediation=f"Could not open keystore: {exc}",
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
continue
|
|
280
|
+
else:
|
|
281
|
+
certs = load_certs_from_pem_bytes(data)
|
|
282
|
+
|
|
283
|
+
if not certs:
|
|
284
|
+
continue
|
|
285
|
+
scanned += 1
|
|
286
|
+
for cert in certs:
|
|
287
|
+
f = _match_finding(
|
|
288
|
+
str(file),
|
|
289
|
+
cert,
|
|
290
|
+
hashes,
|
|
291
|
+
detection_method="detected via PEM/keystore file",
|
|
292
|
+
)
|
|
293
|
+
if f:
|
|
294
|
+
findings.append(f)
|
|
295
|
+
any_fail = True
|
|
296
|
+
|
|
297
|
+
if scanned and not any_fail and not any(f.status == Status.FAIL for f in findings):
|
|
298
|
+
findings.append(
|
|
299
|
+
Finding(
|
|
300
|
+
status=Status.PASS,
|
|
301
|
+
cve=CVE_LABEL,
|
|
302
|
+
target=str(root),
|
|
303
|
+
installed=f"scanned {scanned} file(s)",
|
|
304
|
+
fixed="N/A",
|
|
305
|
+
remediation="No compromised eMudhra hashes matched.",
|
|
306
|
+
detection_method="detected via PEM/keystore file",
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
elif not scanned and not findings:
|
|
310
|
+
findings.append(
|
|
311
|
+
Finding(
|
|
312
|
+
status=Status.UNKNOWN,
|
|
313
|
+
cve=CVE_LABEL,
|
|
314
|
+
target=str(root),
|
|
315
|
+
installed="?",
|
|
316
|
+
fixed="",
|
|
317
|
+
remediation="No parseable certificates found.",
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
return findings
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _docker_extract_ca(image: str, dest: Path) -> list[Path]:
|
|
324
|
+
"""Extract common CA bundle paths from a local Docker image (explicit image name only)."""
|
|
325
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
cid_proc = subprocess.run(
|
|
327
|
+
["docker", "create", image],
|
|
328
|
+
capture_output=True,
|
|
329
|
+
text=True,
|
|
330
|
+
check=False,
|
|
331
|
+
timeout=120,
|
|
332
|
+
)
|
|
333
|
+
if cid_proc.returncode != 0:
|
|
334
|
+
raise RuntimeError(cid_proc.stderr.strip() or "docker create failed")
|
|
335
|
+
cid = cid_proc.stdout.strip()
|
|
336
|
+
extracted: list[Path] = []
|
|
337
|
+
try:
|
|
338
|
+
for remote in CA_BUNDLE_PATHS:
|
|
339
|
+
local = dest / remote.lstrip("/").replace("/", "_")
|
|
340
|
+
cp = subprocess.run(
|
|
341
|
+
["docker", "cp", f"{cid}:{remote}", str(local)],
|
|
342
|
+
capture_output=True,
|
|
343
|
+
text=True,
|
|
344
|
+
check=False,
|
|
345
|
+
timeout=60,
|
|
346
|
+
)
|
|
347
|
+
if cp.returncode == 0 and local.exists():
|
|
348
|
+
extracted.append(local)
|
|
349
|
+
finally:
|
|
350
|
+
subprocess.run(["docker", "rm", "-f", cid], capture_output=True, check=False, timeout=60)
|
|
351
|
+
return extracted
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def check_docker(image_or_dockerfile: str, hashes: set[str] | None = None) -> list[Finding]:
|
|
355
|
+
hashes = hashes or compromised_hash_set()
|
|
356
|
+
path = Path(image_or_dockerfile)
|
|
357
|
+
if path.is_file() and path.name.lower().startswith("dockerfile"):
|
|
358
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
359
|
+
findings: list[Finding] = []
|
|
360
|
+
# Look for COPY/ADD of cert bundles referenced relatively
|
|
361
|
+
for m in re.finditer(r"^\s*(?:COPY|ADD)\s+(\S+)\s+(\S+)", text, re.M | re.I):
|
|
362
|
+
src = path.parent / m.group(1)
|
|
363
|
+
if src.exists():
|
|
364
|
+
findings.extend(check_path(src, hashes=hashes))
|
|
365
|
+
if not findings:
|
|
366
|
+
findings.append(
|
|
367
|
+
Finding(
|
|
368
|
+
status=Status.UNKNOWN,
|
|
369
|
+
cve=CVE_LABEL,
|
|
370
|
+
target=str(path),
|
|
371
|
+
installed="Dockerfile",
|
|
372
|
+
fixed="",
|
|
373
|
+
remediation="No local CA bundle paths found via COPY/ADD; scan the built image with --docker IMAGE.",
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
return findings
|
|
377
|
+
|
|
378
|
+
# Treat as image name
|
|
379
|
+
import tempfile
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
with tempfile.TemporaryDirectory(prefix="cve-emudhra-") as tmp:
|
|
383
|
+
extracted = _docker_extract_ca(image_or_dockerfile, Path(tmp))
|
|
384
|
+
if not extracted:
|
|
385
|
+
return [
|
|
386
|
+
Finding(
|
|
387
|
+
status=Status.UNKNOWN,
|
|
388
|
+
cve=CVE_LABEL,
|
|
389
|
+
target=image_or_dockerfile,
|
|
390
|
+
installed="?",
|
|
391
|
+
fixed="",
|
|
392
|
+
remediation="Could not extract CA bundles from image (is Docker available and image local?).",
|
|
393
|
+
)
|
|
394
|
+
]
|
|
395
|
+
out: list[Finding] = []
|
|
396
|
+
for p in extracted:
|
|
397
|
+
out.extend(check_path(p, hashes=hashes))
|
|
398
|
+
return out
|
|
399
|
+
except (RuntimeError, FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
|
|
400
|
+
return [
|
|
401
|
+
Finding(
|
|
402
|
+
status=Status.UNKNOWN,
|
|
403
|
+
cve=CVE_LABEL,
|
|
404
|
+
target=image_or_dockerfile,
|
|
405
|
+
installed="?",
|
|
406
|
+
fixed="",
|
|
407
|
+
remediation=f"Docker scan failed: {exc}",
|
|
408
|
+
)
|
|
409
|
+
]
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def check_toolchains(config: dict[str, Any] | None = None) -> list[Finding]:
|
|
413
|
+
data = config or load_fixed_versions()
|
|
414
|
+
findings: list[Finding] = []
|
|
415
|
+
|
|
416
|
+
# Go
|
|
417
|
+
go_cfg = data.get("go", {})
|
|
418
|
+
try:
|
|
419
|
+
proc = subprocess.run(["go", "version"], capture_output=True, text=True, timeout=15, check=False)
|
|
420
|
+
banner = (proc.stdout or "") + (proc.stderr or "")
|
|
421
|
+
# Official toolchain: "go version go1.27.3 windows/amd64"
|
|
422
|
+
m = re.search(r"go version go(?P<v>\d+\.\d+(?:\.\d+)?)", banner, re.I)
|
|
423
|
+
ver = m.group("v") if m else ""
|
|
424
|
+
if ver:
|
|
425
|
+
status_s, fixed, reason = go_exposure(ver, go_cfg.get("fixed_by_branch", {}))
|
|
426
|
+
findings.append(
|
|
427
|
+
Finding(
|
|
428
|
+
status=Status[status_s],
|
|
429
|
+
cve="/".join(go_cfg.get("cves", [CVE_LABEL])),
|
|
430
|
+
target="go",
|
|
431
|
+
installed=ver,
|
|
432
|
+
fixed=fixed,
|
|
433
|
+
remediation=go_cfg.get("remediation", "Upgrade Go.") if status_s == "FAIL" else (
|
|
434
|
+
"No action required." if status_s == "PASS" else "Verify Go version."
|
|
435
|
+
),
|
|
436
|
+
detail=reason,
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
else:
|
|
440
|
+
findings.append(
|
|
441
|
+
Finding(
|
|
442
|
+
status=Status.UNKNOWN,
|
|
443
|
+
cve="/".join(go_cfg.get("cves", [CVE_LABEL])),
|
|
444
|
+
target="go",
|
|
445
|
+
installed="not golang toolchain",
|
|
446
|
+
fixed="",
|
|
447
|
+
remediation="Go toolchain not detected (expected `go version goX.Y.Z ...` on PATH).",
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
|
451
|
+
findings.append(
|
|
452
|
+
Finding(
|
|
453
|
+
status=Status.UNKNOWN,
|
|
454
|
+
cve="/".join(go_cfg.get("cves", [CVE_LABEL])),
|
|
455
|
+
target="go",
|
|
456
|
+
installed="not installed",
|
|
457
|
+
fixed="",
|
|
458
|
+
remediation="Go toolchain not found on PATH.",
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
# certifi
|
|
463
|
+
cert_cfg = data.get("certifi", {})
|
|
464
|
+
min_safe = cert_cfg.get("min_safe", "2026.10.18")
|
|
465
|
+
ver = ""
|
|
466
|
+
try:
|
|
467
|
+
import certifi as certifi_mod # type: ignore
|
|
468
|
+
|
|
469
|
+
ver = getattr(certifi_mod, "__version__", "") or ""
|
|
470
|
+
except Exception:
|
|
471
|
+
try:
|
|
472
|
+
proc = subprocess.run(
|
|
473
|
+
["pip", "show", "certifi"],
|
|
474
|
+
capture_output=True,
|
|
475
|
+
text=True,
|
|
476
|
+
timeout=30,
|
|
477
|
+
check=False,
|
|
478
|
+
)
|
|
479
|
+
m = re.search(r"^Version:\s*(\S+)", proc.stdout or "", re.M)
|
|
480
|
+
ver = m.group(1) if m else ""
|
|
481
|
+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
|
482
|
+
ver = ""
|
|
483
|
+
|
|
484
|
+
if not ver:
|
|
485
|
+
findings.append(
|
|
486
|
+
Finding(
|
|
487
|
+
status=Status.UNKNOWN,
|
|
488
|
+
cve=CVE_LABEL,
|
|
489
|
+
target="certifi",
|
|
490
|
+
installed="not installed",
|
|
491
|
+
fixed=min_safe,
|
|
492
|
+
remediation="certifi not found; install/upgrade to 2026.10.18+ if used.",
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
else:
|
|
496
|
+
findings.append(
|
|
497
|
+
evaluate_certifi_version(ver, config=data, detection_method="detected via installed certifi package")
|
|
498
|
+
)
|
|
499
|
+
return findings
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def evaluate_certifi_version(
|
|
503
|
+
version: str,
|
|
504
|
+
config: dict[str, Any] | None = None,
|
|
505
|
+
*,
|
|
506
|
+
detection_method: str = "detected via supplied version string",
|
|
507
|
+
) -> Finding:
|
|
508
|
+
data = config or load_fixed_versions()
|
|
509
|
+
cert_cfg = data.get("certifi", {})
|
|
510
|
+
min_safe = cert_cfg.get("min_safe", "2026.10.18")
|
|
511
|
+
status_s, fixed, reason = certifi_exposure(version, min_safe)
|
|
512
|
+
note = cert_cfg.get("note", "")
|
|
513
|
+
if status_s == "FAIL":
|
|
514
|
+
rem = cert_cfg.get("remediation", "Upgrade certifi.")
|
|
515
|
+
elif status_s == "PASS":
|
|
516
|
+
# Prefer explicit note when the installed build is the stability follow-up.
|
|
517
|
+
if version.strip() == "2026.10.19" or version.strip().startswith("2026.10.19"):
|
|
518
|
+
rem = cert_cfg.get(
|
|
519
|
+
"remediation_restored",
|
|
520
|
+
"PASS - also restores roots incorrectly dropped in 10.18.",
|
|
521
|
+
)
|
|
522
|
+
else:
|
|
523
|
+
rem = "No action required." + (f" ({note})" if note else "")
|
|
524
|
+
else:
|
|
525
|
+
rem = "Verify certifi version."
|
|
526
|
+
return Finding(
|
|
527
|
+
status=Status[status_s],
|
|
528
|
+
cve=CVE_LABEL,
|
|
529
|
+
target="certifi",
|
|
530
|
+
installed=version,
|
|
531
|
+
fixed=fixed,
|
|
532
|
+
remediation=rem,
|
|
533
|
+
detail=reason + (f"; note={note}" if note else ""),
|
|
534
|
+
detection_method=detection_method,
|
|
535
|
+
)
|