diff --git a/README.md b/README.md index 46ebdc7..ecaf298 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Zero Breach -> Release v0.1.5 searches saved Windows DNS events even when logging is currently -> off. It keeps a coverage warning for unrecorded periods and does not change -> logging settings. Optional Candiru patterns still need the pinned YARA-X engine; +> Release v0.1.6 fixes COM file-path parsing when a folder name contains an +> executable extension, and limits historical coverage statements to evidence +> actually examined. Optional Candiru patterns still need the pinned YARA-X engine; > see [pattern validation](DETECTION-UPGRADE.md). 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) @@ -145,7 +145,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.5 includes detached GPG +**1. Verify the publisher's signature.** Release v0.1.6 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). @@ -169,11 +169,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.5 +git checkout v0.1.6 python build/build.py # writes dist/zero-breach.py and prints its SHA-256 ``` -These commands reproduce v0.1.5; substitute the matching tag for another release. +These commands reproduce v0.1.6; 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 f565d61..1752df5 100644 --- a/SIGNING.md +++ b/SIGNING.md @@ -1,17 +1,17 @@ # Verifying ArchiveGenocide release signatures -Zero Breach v0.1.5 supplies detached GPG signatures for the exact released +Zero Breach v0.1.6 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.5 +Official release: https://github.com/interplains/zero-breach/releases/tag/v0.1.6 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.5 release into one directory: +Download these three files from the v0.1.6 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.5 is: +The expected SHA-256 of zero-breach.py v0.1.6 is: - 59811565be507c7d956e77f5f396b81545259c551fa9208d85654a01dec1048e + 09fba9710143c11465c1627a0399b34ad1ee9a49341eddaa7d4656d77bf5ec78 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.5/. +Public verification artifacts are also retained in release-signatures/v0.1.6/. 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.5; +The signed release manifest covers the indicator file shipped with v0.1.6; it does not authenticate later files fetched from the development branch. ## Maintainer release procedure diff --git a/TESTING.md b/TESTING.md index 5b60f6c..a319c88 100644 --- a/TESTING.md +++ b/TESTING.md @@ -35,12 +35,13 @@ untested optional positives as a skip. Use `--require-patterns` before release. - The existing unit/regression suite. - Two byte-identical builds of the downloadable script, plus its help/version. -- Fifteen fixture scenarios, each in fresh source-CLI and single-file processes: +- Sixteen fixture scenarios, each in fresh source-CLI and single-file processes: clean data, contacted domain, harmless research reference, exact hash hit, corrupt database, a hit alongside a corrupt database, `--quick` coverage gap, a DNS hit past the old 20,000-event limit, unread older DNS events without a hit, four disabled-DNS-log cases (retained match, retained clean data, - unreadable retained events and no events), invalid indicator input, and COM-referenced optional file patterns. + unreadable retained events and no events), invalid indicator input, and COM-referenced optional file patterns (both a bare DLL and an unquoted + command under an extension-like folder name). - Real browser-shaped SQLite files and harmless rule-matching bytes pass through the real scanner modules, evidence grading, exit codes and text/JSON reports. Source and download must agree. Cookie values must stay out of reports; diff --git a/build/bundle.py b/build/bundle.py index dbf3d8f..4a1eed0 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.5 (single-file build)"') +A('__version__ = "0.1.6 (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.6/SHA256SUMS.txt b/release-signatures/v0.1.6/SHA256SUMS.txt new file mode 100644 index 0000000..469292a --- /dev/null +++ b/release-signatures/v0.1.6/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 +09fba9710143c11465c1627a0399b34ad1ee9a49341eddaa7d4656d77bf5ec78 zero-breach.py diff --git a/release-signatures/v0.1.6/SHA256SUMS.txt.asc b/release-signatures/v0.1.6/SHA256SUMS.txt.asc new file mode 100644 index 0000000..d4c57ea --- /dev/null +++ b/release-signatures/v0.1.6/SHA256SUMS.txt.asc @@ -0,0 +1,7 @@ +-----BEGIN PGP SIGNATURE----- + +iHUEABYKAB0WIQTCTskrEtZnCiUWBl+bTVdUma+lPAUCatZ9PgAKCRCbTVdUma+l +PM0eAQD9UqQw70HySi8+qMoYF1Udvpu91vUL6pwFm33TzSAaEAD/a8UNqbpldaZp +gWwIHXIV74+Ks7x2hX9tZTVnokYFfwQ= +=+z8G +-----END PGP SIGNATURE----- diff --git a/release-signatures/v0.1.6/archivegenocide-signing-key.asc b/release-signatures/v0.1.6/archivegenocide-signing-key.asc new file mode 100644 index 0000000..e36686c --- /dev/null +++ b/release-signatures/v0.1.6/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.6/zero-breach.py.asc b/release-signatures/v0.1.6/zero-breach.py.asc new file mode 100644 index 0000000..4546339 --- /dev/null +++ b/release-signatures/v0.1.6/zero-breach.py.asc @@ -0,0 +1,7 @@ +-----BEGIN PGP SIGNATURE----- + +iHUEABYKAB0WIQTCTskrEtZnCiUWBl+bTVdUma+lPAUCatZ9PgAKCRCbTVdUma+l +PME3AP9W3R8VAwZMqG6815gYo5grSc/UG83TltFJmtt+uob02AEAuGL7e9TZ8Tx8 +N+VsFtOA0rs+0F+e5QM78LIxandFuws= +=iq+w +-----END PGP SIGNATURE----- diff --git a/scan/README.md b/scan/README.md index bc67c92..c1a96f2 100644 --- a/scan/README.md +++ b/scan/README.md @@ -1,7 +1,7 @@ # adint-scan -> Release v0.1.5 reads retained Windows DNS events even if logging is currently -> disabled, and keeps gaps for unrecorded activity. It never enables logging. +> Release v0.1.6 fixes extension-like folder names in COM command paths and +> avoids treating unread history as absent. It never executes registered commands. > Optional Candiru pattern dependencies and limits are documented in > [DETECTION-UPGRADE.md](../DETECTION-UPGRADE.md). diff --git a/scan/adint_scan/__init__.py b/scan/adint_scan/__init__.py index c62af21..19b16f7 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.5" +__version__ = "0.1.6" diff --git a/scan/adint_scan/modules/host.py b/scan/adint_scan/modules/host.py index 4e3616d..e3ab5aa 100644 --- a/scan/adint_scan/modules/host.py +++ b/scan/adint_scan/modules/host.py @@ -45,6 +45,7 @@ import os from ..localpaths import path_problem import platform +import re import subprocess from ..result import (Hit, ModuleResult, EV_DNS, @@ -320,11 +321,13 @@ def _server_path(raw: str, kind: str) -> str: if kind == "LocalServer32" and s: # Parse only text here. Even an existence check would contact a UNC # server before _check_com has had a chance to reject the path. - low = s.lower() - for ext in (".exe", ".com", ".bat", ".cmd"): - idx = low.find(ext) - if idx != -1: - return s[:idx + len(ext)].strip() + # An extension inside a directory (tool.exe\payload.bat) or a + # longer filename (tool.exe.backup\app.exe) is not the binary's end. + # Match extensions in command order, not by extension preference: + # arguments may themselves contain a later .exe path. + end = re.search(r"\.(?:exe|com|bat|cmd)(?=\s|$)", s, re.IGNORECASE) + if end is not None: + return s[:end.end()].strip() return s @@ -674,9 +677,9 @@ def _check_dns_log(r: ModuleResult, ctx) -> None: r.reach["dns-log"] = { "earliest": None, "days": 0, - "basis": "no DNS history exists on this host", - "note": "Nothing was ever recorded, so there is no window at all — " - "not a short one.", + "basis": "DNS log metadata could not be read", + "note": "No historical window could be established. Older or " + "unread records may still exist.", "how_to_enable": _HOW_TO_ENABLE, } return diff --git a/scan/adint_scan/report.py b/scan/adint_scan/report.py index 32e00c3..14182f6 100644 --- a/scan/adint_scan/report.py +++ b/scan/adint_scan/report.py @@ -299,33 +299,39 @@ def render_human(document: Dict[str, Any]) -> str: def overlap_statement(earliest: datetime.date, source: str) -> List[str]: - """What the oldest surviving evidence means for the campaign window. Three - cases, and the middle one is the common one: evidence that starts INSIDE the - window covers only its tail, and only through whichever record reached it.""" + """Compare the dates reported by this scan with the campaign window. + + Unread or out-of-scope records may be older; an oldest date does not prove + that records cover every day since then or that missing evidence is gone. + """ if earliest > CAMPAIGN_TO: return [ - "** The oldest evidence of any kind on this machine dates from {}.".format( + "** The oldest dated evidence reported by this scan is {} ({}).".format( + source, earliest.isoformat()), " The campaign this tool searches for was active in {}.".format( CAMPAIGN_LABEL), - " Those windows DO NOT OVERLAP. For that period this machine holds no", - " evidence either way, so a clean result above says nothing about it. **", + " Those dates DO NOT OVERLAP. The dates reported here do not reach", + " the campaign period. Older or unread records may still exist;", + " this scan cannot rule out past contact during that period. **", ] if earliest > CAMPAIGN_FROM: return [ - "** The oldest evidence on this machine is {} ({}).".format( + "** The oldest dated evidence reported by this scan is {} ({}).".format( source, earliest.isoformat()), " That reaches PART of the campaign period ({}),".format(CAMPAIGN_LABEL), " from {} onwards, and only through that one kind of record.".format( earliest.isoformat()), - " Everything before it, and every other kind of record, is gone. A clean", - " result covers that partial window thinly - it does not cover 2024-2025. **", + " This does not establish continuous coverage from that date.", + " Older or unread records may still exist. A clean result cannot", + " rule out past contact during the campaign period. **", ] return [ - "** The oldest evidence on this machine is {} ({}), which predates".format( + "** The oldest dated evidence reported by this scan is {} ({}), which predates".format( source, earliest.isoformat()), - " the campaign period. That record type spans the whole window - but only", - " for whatever survived in it, which is never everything. **", + " the campaign period. This does not establish continuous coverage", + " through that period. Surviving records are never everything, and a", + " clean result cannot rule out past contact during that period. **", ] @@ -343,8 +349,8 @@ def _render_reach(w, document: Dict[str, Any]) -> None: w(_RULE) w("HOW FAR BACK THIS SCAN COULD SEE") w("") - w(" A clean result only covers the window the evidence survives in. These are") - w(" the measured windows on THIS machine, not general estimates:") + w(" These dates come from the records examined by this scan. They do not") + w(" establish a complete history; older or unread records may still exist:") w("") earliest_overall = None @@ -352,7 +358,7 @@ def _render_reach(w, document: Dict[str, Any]) -> None: for mod_name, label, info in entries: earliest = info.get("earliest") days = info.get("days") - when = "nothing recorded" if not earliest else earliest[:10] + when = "unknown (no date established)" if not earliest else earliest[:10] age = "" if isinstance(days, int) and earliest: age = " ({} days ago)".format(days) @@ -426,13 +432,11 @@ def _render_reach(w, document: Dict[str, Any]) -> None: sparse = [y for y in range(CAMPAIGN_FROM.year, CAMPAIGN_TO.year + 1) if merged.get(y, 0) < 100] if in_window == 0 and not something_in_window: - # Only make the absolute claim when NOTHING of any kind - no cookie, - # no cache file, no DNS record - reaches the window. Otherwise it - # would contradict the overlap statement printed just above. - w(" NOTHING on this machine dates from the campaign period. A clean") - w(" result cannot speak to it at all.") + w(" No dated evidence summarised here reaches the campaign period.") + w(" Older or unread records may still exist. A clean result cannot") + w(" rule out past contact during that period.") elif in_window == 0 and something_in_window: - w(" No browser CACHE file dates from the campaign period, though some") + w(" No browser CACHE file examined dates from the campaign period, though some") w(" older evidence (shown above) does. Cache is the thinnest record;") w(" the window above is the one to read.") elif sparse: diff --git a/scan/tests/test_reach.py b/scan/tests/test_reach.py index 7ecef0b..c5a59f9 100644 --- a/scan/tests/test_reach.py +++ b/scan/tests/test_reach.py @@ -15,6 +15,7 @@ import sys import tempfile import unittest +from unittest.mock import patch sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -22,11 +23,13 @@ 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.modules import host +from adint_scan.result import ModuleResult class TestOverlapStatement(unittest.TestCase): def text(self, d): - return " ".join(report.overlap_statement(d, "browser/cookies")) + return " ".join(" ".join(report.overlap_statement(d, "browser/cookies")).split()) def test_evidence_after_campaign_end_says_no_overlap(self): t = self.text(datetime.date(2026, 6, 21)) @@ -50,6 +53,63 @@ def test_boundary_days(self): self.assertIn("DO NOT OVERLAP", self.text(datetime.date(2026, 1, 1))) self.assertNotIn("DO NOT OVERLAP", self.text(datetime.date(2025, 12, 31))) + def test_all_windows_are_scoped_to_reported_evidence(self): + for date in (datetime.date(2023, 1, 1), datetime.date(2024, 6, 1), + datetime.date(2026, 1, 1)): + with self.subTest(date=date): + text = self.text(date) + self.assertIn('reported by this scan', text) + self.assertIn('browser/cookies', text) + self.assertIn('cannot rule out past contact', text) + self.assertNotIn('on this machine', text) + self.assertNotIn('is gone', text) + self.assertNotIn('spans the whole window', text) + + +class TestReachScope(unittest.TestCase): + def render(self, reach, gaps=()): + result = {'modules': {'host': {'reach': reach}}, 'verdict': {'gaps': list(gaps)}} + lines = [] + report._render_reach(lines.append, result) + return ' '.join(' '.join(lines).split()) + + def test_unread_older_dns_records_do_not_become_absence_claim(self): + text = self.render({'dns-log': {'earliest': '2026-01-01', + 'basis': 'oldest of 50,000 DNS events read (cap 50,000)', + 'note': 'Older retained events were not read.'}}, ['host/dns-log-older-events']) + self.assertIn('reported by this scan', text) + self.assertIn('Older or unread records may still exist', text) + self.assertNotIn('this machine holds no', text) + self.assertNotIn('oldest evidence of any kind', text) + + def test_cache_dates_do_not_claim_nothing_else_exists(self): + for older in (False, True): + reach = {'cache': {'earliest': '2026-01-01', 'by_year': {2026: 2}}} + if older: + reach['cookies'] = {'earliest': '2024-06-01'} + with self.subTest(older=older): + text = self.render(reach) + self.assertNotIn('NOTHING on this machine', text) + if older: + self.assertIn('No browser CACHE file examined', text) + else: + self.assertIn('No dated evidence summarised here', text) + + def test_unknown_date_is_not_reported_as_nothing_recorded(self): + text = self.render({'dns-log': {'earliest': None, 'basis': 'log could not be read'}}) + self.assertIn('unknown (no date established)', text) + self.assertNotIn('nothing recorded', text) + + def test_unreadable_log_metadata_does_not_claim_history_never_existed(self): + result = ModuleResult('host') + with patch.object(host, '_ps_json', return_value=([], 'fixture access denied')): + host._check_dns_log(result, ScanContext(iocs=None, emit=lambda _: None)) + text = self.render(result.reach) + self.assertIn('metadata could not be read', text) + self.assertIn('unread records may still exist', text) + self.assertNotIn('Nothing was ever recorded', text) + self.assertNotIn('no DNS history exists', text) + class TestCookieWindowMeasured(unittest.TestCase): """A real Chromium-shaped cookie DB with a known oldest creation time.""" diff --git a/scan/tests/test_server_path.py b/scan/tests/test_server_path.py new file mode 100644 index 0000000..72f1b20 --- /dev/null +++ b/scan/tests/test_server_path.py @@ -0,0 +1,50 @@ +"""COM command parsing must preserve the binary path without probing it.""" +import os +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from adint_scan.modules import host + + +class TestServerPath(unittest.TestCase): + def test_command_boundaries(self): + cases = ( + (r'C:\tool.exe\payload.bat -Embedding', r'C:\tool.exe\payload.bat'), + (r'C:\folder.com\payload.exe -Embedding', r'C:\folder.com\payload.exe'), + (r'C:\folder.exe.backup\payload.cmd /quiet', r'C:\folder.exe.backup\payload.cmd'), + (r'C:/folder.exe/payload.COM -Embedding', r'C:/folder.exe/payload.COM'), + (r'C:\folder.cmd\payload.exe', r'C:\folder.cmd\payload.exe'), + (r'C:\Program Files\server.exe -Embedding', r'C:\Program Files\server.exe'), + (r'C:\server.com --helper C:\later.exe', r'C:\server.com'), + (r'C:\server.bat --helper C:\later.exe', r'C:\server.bat'), + (r'C:\server.cmd --helper C:\later.com', r'C:\server.cmd'), + ('C:\\server.EXE\t-Embedding', r'C:\server.EXE'), + (r'"C:\Program Files\tool.exe\payload.bat" -Embedding', + r'C:\Program Files\tool.exe\payload.bat'), + (r'\\remote.invalid\share.exe\payload.bat -Embedding', + r'\\remote.invalid\share.exe\payload.bat'), + ) + with patch.object(host.os.path, 'exists', side_effect=AssertionError('filesystem probe')), \ + patch.object(host.os, 'stat', side_effect=AssertionError('filesystem probe')), \ + patch.object(host.subprocess, 'run', side_effect=AssertionError('command execution')): + for raw, expected in cases: + with self.subTest(raw=raw): + self.assertEqual(host._server_path(raw, 'LocalServer32'), expected) + + def test_bare_dll_values_keep_spaces_and_extension_like_directories(self): + for kind in ('InprocServer32', 'InprocHandler32'): + for raw in (r'C:\folder.exe\my server.dll', r'"C:\my folder\server.dll"'): + with self.subTest(kind=kind, raw=raw): + self.assertEqual(host._server_path(raw, kind), raw.strip('"')) + + def test_environment_expansion_precedes_command_parsing(self): + with patch.dict(os.environ, {'ZB_SERVER_TEST': r'C:\folder.exe'}): + self.assertEqual(host._server_path('${ZB_SERVER_TEST}\\payload.cmd -Embedding', + 'LocalServer32'), r'C:\folder.exe\payload.cmd') + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/release_check.py b/tools/release_check.py index d82996a..df764e5 100644 --- a/tools/release_check.py +++ b/tools/release_check.py @@ -16,7 +16,7 @@ ROOT = Path(__file__).resolve().parents[1] CASES = ("clean", "contact", "reference", "hash", "corrupt", "hit-and-gap", "quick", "dns-cap-hit", "dns-cap-gap", "dns-off-retained-hit", "dns-off-retained-clean", - "dns-off-unreadable", "dns-off-empty", "bad-feed", "patterns") + "dns-off-unreadable", "dns-off-empty", "bad-feed", "patterns", "patterns-localserver") class DownloadPanel(HTMLParser): diff --git a/tools/release_fixture.py b/tools/release_fixture.py index f64c235..fc508a8 100644 --- a/tools/release_fixture.py +++ b/tools/release_fixture.py @@ -85,9 +85,11 @@ def main(): # Protective blocklist entries must never create findings. hosts.write_text("127.0.0.1 localhost\n0.0.0.0 " + DOMAIN + "\n", encoding="utf-8") registered = inputs / "Registered" / "server.dll" - registered.parent.mkdir() + if case == "patterns-localserver": + registered = inputs / "Registered" / "folder.exe" / "payload.bat" + registered.parent.mkdir(parents=True) engine = importlib.util.find_spec("yara_x") is not None - if case == "patterns" and engine: + if case in ("patterns", "patterns-localserver") and engine: from test_patterns import synthetic_mstic_fixture registered.write_bytes(synthetic_mstic_fixture()) else: @@ -132,6 +134,8 @@ def audit(event, args): events = events * 50000 if case == "dns-cap-hit": events[20000] = {"Time": "2025-06-01T00:00:00", "Message": DOMAIN} + else: + events = [{"Time": "2026-01-01T00:00:00", "Message": "ordinary.invalid"}] * 50000 def ps_json(command, timeout=60): if "-ListLog " in command: @@ -148,8 +152,10 @@ def ps_json(command, timeout=60): return [{"Boot": "2026-01-01T00:00:00"}], "" raise RuntimeError("unexpected OS query in fixture") - entries = ([{"clsid": "{FIXTURE}", "kind": "InprocServer32", "raw": str(registered), - "view": "native"}] if case == "patterns" else []) + entries = ([{"clsid": "{FIXTURE}", + "kind": "LocalServer32" if case == "patterns-localserver" else "InprocServer32", + "raw": str(registered) + (" -Embedding" if case == "patterns-localserver" else ""), + "view": "native"}] if case in ("patterns", "patterns-localserver") else []) report_path = outputs / "report.json" argv = ["--iocs", str(feed if case != "bad-feed" else inputs / "missing.txt"), "--json", str(report_path)] @@ -157,7 +163,7 @@ def ps_json(command, timeout=60): argv.append("--no-pause") if case == "quick": argv.append("--quick") - if case == "patterns": + if case in ("patterns", "patterns-localserver"): argv.append("--patterns") with contextlib.ExitStack() as stack: stack.enter_context(patch.object(cli, "no_network", checked_network_guard)) @@ -202,7 +208,7 @@ def ps_json(command, timeout=60): "hash": ("hash", DIGEST, "on-disk"), "dns-cap-hit": ("host", DOMAIN, "dns-record")} expected["dns-off-retained-hit"] = ("host", DOMAIN, "dns-record") - if case == "patterns" and engine: + if case in ("patterns", "patterns-localserver") and engine: expected[case] = ("patterns", "yara:MAL_DevilsTongue_HijackDll", "file-pattern") require(hits == ([expected[case]] if case in expected else []), "wrong evidence/hits: " + str(hits)) @@ -210,12 +216,14 @@ def ps_json(command, timeout=60): require(verdict["hits_total"] == len(hits), "incorrect total finding count") expected_verdict = ("INDICATORS_FOUND" if hits else "NO_KNOWN_INDICATORS_WITH_GAPS" if case in - ("corrupt", "quick", "dns-cap-gap", "patterns", "dns-off-retained-clean", + ("corrupt", "quick", "dns-cap-gap", "patterns", "patterns-localserver", "dns-off-retained-clean", "dns-off-unreadable", "dns-off-empty") else "NO_KNOWN_INDICATORS") require(verdict["status"] == expected_verdict, "incorrect overall verdict: " + str(verdict)) if case.startswith("dns-cap-"): require(any("dns-log-older-events" in g for g in verdict["gaps"]), "missing DNS cap gap") require(modules["host"]["submetrics"]["dns_log_events_scanned"] == 50000, "wrong DNS count") + require('this machine holds no' not in text, 'unread history misreported as absent') + require('reported by this scan' in text, 'reach statement is not scoped to scan') if case.startswith("dns-off-"): require(modules["host"]["submetrics"]["dns_log_enabled"] == 0, "logging state misreported") require(bool(verdict["gaps"]), "disabled logging coverage gap missing") @@ -230,7 +238,7 @@ def ps_json(command, timeout=60): require(any("history" in g for g in verdict["gaps"]), "corrupt database gap missing") if case in ("contact", "hit-and-gap"): require(str(cookie) in text, "human report lost the full cookie file path") - if case == "patterns": + if case in ("patterns", "patterns-localserver"): require(modules["patterns"]["status"] == ("HITS" if engine else "UNKNOWN"), "optional engine status is dishonest") require("HOW FAR BACK THIS SCAN COULD SEE" in text, "human report did not render")