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
24 changes: 24 additions & 0 deletions COVERAGE-UPGRADE.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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:**

Expand All @@ -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).
Expand All @@ -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.
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.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
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.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.
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.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
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.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')
Expand Down
15 changes: 15 additions & 0 deletions release-signatures/v0.1.4/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
5ab89ef3dda3579dfc0b4930b0d66290fdc755611afd91e28c373e30fe9996cc zero-breach.py
7 changes: 7 additions & 0 deletions release-signatures/v0.1.4/SHA256SUMS.txt.asc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-----BEGIN PGP SIGNATURE-----

iHUEABYKAB0WIQTCTskrEtZnCiUWBl+bTVdUma+lPAUCatYErwAKCRCbTVdUma+l
POrwAPsFijxIYbF7Ecr9/fSvXhNLpjIZLxCHhKRA8Og5cj1auAD/Xc6HYbK32az9
J0Di5B8K+sHD83/3AoINu2zn8GuHuQM=
=5td8
-----END PGP SIGNATURE-----
10 changes: 10 additions & 0 deletions release-signatures/v0.1.4/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.4/zero-breach.py.asc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-----BEGIN PGP SIGNATURE-----

iHUEABYKAB0WIQTCTskrEtZnCiUWBl+bTVdUma+lPAUCatYErwAKCRCbTVdUma+l
PB4GAQCzQgvo8O9de36OHoEi4BHNsWan6DzQjmqCUJI/uQzH4wD9GjewwaTVXbVw
zFDH0SAfi3O+a0lp7/+quZ7rYqMreAM=
=FrWb
-----END PGP SIGNATURE-----
3 changes: 3 additions & 0 deletions scan/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions scan/README.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
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.3"
__version__ = "0.1.4"
4 changes: 4 additions & 0 deletions scan/adint_scan/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
52 changes: 39 additions & 13 deletions scan/adint_scan/modules/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -447,14 +450,35 @@ 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)

sigs, sig_err = _signature_status(on_disk)
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 = []
Expand Down Expand Up @@ -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 "
Expand All @@ -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")
Expand All @@ -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))

Expand Down
Loading
Loading