diff --git a/COVERAGE-UPGRADE.md b/COVERAGE-UPGRADE.md new file mode 100644 index 0000000..c5772dd --- /dev/null +++ b/COVERAGE-UPGRADE.md @@ -0,0 +1,24 @@ +# Candidate coverage improvements after v0.1.3 + +This branch keeps the ordinary scan read-only, dependency-free and offline. +With optional `--patterns`, local user-writable files named by per-user COM +registrations are scanned before broad folders. They are capped at 128 paths; +omitted, unsafe, changed and unreadable files remain coverage gaps. A registry +entry alone is not a detection. An explicit `--pattern-path` still limits the +optional check to the path the user selected. + +Windows DNS history now reads up to 50,000 recent retained records rather than +20,000. If the log reports more records than were read, the older portion is +classified as incomplete, not clean. No DNS logging is enabled and no network +request is made. Logs that were never enabled cannot be reconstructed. + +The proposed larger-file change was rejected during review: both embedded +published Candiru rules contain explicit file-size conditions below roughly +1 MB. Raising the scanner's 1 MB cap would add work without making either +rule match a larger file. This cap should only be revisited with a validated +additional rule whose size condition warrants it. + +Release gate: full default and optional-engine suites, cross-platform CI, +single-file reproducibility, safe-path and incomplete-log tests, final public +artifact signatures and an independent Astra review. Do not deploy from this +branch before those checks finish. diff --git a/README.md b/README.md index bcfac58..c8b66b0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Zero Breach -> Release v0.1.3 adds an optional Candiru file-pattern check. See -> [its scope and validation](DETECTION-UPGRADE.md). It needs a pinned YARA-X +> Release v0.1.4 prioritizes local COM-registered files in the optional Candiru pattern check and expands Windows DNS-history coverage. See +> [the v0.1.4 scope](COVERAGE-UPGRADE.md) and [pattern validation](DETECTION-UPGRADE.md). It needs a pinned YARA-X > dependency; default checks remain standard-library-only. [![tests](https://github.com/interplains/zero-breach/actions/workflows/tests.yml/badge.svg)](https://github.com/interplains/zero-breach/actions/workflows/tests.yml) @@ -112,6 +112,16 @@ python3 -m venv .venv file or folder; it can be repeated. The normal browser, hash and host checks keep their usual scope. Pattern matches are leads for investigation, not proof of an active infection. A no-match result cannot rule out Candiru or other spyware. +Without `--pattern-path`, the optional check first examines up to 128 safe local +files named by per-user Windows COM registrations, then its usual file roots. +A COM registration by itself is not a spyware finding. The two published rules +only apply to files smaller than about 1 MB, so larger files remain outside +their detection scope. + +On Windows, the DNS-history check reads up to the newest 50,000 retained +events. If older retained records remain, the report marks that history as +partial rather than complete. This does not turn on logging or recover events +that were never recorded. **From source:** @@ -134,7 +144,7 @@ set up (e.g. a bad indicator file). Use the official repository above and check the downloaded file before running it. -**1. Verify the publisher's signature.** Release v0.1.3 includes detached GPG +**1. Verify the publisher's signature.** Release v0.1.4 includes detached GPG signatures for the scanner and its checksum manifest, using ArchiveGenocide's established release key. Follow [SIGNING.md](SIGNING.md), and compare the full key fingerprint with [ArchiveGenocide's verification page](https://archivegenocide.com/verify.html). @@ -158,11 +168,11 @@ you do not have to trust the download at all; you can reproduce it: ``` git clone https://github.com/interplains/zero-breach.git cd zero-breach -git checkout v0.1.3 +git checkout v0.1.4 python build/build.py # writes dist/zero-breach.py and prints its SHA-256 ``` -These commands reproduce v0.1.3; substitute the matching tag for another release. +These commands reproduce v0.1.4; substitute the matching tag for another release. On macOS or Linux use `python3` for the build command. Compare the hash with that release's published hash. The development branch may contain later changes and is not expected to reproduce an earlier release. diff --git a/SIGNING.md b/SIGNING.md index 54af98d..5924710 100644 --- a/SIGNING.md +++ b/SIGNING.md @@ -1,17 +1,17 @@ # Verifying ArchiveGenocide release signatures -Zero Breach v0.1.3 supplies detached GPG signatures for the exact released +Zero Breach v0.1.4 supplies detached GPG signatures for the exact released scanner and its SHA256SUMS.txt manifest. They use ArchiveGenocide's existing Ed25519 release-signing key, also published on the archive's verification page. Official repository: https://github.com/interplains/zero-breach -Official release: https://github.com/interplains/zero-breach/releases/tag/v0.1.3 +Official release: https://github.com/interplains/zero-breach/releases/tag/v0.1.4 Independent publisher page: https://archivegenocide.com/scan/#verify-download Established key identity: https://archivegenocide.com/verify.html ## Check the publisher and file -Download these three files from the v0.1.3 release into one directory: +Download these three files from the v0.1.4 release into one directory: - zero-breach.py - zero-breach.py.asc @@ -36,9 +36,9 @@ Verification must succeed using the expected ArchiveGenocide key. A warning that the key is not certified by your own web of trust is different from a bad signature; do not ignore a fingerprint mismatch or failed verification. -The expected SHA-256 of zero-breach.py v0.1.3 is: +The expected SHA-256 of zero-breach.py v0.1.4 is: - 1a1eb0e76cd2791349be9861840a5756940c3a950a134b5436bc7f9d1b50e3b8 + 5ab89ef3dda3579dfc0b4930b0d66290fdc755611afd91e28c373e30fe9996cc A signature verifies which key signed these exact bytes. It does not establish that the program has no bugs. A checksum alone is not publisher authentication. @@ -58,14 +58,14 @@ The manifest lists the fifteen release-content files, including the optional requirements file and Candiru rule attribution and licence records. The manifest itself and the added public key/signatures are not self-listed. -Public verification artifacts are also retained in release-signatures/v0.1.3/. +Public verification artifacts are also retained in release-signatures/v0.1.4/. No private key is stored in this repository or in its project backups. ## Indicator updates are a separate trust path The manually run scan/update-iocs.py still checks HTTPS, a published SHA-256, and indicator-file validity. It does not automatically verify GPG signatures. -The signed release manifest covers the indicator file shipped with v0.1.3; +The signed release manifest covers the indicator file shipped with v0.1.4; it does not authenticate later files fetched from the development branch. ## Maintainer release procedure diff --git a/build/bundle.py b/build/bundle.py index 539dd52..1798274 100644 --- a/build/bundle.py +++ b/build/bundle.py @@ -160,7 +160,7 @@ def _asof_epoch(text): A('import sys') A('import time') A('') -A('__version__ = "0.1.3 (single-file build)"') +A('__version__ = "0.1.4 (single-file build)"') A('') A('# --- embedded indicator data ------------------------------------------------') A('# The real file is iocs/adfection_iocs.txt in the project. Its provenance') diff --git a/release-signatures/v0.1.4/SHA256SUMS.txt b/release-signatures/v0.1.4/SHA256SUMS.txt new file mode 100644 index 0000000..b61c78d --- /dev/null +++ b/release-signatures/v0.1.4/SHA256SUMS.txt @@ -0,0 +1,15 @@ +b013f21a44956c6d717f211990b450aeaa4d7266da4614ab8c386b479565296e LICENSE-CC-BY-SA-4.0.txt +8a959b2413d0c83ca1b3ce9153076c649abf2e4bbfebbb493fb24c2074a1eb8a LICENSE-DRL-1.1.txt +b84b756654d259f98841cb8ecc9ce7560d1b407276f2a1e640d462559fefc0c4 NOTICE +437a044d6ab5157e5e7af5a17ea3e311e409ff7093a6173d53376f3a1d42962c adfection.adblock.txt +712e2ec22cd6020c7e72c995c1e2c925546c24616a27bfe6b57692a53f2f093d adfection.dnsmasq.conf +d47609b69b58b918305b1c2e7fa6a8d1386cded512fe167613aec1375d254b71 adfection.domains.txt +8276d3ec8c4210ea4c3c7fbc2065a6d02fdc782003bd168a11cc39e127c85e49 adfection.hosts.txt +5ee47a8d062082f7a98ab85284a46d379141dc6172ba1e8e0d3fb22960a5ebe7 adfection.misp.json +b346ad6fa1be58172847a5efca8567abb92428bd5359e9150922c37d1567c745 adfection.stix.json +b86d4526066599e80b12971739e70a701a217e4c046ec29d06b8716ef2bf0572 adfection.withdrawals.stix.json +c9801e3ba37376535a676d9938d65550bcaa5478578cb80a6dd09eb2ac402d38 adfection_iocs.txt +9e8a80c2f80cf2fe7ff74be6308b4d9fc3a7fe14d6b90c9c9567e3479131b6c8 adfection_iocs.txt.sha256 +d1c2ca7de2def9915071835ec3755ab4c856b7672fae184a792051c7952f19cb candiru-rule-PROVENANCE.json +649f9eb135b22de90ed1d18d227fb80506ff403f70905330806f8a98346d0e97 requirements-patterns.txt +5ab89ef3dda3579dfc0b4930b0d66290fdc755611afd91e28c373e30fe9996cc zero-breach.py diff --git a/release-signatures/v0.1.4/SHA256SUMS.txt.asc b/release-signatures/v0.1.4/SHA256SUMS.txt.asc new file mode 100644 index 0000000..2338e4a --- /dev/null +++ b/release-signatures/v0.1.4/SHA256SUMS.txt.asc @@ -0,0 +1,7 @@ +-----BEGIN PGP SIGNATURE----- + +iHUEABYKAB0WIQTCTskrEtZnCiUWBl+bTVdUma+lPAUCatYErwAKCRCbTVdUma+l +POrwAPsFijxIYbF7Ecr9/fSvXhNLpjIZLxCHhKRA8Og5cj1auAD/Xc6HYbK32az9 +J0Di5B8K+sHD83/3AoINu2zn8GuHuQM= +=5td8 +-----END PGP SIGNATURE----- diff --git a/release-signatures/v0.1.4/archivegenocide-signing-key.asc b/release-signatures/v0.1.4/archivegenocide-signing-key.asc new file mode 100644 index 0000000..e36686c --- /dev/null +++ b/release-signatures/v0.1.4/archivegenocide-signing-key.asc @@ -0,0 +1,10 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEakkTBRYJKwYBBAHaRw8BAQdA+pOHPwElYpMviQuBZYxZeFXDSxJeG+Ib+RfE +IBDND8+0LkFyY2hpdmUgR2Vub2NpZGUgPGFyY2hpdmVAYXJjaGl2ZWdlbm9jaWRl +LmNvbT6IkwQTFgoAOxYhBMJOySsS1mcKJRYGX5tNV1SZr6U8BQJqSRMFAhsDBQsJ +CAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEJtNV1SZr6U8dS4BANz2SqF8JJEe +npU+A9bm+aYLKrTYsG1SUKmuctWU2ZnYAQCQS5WMaZM6tIUBL9tQDnW7gVCjFBQr +/lMpsWDm8BG2Aw== +=L8Ay +-----END PGP PUBLIC KEY BLOCK----- diff --git a/release-signatures/v0.1.4/zero-breach.py.asc b/release-signatures/v0.1.4/zero-breach.py.asc new file mode 100644 index 0000000..a787e1d --- /dev/null +++ b/release-signatures/v0.1.4/zero-breach.py.asc @@ -0,0 +1,7 @@ +-----BEGIN PGP SIGNATURE----- + +iHUEABYKAB0WIQTCTskrEtZnCiUWBl+bTVdUma+lPAUCatYErwAKCRCbTVdUma+l +PB4GAQCzQgvo8O9de36OHoEi4BHNsWan6DzQjmqCUJI/uQzH4wD9GjewwaTVXbVw +zFDH0SAfi3O+a0lp7/+quZ7rYqMreAM= +=FrWb +-----END PGP SIGNATURE----- diff --git a/scan/CONTRACT.md b/scan/CONTRACT.md index e1d2a9f..512921f 100644 --- a/scan/CONTRACT.md +++ b/scan/CONTRACT.md @@ -2,6 +2,9 @@ Development extension: `ctx.patterns` explicitly enables the optional `patterns` module; `ctx.pattern_paths` supplies absolute local paths for that module only. +The Windows host module supplies bounded `ctx.pattern_candidate_paths` from +local, user-writable per-user COM server registrations when pattern paths are +not explicitly selected; the pattern module rechecks each path before reading. The default checks remain standard-library-only. The optional module uses the pinned YARA-X engine without downloads, writes or sample execution. Its hits use `file-pattern` evidence and `candiru-pattern` tier, never exact-hash claims. diff --git a/scan/README.md b/scan/README.md index 22b56b4..d90d121 100644 --- a/scan/README.md +++ b/scan/README.md @@ -1,7 +1,7 @@ # adint-scan -> Release v0.1.3 adds experimental, opt-in `--patterns` support. See -> [DETECTION-UPGRADE.md](../DETECTION-UPGRADE.md) for dependencies, limits and +> Release v0.1.4 improves coverage for the experimental, opt-in `--patterns` check. See +> [COVERAGE-UPGRADE.md](../COVERAGE-UPGRADE.md) and [DETECTION-UPGRADE.md](../DETECTION-UPGRADE.md) for dependencies, limits and > validation evidence. Default checks need no added packages. A local, read-only check for the published AdFection / Candiru indicators, @@ -63,8 +63,8 @@ after use. `--json PATH` writes the report you request. |---|---| | `browser` | history, cookies, favicons, HTTP cache, LevelDB and Service Worker caches across Chrome / Edge / Brave / Vivaldi / Chromium (every profile) and Firefox. Locked databases are copied before being opened. | | `hash` | SHA-256 of candidate files under AppData, ProgramData, both Temps, Downloads, Desktop, Documents and Public, against the sample hashes in the IOC set. | -| `host` | per-user COM registrations (this chain's documented persistence), the DNS resolver cache, whether DNS history exists at all, and the hosts file. | -| `patterns` (optional) | file bytes checked against two published Candiru research rules; a match needs investigation and cannot confirm infection | +| `host` | per-user COM registrations (this chain's documented persistence), the DNS resolver cache, up to 50,000 recent retained DNS-history events on Windows, and the hosts file. Older retained events are an explicit gap. | +| `patterns` (optional) | file bytes checked against two published Candiru research rules; without an explicit pattern path, safely checks up to 128 local user-writable COM server files first. A match needs investigation and cannot confirm infection. | Why the browser module looks at the cache and not just history: history records only top-level navigations, and an ad is a subresource inside an iframe. A real diff --git a/scan/adint_scan/__init__.py b/scan/adint_scan/__init__.py index 8d4fc42..f5d6c89 100644 --- a/scan/adint_scan/__init__.py +++ b/scan/adint_scan/__init__.py @@ -4,4 +4,4 @@ that can be checked." It does NOT mean the device is clean. See SPEC-scanner.md. """ -__version__ = "0.1.3" +__version__ = "0.1.4" diff --git a/scan/adint_scan/context.py b/scan/adint_scan/context.py index 2ec6c33..382f448 100644 --- a/scan/adint_scan/context.py +++ b/scan/adint_scan/context.py @@ -30,3 +30,7 @@ class ScanContext: # Optional research feature. Never installs a dependency or fetches rules. patterns: bool = False pattern_paths: tuple = () + # Local, per-user COM server files discovered by the host check. Only the + # optional pattern module consumes these; explicit --pattern-path wins. + pattern_candidate_paths: tuple = () + pattern_candidate_omitted: int = 0 diff --git a/scan/adint_scan/modules/host.py b/scan/adint_scan/modules/host.py index 5bf5f95..744b335 100644 --- a/scan/adint_scan/modules/host.py +++ b/scan/adint_scan/modules/host.py @@ -62,6 +62,7 @@ "-ExecutionPolicy", "Bypass", "-Command"] _DNS_LOG = "Microsoft-Windows-DNS-Client/Operational" +_DNS_EVENT_CAP = 50000 # Addresses that mean "this name was deliberately pointed nowhere". Shared by the # hosts-file check and the resolver-cache check, because the two have to make the @@ -77,6 +78,7 @@ # checks them. InprocHandler32 is included because it is loaded the same way # and is the quieter of the three. _SERVER_KINDS = ("InprocServer32", "LocalServer32", "InprocHandler32") +_COM_PATTERN_CANDIDATE_CAP = 128 # HKCU CLSID. Both the 64-bit and 32-bit registry views must be read, and the # right way is explicit KEY_WOW64_*KEY flags on the ONE logical path - not two @@ -425,13 +427,14 @@ def _check_com(r: ModuleResult, ctx) -> None: # Expand, de-quote and test each registered path before asking about # signatures, so the batched call only covers files that exist on disk. - writable = _user_writable_dirs() + writable = [os.path.normcase(os.path.normpath(d)).casefold() + for d in _user_writable_dirs()] remote_letters, drives_known = _network_drive_letters() on_disk = [] for e in entries: expanded = _server_path(e["raw"], e["kind"]) e["path"] = expanded - low = expanded.lower() + low = os.path.normcase(os.path.normpath(expanded)).casefold() if expanded else "" # Decided before the path is touched: os.path.exists() on a remote path # is itself the network contact this tool promises never to make, and it # happens inside Windows where netguard cannot see it. @@ -447,7 +450,8 @@ def _check_com(r: ModuleResult, ctx) -> None: e["exists"] = bool(expanded) and os.path.exists(expanded) except OSError: e["exists"] = False - e["user_writable"] = any(low.startswith(d) for d in writable) + e["user_writable"] = any(low == d or low.startswith(d + os.sep) + for d in writable) if e["exists"]: on_disk.append(expanded) @@ -455,6 +459,26 @@ def _check_com(r: ModuleResult, ctx) -> None: if sig_err: r.could_not_run("com-signatures", sig_err) + # The existing general file roots miss a registered server in an unusual + # local directory. Pass only bounded, local, user-writable file paths to + # the opt-in pattern module; the latter rechecks each path before reading. + # A user's explicit --pattern-path retains its documented limited scope. + if getattr(ctx, "patterns", False) and not getattr(ctx, "pattern_paths", ()): + unique = {} + for e in entries: + if not (e.get("exists") and e.get("user_writable")) or e.get("remote_reason"): + continue + try: + if not os.path.isfile(e["path"]): + continue + except OSError: + continue + key = os.path.normcase(os.path.normpath(e["path"])) + unique.setdefault(key, e["path"]) + candidates = [unique[key] for key in sorted(unique)] + ctx.pattern_candidate_paths = tuple(candidates[:_COM_PATTERN_CANDIDATE_CAP]) + ctx.pattern_candidate_omitted = max(0, len(candidates) - _COM_PATTERN_CANDIDATE_CAP) + user_writable = 0 reviewable = [] unreachable = [] @@ -694,11 +718,12 @@ def _check_dns_log(r: ModuleResult, ctx) -> None: # silently certifying an unread log as clean history. ToString('o') gives ISO # 8601 so min()/max() sort chronologically rather than by locale date string. events, ev_err = _ps_json( - "Get-WinEvent -LogName '{}' -MaxEvents 20000" + "Get-WinEvent -LogName '{}' -MaxEvents {}" " -ErrorAction Stop" " | Select-Object @{{n='Time';e={{$_.TimeCreated.ToString('o')}}}}," " @{{n='Message';e={{[string]$_.Message}}}}" - " | ConvertTo-Json -Depth 3 -Compress".format(_DNS_LOG), timeout=300) + " | ConvertTo-Json -Depth 3 -Compress".format( + _DNS_LOG, _DNS_EVENT_CAP), timeout=300) if (ev_err and not events) or (records > 0 and not events): reason = ("log is enabled with {} records but none could be read{} - this " "machine's DNS history was NOT searched, so nothing here is " @@ -715,10 +740,10 @@ def _check_dns_log(r: ModuleResult, ctx) -> None: r.reach["dns-log"] = { "earliest": min(times), "days": None, - "basis": "oldest of {:,} DNS events read (cap 20,000)".format( - len(times)), + "basis": "oldest of {:,} DNS events read (cap {:,})".format( + len(times), _DNS_EVENT_CAP), "note": "Queries before this are either rotated out of the log or " - "beyond the 20,000-event read cap.", + "beyond the bounded event read.", } for ev in events: when = _str(ev, "Time") @@ -736,11 +761,12 @@ def _check_dns_log(r: ModuleResult, ctx) -> None: r.submetrics["dns_log_events_scanned"] = len(events) r.ran("dns-log") if records > len(events): - # Honest about the window actually examined (D3): a capped read is not - # the same as having read the whole log. - r.note("dns-log: examined the most recent {} of {} records (cap is " - "20000); older entries were not checked.".format( - len(events), records)) + # A note alone let the overall check appear complete and clean even + # though older retained DNS records were never examined. + r.could_not_run("dns-log-older-events", + "examined the most recent {} of {} retained records " + "(read cap {:,}); older entries were not checked".format( + len(events), records, _DNS_EVENT_CAP)) ctx.emit("[host] DNS log events scanned: {} ({} indicator matches)" .format(len(events), hits)) diff --git a/scan/adint_scan/modules/patternscan.py b/scan/adint_scan/modules/patternscan.py index 5d2c166..2c0e507 100644 --- a/scan/adint_scan/modules/patternscan.py +++ b/scan/adint_scan/modules/patternscan.py @@ -211,7 +211,10 @@ def _pattern_engine(): def _pattern_selected_roots(ctx): requested = getattr(ctx, "pattern_paths", ()) - roots = requested if requested else _pattern_roots() + # High-value registered files go first. A broad root can exhaust the + # bounded budget; it must not prevent these targeted paths from being read. + roots = requested if requested else list(getattr(ctx, "pattern_candidate_paths", ())) + list( + _pattern_roots()) # Lexical normalization only: resolving first could follow a refused link. unique = [] for root in roots: @@ -257,6 +260,24 @@ def run(ctx) -> ModuleResult: r.could_not_run("pattern-roots", "no local pattern paths were selected") return r + auto_candidates = set() + if not getattr(ctx, "pattern_paths", ()): + auto_candidates = {_pattern_os.path.normcase(_pattern_os.path.normpath(p)) + for p in getattr(ctx, "pattern_candidate_paths", ())} + if auto_candidates: + auto_candidates.difference_update( + _pattern_os.path.normcase(_pattern_os.path.normpath(p)) + for p in _pattern_roots()) + if auto_candidates: + r.note("Also checking up to {} local, user-writable files named by " + "per-user COM registrations; registration alone is not a finding." + .format(len(auto_candidates))) + omitted = getattr(ctx, "pattern_candidate_omitted", 0) + if omitted: + r.could_not_run("com-pattern-candidates", "{} registered file path(s) " + "were omitted by the registered-path safety cap".format( + omitted)) + counts = {"files": 0, "dirs": 0, "entries": 0, "bytes": 0} seen_files = {} # bounded by MAX_FILES; value says whether scan succeeded for root in roots: @@ -278,6 +299,8 @@ def _pattern_gap(reason, location=root): if _pattern_stat.S_ISREG(info.st_mode): entries = iter((root,)) elif _pattern_stat.S_ISDIR(info.st_mode): + if _pattern_os.path.normcase(_pattern_os.path.normpath(root)) in auto_candidates: + raise OSError("registered COM target changed into a directory; not traversed") entries = _pattern_walk(root, counts, r, _pattern_gap) else: raise OSError("not a regular file or directory") diff --git a/scan/tests/test_evidence.py b/scan/tests/test_evidence.py index ea63df2..17a04a5 100644 --- a/scan/tests/test_evidence.py +++ b/scan/tests/test_evidence.py @@ -30,7 +30,7 @@ from adint_scan import report # noqa: E402 from adint_scan.context import ScanContext # noqa: E402 from adint_scan.modules import browser # noqa: E402 -from adint_scan.result import Hit, ModuleResult # noqa: E402 +from adint_scan.result import Hit, ModuleResult, PARTIAL # noqa: E402 TEST_DOMAIN = "synthetic-test-indicator.invalid" @@ -305,6 +305,39 @@ def test_real_address_is_a_dns_record_without_proof_of_contact(self): class TestDnsLogIsNotProofOfContact(unittest.TestCase): + def test_dns_match_after_old_20000_event_limit_is_read(self): + from adint_scan.modules import host + with tempfile.TemporaryDirectory(prefix="dnslog_extended_") as tmp: + ctx = make_ctx(tmp) + result = ModuleResult("host") + events = [{"Time": "2025-03-04T12:00:00Z", "Message": ""} + for _ in range(20000)] + events.append({"Time": "2025-03-03T12:00:00Z", + "Message": "DNS query for " + TEST_DOMAIN}) + with patch.object(host, "_ps_json", side_effect=[ + ([{"IsEnabled": True, "RecordCount": 20001}], ""), + (events, "")]): + host._check_dns_log(result, ctx) + self.assertEqual(result.submetrics["dns_log_events_scanned"], 20001) + self.assertEqual(len(result.hits), 1) + self.assertFalse(any(c.name == "dns-log-older-events" for c in result.checks)) + + def test_retained_older_dns_events_are_a_coverage_gap(self): + from adint_scan.modules import host + with tempfile.TemporaryDirectory(prefix="dnslog_cap_") as tmp: + ctx = make_ctx(tmp) + result = ModuleResult("host") + fixture = [ + ([{"IsEnabled": True, "RecordCount": 50001}], ""), + ([{"Time": "2025-03-04T12:00:00Z", "Message": "ordinary.test"}], ""), + ] + with patch.object(host, "_ps_json", side_effect=fixture) as query: + host._check_dns_log(result, ctx) + self.assertEqual(result.status, PARTIAL) + self.assertTrue(any(c.name == "dns-log-older-events" and not c.ran + for c in result.checks)) + self.assertIn("-MaxEvents 50000", query.call_args_list[1].args[0]) + def test_failed_nxdomain_lookup_remains_dns_evidence(self): from adint_scan.modules import host from adint_scan.result import EV_DNS diff --git a/scan/tests/test_patterns.py b/scan/tests/test_patterns.py index 8dcbcf3..922b6cf 100644 --- a/scan/tests/test_patterns.py +++ b/scan/tests/test_patterns.py @@ -21,10 +21,10 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from adint_scan.modules import patternscan as patterns +from adint_scan.modules import host, patternscan as patterns from adint_scan.candiru_rules import (CANDIRU_RULE_METADATA, CANDIRU_RULE_SOURCES, CANDIRU_ENGINE_VERSION) -from adint_scan.result import CLEAN, HITS, PARTIAL, UNKNOWN +from adint_scan.result import CLEAN, HITS, PARTIAL, UNKNOWN, ModuleResult def synthetic_malpedia_fixture(): @@ -280,6 +280,82 @@ def test_default_roots_use_hash_roots(self): roots.assert_called_once_with() self.assertEqual(result.checked, 1) + def test_default_scan_also_checks_registered_local_file_outside_roots(self): + ordinary = self.file("ordinary") + registered = self.file("elsewhere/registered.dll", b"registered harmless fixture") + self.ctx.pattern_paths = () + self.ctx.pattern_candidate_paths = (str(registered),) + self.scanner.scan.side_effect = lambda data: SimpleNamespace(matching_rules=( + [SimpleNamespace(identifier="win_devilstongue_auto")] + if data == registered.read_bytes() else [])) + with patch.object(patterns, "_pattern_roots", return_value=[str(ordinary)]): + result = self.run_mock() + self.assertEqual(result.checked, 2) + self.assertEqual([hit.location for hit in result.hits], [str(registered)]) + self.assertEqual(result.status, HITS) + self.assertEqual(self.scanner.scan.call_args_list[0].args[0], + registered.read_bytes()) + + def test_explicit_path_does_not_expand_to_registered_files(self): + requested = self.file("requested") + registered = self.file("registered") + self.ctx.pattern_paths = (str(requested),) + self.ctx.pattern_candidate_paths = (str(registered),) + result = self.run_mock() + self.assertEqual(result.checked, 1) + self.assertEqual(self.scanner.scan.call_count, 1) + + def test_registered_directory_is_not_recursively_scanned(self): + inside = self.file("elsewhere/inside") + self.ctx.pattern_paths = () + self.ctx.pattern_candidate_paths = (str(inside.parent),) + with patch.object(patterns, "_pattern_roots", return_value=[]): + result = self.run_mock() + self.assertEqual(result.status, UNKNOWN) + self.assertEqual(result.checked, 0) + self.scanner.scan.assert_not_called() + + def test_registered_candidate_cap_is_coverage_gap(self): + registered = self.file("registered") + self.ctx.pattern_paths = () + self.ctx.pattern_candidate_paths = (str(registered),) + self.ctx.pattern_candidate_omitted = 1 + with patch.object(patterns, "_pattern_roots", return_value=[]): + result = self.run_mock() + self.assertEqual(result.checked, 1) + self.assertEqual(result.status, PARTIAL) + self.assertTrue(any(c.name == "com-pattern-candidates" for c in result.checks)) + + def test_host_passes_only_local_writable_com_targets(self): + local = self.file("registered.dll") + second = self.file("registered2.dll") + remote = r"\\attacker.invalid\share\bad.dll" + entries = [{"clsid": "{A}", "kind": "InprocServer32", "raw": str(local), + "view": "native"}, + {"clsid": "{C}", "kind": "InprocServer32", "raw": str(second), + "view": "native"}, + {"clsid": "{B}", "kind": "InprocServer32", "raw": remote, + "view": "native"}] + ctx = SimpleNamespace(patterns=True, pattern_paths=(), emit=lambda _: None, + iocs=SimpleNamespace(search=lambda _: []), + pattern_candidate_paths=(), pattern_candidate_omitted=0) + original_exists = os.path.exists + + def safe_exists(path): + if path == remote: + raise AssertionError("remote path was handed to the filesystem") + return original_exists(path) + + with patch.object(host, "_enum_com_registrations", return_value=(entries, [])), \ + patch.object(host, "_network_drive_letters", return_value=(set(), True)), \ + patch.object(host, "_user_writable_dirs", return_value=[str(self.root).lower()]), \ + patch.object(host, "_COM_PATTERN_CANDIDATE_CAP", 1), \ + patch.object(host, "_signature_status", return_value=({}, "")), \ + patch.object(os.path, "exists", side_effect=safe_exists): + host._check_com(ModuleResult("host"), ctx) + self.assertEqual(ctx.pattern_candidate_paths, (str(local),)) + self.assertEqual(ctx.pattern_candidate_omitted, 1) + def test_overlapping_roots_do_not_duplicate_files(self): target = self.file("nested/extensionless") self.ctx.pattern_paths = (str(target), str(target.parent), str(self.root), str(self.root)) @@ -630,6 +706,16 @@ def test_real_explicit_pattern_blob_survives_parent_pruning(self): self.assertEqual([hit.location for hit in result.hits], [str(wanted)]) self.assertEqual([hit.indicator for hit in result.hits], ["yara:win_devilstongue_auto"]) + def test_real_registered_candidate_outside_general_roots(self): + wanted = self.file("elsewhere/registered.dll", synthetic_malpedia_fixture()) + self.ctx.pattern_paths = () + self.ctx.pattern_candidate_paths = (str(wanted),) + with patch.object(patterns, "_pattern_roots", return_value=[]): + result = patterns.run(self.ctx) + self.assertEqual([hit.indicator for hit in result.hits], + ["yara:win_devilstongue_auto"]) + self.assertEqual([hit.location for hit in result.hits], [str(wanted)]) + def test_300_oversized_files_do_not_exhaust_data_read_budget(self): # Sparse temporary test files: metadata says >1 MiB, no contents read. for number in range(300):