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
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Zero Breach

> 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.
> 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;
> 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 @@ -144,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.4 includes detached GPG
**1. Verify the publisher's signature.** Release v0.1.5 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 @@ -168,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.4
git checkout v0.1.5
python build/build.py # writes dist/zero-breach.py and prints its SHA-256
```

These commands reproduce v0.1.4; substitute the matching tag for another release.
These commands reproduce v0.1.5; 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.4 supplies detached GPG signatures for the exact released
Zero Breach v0.1.5 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.4
Official release: https://github.com/interplains/zero-breach/releases/tag/v0.1.5
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.4 release into one directory:
Download these three files from the v0.1.5 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.4 is:
The expected SHA-256 of zero-breach.py v0.1.5 is:

5ab89ef3dda3579dfc0b4930b0d66290fdc755611afd91e28c373e30fe9996cc
59811565be507c7d956e77f5f396b81545259c551fa9208d85654a01dec1048e

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.4/.
Public verification artifacts are also retained in release-signatures/v0.1.5/.
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.4;
The signed release manifest covers the indicator file shipped with v0.1.5;
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,11 +35,12 @@ 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.
- Eleven fixture scenarios, each in fresh source-CLI and single-file processes:
- Fifteen 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, invalid indicator input, and COM-referenced optional file patterns.
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.
- 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.4 (single-file build)"')
A('__version__ = "0.1.5 (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.5/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
59811565be507c7d956e77f5f396b81545259c551fa9208d85654a01dec1048e zero-breach.py
7 changes: 7 additions & 0 deletions release-signatures/v0.1.5/SHA256SUMS.txt.asc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-----BEGIN PGP SIGNATURE-----

iHUEABYKAB0WIQTCTskrEtZnCiUWBl+bTVdUma+lPAUCatZ1qQAKCRCbTVdUma+l
PAQyAQD1/O34Yzh4/UVtZSbMboGlTw9vC3QmMyNri1hCLnPHpgEA6go9S8A8JGNB
JACPBVNIu+fDBaCvcvFOJPdia7ensgM=
=15hH
-----END PGP SIGNATURE-----
10 changes: 10 additions & 0 deletions release-signatures/v0.1.5/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.5/zero-breach.py.asc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-----BEGIN PGP SIGNATURE-----

iHUEABYKAB0WIQTCTskrEtZnCiUWBl+bTVdUma+lPAUCatZ1qQAKCRCbTVdUma+l
PIbMAP9sPoqzbgq0F7iq5NaiTOH568jl+cQvxsBeNSCLLqg61gEA3U6VctZGc4HO
HDWOAPdGMjncfZ8VtQ9+Jq+xCDp3dwI=
=amYZ
-----END PGP SIGNATURE-----
7 changes: 4 additions & 3 deletions scan/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# adint-scan

> 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.
> 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.
> Optional Candiru pattern dependencies and limits are documented in
> [DETECTION-UPGRADE.md](../DETECTION-UPGRADE.md).

A local, read-only check for the published AdFection / Candiru indicators,
with explicit reporting of missing evidence and checks that could not run.
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.4"
__version__ = "0.1.5"
28 changes: 19 additions & 9 deletions scan/adint_scan/modules/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,7 @@ def _check_dns_log(r: ModuleResult, ctx) -> None:
r.submetrics["dns_log_enabled"] = 1 if enabled else 0
r.submetrics["dns_log_records"] = records

if not enabled or records <= 0:
if records <= 0:
# The important branch, and the common one. This must never read clean.
r.could_not_run("dns-log", _DNS_LOG_GAP)
r.note("dns-log: {} enabled={} records={} — forward-looking checks are "
Expand All @@ -702,14 +702,23 @@ def _check_dns_log(r: ModuleResult, ctx) -> None:
"days": 0,
"basis": "DNS logging is {} and holds {} records".format(
"enabled" if enabled else "disabled", records),
"note": "Nothing was ever recorded, so there is no window at all — "
"not a short one. This is the Windows default, on every PC.",
"note": "No saved DNS events were available for this scan. "
"This does not establish whether older events existed or were cleared.",
"how_to_enable": _HOW_TO_ENABLE,
}
return

ctx.emit("[host] DNS-Client/Operational is enabled ({} records); "
"scanning messages".format(records))
# IsEnabled controls current collection, not whether retained events can
# be read. Disabling logging must not hide existing historical evidence.
if not enabled:
r.could_not_run("dns-log-recording",
"DNS logging is currently off; queries made while "
"logging was off may be missing")
r.note("dns-log: logging is off; the log reports {} saved event(s). "
"Checking them does not enable logging."
.format(records))
ctx.emit("[host] DNS-Client/Operational logging is {} ({} retained records); "
"scanning messages".format("on" if enabled else "off", records))
# -ErrorAction Stop, not SilentlyContinue: with SilentlyContinue a failure to
# read the channel (ACL, corrupt .evtx, Event Log service degraded) exits 0
# with no output, so `events` is [] and `ev_err` is "" - and the log, which is
Expand All @@ -725,18 +734,16 @@ def _check_dns_log(r: ModuleResult, ctx) -> None:
" | 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 "
reason = ("log has {} retained records but none could be read{} - this "
"machine's DNS history was NOT searched, so nothing here is "
"cleared".format(records, ": " + ev_err if ev_err else ""))
r.could_not_run("dns-log", reason)
r.note(_ARM_HINT)
return

hits = 0
times = [t for t in (_str(ev, "Time") for ev in events) if t]
if times:
# The log IS on, so there is a real window. Report its true extent rather
# than implying the whole history of the machine.
# Retained records have dates even when logging is currently disabled.
r.reach["dns-log"] = {
"earliest": min(times),
"days": None,
Expand All @@ -745,6 +752,9 @@ def _check_dns_log(r: ModuleResult, ctx) -> None:
"note": "Queries before this are either rotated out of the log or "
"beyond the bounded event read.",
}
if not enabled:
r.reach["dns-log"]["note"] += " Logging is currently off; queries during unrecorded periods may be missing."
r.reach["dns-log"]["how_to_enable"] = _HOW_TO_ENABLE
for ev in events:
when = _str(ev, "Time")
hits += _match(
Expand Down
52 changes: 51 additions & 1 deletion scan/tests/test_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, PARTIAL # noqa: E402
from adint_scan.result import Hit, ModuleResult, PARTIAL, UNKNOWN # noqa: E402

TEST_DOMAIN = "synthetic-test-indicator.invalid"

Expand Down Expand Up @@ -305,6 +305,56 @@ def test_real_address_is_a_dns_record_without_proof_of_contact(self):


class TestDnsLogIsNotProofOfContact(unittest.TestCase):
def disabled_log(self, records, events, error=""):
from adint_scan.modules import host
with tempfile.TemporaryDirectory(prefix="dnslog_off_") as tmp:
result = ModuleResult("host")
replies = [([{"IsEnabled": False, "RecordCount": records}], "")]
if records:
replies.append((events, error))
with patch.object(host, "_ps_json", side_effect=replies) as query:
host._check_dns_log(result, make_ctx(tmp))
self.assertEqual(query.call_count, 2 if records else 1)
for call in query.call_args_list:
self.assertIn("Get-WinEvent", call.args[0])
self.assertNotIn("wevtutil", call.args[0])
return result

def test_disabled_log_still_searches_retained_matching_event(self):
result = self.disabled_log(1, [{"Time": "2025-03-04T12:00:00Z", "Message": TEST_DOMAIN}])
self.assertEqual(result.status, "HITS")
self.assertEqual([(h.indicator, h.evidence) for h in result.hits], [(TEST_DOMAIN, "dns-record")])
self.assertEqual(result.hits[0].observed, "2025-03-04T12:00:00Z")
self.assertEqual(result.submetrics["dns_log_events_scanned"], 1)
self.assertEqual(result.submetrics["dns_log_enabled"], 0)
self.assertIn("how_to_enable", result.reach["dns-log"])

def test_disabled_log_clean_retained_event_keeps_recording_gap(self):
result = self.disabled_log(1, [{"Time": "2025-03-04T12:00:00Z", "Message": "ordinary.invalid"}])
self.assertEqual(result.status, PARTIAL)
self.assertEqual(result.hits, [])
self.assertTrue(any(c.name == "dns-log" and c.ran for c in result.checks))
self.assertTrue(any(c.name == "dns-log-recording" and not c.ran for c in result.checks))

def test_disabled_empty_log_is_not_clean(self):
result = self.disabled_log(0, [])
self.assertEqual(result.status, UNKNOWN)
self.assertNotIn("dns_log_events_scanned", result.submetrics)

def test_disabled_log_read_failure_is_not_clean(self):
result = self.disabled_log(1, [], "fixture permission denied")
self.assertEqual(result.status, UNKNOWN)
self.assertEqual(result.hits, [])
self.assertTrue(any(c.name == "dns-log" and not c.ran for c in result.checks))
self.assertNotIn("dns_log_events_scanned", result.submetrics)

def test_disabled_log_preserves_hit_and_older_retained_gap_together(self):
result = self.disabled_log(50001, [{"Time": "2025-03-04T12:00:00Z", "Message": TEST_DOMAIN}])
self.assertEqual(result.status, "HITS")
self.assertEqual(len(result.hits), 1)
self.assertTrue(any(c.name == "dns-log-older-events" and not c.ran for c in result.checks))
self.assertTrue(any(c.name == "dns-log-recording" and not c.ran for c in result.checks))

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:
Expand Down
3 changes: 2 additions & 1 deletion tools/release_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

ROOT = Path(__file__).resolve().parents[1]
CASES = ("clean", "contact", "reference", "hash", "corrupt", "hit-and-gap", "quick",
"dns-cap-hit", "dns-cap-gap", "bad-feed", "patterns")
"dns-cap-hit", "dns-cap-gap", "dns-off-retained-hit", "dns-off-retained-clean",
"dns-off-unreadable", "dns-off-empty", "bad-feed", "patterns")


class DownloadPanel(HTMLParser):
Expand Down
Loading
Loading