Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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).
Expand All @@ -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.
Expand Down
14 changes: 7 additions & 7 deletions SIGNING.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion build/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
15 changes: 15 additions & 0 deletions release-signatures/v0.1.6/SHA256SUMS.txt
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions release-signatures/v0.1.6/SHA256SUMS.txt.asc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-----BEGIN PGP SIGNATURE-----

iHUEABYKAB0WIQTCTskrEtZnCiUWBl+bTVdUma+lPAUCatZ9PgAKCRCbTVdUma+l
PM0eAQD9UqQw70HySi8+qMoYF1Udvpu91vUL6pwFm33TzSAaEAD/a8UNqbpldaZp
gWwIHXIV74+Ks7x2hX9tZTVnokYFfwQ=
=+z8G
-----END PGP SIGNATURE-----
10 changes: 10 additions & 0 deletions release-signatures/v0.1.6/archivegenocide-signing-key.asc
Original file line number Diff line number Diff line change
@@ -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-----
7 changes: 7 additions & 0 deletions release-signatures/v0.1.6/zero-breach.py.asc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-----BEGIN PGP SIGNATURE-----

iHUEABYKAB0WIQTCTskrEtZnCiUWBl+bTVdUma+lPAUCatZ9PgAKCRCbTVdUma+l
PME3AP9W3R8VAwZMqG6815gYo5grSc/UG83TltFJmtt+uob02AEAuGL7e9TZ8Tx8
N+VsFtOA0rs+0F+e5QM78LIxandFuws=
=iq+w
-----END PGP SIGNATURE-----
4 changes: 2 additions & 2 deletions scan/README.md
Original file line number Diff line number Diff line change
@@ -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).

Expand Down
2 changes: 1 addition & 1 deletion scan/adint_scan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
19 changes: 11 additions & 8 deletions scan/adint_scan/modules/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
import os
from ..localpaths import path_problem
import platform
import re
import subprocess

from ..result import (Hit, ModuleResult, EV_DNS,
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down
46 changes: 25 additions & 21 deletions scan/adint_scan/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. **",
]


Expand All @@ -343,16 +349,16 @@ 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
earliest_source = ""
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)
Expand Down Expand Up @@ -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:
Expand Down
62 changes: 61 additions & 1 deletion scan/tests/test_reach.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,21 @@
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__))))

from adint_scan import iocs as iocmod # noqa: E402
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))
Expand All @@ -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."""
Expand Down
Loading
Loading