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.
@@ -0,0 +1,3 @@
1
+ """FourIsNotRandom: detection-only CVE exposure fingerprinting."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ from fourisnotrandom.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ # Disable Click's Windows argv glob expansion so a literal "*" is rejected
5
+ # by TargetError instead of being expanded into a directory sweep.
6
+ app(windows_expand_args=False)
@@ -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
+ )