# PushWarden documentation This file is the complete PushWarden documentation in one plain-text Markdown file, generated from the pages at https://faheemrafiq.github.io/pushwarden/. Each page starts with a level-1 heading and a `Source:` line. --- # PushWarden overview Source: https://faheemrafiq.github.io/pushwarden/guide/overview.html PushWarden is a free security tool that protects developer machines and GitHub repositories from the PolinRider / Contagious Interview supply-chain malware. It runs on Linux, macOS and Windows as a single program with no dependencies. It works like an antivirus that knows one malware family very well. It catches a malicious file the moment it is written, moves it to quarantine before it can run, names the threat, and lets you decide in a native dialog whether to remove it for good or restore it. ## The threat PolinRider is a campaign attributed to North Korean (DPRK, Lazarus) operators. It targets software developers, often through fake job interviews in which the victim is asked to clone and run a repository. Between March and September 2026 it reached more than 2,000 GitHub owners and 4,000 repositories. The malware: 1. **Hides loaders in ordinary project files.** Obfuscated JavaScript is appended to framework config files such as `postcss.config.mjs`, `eslint.config.mjs` and `next.config.js`, placed in files that pretend to be fonts (`fa-solid-900.woff2`), and started by `.vscode/tasks.json` tasks that run when a folder is opened. 2. **Runs as soon as the project is opened or built.** Opening the folder in VS Code, running the dev server or installing dependencies is enough. 3. **Steals credentials.** Tokens, SSH keys, browser sessions, wallet files and environment files are collected and sent to command-and-control (C2) servers. 4. **Spreads through git.** With a stolen token it rewrites repositories and force-pushes the backdoor to every branch it can reach, so colleagues who pull become infected too. 5. **Stays on the machine.** It installs remote access tools and persistence entries (systemd units, LaunchAgents, scheduled tasks, cron lines). ## What PushWarden does about it | Capability | What it means | Guide | |---|---|---| | Real-time file protection | Every script, config, font, image and editor task file written under your project folders is scanned within a second | [Real-time guard](real-time-guard.md) | | Process and network watch | Every 5 seconds: running processes and connections to known C2 addresses are checked, and confirmed payloads are killed | [Real-time guard](real-time-guard.md) | | Scheduled full sweep | Every 6 hours: every repository plus the host (persistence, remote access tools, editor injection, exposed credentials) | [Real-time guard](real-time-guard.md) | | On-demand scan | Scan any folder or the whole home directory, interactively or in CI | [Scanning](scanning.md) | | Reversible response | Payloads are stripped by byte offset or the file is quarantined; everything can be restored | [Response and recovery](response-and-recovery.md) | | Native dialogs and notifications | Desktop notifications name the threat; a dialog shows the evidence and asks what to do | [Response and recovery](response-and-recovery.md) | | C2 firewall block | Outgoing traffic to the C2 servers is dropped system-wide and the block survives reboots | [Blocking C2 servers](network-block.md) | | Editor and npm hardening | Turns off automatic tasks and turns on workspace trust in every VS Code-family editor | [Hardening](hardening.md) | | GitHub clean-up | Removes the malware from every branch of every repository you can push to, without rewriting history | [Cleaning GitHub repositories](github-clean.md) | | Guided screens | The easy way to clean your GitHub repositories: sign in, tick repositories, check, review, fix. Opens from the Start menu on Windows and the Applications folder on macOS | [Guided screens](guided-screens.md) | | CI gate and pre-commit hook | Fails a pipeline or refuses a commit that carries an indicator | [CI and automation](ci-and-automation.md) | | Complete activity record | Every finding, action, decision, sweep and error is journaled and searchable | [Activity history](activity-history.md) | | Team reporting | Webhook alerts, a redacted feedback bundle, an opt-in daily digest and an opt-in central event upload | [Team reporting](team-reporting.md) | | Signed self-update | Indicators refresh daily; the program updates itself after verifying a signature, and rolls back if a release fails | [Updates](updates.md) | | Bounded footprint | Every store on disk has a size or age limit that is enforced daily | [Disk use and cleanup](disk-and-cleanup.md) | ## Design principles - **Per user, no administrator rights.** Installation, the background guard and every command run as your user. Only the optional firewall block needs elevated rights, and it asks once. - **Every destructive step is reversible.** A file is copied to quarantine before it is stripped or deleted. `pushwarden history` puts it back. - **Evidence first.** A file is acted on only with strong evidence: a literal campaign signature, a known key or hash, or a file whose content does not match its type. Heuristic findings are reported and never acted on automatically. - **Explained decisions.** Every finding carries a one-sentence reason for its severity, and every response carries a reason for what was done. - **Indicators are data.** All signatures live in one file, `iocs.json`, which is updated daily without a new release. - **Nothing leaves the machine unless you opt in.** Alerts by webhook, the daily digest and the central upload are all off until you set their URL. ## Supported platforms | Platform | Versions | Real-time backend | Service manager | |---|---|---|---| | Linux | x86-64 and ARM64, any distribution with a recent kernel | inotify | systemd user service | | macOS | 11 or later, Intel and Apple silicon | kqueue | LaunchAgent | | Windows | 10 and 11, x86-64 and ARM64 | ReadDirectoryChangesW | Scheduled Task | Where a native watcher is unavailable the guard falls back to polling. ## Project status PushWarden is in early development (version 0.x). Releases are frequent while detection is extended for new variants. Up to version 0.4 it was called ThreatScan; see [Moving from ThreatScan](moving-from-threatscan.md). It is a single Go program; the earlier Python versions (v4, v5) are archived on the `archive/python-v5` branch. ## Where to go next - [Installation](installation.md) to set it up. - [Quick start](quick-start.md) for the commands you will use most. - [Guided screens](guided-screens.md) to clean your GitHub repositories with ease. - [How it works](how-it-works.md) for the architecture. - [Command-line reference](../reference.md) for every option of every command. --- # Installation Source: https://faheemrafiq.github.io/pushwarden/guide/installation.html Coming from ThreatScan, the earlier name of this program? See [Moving from ThreatScan](moving-from-threatscan.md) first. PushWarden installs for the current user only and needs no administrator password. The download page at picks the right file for your system. ## Linux and macOS: one command Paste into a terminal. No `sudo` is needed. ```sh curl -fsSL https://raw.githubusercontent.com/FaheemRafiq/pushwarden/main/installers/install.sh | sh ``` The script downloads the release binary for your system, verifies its SHA-256 against the published `checksums.txt`, and runs `pushwarden install --unattended`. Options are passed as environment variables in front of `sh`: | Variable | Effect | |---|---| | `PUSHWARDEN_ROOTS="~/code ~/work"` | project directories to watch. Default: auto-discover the usual places under your home folder | | `PUSHWARDEN_WEBHOOK=URL` | send alerts from this machine to a Slack, Discord, Teams or custom webhook | | `PUSHWARDEN_FEEDBACK_URL=URL` | opt in to the daily digest of counts. See [Team reporting](team-reporting.md) | | `PUSHWARDEN_UPLOAD_URL=URL`, `PUSHWARDEN_UPLOAD_KEY=KEY` | opt in to the central event upload. See [Team reporting](team-reporting.md) | | `PUSHWARDEN_VERSION=v0.2.0-rc1` | install a specific release, for example a pre-release | | `PUSHWARDEN_NO_INSTALL=1` | only put the program in place; do not start the guard. Useful in CI | | `PUSHWARDEN_NO_BLOCK=1` | do not ask for administrator rights to block the C2 servers | | `PUSHWARDEN_BASE_URL=URL` | download the release assets from a mirror | Example: ```sh curl -fsSL https://raw.githubusercontent.com/FaheemRafiq/pushwarden/main/installers/install.sh | \ PUSHWARDEN_WEBHOOK=https://hooks.slack.com/services/T000/B000/XXXX PUSHWARDEN_ROOTS="~/code ~/work" sh ``` ## Windows Download and run [PushWarden-Setup.exe](https://github.com/FaheemRafiq/pushwarden/releases/latest/download/PushWarden-Setup.exe). The installer is not code-signed yet. If SmartScreen appears, choose *More info*, then *Run anyway*. ## Packages Every release also ships packages on the [latest release page](https://github.com/FaheemRafiq/pushwarden/releases/latest). | File | System | Notes | |---|---|---| | `PushWarden.pkg` | macOS | Not notarized yet. Open it once, then go to *System Settings, Privacy & Security* and click *Open Anyway*. Or run `installer -pkg ~/Downloads/PushWarden.pkg -target CurrentUserHomeDirectory` | | `pushwarden-linux-amd64.deb`, `pushwarden-linux-arm64.deb` | Debian, Ubuntu | Installed with `sudo`; sets PushWarden up for the user who ran `sudo` | | `pushwarden-linux-amd64.rpm`, `pushwarden-linux-arm64.rpm` | Fedora, RHEL, openSUSE | Same | | `pushwarden--` | any | The bare binary. Run `./pushwarden-... install` yourself | Every file is listed with its SHA-256 in `checksums.txt`, and `checksums.txt.sig` is its ed25519 signature. ## What the installer does 1. **Places the program.** The binary is copied to a per-user install directory (on Linux `~/.local/share/pushwarden`) and linked from `~/.local/bin`. If that folder is not on your `PATH`, one line marked `# added by pushwarden install` is appended to your shell start-up file. Open a new terminal afterwards. 2. **Hardens editors.** Every VS Code-family editor found gets `task.allowAutomaticTasks = off` and workspace trust turned on. See [Hardening](hardening.md). 3. **Registers the guard.** The background guard becomes a user service: a systemd `--user` unit on Linux, a LaunchAgent on macOS, a Scheduled Task on Windows. It starts at sign-in. 4. **Blocks the C2 servers.** This is the only step that needs administrator rights. The installer asks once through the native password dialog, polkit or UAC. Declining is fine; add it later with `pushwarden protect --install`. See [Blocking C2 servers](network-block.md). 5. **Runs a first full scan** in the background and cleans anything CRITICAL it finds. Originals go to quarantine. 6. **Adds a shortcut to the guided screens.** On Windows, **PushWarden** in the Start menu; on macOS, `PushWarden.app` in the `Applications` folder of your home folder. Both open [the guided screens](guided-screens.md) for cleaning GitHub repositories. On Linux, run `pushwarden ui`. 7. **Keeps itself up to date.** See [Updates](updates.md). ## Installing by hand: `pushwarden install` The one-liner and the packages call this command for you. Run it yourself to change the setup or after downloading the bare binary. ```sh pushwarden install [options] ``` | Option | Effect | |---|---| | `--roots DIR` | project directory to watch. Repeatable | | `--webhook URL` | URL that receives JSON alerts | | `--feedback-url URL` | opt in to the daily digest | | `--upload-url URL`, `--upload-key KEY` | opt in to the central event upload | | `--no-block-c2` | do not ask for administrator rights to block the C2 servers | | `--deep` | the guard also scans `node_modules` and `vendor` (slow) | | `--full-interval SECONDS` | seconds between full sweeps (default 21600) | | `--no-harden` | skip editor hardening | | `--npm-ignore-scripts` | also set `ignore-scripts=true` in `~/.npmrc` | | `--no-kill` | never kill processes automatically | | `--no-prompt` | never show dialogs; quarantine automatically | | `--no-clean` | when no dialog can be shown, leave files in place instead of quarantining | | `--unattended` | for installers: no questions, first scan in the background | | `--dry-run` | show what would be done and change nothing | ## Verify the installation ```sh pushwarden status ``` The output opens with a protection score out of 100 and a list of every layer of protection with `OK` or `!!`; a missing layer names the command that adds it. Below that it shows whether the guard is alive, which real-time backend it uses, the service state, the indicator version, the firewall state, disk use and the hardening state of each editor. ## Updating Nothing to do: the guard updates the program and the indicators on its own. To check or update by hand: ```sh pushwarden update --check pushwarden update pushwarden update-iocs ``` ## Uninstalling ```sh pushwarden uninstall # remove the background guard pushwarden uninstall --unblock # also remove the firewall block (asks for administrator rights) pushwarden uninstall --unblock --purge # also delete the data directory: settings, reports, quarantine ``` The macOS shortcut and notification helper are removed too; on Windows the Start menu entry goes when you uninstall under *Settings, Apps*. Editor hardening is left in place because it is a safe default. To remove the program itself, delete the install directory shown by `pushwarden status`. --- # Quick start Source: https://faheemrafiq.github.io/pushwarden/guide/quick-start.html Five commands cover most needs. Prefer to be guided? `pushwarden ui` walks you through cleaning your GitHub repositories on [guided screens](guided-screens.md). ```sh pushwarden install # background guard + editor hardening + first scan pushwarden status # is everything running? pushwarden scan --home # audit every project under your home folder now pushwarden github-clean # dry run: which of my GitHub repositories and branches are infected? pushwarden github-clean --apply # fix and push them ``` ## Check the protection ```sh pushwarden status ``` The top of the output is the verdict: a score out of 100 and one word, `PROTECTED`, `PARTLY PROTECTED`, `AT RISK` or `THREATS FOUND`. ``` ▄▄████████▄▄ PushWarden 0.5.2 ████████████████ PolinRider / Contagious Interview protection ███▀▀▀▀▀▀▀▀▀▀███ ███ █▀▀▀▀█ ███ PROTECTED ████████████████████████ 100/100 ███ █▄▄▄▄▀ ███ Files are checked as they are written, the malware's ███ █ ███ servers are blocked and editors cannot auto-run tasks. ███ ▀ ███ ▀███▄▄▄▄███▀ ● watching 18 repositories ▀▀██▀▀ PROTECTION OK Background guard alive, v0.5.2 OK Real-time file protection every file is checked as it is written (inotify) OK Starts when you sign in active !! Malware servers blocked not blocked fix: pushwarden protect --install ``` Under it, every layer of protection is listed with `OK` or `!!`, and each missing one names the command that adds it. The score says how much of the protection is switched on; it is not a guarantee that the machine is clean. The details follow: look for `Guard: alive`, a real-time backend other than `off`, and `Firewall: active (persistent ...)`. ## Scan now ```sh pushwarden scan # the current directory pushwarden scan ~/code ~/work # specific directories pushwarden scan --home # every project under your home folder, plus the host pushwarden scan --home --no-prompt # report only, change nothing pushwarden scan --home --fix # clean up without asking (reversible) ``` `pushwarden [dirs]` without a command is short for `pushwarden scan [dirs]`. See [Scanning](scanning.md). ## See what happened ```sh pushwarden alerts # recent alerts and why each one fired pushwarden history # each distinct finding once, then what was done pushwarden history --all --since 7d # everything this week, nothing collapsed ``` See [Activity history](activity-history.md). ## Undo an action ```sh pushwarden history --restore PATH # put the original back (it is still malicious) pushwarden history --allow PATH # restore and stop flagging this exact content for 30 days pushwarden history --remove PATH # delete the quarantined copies for good ``` See [Response and recovery](response-and-recovery.md). ## Clean GitHub ```sh pushwarden github-clean --list # which repositories can my token push to? pushwarden github-clean # dry run over all of them pushwarden github-clean --select --apply # pick from a list, then fix and push pushwarden ui # the same on guided screens: tick, check, review, fix ``` See [Cleaning GitHub repositories](github-clean.md) and [Guided screens](guided-screens.md). ## Change a setting ```sh pushwarden config # show all settings pushwarden config --set action=ask # ask before touching any file pushwarden config --set webhook_url=URL # send alerts to Slack, Discord or Teams ``` Restart the guard after a change. See [Configuration](configuration.md). ## Get help offline ```sh pushwarden help # list of commands pushwarden help scan # documentation for one command pushwarden help all # the whole reference ``` The help text is embedded in the program, so it always matches the installed version. ## Conventions that hold everywhere - Flags accept one or two dashes: `-fix` and `--fix` are the same. Flags may come before or after directories. - Exit codes: `0` clean or done, `1` threats found or not fully fixed, `2` usage error, nothing scanned or an internal failure. - Nothing needs administrator rights except `protect`. - Set `NO_COLOR=1` to disable coloured output. --- # Moving from ThreatScan Source: https://faheemrafiq.github.io/pushwarden/guide/moving-from-threatscan.html PushWarden was called ThreatScan up to version 0.4. The name changed in version 0.5.0 because "ThreatScan" is used by other security products. It is the same program with the same features and the same release signing key. ## What was renamed | | Before | Now | |---|---|---| | Command | `threatscan` | `pushwarden` | | Repository | `github.com/FaheemRafiq/threatscan` | `github.com/FaheemRafiq/pushwarden` | | Documentation | `faheemrafiq.github.io/threatscan` | `faheemrafiq.github.io/pushwarden` | | Data directory | `~/.threatscan` | `~/.pushwarden` (an existing `~/.threatscan` keeps being used, see below) | | Environment variables | `THREATSCAN_HOME`, `THREATSCAN_ROOTS`, ... | `PUSHWARDEN_HOME`, `PUSHWARDEN_ROOTS`, ... | | Background service | `threatscan-guard.service`, `com.threatscan.guard`, "ThreatScan Guard" | `pushwarden-guard.service`, `com.pushwarden.guard`, "PushWarden Guard" | | Downloads | `ThreatScan-Setup.exe`, `ThreatScan.pkg`, `threatscan-linux-amd64.deb` | `PushWarden-Setup.exe`, `PushWarden.pkg`, `pushwarden-linux-amd64.deb` | | Marker for files that hold signatures on purpose | `threatscan:allow-signatures` | `pushwarden:allow-signatures` | ## Moving an existing installation ThreatScan does not turn into PushWarden through its own update, because the download names changed. Do this once on each machine. 1. **Remove ThreatScan.** Your settings, history and quarantine are not deleted by this. ```sh threatscan uninstall --unblock ``` On Windows you can also remove it under *Settings, Apps*. `--unblock` removes the firewall block, which asks for administrator rights; PushWarden sets up its own in the next step. 2. **Install PushWarden** as described in [Installation](installation.md). 3. **Check it.** ```sh pushwarden status pushwarden history ``` `status` should show the guard alive, and `history` should show what ThreatScan recorded. ## Your data is kept PushWarden looks for its data directory in this order: 1. the folder named by `PUSHWARDEN_HOME`, if that variable is set; 2. `~/.pushwarden`, if it exists; 3. `~/.threatscan`, if it exists; 4. otherwise a new `~/.pushwarden`. So a machine that had ThreatScan keeps its settings, protection history, quarantine, allow decisions and github-clean progress without any step from you. New installations get `~/.pushwarden`. To switch an existing machine to the new folder name, do it while the guard is stopped: ```sh pushwarden uninstall mv ~/.threatscan ~/.pushwarden pushwarden install ``` ## What you need to change yourself - **Environment variables.** The old `THREATSCAN_*` names are not read. Rename them in CI pipelines, scripts and shell start-up files, for example `THREATSCAN_HOME` to `PUSHWARDEN_HOME`. The full list is in [Configuration](configuration.md#environment-variables). - **The install one-liner and workflow references.** Use `FaheemRafiq/pushwarden` in the install command and in any `uses:` line that calls the reusable workflow. See [CI and automation](ci-and-automation.md). - **The allow marker.** A file in your own repositories that carries `threatscan:allow-signatures` needs `pushwarden:allow-signatures` instead, or PushWarden flags it. - **Commands in scripts and hooks.** Replace `threatscan` with `pushwarden`. A pre-commit hook installed by ThreatScan calls the old command: run `pushwarden harden --pre-commit .` in that repository to install it again. Nothing changes for the central event upload: the upload address is a setting and stays as you configured it. Only the example table name in the documentation is now `pushwarden_events`. --- # How it works Source: https://faheemrafiq.github.io/pushwarden/guide/how-it-works.html PushWarden is one program. Every feature is a subcommand, and the background protection is the same program running the `guard` command as a user service. ## From file write to decision ``` file written --> real-time watcher --> scan for evidence --> quarantine --> notification | v native dialog: [Remove] [Restore and allow] | v pushwarden history (restore, allow, remove) ``` 1. A file is written inside a watched folder. 2. The operating system's file watcher reports it to the guard within a fraction of a second. 3. The scanner looks for indicators in that file. 4. With strong evidence, the configured `action` policy applies. By default the file is moved to quarantine immediately. 5. A desktop notification names the threat. A dialog shows the evidence and asks whether to remove the file for good or restore and allow it. 6. The action is recorded in the journal and the protection history. ## Four protection layers The guard runs four layers. Each has its own schedule and configuration key. | Layer | Work | Schedule | Key | |---|---|---|---| | Real-time | Native file-system watcher over the project folders, `~/Downloads` and `~/Desktop`. Every written script, config, font, image or `.vscode/*.json` file is scanned as it lands | under 1 second | `realtime` | | Quick | Process command lines and network connections to C2 addresses | every 5 seconds | `quick_interval` | | Full | Every repository (all script files, not only known config names) plus host persistence, remote access tools, editor injection and exposed credentials | every 6 hours | `full_interval` | | Definitions | Download and validate the indicator file `iocs.json` | every 24 hours | `ioc_update_interval` | Around these, the guard also runs periodic tasks: the self-update check (every 6 hours), a reminder when the firewall block is missing (daily), the opt-in daily digest and central upload, and the daily [disk housekeeping](disk-and-cleanup.md). ## Components | Component | Role | |---|---| | Indicators (`iocs.json`) | All signatures, keys, hashes, C2 addresses and package versions. Data only, validated before use, embedded in the program as a fallback | | Scanner | Repository checks and host checks. Produces findings with a severity, a category, evidence and a reason | | Protector | Kills processes, strips payloads, quarantines files, removes persistence entries. Always copies to quarantine first | | Guard | The background loop and the response policy | | Real-time watcher | inotify on Linux, kqueue on macOS, ReadDirectoryChangesW on Windows, polling as a fallback | | Prompt and notify | Native dialogs, desktop notifications, the alerts log and the webhook | | Journal | The complete activity record on disk | | Network block | Firewall rules and hosts-file sinkhole, kept by a small privileged job | | Remediator | `github-clean`: clone, check every branch, fix, commit, push | | Updater | Signed program updates with rollback, and indicator updates | ## What counts as strong evidence Automatic action is taken only on CRITICAL findings backed by strong evidence: - a literal campaign signature or marker string - a known payload XOR key or dead-drop wallet address - a known loader hash - a font, image or dictionary file whose bytes are actually code - an editor task that runs on folder open together with a loader - a known propagation script Heuristic findings, such as an unusually large config file or a backdated commit, are HIGH or WARNING. They are reported and never acted on automatically. See [What it detects](detection.md). ## Response policy What the guard does with a CRITICAL file is set by `action`: | `action` | Behaviour | |---|---| | `quarantine` (default) | The file is quarantined immediately, then a dialog offers Remove or Restore and allow. No answer within 3 minutes: it stays in quarantine | | `ask` | The dialog comes first and nothing is touched until you answer. No answer: quarantine, if `auto_clean` is on | | `delete` | Remove without asking | | `report` | Notify only, never touch files | Processes are different: a running payload cannot wait for a dialog. A process whose command line carries a strict campaign marker, or a connection to a C2 address, is killed at once when `auto_kill` is on. See [Response and recovery](response-and-recovery.md). ## Where everything is stored All state lives in one data directory, `~/.pushwarden` (override with `PUSHWARDEN_HOME`; an existing `~/.threatscan` from the earlier name is used when it is the only one): settings, indicators, the journal, logs, reports, quarantine and small state files. Every store has a size or age limit. See [Disk use and cleanup](disk-and-cleanup.md) and the file table in [Configuration](configuration.md). The firewall block is the one exception. It runs as root, so it keeps its own root-owned copy of the program and of the indicators in a system location, and never trusts files under your home folder. See [Blocking C2 servers](network-block.md). ## Privileges | Part | Runs as | |---|---| | Every command, the guard, real-time protection, quarantine, updates | your user | | The boot-time job that re-applies the firewall block | root or SYSTEM, installed once with your consent | ## Network use | Connection | When | Can be turned off | |---|---|---| | Indicator download from the project repository | daily | `ioc_update=false` | | Release check and download from GitHub | every 6 hours | `auto_update=false` | | GitHub API and git over HTTPS | only during `github-clean` | not used unless you run it | | Webhook, daily digest, central upload | only when you set their URL | off by default | Protection itself needs no network. See [Security and privacy](security-and-privacy.md). --- # What it detects Source: https://faheemrafiq.github.io/pushwarden/guide/detection.html PushWarden looks for the artefacts and behaviour of the PolinRider / Contagious Interview campaign in two places: your repositories and the computer itself. Every finding has a severity, a category, a threat name, the evidence and a one-sentence reason. ## Severities | Severity | Meaning | Automatic action | |---|---|---| | CRITICAL | A confirmed PolinRider artefact or activity | Yes, according to the `action` policy | | HIGH | A strong indicator that needs a human decision, for example a compromised package version or exposed keys on an infected host | No, listed for review | | WARNING | Context worth checking, for example a suspicious git reflog, a secrets file in an infected repository, or files that arrived in the same commit as a loader | No | | INFO | Informational | No | Scan commands exit with code `1` when anything HIGH or CRITICAL is found. ## In repositories Every script, config, font, image and `.vscode/*.json` file in a project is checked, not only files with well-known names. `node_modules` and `vendor` are skipped unless `--deep` or `deep=true` is set. | What | Examples | Severity | |---|---|---| | Campaign signatures and build tags in any script file | Loader marker strings, obfuscator fingerprints and build tags found in `.js`, `.ts`, `.json`, `.py`, `.sh`, `.bat`, `.ps1` and similar files | CRITICAL | | Payload injected into a config file | Code appended, prepended or inserted into `postcss.config.mjs`, `eslint.config.mjs`, `next.config.js`, `tailwind.config.js` and other framework configs | CRITICAL | | Entry-file hooks | A loader call added to an application entry file | CRITICAL | | Known keys and wallets | The campaign's XOR payload keys; TRON, Aptos and Ethereum wallet addresses used as dead drops; blockchain RPC hosts next to a marker | CRITICAL | | C2 references | Known C2 IP addresses, first-stage hosts, C2 URL paths and the payload header | CRITICAL | | Disguised payloads | A font, image or dictionary file whose bytes are code, with or without whitespace padding; files matching known loader hashes | CRITICAL | | Editor autorun | `.vscode/tasks.json` with `runOn: folderOpen` together with a loader or C2 host; settings that force automatic tasks on | CRITICAL or HIGH | | Propagation scripts | The batch scripts the malware uses to commit and force-push itself, and the helper files they create | CRITICAL or HIGH | | `.gitignore` tampering | Entries that hide the malware's files, or the `.gitignore` hiding itself | CRITICAL or HIGH | | Git hooks and git config | Malicious hooks; `core.fsmonitor` set to run node | CRITICAL | | Compromised packages | Known bad versions of npm packages, Go modules and a Packagist package, including typosquats; suspicious lifecycle scripts | CRITICAL or HIGH | | Git history | Backdated commits, force-push traces in the reflog, commits that touch payload code on any branch | HIGH or WARNING | | Camouflage | Files that arrived in the same commit as a loader: decoy fonts, a decoy README, a `.vscode` set | WARNING | ## On the host Host checks run in `scan` (unless `--no-system`) and in the guard's full sweep. Process and network checks also run every 5 seconds. | What | Examples | Severity | |---|---|---| | Malicious processes | A running process whose command line carries a campaign marker, or a known payload process name | CRITICAL | | C2 connections | An open socket to a known C2 address | CRITICAL | | Remote access tool footprint | The directories, files and environment variables the campaign's remote access tools leave behind; portable Node or Python runtimes dropped by a later stage | CRITICAL or HIGH | | Persistence | Entries in cron, systemd user units, XDG autostart, LaunchAgents, Windows scheduled tasks, the Startup folder and the `HKCU\...\Run` registry key | CRITICAL or HIGH | | Shell start-up injection | Lines added to shell rc files or the PowerShell profile | HIGH | | Editor and tool injection | Modified files inside VS Code, Cursor, VSCodium, Windsurf, Discord or GitHub Desktop; a backdoored or oversized global `npm/lib/cli.js` | CRITICAL or HIGH | | Hosts-file redirects | Package registries redirected in the hosts file | HIGH | | Exposed credentials on an infected host | Plain-text tokens in `.npmrc`, `.git-credentials`, the `gh`, AWS and Docker configs, and SSH keys. Reported only when the host shows other signs of infection | HIGH | ## Threat names Notifications, dialogs, reports and the history name threats in the style of Windows Defender, so an alert is recognisable at a glance. | Threat name | What it is | |---|---| | `Trojan:JS/PolinRider.ConfigInject` | Loader code injected into a framework config file, or a known XOR key | | `Trojan:JS/PolinRider.EntryHook` | Loader call added to an application entry file | | `Trojan:JS/PolinRider.FakeFont` | A script disguised as a font file | | `Trojan:JS/PolinRider.Disguised` | Code hidden in another non-code file type | | `Trojan:Script/PolinRider.TaskJacker` | An editor task that runs the loader when a folder is opened | | `Trojan:BAT/PolinRider.AutoPush` | The propagation script that commits and force-pushes the backdoor | | `Trojan:JS/PolinRider.DeadDrop` | A blockchain wallet or RPC reference used to fetch the payload location | | `Trojan:JS/PolinRider.C2` | A reference to a command-and-control server | | `Trojan:JS/OmniStealer.Exfil` | Credential exfiltration through a messaging API | | `Trojan:Script/PolinRider.GitHook` | A malicious git hook | | `Trojan:JS/PolinRider.Package` | A compromised package version | | `Trojan:JS/PolinRider.EditorInject` | Code injected into an editor or desktop tool | | `Trojan:Script/PolinRider.Hide` | `.gitignore` entries that hide the malware | | `Behavior:Node/PolinRider.Payload` | A running payload process | | `Behavior:Net/PolinRider.C2` | A live connection to a C2 server | | `Backdoor:JS/RuntimeDevLink` | The remote access tool and its runtime | | `Backdoor:Script/PolinRider.Persist` | A persistence entry | | `Trojan:Script/PolinRider` | Any other injected loader | Windows Defender itself names the family `Trojan:JS/PolinRider.DB!MTB`. ## Reasons on every finding Every finding has a `why` line: one sentence that says what the evidence means and why it has that severity. Every response has a `response` line: what was done and why (killed, stripped, quarantined, or left for you to decide). These lines appear in scan output, dialogs, `pushwarden alerts`, `pushwarden history`, desktop notifications, the webhook JSON (`reasons` array) and `guard.log`. ## Indicators are data All indicators live in one file, `iocs.json`: signature strings and regular expressions, XOR keys, wallet addresses, C2 IPs and hostnames, URL paths, loader hashes, process markers and compromised package versions. - The program carries an embedded copy, so it works offline from the first run. - The guard downloads a newer copy from the project repository every 24 hours. The download is parsed and validated before it replaces the previous copy: every regular expression must compile, every IP and hash must be well formed, and the version must be newer. - `pushwarden update-iocs` forces a download. `--url` and the `ioc_update_url` setting point it at a mirror or a fork. - New variants are usually covered by adding an indicator, with no new release. The current list is in [`pushwarden/iocs.json`](https://github.com/FaheemRafiq/pushwarden/blob/main/pushwarden/iocs.json) in the repository. ## Kill list versus report list A process is killed only when its command line holds a strict marker that legitimate tools never use. A broad indicator alone is reported but not killed, and the reason says so. This is what keeps ordinary `node` processes safe. ## Files that legitimately contain signatures Rule sets, tests and indicator databases contain the same strings the scanner looks for. Such a file opts out by carrying the token `pushwarden:allow-signatures` in its first 512 bytes. The token is never honoured for config files, entry files, asset files or JavaScript and TypeScript files, so malware cannot use it to hide. ## False positives If a legitimate file is flagged: ```sh pushwarden history --allow PATH --note "what the file really is" ``` This restores the file, stops flagging that exact content for 30 days, and records the note as feedback. Use `exclude` in the [configuration](configuration.md) to skip a directory entirely. See [Response and recovery](response-and-recovery.md). --- # Scanning Source: https://faheemrafiq.github.io/pushwarden/guide/scanning.html `pushwarden scan` is a one-off scan of repositories and of the computer. The background guard does the same work on a schedule; this command runs it now and shows the result. ```sh pushwarden scan [options] [directories] ``` `pushwarden [directories]` without a command means the same. ## What is scanned With no directories, the current working directory is scanned. 1. **Repositories.** Each directory is walked for git repositories and for projects that have a `package.json`, `go.mod` or `composer.json`. Every script, config, font, image and `.vscode/*.json` file in them is checked. 2. **The host.** Running processes, network connections to known C2 addresses, persistence (systemd units, LaunchAgents, scheduled tasks, cron, shell rc files), remote access tool footprints, editor injection and exposed credentials. In a terminal a progress bar shows which repository is being scanned. In CI or a pipe, numbered progress lines are printed instead. ## Options | Option | Effect | |---|---| | `--home` | also scan the common project directories under your home folder, or `scan_roots` from the configuration | | `--deep` | also descend into `node_modules` and `vendor` (slow) | | `--configs-only` | only check the known config and entry-file names (the scope of the old v4 release) | | `--no-system` | skip the host checks | | `--no-repos` | skip the repository checks | | `--verbose` | list every repository, including clean ones | | `--fix` | act on CRITICAL findings without asking. Reversible | | `--dry-run` | show what `--fix` would do and change nothing | | `--gui` | ask about each malicious file with a native dialog instead of in the terminal | | `--no-prompt` | report only. Never ask, never change a file | | `--notify` | send a desktop notification and, if configured, a webhook when anything HIGH or above is found | | `--json FILE` | also write the report as JSON to `FILE` | | `--no-report` | do not save the report under the data directory | | `--ci` | CI mode: no colour, compact output, never prompt | ## What happens when something is found | How you ran it | Behaviour for a CRITICAL finding with strong evidence | |---|---| | In an interactive terminal | One question per file: remove, or keep and allow | | `--gui` | The same question in a native dialog | | `--fix` | The reversible action is taken without asking | | `--no-prompt` or `--ci` | Nothing is touched; the finding is reported | | `--dry-run` | The action that would be taken is shown | What `--fix` does: - strips an appended payload from a config or entry file and keeps the legitimate code - quarantines whole-file threats such as fake fonts, malicious editor tasks and propagation scripts - removes PolinRider entries from `.gitignore` - kills malicious processes - disables and quarantines persistence entries Every changed or removed file is copied to quarantine first. See [Response and recovery](response-and-recovery.md). Findings that PushWarden cannot fix on its own, such as a compromised package version in a lockfile, are printed with a remediation hint. ## Exit codes | Code | Meaning | |---|---| | `0` | clean | | `1` | HIGH or CRITICAL findings | | `2` | error, or nothing was scanned | ## Reports Each scan saves a JSON report under `reports/` in the data directory, plus `latest.json`. The newest 60 are kept (`report_keep`). `--json FILE` writes the same report to a file of your choice, and `--no-report` skips saving. The report contains the version, the indicator version, statistics (repositories scanned and infected, files checked, counts per severity, duration) and every finding with its severity, category, title, path, details, remediation hint, action taken and evidence. `pushwarden status` shows a summary of the latest report. ## Examples ```sh pushwarden scan # this directory pushwarden scan ~/code ~/work # specific directories pushwarden scan --home --no-system # repositories only pushwarden scan --home --fix # clean up without questions pushwarden scan --home --no-prompt --verbose --json audit.json # audit without touching anything pushwarden scan --ci --json report.json # in a pipeline; exit 1 fails the job pushwarden --deep ~/proj # same as: pushwarden scan --deep ~/proj ``` ## Scanning staged files before a commit `pushwarden check-staged` scans only the files staged for commit and exits `1` if any carries an indicator at HIGH or above, which blocks the commit. ```sh pushwarden harden --pre-commit . # install it as a pre-commit hook in this repository ``` Or add it to `.git/hooks/pre-commit` yourself: ```sh #!/bin/sh pushwarden check-staged || exit 1 ``` See [CI and automation](ci-and-automation.md). ## Excluding directories Set `exclude` to a comma-separated list of directories that must never be scanned: ```sh pushwarden config --set exclude=~/code/malware-samples,~/archive ``` ## Every scan is recorded A scan writes its start, its end with duration and counts, every finding and every action to the journal. See [Activity history](activity-history.md). --- # Real-time guard Source: https://faheemrafiq.github.io/pushwarden/guide/real-time-guard.html The guard is the background process that protects the machine continuously. `pushwarden install` registers it as a user service that starts at sign-in. ## What it does | Layer | Work | Latency or schedule | |---|---|---| | Real-time | Watches the project folders, `~/Downloads` and `~/Desktop`. Every written script, config, font, image or `.vscode/*.json` file is scanned as it lands | under 1 second; idle otherwise | | Behaviour | Process command lines and sockets to C2 addresses | every 5 seconds; a few milliseconds of work | | Scheduled sweep | Every repository (all script files) plus host persistence, remote access tools, editor injection and credentials | every 6 hours; seconds to a minute | | Definitions | `iocs.json` downloaded and validated | every 24 hours; one HTTPS request | The real-time watcher uses the operating system's own mechanism: inotify on Linux, kqueue on macOS, ReadDirectoryChangesW on Windows. If none is available it falls back to polling, and the 5-second and 6-hour layers keep working either way. ## Which folders are watched By default the guard discovers the usual project folders under your home directory. To set them explicitly: ```sh pushwarden config --set scan_roots=~/code,~/work ``` `~/Downloads` and `~/Desktop` are always watched, because that is where a cloned or unpacked interview project usually lands. ## What happens on a detection 1. With strong evidence the `action` policy applies. The default, `quarantine`, moves the file to quarantine at once. 2. A desktop notification names the threat, for example *Threats found: Trojan:JS/PolinRider.FakeFont*. 3. A native dialog shows the file, the evidence and the reason, with two choices: **Remove** (permanent) or **Restore and allow**. 4. With no answer within 3 minutes (`prompt_timeout`), the file stays in quarantine. 5. The alert is written to `alerts.log` and sent to the webhook if one is configured. 6. Everything is recorded in the journal. Clicking a notification opens the recent alerts (`pushwarden alerts --gui`). Other responses do not wait for a dialog: | Finding | Response | |---|---| | A process whose command line carries a strict campaign marker, or a socket to a C2 address | Killed at once (`auto_kill`). A running payload cannot wait | | A config or entry file with an appended payload | The payload is stripped by byte offset and the legitimate code kept. Prepended or mid-file injections quarantine the whole file instead of guessing | | A remote access tool's service, LaunchAgent, scheduled task, cron line or directory | Disabled and quarantined; the crontab is backed up first | | HIGH and WARNING findings | Alert only, never acted on automatically | See [Response and recovery](response-and-recovery.md) for the full policy. ## Notifications | Setting | Default | Effect | |---|---|---| | `notify_desktop` | `true` | desktop notifications (notify-send, Notification Center, Windows toast) | | `notify_min_severity` | `HIGH` | lowest severity that notifies | | `notify_sweeps` | `true` | a notification when a background full sweep starts and when it finishes, with the result | | `webhook_url` | empty | receives a JSON POST per alert. See [Team reporting](team-reporting.md) | | `webhook_min_severity` | `HIGH` | lowest severity that posts | The same alert about the same finding is not repeated for 6 hours. ## Checking that it runs ```sh pushwarden status ``` `status` opens with a protection score and a checklist of the layers (guard, real-time protection, start at sign-in, firewall block, editor hardening, indicators, sweeps), then shows: - whether the guard is alive, and its real-time backend. During a sweep the line reads `scanning 7/17 repositories (name)` and updates as it goes - the service state and the installed binary - the time of the last full sweep and how many repositories are tracked - the indicator version - the action policy, webhook, central upload and firewall state - the data directory and its disk use - a summary of the latest report - each VS Code-family editor with its `task.allowAutomaticTasks` setting The guard writes a heartbeat file every few seconds; `status` reads it, so the answer does not depend on the service manager. ## Running the guard by hand ```sh pushwarden guard [--once] [--dry-run] [--verbose] ``` | Option | Effect | |---|---| | `--once` | one full pass, then exit | | `--dry-run` | detect and alert but never kill or quarantine | | `--verbose` | print the log to the terminal as well as to `guard.log` | Use this to debug, or on a machine without a service manager. ## Managing the service | System | Commands | |---|---| | Linux | `systemctl --user status pushwarden-guard.service`, `systemctl --user restart pushwarden-guard.service` | | macOS | `launchctl list com.pushwarden.guard` | | Windows | Task Scheduler, task "PushWarden Guard" | The guard reads its configuration when it starts. Restart it after `pushwarden config --set`. ## Tuning | Setting | Default | Effect | |---|---|---| | `realtime` | `true` | real-time file watcher on | | `quick_interval` | `5` | seconds between process and network checks | | `full_interval` | `21600` | seconds between full sweeps | | `deep` | `false` | also scan `node_modules` and `vendor` | | `exclude` | empty | directories never scanned | | `auto_kill` | `true` | kill processes that match a kill-list indicator | | `prompt` | `true` | show native dialogs | | `prompt_timeout` | `180` | seconds a dialog waits before the default applies | ## The guard log `guard.log` in the data directory holds one text line per event: starts, sweeps, findings, responses, updates and errors. It rotates at 5 MB and the newest 5 archives are kept. The structured record of the same events is the journal; see [Activity history](activity-history.md). ## Optional: kernel-level blocking on Linux with Falco For Linux workstations and CI runners, the repository ships a [Falco](https://falco.org) rule set in `falco/`. Tier 1 rules terminate known payload patterns at the system-call level; tier 2 rules alert on suspicious activity such as automated force-pushes or node spawning shells. ```sh sudo bash falco/install-falco.sh # needs Falco and jq installed ``` This is an add-on. The guard does not depend on it. --- # Response and recovery Source: https://faheemrafiq.github.io/pushwarden/guide/response-and-recovery.html PushWarden acts on confirmed threats and keeps every action reversible. This page describes each response, the quarantine, and what to do after an infection. ## The action policy The `action` setting decides what happens to a CRITICAL file with strong evidence. | `action` | Behaviour | |---|---| | `quarantine` (default) | The file is moved to quarantine first, then a dialog offers **Remove** for good or **Restore and allow** | | `ask` | The dialog comes first and nothing is touched until you answer. On timeout the file is quarantined if `auto_clean` is on | | `delete` | Remove without asking. Stripped files keep a backup in quarantine; deleted files do not | | `report` | Alert only, never touch files. Processes are still killed if `auto_kill` is on | ```sh pushwarden config --set action=ask ``` Related settings: | Setting | Default | Effect | |---|---|---| | `auto_kill` | `true` | kill processes that match a kill-list indicator | | `auto_clean` | `true` | with `action=ask`, quarantine when nobody answers the dialog | | `prompt` | `true` | show native dialogs | | `prompt_timeout` | `180` | seconds a dialog waits before the default applies | ## Responses by finding | Finding | Response | |---|---| | Process with a strict campaign marker, or a socket to a C2 address | **Killed** at once. No dialog | | Config or entry file with a payload appended after the legitimate code | **Stripped**: the payload is cut off at its byte offset and the legitimate export is kept. The original goes to quarantine | | Config file with a payload prepended or inserted mid-file | **Quarantined whole**. PushWarden does not guess where legitimate code ends | | Fake font, disguised payload, malicious editor task file, propagation script | **Quarantined** | | `.gitignore` entries that hide the malware | The listed **lines are removed**, the rest of the file is kept | | Remote access tool service, LaunchAgent, scheduled task, cron line or directory | **Disabled and quarantined**. The crontab is backed up first | | HIGH and WARNING findings | **Reported only** | ## The dialog A dialog shows the threat name, the file, the reason, and the exact indicators found (the matched string, its offset, whitespace padding, the C2 host). | Answer | Effect | |---|---| | Remove | The quarantined copy is deleted. A record stays in the history | | Restore and allow | The exact file is put back and that content is not flagged again for 30 days | | No answer | The file stays in quarantine | In a terminal scan the same question is asked as text. `--gui` forces the dialog, `--no-prompt` suppresses questions. ## Quarantine Quarantine is a folder in the data directory, `~/.pushwarden/quarantine`. Before any file is stripped or deleted, a copy of the original is placed there, and the action is written to `index.jsonl`, the protection history. - A stripped file stays in place with the payload removed; the untouched original is in quarantine. - A quarantined file is moved out of the project entirely. - Copies are kept for 90 days and up to 500 MB in total, oldest deleted first (`quarantine_keep_days`, `quarantine_keep_mb`). An expired copy can no longer be restored, and the history shows the entry as expired. Set a limit to `0` to keep copies until you remove them yourself. ## Restoring, allowing and removing ```sh pushwarden history # what was quarantined, stripped or removed, with threat names pushwarden history --restore PATH # put the original back pushwarden history --allow PATH # restore and stop flagging this exact content for 30 days pushwarden history --allow PATH --note "our own icon font" # also record why it was a false positive pushwarden history --remove PATH # delete the quarantined copies for good pushwarden restore PATH # same as history --restore PATH ``` `PATH` is the original location as shown in the list. | Command | When to use it | |---|---| | `--restore` | You need the original for analysis. It is still malicious and will be flagged again | | `--allow` | The finding was wrong. The decision is tied to the file's content hash, so a changed file is checked again | | `--remove` | You are sure and want the copy gone | Allowed files are stored in `decisions.json` and expire after 30 days. ## Alerts ```sh pushwarden alerts [--last N] [--gui] [--json] ``` Shows recent alerts: when, the severity and threat name, the file or process, a `why:` line and a `response:` line. This is what a click on a desktop notification opens. Alerts are stored in `alerts.log`. ## Reporting a false positive ```sh pushwarden feedback --false-positive PATH --note "what it really is" ``` This records that the finding was wrong without changing the file. The note is included in the feedback bundle and, if enabled, the daily digest and central upload, so the indicator can be tightened. See [Team reporting](team-reporting.md). ## If an infection is found PushWarden removes the malware. It cannot undo the theft of credentials that already happened. Treat every secret on the machine as leaked. 1. **Do not `git pull`.** The remote may re-infect you. Clean the remote first with [`pushwarden github-clean`](github-clean.md) or the GitHub web editor. 2. **Confirm processes and persistence are gone.** The guard has already handled the CRITICAL ones. Check `pushwarden status` and `pushwarden history`. 3. **Rotate everything.** GitHub password and personal access tokens, SSH keys, OAuth apps, the npm token, cloud tokens (Vercel, Netlify, AWS), every value in `.env` files, browser sessions, and the password-manager vault if it was unlocked. 4. **Audit every repository you can push to** for force-pushes and unverified commits. Check *Settings, Applications* and *Deploy keys* on GitHub for anything you did not add. 5. **Reinstall Node and npm** from nodejs.org if `npm/lib/cli.js` was touched. 6. **Tell collaborators** to scan their machines and to `git pull` only after the remote is clean. 7. **Report it** at . The full checklist is in [`analysis/remediation-checklist.md`](https://github.com/FaheemRafiq/pushwarden/blob/main/analysis/remediation-checklist.md). --- # Blocking C2 servers Source: https://faheemrafiq.github.io/pushwarden/guide/network-block.html `pushwarden protect` blocks the PolinRider command-and-control (C2) servers for the whole computer and keeps them blocked. Even if a loader runs, it cannot fetch its payload or send stolen data to the known servers. This is the only part of PushWarden that needs administrator rights, because it edits the firewall and the hosts file. ```sh pushwarden protect [--install | --refresh | --uninstall | --status | --block-c2 | --unblock] [--dry-run] ``` `pushwarden install` does this for you. It asks once for administrator rights (the native password dialog on macOS, polkit or sudo on Linux, UAC on Windows). You only need `protect` directly to check on the block, add it later or remove it. ## Options | Option | Effect | |---|---| | `--install` (default) | block now and register the boot-time job. Asks for administrator rights when run as a normal user | | `--status` | is the block active, and will it survive a reboot? No rights needed | | `--refresh` | what the job runs: re-apply the rules from the root-owned indicators, then fetch newer ones and re-apply | | `--uninstall` | remove the job, the firewall rules and the hosts entries | | `--block-c2` | one-shot block for this boot only, with no job | | `--unblock` | remove the rules and hosts entries but keep the job, if any | | `--dry-run` | show what `--install` would do | ## What gets installed | Part | Linux | macOS | Windows | |---|---|---|---| | Firewall rule dropping outgoing traffic to every C2 IP | an `iptables` chain or `nftables` table named `pushwarden` | a `pf` anchor | a Windows Firewall rule named "PushWarden C2 block" | | Hostname sinkhole | hosts-file entries between `# BEGIN PUSHWARDEN C2 SINKHOLE` and `# END` markers | same | same | | Job that re-applies the block at boot and refreshes the list daily | systemd timer `pushwarden-netblock.timer` | LaunchDaemon `com.pushwarden.netblock` | Scheduled Task "PushWarden NetBlock", running as SYSTEM | ## Why it keeps its own copy of the indicators The job runs as root. The `iocs.json` under your home folder is writable by anything running as you, including malware. A privileged job must not let a user-writable file decide what goes into the hosts file. So the job keeps: - a root-owned copy of the indicators, refreshed from the project repository only - a root-owned copy of the program | System | Indicators and state | Program | |---|---|---| | Linux | `/etc/pushwarden` | `/usr/local/lib/pushwarden` | | macOS | `/Library/Application Support/PushWarden` | `/usr/local/libexec/pushwarden` | | Windows | `%ProgramData%\PushWarden` | `%ProgramFiles%\PushWarden` | ## Status values Shown by `pushwarden status` and `pushwarden protect --status`. | Firewall | Meaning | |---|---| | `active (persistent; 25 IPs, 15 hosts; applied 3h ago; indicators ...)` | all good | | `active until reboot (...)` | a one-shot block from `--block-c2`; run `protect --install` to keep it | | `not active (rules from a previous boot were lost)` | the same, after a reboot | | `not active` | never installed, or removed | ## Opting out ```sh pushwarden install --no-block-c2 # at install time pushwarden config --set block_c2=false # later; the guard stops reminding you ``` While `block_c2` is on and the block is missing, the guard reminds you once a day. Setting `PUSHWARDEN_NO_BLOCK=1` skips the administrator prompt for one run. The package installers use it because they already did the work as root. ## Adding it later ```sh sudo pushwarden protect --install # Linux and macOS pushwarden protect --install # Windows, from an elevated terminal pushwarden protect --status ``` ## Removing it ```sh pushwarden protect --uninstall pushwarden uninstall --unblock # together with the guard ``` ## Limits The block covers the addresses on the indicator list. A new C2 server is blocked once it is added to `iocs.json`, which the job picks up within a day. It is a second line of defence, not a replacement for removing the malware. --- # Hardening Source: https://faheemrafiq.github.io/pushwarden/guide/hardening.html Hardening closes the door the malware uses to start. PolinRider's first stage usually runs through a VS Code task that executes when a folder is opened, or through a package lifecycle script. Both can be turned off. `pushwarden install` applies the editor hardening automatically. Run `pushwarden harden` to apply it without installing the guard, or to add the opt-in settings. ```sh pushwarden harden [options] ``` | Option | Effect | |---|---| | (none) | harden every VS Code-family editor found | | `--npm-ignore-scripts` | set `ignore-scripts=true` in `~/.npmrc`, so installs never run lifecycle scripts | | `--undo-npm` | remove that setting again | | `--pre-commit REPO` | install a pre-commit hook in `REPO` that runs `pushwarden check-staged`. Repeatable | | `--dry-run` | show what would change | ## Editor settings These four settings are written into each editor's user `settings.json`: ```json "task.allowAutomaticTasks": "off", "security.workspace.trust.enabled": true, "security.workspace.trust.startupPrompt": "always", "security.workspace.trust.untrustedFiles": "prompt" ``` | Setting | Why | |---|---| | `task.allowAutomaticTasks: off` | A task with `runOn: folderOpen` no longer starts by itself. This is the campaign's main entry point | | Workspace trust on, prompt always | A newly opened folder starts in restricted mode until you trust it | Editors covered: VS Code, VS Code Insiders, VSCodium, Cursor, Windsurf and Positron, on all three platforms. The file is edited in place: comments, trailing commas and your other settings are preserved. A backup of the previous file is written next to it as `settings.json.pushwarden-.bak`; the newest two backups are kept. `pushwarden status` lists each editor with its current `task.allowAutomaticTasks` value. The guard also reports a project that tries to force automatic tasks back on. ## npm lifecycle scripts ```sh pushwarden harden --npm-ignore-scripts ``` This sets `ignore-scripts=true` in `~/.npmrc`. Package `preinstall`, `install` and `postinstall` scripts then never run during `npm install`, which blocks compromised packages from executing code at install time. Some packages need their install script to build native code. For those, run the script deliberately, or undo the setting: ```sh pushwarden harden --undo-npm ``` pnpm version 10 and later already ignores lifecycle scripts by default. ## Pre-commit hook ```sh pushwarden harden --pre-commit ~/code/repo1 for r in ~/code/*/; do pushwarden harden --pre-commit "$r"; done ``` The hook runs `pushwarden check-staged`, which scans the files staged for commit and refuses the commit if any carries a PolinRider indicator at HIGH or above. This stops an infected machine from committing the malware into a clean repository. An existing hook is kept as `pre-commit.pre-pushwarden`. ## Hardening is kept on uninstall `pushwarden uninstall` leaves the editor settings in place, because they are a safe default for any developer machine. --- # Cleaning GitHub repositories Source: https://faheemrafiq.github.io/pushwarden/guide/github-clean.html PolinRider steals a token, rewrites your repositories and force-pushes its backdoor to every branch it can reach. `pushwarden github-clean` undoes that at the same scale: it removes the malware from every branch of every repository your token can push to. ```sh pushwarden github-clean [options] ``` ## Prefer screens to commands? `pushwarden ui` does the same work on guided screens: sign in, tick the repositories, check them, review what was found, then fix and push after you confirm. On Windows open **PushWarden** from the Start menu; on macOS open **PushWarden** from `~/Applications` (the first time, macOS asks whether it may control Terminal: allow it). Progress is shared with `github-clean`, so you can stop in one and continue in the other. See [Guided screens](guided-screens.md). ## What it does for each repository 1. Clones it once as a bare clone. 2. Checks out each branch in its own worktree. 3. Runs the repository scanner. 4. Fixes what it finds, with the same reversible protector as `scan --fix`: strips payloads appended to config files, deletes whole-file threats (fake fonts, malicious `.vscode/tasks.json`, propagation scripts) and removes the `.gitignore` entries that hide them. 5. Makes one commit per infected branch and pushes it with an explicit refspec. **History is never rewritten and nothing is force-pushed.** A push the remote refuses (protected branch, archived repository, missing permission) is reported, not forced. ## Dry run is the default Without `--apply` nothing is committed or pushed. The output says what would change on each branch. ```sh pushwarden github-clean # dry run over everything pushwarden github-clean --apply # fix and push every infected branch ``` A dry run in a terminal that finds infected branches ends with a question, for example *Fix and push these 12 branches in 4 repositories now?* Answer `y` and the fixes are committed and pushed in the same run. `--no-ask` and `--ci` suppress the question. ## Options | Option | Effect | |---|---| | `--apply` | commit and push the fixes | | `--token PAT` | the GitHub token. Also read from `GITHUB_TOKEN`, then `GH_TOKEN`, then the `gh` CLI login | | `--token-stdin` | read the token from standard input, for secret managers and CI | | `--list` | print the repositories the token can push to and exit | | `--select` | list the reachable repositories with numbers and pick which to clean and in which order | | `--repo owner/name` | only this repository. Repeatable; run in the order given | | `--owner NAME` | only repositories under this user or organisation. Repeatable | | `--branch GLOB` | only branches matching this glob, for example `release/*`. Repeatable. Default: all | | `--include-forks` | also clean forks (skipped by default) | | `--include-archived` | also clean archived repositories. GitHub rejects pushes to them until they are unarchived | | `--author "Name "` | identity for the fix commits. Default: your git config, then `PushWarden ` | | `--keep-clones DIR` | keep the clones under `DIR` for inspection instead of deleting them | | `--json FILE` | write the full result to `FILE` | | `--api URL` | GitHub Enterprise API base. Default `https://api.github.com` | | `--ci` | no colour, no interactive prompts | | `--no-ask` | after a dry run, do not offer to fix what was found | | `--progress` | show what earlier runs already verified and exit. Needs no token and no network | | `--fresh` | forget the progress of earlier runs, delete the kept copies and check every branch again | ## The token Create a **fine-grained personal access token** with *Repository permissions, Contents: Read and write* on the repositories you want cleaned. A classic token needs the `repo` scope. How the token is handled: - It is passed to git through an askpass helper built into the program and an environment variable. - It is never written to disk, into a remote URL, into the report or into the progress file. - Stored git credential helpers, git hooks and `core.fsmonitor` are disabled while git runs, so a malicious git configuration cannot run code or redirect credentials. Rotate the token after the clean-up. ### More than one account `github-clean` works with one token per run. For a second account, run it again with that account's token: ```sh pushwarden github-clean --token "$(gh auth token --user OTHER-LOGIN)" ``` `gh auth login` adds an account next to the ones `gh` already has. The [guided screens](guided-screens.md#more-than-one-github-account) list every account and switch between them with one key. They can also use the SSH logins git already has, which `github-clean` cannot: it always needs a token. ## Selecting repositories ```sh pushwarden github-clean --list pushwarden github-clean --select --apply pushwarden github-clean --repo me/api --repo me/web --apply pushwarden github-clean --owner my-org --branch main --branch 'release/*' --apply ``` `--select` prints a numbered list. Type `3`, `1,4,2`, `5-9` or `all`. The order you type is the order the repositories are processed, so put the important one first. ## Progress is remembered Every branch that is finished (clean, fixed and pushed, or flagged for manual review) is recorded right away, together with the commit it was verified at. If a run is interrupted with Ctrl-C, hangs, or the machine goes to sleep, run the same command again: - Branches whose tip is still the verified commit are skipped. - A repository where nothing moved is not even cloned; one quick remote query confirms it. - The summary counts what this run did and what earlier runs did. A branch is checked again when: - its tip commit changes, because the attacker can push again - PushWarden or its indicators are updated, because a newer version may find more Dry-run findings, refused pushes and errors are never recorded as done. ```sh pushwarden github-clean --progress # what is already verified, per repository pushwarden github-clean --fresh # forget it and check everything ``` The reason progress is tied to the commit and not to the branch name: the attacker force-pushes with a stolen token, so "this branch was cleaned yesterday" proves nothing about today. ## From dry run to fix without doing the work twice | After a dry run, `--apply` ... | | |---|---| | branches found clean | are skipped, as long as their tip has not moved | | repositories where every branch was clean | are not cloned | | repositories with infected branches | are not cloned again: the dry run's copy is kept and only new commits are fetched | | the infected branches | are scanned once more, then fixed, committed and pushed | The second scan of an infected branch is deliberate. The fix is made from the files as they are on the branch at that moment, so nothing stale is ever pushed. A kept copy is a bare repository with no checked-out files and no token. It is deleted as soon as its repository has nothing left to fix, after 3 days without use (`clone_keep_days`), when the copies together pass 2 GB (`clone_keep_mb`), or by `--fresh`. A repository larger than that limit is not kept at all. ## Per-branch status | Status | Meaning | |---|---| | `clean` | nothing to do | | `clean, unchanged since verified ...` or `fixed earlier ...` | taken from an earlier run. In the JSON: `"resumed": true` | | `infected` | dry run: this branch needs fixes | | `pushed` | fixed and pushed; the short commit hash is shown | | `push-failed` | fixed locally, the remote refused the push. The error is shown | | `manual` | HIGH or CRITICAL findings PushWarden does not fix automatically. Review them | | `error` | clone, worktree, commit or scan failure | Repository-wide findings about commits in history that touch payload code are listed under `history`. They are informational; removing them would need a force-push. ## The fix commit Each infected branch gets one normal commit on top, titled `security: remove PolinRider malware`. The message lists every file with the threat name and what was done, and states that history was not rewritten. ## Exit codes | Code | Meaning | |---|---| | `0` | everything clean, or fixed and pushed | | `1` | infected branches remain: dry run, refused pushes or errors | | `2` | no token, bad token, or an API failure | ## JSON output `--json FILE` writes the version, the user, whether it was an apply run, the start time, and for each repository its branches with status, findings, fixed files, commit, the verified commit (`sha`) and whether the result was resumed. ## Afterwards 1. Rotate the token you used and every secret those repositories or their CI could read. 2. Check *Settings, Applications* and *Deploy keys* on GitHub for anything you did not add. 3. Tell collaborators to `git pull`. 4. Quarantined originals are in `pushwarden history`. ## Recipes Organisation-wide clean-up after a compromise: ```sh pushwarden github-clean --owner my-org --list pushwarden github-clean --owner my-org --json dryrun.json # review dryrun.json pushwarden github-clean --owner my-org --apply --json applied.json ``` In CI or with a secret manager: ```sh GITHUB_TOKEN=... pushwarden github-clean --ci --apply --json clean.json op read op://Vault/GitHub/token | pushwarden github-clean --token-stdin --apply ``` GitHub Enterprise: ```sh pushwarden github-clean --api https://github.example.com/api/v3 --owner my-org ``` ## Troubleshooting | Symptom | Cause and fix | |---|---| | `push-failed` | The branch is protected, the repository is archived, or the token lacks write access. Allow the push temporarily, or open a pull request from a clean branch, then run again with `--repo owner/name --apply` | | `push-failed` with a non-fast-forward error | Someone pushed between clone and push. Run again | | No token found | Pass `--token`, export `GITHUB_TOKEN`, or run `gh auth login` | | A repository is missing from `--list` | Forks and archived repositories are hidden unless `--include-forks` or `--include-archived` is given; a fine-grained token must list the repository | --- # Guided screens Source: https://faheemrafiq.github.io/pushwarden/guide/guided-screens.html `pushwarden ui` does the work of [github-clean](github-clean.md) on screens you move through with the keyboard: sign in, tick the repositories, check them, read what was found, then fix and push after you confirm. There are no commands to remember. ## Opening it | System | How | |---|---| | Windows | Open **PushWarden** from the Start menu. The installer puts it there | | macOS | Open **PushWarden** from the `Applications` folder in your home folder (`~/Applications`), or find it with Spotlight. The first time, macOS asks whether PushWarden may control Terminal: allow it | | Linux | Run `pushwarden ui` in a terminal | On every system `pushwarden ui` in a terminal does the same. The shortcuts open a terminal window for you. Git must be installed; the screens tell you if it is not. ## The steps ### 1. Sign in PushWarden looks for accounts you are already signed in to on this computer: - **Tokens:** `GITHUB_TOKEN` and `GH_TOKEN`, and every account logged in to the GitHub command-line tool (`gh`). - **Git over SSH:** every SSH host in `~/.ssh/config` that leads to `github.com`, and `github.com` itself. Each is tried once (`ssh -T`), and GitHub's answer names the account. A key that needs a passphrase which is not in your SSH agent is skipped, because nothing is ever asked. What happens next depends on how many it finds: - **One account:** it is used and you go straight to the next step. - **Several:** you choose one from a list. The last row, *Use another token*, lets you paste a token instead. - **None:** the screen explains how to create a token and shows a field to paste it into. ``` Which account's repositories do you want to check? > me gh login me-at-work SSH (github-work) Use another token paste it on the next screen ``` In the token field, what you paste is shown as `*`. The token is used for this run only and is never written to disk. A fine-grained token needs *Contents: Read and write* on the repositories to clean; a classic token needs the `repo` scope. See [the token section](github-clean.md#the-token). When the same account is found both as a token and over SSH, the token is listed: it can see every repository, SSH cannot (see [SSH accounts](#ssh-accounts)). ### 2. Choose the repositories The repositories of the account are listed with a checkbox each. **Nothing is ticked to begin with**: you choose what is checked. ``` Choose the repositories to check 0 of 3 selected 2 verified by earlier runs, 1 not checked yet. > [ ] me/app private fixed · 3 branches · 3 Oct 14:15 [ ] me/lib local clone clean · 1 branch · 2 Oct 23:00 [ ] me/site not checked yet On this computer: ~/code/lib ``` Each row shows what is known about the repository: - **Tags:** `private`, `fork`, `archived`, and `local clone` when the repository is also cloned on this computer. For the highlighted row the folder of that clone is shown under the list. - **Earlier result:** what earlier runs verified, with the number of branches and when: `clean`, `fixed` (PushWarden pushed a fix), `needs review`, or `not checked yet`. A repository that was verified is not downloaded again unless a branch has moved since. | Key | Action | |---|---| | Up, Down | move | | Space | tick or untick the repository | | `a`, `n` | tick all, or none, of the repositories shown | | `u` | tick the repositories no earlier run has checked | | `l` | tick the repositories that are cloned on this computer | | `/` | type to filter the list by name; Enter keeps the filter, Esc clears it | | `f`, `r` | show or hide forks and archived repositories. They are hidden until you show them | | `+` | SSH accounts only: add a repository by typing `owner/name` | | `s` | switch to another account | | Enter | check the ticked repositories | | `q` | quit | ### 3. Check Each repository is downloaded and every branch is scanned. **Nothing is changed in this step.** ``` Checking 4 repositories. Nothing is changed. ████████████░░░░░░░░░░░░ 2/4 50% 1:12 Branches so far: 6 clean · 1 infected · 0 need review · 0 failed + me/app 3 clean ! me/lib 1 INFECTED / me/site 2 branches done > me/site @ main . me/api waiting ``` The bar counts finished repositories; the line under it counts branches as they finish. A large repository with many branches takes the longest, because it is downloaded in full. Press `q` to stop; what is finished is remembered. ### 4. Review The report opens with the totals, then lists every infected branch and the files that would be fixed. ``` Check finished ┌────────────────┐┌─────────┐┌────────────┐┌───────────────┐┌──────────┐ │ 2 ││ 1 ││ 2 ││ 0 ││ 0 │ │ repositories ││ clean ││ infected ││ need review ││ failed │ └────────────────┘└─────────┘└────────────┘└───────────────┘└──────────┘ ! me/app @ main INFECTED, 5 files would be fixed - postcss.config.mjs: payload stripped - temp_auto_push.bat: deleted - .gitignore: entries removed - .vscode/tasks.json: deleted - public/fonts/fa-solid-900.woff2: deleted Press Enter to fix and push 2 branches in 1 repository. Nothing has been changed so far. ``` If nothing is infected the report says so and Enter closes the program. Press `b` to go back to the repository list and check more of the same account, or `q` to leave without changing anything. ### 5. Fix and push Enter asks once more, and only `y` goes ahead. Each infected branch then gets one normal commit, "security: remove PolinRider malware", which is pushed to GitHub. History is not rewritten and nothing is force-pushed. The original files are kept in quarantine on your computer. The final report shows what was pushed and what to do next: rotate the token and every secret those repositories or their CI could read, review *Settings, Applications* and *Deploy keys* on GitHub, and ask collaborators to `git pull`. A push that GitHub refuses, for example to a protected branch, is reported and not forced. See [Per-branch status](github-clean.md#per-branch-status). ## More than one GitHub account A personal and a work account are cleaned one after the other. Clean the first, then press `s` on the repository list or on the final report to return to the list of accounts, and choose the next. The header always shows which account is signed in and how, and each account's fixes are pushed as that account. To make a second account appear in the list, any of these works: - **It already has an SSH key set up for git.** Then it is listed without you doing anything. - **Log it in to `gh`.** Run `gh auth login` again for the other account; `gh` keeps several accounts side by side and PushWarden lists them all. - **Paste a token.** Choose *Use another token*. An accepted token stays in the list until you close the program, so you can switch back without pasting it again. It is not saved. A fine-grained token for repositories that belong to an organisation may need that organisation's approval before it can push. Progress is remembered per repository, so the accounts do not disturb each other's resume state. ## SSH accounts An SSH account uses the key git already has, so there is nothing to paste. The trade-off: **SSH can download and push a repository that is named, but it cannot ask GitHub for the list of your repositories.** PushWarden therefore builds the list from what it can find: 1. **Clones on this computer** whose remote goes through that SSH host, for example `git@github-work:my-org/api.git`. The folders searched are the ones the guard watches (`scan_roots`, or the usual project folders under your home folder). 2. **The account's public repositories**, which GitHub lists without a token. 3. **Names you type.** Press `+` and enter `owner/name`. A private repository that is not cloned on this computer does not appear unless you add it. The malware pushes to every repository a stolen token could reach, so for a complete clean-up use a token account, or add the missing repositories by name. Downloads and pushes go through `git@HOST:owner/name.git` with your own SSH command and settings. PushWarden reads the host names from `~/.ssh/config`; it never reads key files. ## Stopping and continuing `q` or Ctrl+C during a check or a fix stops cleanly: the branch in progress is abandoned, everything already finished is saved, and the next run skips what is verified and unchanged. The progress is shared with `github-clean`, so you can stop on the screens and continue with the command, or the other way round. `pushwarden ui --fresh` forgets the progress and checks everything again. ## Options | Option | Effect | |---|---| | `--api URL` | GitHub API base URL, for GitHub Enterprise | | `--fresh` | forget the progress of earlier runs and check every branch again | | `--pause` | if the screens cannot start, wait for Enter before closing. The shortcuts use this so a message stays readable | The screens always check first and ask before fixing. For scripts, CI, choosing branches or owners, a commit author or JSON output, use [github-clean](github-clean.md). ## Exit codes | Code | Meaning | |---|---| | `0` | everything checked is clean, or was fixed and pushed | | `1` | infected branches remain, a push was refused or a repository failed | | `2` | no interactive terminal, or git is not installed | --- # CI and automation Source: https://faheemrafiq.github.io/pushwarden/guide/ci-and-automation.html PushWarden is a single binary with stable exit codes and JSON output, so it fits in pipelines and scripts. ## Exit codes | Code | Meaning | |---|---| | `0` | clean or done | | `1` | threats found, or not fully fixed | | `2` | usage error, nothing scanned, or an internal failure | Commands that only display information return `0`. ## Reusable GitHub Actions workflow The repository publishes a workflow you can call from any repository. It downloads the latest release, verifies its SHA-256, scans the checkout, posts a commit comment when malware is found, sets a commit status named `pushwarden`, and fails the job. ```yaml # .github/workflows/malware-scan.yml name: Malware Scan on: [push, pull_request] jobs: scan: uses: FaheemRafiq/pushwarden/.github/workflows/malware-scan.yml@main ``` ## A CI gate in any pipeline Install the program only (no guard) and scan the checkout: ```yaml - run: curl -fsSL https://raw.githubusercontent.com/FaheemRafiq/pushwarden/main/installers/install.sh | PUSHWARDEN_NO_INSTALL=1 sh - run: ~/.local/bin/pushwarden scan --ci --no-system --json pushwarden.json . ``` | Flag | Why in CI | |---|---| | `--ci` | no colour, compact output, never prompts | | `--no-system` | skip host checks; the runner is not your machine | | `--no-report` | do not save a report under the data directory | | `--no-prompt` | never change a file | | `--json FILE` | machine-readable result to keep as an artifact | Exit code `1` fails the job when a HIGH or CRITICAL finding is present. To pin a version, set `PUSHWARDEN_VERSION=v0.3.3` in front of `sh`. ## Pre-commit hook ```sh pushwarden harden --pre-commit . ``` The hook runs `pushwarden check-staged`, which scans only the staged files and exits `1` when any carries an indicator at HIGH or above. For a hook manager, call the command directly: ```sh #!/bin/sh pushwarden check-staged || exit 1 ``` ## JSON outputs | Command | Output | |---|---| | `pushwarden scan --json FILE` | the scan report: statistics and every finding | | `pushwarden github-clean --json FILE` | per repository and branch: status, findings, fixed files, commit | | `pushwarden history --json` | raw journal events | | `pushwarden alerts --json` | raw alert records | | `pushwarden feedback --digest` | the daily digest as JSON | | `pushwarden feedback --preview` | the next events the central upload would send | | `pushwarden config` | the effective configuration | The latest scan report is also always at `reports/latest.json` in the data directory. ## Webhook for alerts ```sh pushwarden config --set webhook_url=https://hooks.slack.com/services/T000/B000/XXXX ``` Each alert is one JSON POST. The body contains `text` (Slack), `content` (Discord), `host`, `platform`, `context`, the `findings` array and a `reasons` array, so one URL works for Slack, Discord, Teams and your own endpoint. See [Team reporting](team-reporting.md). ## Cleaning GitHub from a pipeline ```sh GITHUB_TOKEN=... pushwarden github-clean --ci --apply --json clean.json op read op://Vault/GitHub/token | pushwarden github-clean --token-stdin --apply ``` `--ci` never asks questions. See [Cleaning GitHub repositories](github-clean.md). ## Environment variables for unattended use | Variable | Effect | |---|---| | `PUSHWARDEN_HOME` | data directory instead of `~/.pushwarden`. Use a temporary folder in CI | | `PUSHWARDEN_NO_BLOCK=1` | never ask for administrator rights | | `PUSHWARDEN_NO_INSTALL=1` | install script: download the program only | | `PUSHWARDEN_VERSION` | install script: install this release | | `NO_COLOR=1` | disable coloured output | | `GITHUB_TOKEN`, `GH_TOKEN` | token for `github-clean` | ## Machine audit without touching anything ```sh pushwarden scan --home --no-prompt --verbose --json audit.json ``` ## Rolling out to a team 1. Colleagues run the install one-liner with `PUSHWARDEN_WEBHOOK` set to a channel you watch. 2. Every alert arrives with the host name, platform, findings and the action taken. 3. When a new variant appears, add its indicator to `pushwarden/iocs.json` in your fork, raise `"version"` and push. Every guard picks it up within 24 hours; `pushwarden update-iocs` forces it. Point machines at a fork with `ioc_update_url`. 4. Add the reusable workflow to your repositories. 5. For fleet-wide diagnostics, turn on the [central event upload](team-reporting.md). --- # Activity history Source: https://faheemrafiq.github.io/pushwarden/guide/activity-history.html PushWarden keeps a complete record of what it saw and did on the machine. The record is the journal; `pushwarden history` reads it. ## The journal `journal.jsonl` in the data directory holds one JSON object per line. It is append-only and written by the guard, by `scan` and by `github-clean`. | Kind | What is recorded | |---|---| | `finding` | every sighting of every finding at WARNING or above, with no deduplication | | `action` | kill, quarantine, strip, delete, restore, purge, allow | | `decision` | an answer you gave in a dialog or terminal question | | `sweep` | the start and end of a full pass, with duration and counts; each `github-clean` branch result | | `guard` | guard starts and stops | | `update` | indicator and program updates | | `error` | failed actions, recovered crashes, refused or rolled-back updates | | `feedback` | a finding you marked as a false positive | Each event carries the time, the PushWarden and indicator versions, the context that produced it (`guard-full`, `guard-quick`, `realtime`, `scan`, `github-clean`, `cli`), and for findings the severity, category, threat name, title, path, process id and command line, the matched text, the evidence, the reason and the response. Settings: | Setting | Default | Effect | |---|---|---| | `journal` | `true` | keep the journal. When off, history shows only the actions in the quarantine index | | `journal_min_severity` | `WARNING` | lowest finding severity recorded | | `journal_keep_mb` | `100` | rotated archives kept in total | | `journal_keep_days` | `365` | oldest archive kept | At 10 MB the file is rolled into a compressed archive (`journal-DATE.jsonl.gz`) and a new file starts. The oldest archives are deleted once the archives pass the size or age limit. See [Disk use and cleanup](disk-and-cleanup.md). ## Reading it: `pushwarden history` ```sh pushwarden history [filters] [--all] [--details] [--json] ``` ### The default view Two parts. **Detections** lists each distinct finding once, with when it was last seen, how many times (`14x`), and where it stands: | State | Meaning | |---|---| | `handled` | a response was taken | | `open` | reported, nothing done | | `allowed` | you chose to keep it | | `dry-run` | a dry run showed what would be done | | `FAILED` | the response failed | Each detection shows a `why:` line and a `response:` line. **Actions and decisions** lists what was done, in order: kills, quarantines, strips, your dialog answers, errors and false-positive reports. ### Options | Option | Effect | |---|---| | `--all` | every recorded event in time order, nothing collapsed: each sighting, sweep, update and error | | `--details` | also the exact matched text, the evidence, the command line and which version recorded it | | `--severity LEVEL` | only findings at `warning`, `high` or `critical` and above | | `--kind KIND` | only this kind of event. Repeatable | | `--since WHEN` | only newer events: `30m`, `24h`, `7d`, `2w`, or a date such as `2026-10-01` | | `--path TEXT` | only events whose path, title or command line contains the text | | `--archive` | also read the rotated journal archives | | `--not-uploaded` | only events still waiting for the central upload. Implies `--all --archive` | | `--limit N` | number of rows per part (default 50) | | `--json` | the raw events as JSON | | `--restore PATH` | put a quarantined original back | | `--allow PATH` | restore and stop flagging this exact content for 30 days. With `--note` the reason is recorded | | `--remove PATH` | delete the quarantined copies permanently | ### Examples ```sh pushwarden history # the summary pushwarden history --severity critical --since 7d # what was serious this week pushwarden history --all --kind sweep # every sweep with duration and counts pushwarden history --kind error # anything PushWarden itself got wrong pushwarden history --all --path my-project --details # everything about one project, with evidence pushwarden history --all --since 2026-10-01 --json # machine-readable ``` ## Upload state per event When the [central event upload](team-reporting.md) is on, each event is either `uploaded` or `waiting`: - `history --all` shows an `upload` column - `history --details` shows a `central upload:` line - `history --json` carries `"uploaded": true` or `false` - `history --not-uploaded` lists what is still waiting ## Alerts ```sh pushwarden alerts [--last N] [--gui] [--json] ``` | Option | Effect | |---|---| | `--last N` | number of alerts to show (default 10) | | `--gui` | show them in a native dialog | | `--json` | print the raw records from `alerts.log` | An alert is written for every batch of findings that contains something HIGH or CRITICAL. Each alert shows when it happened, the severity and threat name, the file or process, a `why:` line and a `response:` line. Clicking a desktop notification opens this view. The difference from history: alerts are what you were notified about; history is everything. ## Other records | File | Content | |---|---| | `guard.log` | the guard's text log, one line per event | | `reports/` | one JSON report per full scan, plus `latest.json` | | `quarantine/index.jsonl` | the protection history: every action on a file | ## Sending the record to someone `pushwarden feedback` packages the journal, the log tail, the latest report, the settings and a status summary into one redacted zip file. See [Team reporting](team-reporting.md). --- # Team reporting Source: https://faheemrafiq.github.io/pushwarden/guide/team-reporting.html PushWarden keeps everything on the machine by default. A team lead or maintainer who wants to see what is happening across machines has four tools. Three send data and are off until their URL is set; the fourth writes a file that a person sends by hand. | Tool | What leaves the machine | How | Default | |---|---|---|---| | Webhook alerts | each alert: host name, platform, findings with paths, reasons | JSON POST per alert | off | | Feedback bundle | the journal, log tail, latest report, settings and status, redacted | a zip file the person sends | nothing is sent | | Daily digest | counts only; no paths, command lines or file contents | JSON POST once a day | off | | Central event upload | every journal event, redacted | batches to a database table | off | `pushwarden status` shows whether the webhook and the central upload are on. ## Redaction The bundle and the central upload pass every text through the same redactor: - the home folder is written as `~` - the user name and the host name are removed - anything shaped like a secret is masked: GitHub, AWS, Slack and API tokens, private keys, Supabase keys and other JSON Web Tokens, credentials inside URLs, and the values of `token`, `password`, `api_key`, `webhook_url`, `upload_key` and similar keys ## Webhook alerts ```sh pushwarden config --set webhook_url=https://hooks.slack.com/services/T000/B000/XXXX ``` Or at install time with `PUSHWARDEN_WEBHOOK=URL`. Each alert at or above `webhook_min_severity` (default `HIGH`) is posted as JSON: | Field | Content | |---|---| | `text` | a formatted message for Slack | | `content` | the same for Discord | | `host`, `platform` | the machine | | `context` | what produced the alert: guard, real-time, scan | | `findings` | the findings with severity, category, title, path, details, action | | `reasons` | for each finding: the `why` and the `response` | One URL therefore works for Slack, Discord, Teams and your own endpoint. Webhook alerts are not redacted: they are meant for your own channel and include the host name and paths. ## Feedback bundle ```sh pushwarden feedback [--days N] [--out FILE] [--no-redact] ``` Writes `pushwarden-feedback-DATE.zip` in the current folder. Nothing is uploaded; the command tells you what is in the file so you can look before you send it. | File in the zip | Content | |---|---| | `README.txt` | the period, the number of events, whether it is redacted | | `summary.txt` | version, system, guard, service, firewall and policy status | | `journal.jsonl` | every event in the period (default: the last 14 days) | | `guard.log` | the last 2000 lines of the guard log | | `latest-report.json` | the most recent full scan report | | `config.json` | the settings in effect, with URLs and keys masked | | Option | Effect | |---|---| | `--days N` | how many days of activity to include (default 14) | | `--out FILE` | where to write the bundle | | `--no-redact` | keep paths, user and host names. Token-shaped strings are masked regardless | ## Reporting a false positive ```sh pushwarden feedback --false-positive PATH --note "what it really is" ``` Records that the finding on `PATH` was wrong, with an optional note. It does not change the file; `pushwarden history --allow PATH` does that. The note travels with the bundle, the digest and the central upload. ## Daily digest Off unless `feedback_url` is set. ```sh pushwarden config --set feedback_url=https://hooks.slack.com/services/T000/B000/XXXX ``` Or at install time with `PUSHWARDEN_FEEDBACK_URL=URL`. Once a day the guard posts a summary to that URL. A Slack or Discord webhook works. It contains: - a random machine id, the version, the operating system and the indicator version - findings per severity and per category - actions taken and failed - dialog answers - sweep count and durations - errors - the notes written with false-positive reports It never contains file contents, command lines or paths. A false-positive report carries the file's base name only. The host name is included only with `feedback_identify=true`. ```sh pushwarden feedback --digest # print exactly what would be sent ``` ## Central event upload Off unless `upload_url` is set. No URL is built into the program. It gives whoever maintains PushWarden for a team the full record from every machine in one database table, so a false positive or a failed action on a colleague's machine can be diagnosed without asking for a bundle. ### What is sent Every journal event: findings, actions, dialog answers, sweeps, guard starts, updates, errors and false-positive reports. Each row has the severity, category, threat name, title, path, command line, matched text, evidence, the reason and response texts, the action and whether it worked, plus the PushWarden version, the operating system and the random machine id. Redaction is always applied on this path and cannot be turned off. File contents are never sent, apart from the matched text and evidence lines of a finding. ```sh pushwarden feedback --preview # the next rows exactly as they would be stored; sends nothing pushwarden feedback --upload # send every waiting event now ``` ### Delivery - Batches of 200 over HTTPS. A plain `http://` URL is refused unless it points at this machine. Redirects are not followed. - The guard checks every minute whether an upload is due, and uploads every 10 minutes when there are new events. - A long backlog is sent in slices of 2000 events, up to 20000 per run. ### Uploaded or waiting Every event is either `uploaded` or `waiting`. An event counts as uploaded only after the server accepted the batch it was in. - `pushwarden status` shows the totals and the time of the last upload, or since when the server has been unreachable and why. - `pushwarden history --all` shows the state of each event. `--not-uploaded` lists what is waiting. - The state belongs to the URL that is set. After `upload_url` changes, every event is waiting again and the new destination receives the full record. - The state is counted by an event's position in the journal, not by its timestamp, so a clock change cannot make an event look uploaded. ### Offline behaviour Protection does not need the network, and every event is written to the local journal first. - While the server cannot be reached, events stay `waiting`. - The guard tries again after 1, 2, 4 and 8 minutes, then every 10 minutes. A machine that is back online delivers its backlog within minutes. - `guard.log` gets one line when uploads start failing and one when they work again. - If a batch was stored but the answer was lost, it is sent again. Each event has a fixed id and the table skips ids it already holds, so nothing is stored twice. - Journal archives that still hold unsent events are kept until twice the journal limits. ### Setting it up with Supabase [Supabase](https://supabase.com) is hosted Postgres with a REST API; there is no server to run. 1. Create a project. Open the SQL editor, paste [`docs/supabase.sql`](https://github.com/FaheemRafiq/pushwarden/blob/main/docs/supabase.sql) and run it. It creates the table `pushwarden_events`, four views and the access rules. 2. In the project's API settings copy the project URL and the public key, named `anon` or `publishable`. Never put the `service_role` or `secret` key on a machine. 3. On each machine, at install time: ```sh curl -fsSL https://raw.githubusercontent.com/FaheemRafiq/pushwarden/main/installers/install.sh | \ PUSHWARDEN_UPLOAD_URL=https://PROJECT.supabase.co/rest/v1/pushwarden_events PUSHWARDEN_UPLOAD_KEY=KEY sh ``` Or on a machine that already runs PushWarden: ```sh pushwarden config --set upload_url=https://PROJECT.supabase.co/rest/v1/pushwarden_events upload_key=KEY pushwarden feedback --preview pushwarden feedback --upload ``` Restart the guard after `config --set`. 4. Read the data in the Supabase dashboard. | View | Shows | |---|---| | `pushwarden_false_positive_signals` | what users said was wrong: dialog answers "keep" and false-positive reports, with their notes | | `pushwarden_findings_summary` | each distinct finding with sightings and how many machines see it. One machine only is a hint of a false positive | | `pushwarden_tool_errors` | failed actions, recovered crashes, refused updates | | `pushwarden_machines` | per machine: last event, last upload, version, operating system, indicator version, average sweep time | ### Why the key on the machines is safe to distribute The SQL turns row level security on with a single insert policy for the public key. That key can add rows and nothing else: it cannot read, change or delete anything, and the views are closed to it as well. Someone who extracts the key from a machine can add junk rows, nothing more. ### Other servers Any server works if it accepts a JSON array by POST, with the key in the `apikey` and `Authorization: Bearer` headers, and answers with a 2xx status. A `409` answer means "already stored". ## Telling users If you enable the digest or the central upload for a team, tell the people whose machines report. `pushwarden status` shows the upload state, `pushwarden feedback --preview` and `--digest` show exactly what is sent, and `pushwarden install` prints a line when the upload is on. --- # Updates Source: https://faheemrafiq.github.io/pushwarden/guide/updates.html Two things are updated, on separate schedules: the indicators (what to look for) and the program. ## Indicator updates Most new malware variants are covered by adding an indicator, without a new release. - Every 24 hours (`ioc_update_interval`) the guard downloads `iocs.json` from the project repository's `main` branch. - The file is parsed and validated before it replaces the previous copy: every regular expression must compile, and every IP address and hash must be well formed. - A file whose version is not newer is ignored. - If the download fails, the previous copy stays in use. The program always has an embedded copy as a fallback. ```sh pushwarden update-iocs # download now pushwarden update-iocs --url URL # from a mirror or a fork ``` A running guard picks a new file up at its next definitions check. Restart the service to use it immediately. | Setting | Default | Effect | |---|---|---| | `ioc_update` | `true` | download indicators at all | | `ioc_update_interval` | `86400` | seconds between downloads | | `ioc_update_url` | empty | alternative indicator URL | ## Program updates Every 6 hours (`update_interval`) the guard checks GitHub for a newer release. ```sh pushwarden update --check # is a newer release available? pushwarden update # update now ``` ### Verification A release is verified before it replaces the program: 1. `checksums.txt` is downloaded with its signature, `checksums.txt.sig`. 2. The signature is checked against the project's ed25519 public key, which is built into the program. 3. The downloaded binary's SHA-256 must match the entry in `checksums.txt`. A release that fails any step is refused and recorded as an error in the journal. ### Rollback The previous binary is kept beside the new one. After an update the guard restarts and must write a heartbeat with the new version. If the new version does not start cleanly, the previous binary is restored, and that release is never retried. Once the new version has confirmed itself, the old binary is deleted. ### Channels | `update_channel` | Considers | |---|---| | `stable` (default) | full releases only | | `beta` | pre-releases too | ### Settings | Setting | Default | Effect | |---|---|---| | `auto_update` | `true` | update the program automatically | | `update_channel` | `stable` | `stable` or `beta` | | `update_interval` | `21600` | seconds between update checks | | `update_api_url` | empty | alternative release API, for mirrors | To turn automatic updates off: ```sh pushwarden config --set auto_update=false ``` ## The firewall block updates separately The privileged job that keeps the [C2 block](network-block.md) refreshes its own root-owned copy of the indicators once a day, from the project repository only. ## What is recorded Every indicator and program update, and every refused or rolled-back update, is written to the journal with kind `update` or `error`. ```sh pushwarden history --all --kind update ``` ## Versions ```sh pushwarden version ``` Release builds print the release number; development builds end in `-dev`. `pushwarden status` shows the program version and the indicator version. ## How releases are made A release is cut by pushing a tag `vX.Y.Z`; a hyphen, as in `v0.2.0-rc1`, makes a pre-release. The release workflow builds every asset, installs it on Windows, macOS and Linux until the guard reports alive, and only then publishes with a signed `checksums.txt`. Installed copies pick the release up within 6 hours. --- # Disk use and cleanup Source: https://faheemrafiq.github.io/pushwarden/guide/disk-and-cleanup.html PushWarden is meant to run for months without attention, so nothing it stores may grow without bound. Every store in the data directory has a limit. The guard enforces the limits once a day, and `pushwarden cleanup` does the same on demand. ```sh pushwarden cleanup [--dry-run] ``` The command prints each store with its size and limit, then removes what is past its limit. `--dry-run` shows what would be removed and removes nothing. `pushwarden status` shows the total in its `Disk use` line. ## Limits | Store | Limit | Setting | |---|---|---| | Journal archives (`journal-*.jsonl.gz`) | 100 MB in total and 365 days; oldest deleted first. The active `journal.jsonl` rotates at 10 MB | `journal_keep_mb`, `journal_keep_days` | | Quarantined originals | 90 days and 500 MB; oldest deleted first | `quarantine_keep_days`, `quarantine_keep_mb` | | Repository copies kept by `github-clean` | 3 days without use and 2 GB in total; a repository larger than the limit is not kept | `clone_keep_days`, `clone_keep_mb` | | `github-clean` progress | repositories not seen for 90 days are forgotten | | | Guard log | rotates at 5 MB; newest 5 archives kept | | | Alerts log | rotates at 5 MB; newest 3 archives kept | | | Reports | newest 60 | `report_keep` | | Allow decisions | removed when they expire after 30 days | | | Editor settings backups (`settings.json.pushwarden-*.bak`) | newest 2 per file | | | Leftovers in the system temp folder from an interrupted `github-clean` | removed after a day | | Set a limit to `0` to turn it off: ```sh pushwarden config --set quarantine_keep_days=0 # keep quarantined files until removed by hand pushwarden config --set journal_keep_mb=500 # keep more history ``` ## What expiry means for each store **Journal.** The oldest archives are deleted. History before that point is no longer available with `history --archive`. **Quarantine.** An expired copy can no longer be restored. The history keeps the entry and shows it as expired with the reason. **Repository copies.** `github-clean --apply` downloads the repository again. Nothing else is lost. ## Journal pruning and the central upload When the [central event upload](team-reporting.md) is on: - A journal archive whose events are not all uploaded yet is kept until twice the journal limits, so a long offline period does not lose events. - Past that, the disk limit wins. `guard.log` says how many events were dropped unsent. - Deleting an archive does not disturb the upload. A small marker file (`journal-DATE.pruned`) keeps the event count, so no event is sent twice and the uploaded or waiting state of the remaining events stays correct. ## What is never deleted automatically - the active journal, guard log and alerts log - `config.json`, `iocs.json`, the machine id and state files - any file PushWarden does not recognise by name. The cleanup only removes files it created, identified by their name pattern, inside the data directory and its own leftovers in the temp folder ## Example output ``` store size limit journal 12 MB 100 MB of archives, 365 days quarantine 5.7 MB 500 MB, 90 days logs 1.5 MB 5 MB each, 5 + 3 archives reports 120 KB 60 reports clones 3.8 MB 2048 MB, 3 days other 40 KB settings, indicators, state total 23 MB Would remove 3 old guard logs (585 KB) Would remove 1 quarantined copies (5.7 MB): past the quarantine limit; they can no longer be restored ``` ## Memory The guard is idle between events. The real-time layer reacts to file-system notifications, the quick layer does a few milliseconds of work every 5 seconds, and a full sweep runs every 6 hours. A long upload backlog is read and sent in slices of 2000 events, so memory use does not grow with the backlog. ## Removing everything ```sh pushwarden uninstall --unblock --purge ``` `--purge` deletes the whole data directory. Then delete the install directory shown by `pushwarden status`. --- # Configuration Source: https://faheemrafiq.github.io/pushwarden/guide/configuration.html Settings are stored in `config.json` in the data directory. Read and change them with `pushwarden config`. ```sh pushwarden config # print the current configuration pushwarden config --set key=value [--set key=value] # change settings ``` Booleans take `true` or `false`. Lists are comma-separated. A setting missing from the file has its default value. The guard reads the file when it starts. Restart it after a change, for example `systemctl --user restart pushwarden-guard.service` on Linux. ```sh pushwarden config --set action=ask pushwarden config --set scan_roots=~/code,~/work pushwarden config --set webhook_url=https://hooks.slack.com/services/... pushwarden config --set auto_update=false ``` ## Settings ### What is scanned | Key | Default | Meaning | |---|---|---| | `scan_roots` | `[]` | directories the guard watches and `scan --home` covers. Empty means auto-discover | | `exclude` | `[]` | directories never scanned | | `js_all` | `true` | scan every script file, not only known config names | | `deep` | `false` | also scan `node_modules` and `vendor` | ### Schedule | Key | Default | Meaning | |---|---|---| | `realtime` | `true` | real-time file watcher on | | `quick_interval` | `5` | seconds between process and network checks | | `full_interval` | `21600` | seconds between full sweeps | ### Response | Key | Default | Meaning | |---|---|---| | `action` | `quarantine` | what the guard does with a CRITICAL file: `quarantine`, `ask`, `delete` or `report` | | `auto_kill` | `true` | kill processes that match a kill-list indicator | | `auto_clean` | `true` | with `action=ask`, quarantine when nobody answers the dialog | | `prompt` | `true` | show native dialogs | | `prompt_timeout` | `180` | seconds a dialog waits before the default applies | `action` values: | Value | Behaviour | |---|---| | `quarantine` | move the file to quarantine first (reversible), then show a dialog: remove for good, or restore and allow | | `ask` | show the dialog first; on timeout quarantine if `auto_clean` is on | | `delete` | remove without asking. Stripped files keep a backup in quarantine; deleted files do not | | `report` | alert only, never touch files. Processes are still killed if `auto_kill` is on | ### Notifications | Key | Default | Meaning | |---|---|---| | `notify_desktop` | `true` | desktop notifications | | `notify_min_severity` | `HIGH` | lowest severity that notifies | | `notify_sweeps` | `true` | notification when a background full sweep starts and finishes | | `webhook_url` | `""` | receives a JSON POST per alert | | `webhook_min_severity` | `HIGH` | lowest severity that posts | ### Network block | Key | Default | Meaning | |---|---|---| | `block_c2` | `true` | keep the system-wide C2 block installed; the guard reminds you daily while it is missing | ### Indicators and updates | Key | Default | Meaning | |---|---|---| | `ioc_update` | `true` | download indicators | | `ioc_update_interval` | `86400` | seconds between indicator downloads | | `ioc_update_url` | `""` | alternative indicator URL, for mirrors and forks | | `auto_update` | `true` | update the program automatically | | `update_channel` | `stable` | `stable` or `beta` | | `update_interval` | `21600` | seconds between update checks | | `update_api_url` | `""` | alternative release API, for mirrors | ### Records and disk limits | Key | Default | Meaning | |---|---|---| | `report_keep` | `60` | number of reports kept | | `journal` | `true` | record every finding, action, decision, sweep and error in `journal.jsonl` | | `journal_min_severity` | `WARNING` | lowest finding severity recorded in the journal | | `journal_keep_mb` | `100` | journal archives kept, in total. `0` = no limit | | `journal_keep_days` | `365` | oldest journal archive kept. `0` = no limit | | `quarantine_keep_days` | `90` | quarantined originals are deleted after this many days. `0` = keep until removed by hand | | `quarantine_keep_mb` | `500` | size of the quarantine folder. `0` = no limit | | `clone_keep_days` | `3` | how long `github-clean` keeps a repository copy waiting for `--apply` | | `clone_keep_mb` | `2048` | those copies in total; a larger repository is not kept | ### Team reporting (all opt-in) | Key | Default | Meaning | |---|---|---| | `feedback_url` | `""` | the guard posts a daily digest of counts here | | `feedback_identify` | `false` | include the host name in the digest | | `upload_url` | `""` | the guard uploads redacted journal events to this table endpoint | | `upload_key` | `""` | the insert-only API key sent with each upload | ## Files and directories The data directory is `~/.pushwarden`. Override it with `PUSHWARDEN_HOME`. A machine that only has `~/.threatscan`, from when the program was called ThreatScan, keeps using that folder; see [Moving from ThreatScan](moving-from-threatscan.md). | Path | Content | |---|---| | `config.json` | settings | | `iocs.json` | downloaded indicators; the program carries an embedded copy as fallback | | `journal.jsonl`, `journal-DATE.jsonl.gz` | the activity record and its rotated archives | | `journal-DATE.pruned` | a tiny marker for a deleted archive, holding its event count | | `guard.log`, `guard-DATE.log.gz` | the guard's text log and its archives | | `alerts.log`, `alerts-DATE.log.gz` | one JSON line per alert, and its archives | | `reports/` | timestamped JSON reports plus `latest.json` | | `quarantine/` | copies of every stripped or deleted file, and `index.jsonl`, the protection history | | `decisions.json` | files you chose to allow, with their content hash | | `guard/` | heartbeat and state of the running guard | | `machine-id` | a random identifier used only in the optional digest and central upload | | `upload-state.json` | central upload: how many events are uploaded, last attempt, last success, last error | | `github-clean-state.json` | what `github-clean` already verified, per repository and branch. No token | | `github-clean-clones/` | repository copies kept so `--apply` does not download them again | Outside the data directory: | Path | Content | |---|---| | per-user install directory, on Linux `~/.local/share/pushwarden` with a link in `~/.local/bin` | the program. Override with `PUSHWARDEN_INSTALL_DIR` | | `PushWarden Notifier.app` in the install directory | macOS only: the helper that posts notifications | | `/etc/pushwarden`, `/Library/Application Support/PushWarden` or `%ProgramData%\PushWarden` | root-owned state and indicators of the C2 block | | `/usr/local/lib/pushwarden`, `/usr/local/libexec/pushwarden` or `%ProgramFiles%\PushWarden` | root-owned copy of the program for the C2 block | | `settings.json.pushwarden-.bak` beside each editor's settings | backup made by hardening | ## Environment variables | Variable | Read by | Effect | |---|---|---| | `PUSHWARDEN_HOME` | every command | data directory instead of `~/.pushwarden` | | `PUSHWARDEN_INSTALL_DIR` | `install`, `update` | where the program is installed | | `GITHUB_TOKEN`, `GH_TOKEN` | `github-clean` | token when `--token` is not given | | `NO_COLOR` | every command | disable coloured output | | `PAGER`, `PUSHWARDEN_NO_PAGER` | `help` | pager for long pages; set the second to disable paging | | `PUSHWARDEN_NO_BLOCK` | `install`, `protect`, `uninstall` | never ask for administrator rights | | `PUSHWARDEN_ROOTS` | install script | space-separated project directories to watch | | `PUSHWARDEN_WEBHOOK` | install script | webhook URL | | `PUSHWARDEN_FEEDBACK_URL` | install script | opt in to the daily digest | | `PUSHWARDEN_UPLOAD_URL`, `PUSHWARDEN_UPLOAD_KEY` | install script | opt in to the central event upload | | `PUSHWARDEN_VERSION` | install script | install this release instead of the latest | | `PUSHWARDEN_NO_INSTALL` | install script | download the program only, do not register the guard | | `PUSHWARDEN_BASE_URL` | install script | download from a mirror | --- # Security and privacy Source: https://faheemrafiq.github.io/pushwarden/guide/security-and-privacy.html A security tool runs on machines that may already be compromised and handles sensitive material. This page states what PushWarden does and does not do, so you can decide whether to trust it. ## Privileges | Part | Runs as | Notes | |---|---|---| | Every command and the background guard | your user | installs per user; no administrator password | | The C2 block and its boot-time job | root or SYSTEM | installed once, with your consent, through the system's own elevation dialog | The privileged job never reads files under your home folder to decide what to block. It uses a root-owned copy of the indicators, refreshed only from the project repository, and a root-owned copy of the program. A process running as you therefore cannot make the job edit the hosts file or firewall. See [Blocking C2 servers](network-block.md). ## Data that leaves the machine Nothing leaves the machine unless you opt in. | Connection | Content | Default | |---|---|---| | Indicator download | an HTTPS GET of `iocs.json` from the project repository | on; `ioc_update=false` turns it off | | Program update | HTTPS GETs of the release list, checksums, signature and binary from GitHub | on; `auto_update=false` turns it off | | `github-clean` | GitHub API calls and git over HTTPS with your token | only when you run it | | Webhook alerts | each alert with host name, paths and reasons | off until `webhook_url` is set | | Daily digest | counts only | off until `feedback_url` is set | | Central event upload | redacted journal events | off until `upload_url` is set | There is no built-in telemetry endpoint. No URL for the digest or the upload is compiled into the program. Before turning the digest or the upload on you can see exactly what would be sent: ```sh pushwarden feedback --digest pushwarden feedback --preview ``` See [Team reporting](team-reporting.md) for the contents and the redaction rules. ## Redaction The feedback bundle and the central upload remove what identifies the person or opens their accounts: the home folder becomes `~`, the user name and host name are removed, and token-shaped strings, private keys, credentials in URLs and secret-keyed values are masked. On the upload path this cannot be turned off. ## Token handling in `github-clean` - The token is read from `--token`, standard input, an environment variable or the `gh` CLI. - It is handed to git through an askpass helper built into the program and an environment variable of the child process. - It is never written to disk, into a remote URL, into the JSON report, into the progress file or into kept clones. - Git's error output is scrubbed of the token before it is shown. - While git runs, stored credential helpers, hooks and `core.fsmonitor` are disabled, so a malicious global git configuration cannot run code or send the token elsewhere. Use a fine-grained token limited to the repositories being cleaned, and rotate it afterwards. ## What the guided screens read to sign in `pushwarden ui` looks for GitHub logins that already exist on the machine. It reads the `GITHUB_TOKEN` and `GH_TOKEN` variables, asks `gh` for the tokens of its logged-in accounts, reads the host names in `~/.ssh/config`, and runs `ssh -T` once per GitHub host to learn which account the key belongs to. It never reads SSH key files, and no token is written to disk. To mark local clones it reads the remote addresses in the `.git/config` of the repositories under your project folders; nothing in those repositories is run. See [Guided screens](guided-screens.md). ## Update integrity - Release checksums are signed with the project's ed25519 key. The public key is built into the program. - A downloaded release must pass the signature check and the SHA-256 check before it replaces the running program. - A release that does not start cleanly is rolled back and never retried. - Indicator files are validated before use and cannot contain code: they are data only. The install script verifies the SHA-256 of the download against `checksums.txt`. It does not check the signature; the program checks it on every self-update. ## Handling of malicious files - Detection reads files; it never executes them. - A file is copied to quarantine before it is changed or removed, so every action can be undone. - Quarantine is a folder under your data directory with user-only permissions. Files in it are inert copies. - Automatic action requires strong evidence. Heuristic findings are reported only. - A process is killed only on a strict marker that legitimate tools never use. ## The central upload key The key that every machine holds for the central upload is an insert-only key. The provided SQL enables row level security with a single insert policy, so the key cannot read, change or delete rows, and the triage views are closed to it. Extracting the key from a machine allows adding junk rows and nothing more. See [Team reporting](team-reporting.md). ## Local files and permissions The data directory and the files in it are created with user-only permissions (`0700` for directories, `0600` for files). `config.json` can contain a webhook URL and an upload key; treat it like any other credentials file. ## What PushWarden does not do - It does not collect analytics or usage statistics. - It does not upload file contents. The central upload carries only the matched text and evidence lines of a finding, redacted. - It does not rewrite git history or force-push. - It does not need or request administrator rights except for the C2 block. - It does not protect against malware families other than the one it targets. It complements a general antivirus; it does not replace one. ## Reporting a vulnerability Open an issue at . For anything that should not be public, contact the maintainer through the GitHub profile first. ## Source The source code can be read at . --- # Troubleshooting and FAQ Source: https://faheemrafiq.github.io/pushwarden/guide/troubleshooting.html ## Troubleshooting ### `status` says the guard is not alive Check the service: | System | Command | |---|---| | Linux | `systemctl --user status pushwarden-guard.service` | | macOS | `launchctl list com.pushwarden.guard` | | Windows | Task Scheduler, task "PushWarden Guard" | Run `pushwarden guard --verbose --once` in a terminal to see errors directly. `pushwarden install` registers the service again. ### `pushwarden: command not found` after installing The install directory was added to your `PATH` in the shell start-up file. Open a new terminal. ### Real-time shows `off` or `polling` On Linux the inotify watch limit may be exhausted when there are many large repositories. Raise `fs.inotify.max_user_watches`. The guard falls back to polling, and the 5-second and 6-hour layers keep working, so protection continues. ### Firewall says `not active` or `until reboot` The one-time administrator prompt was declined or could not be shown, for example on a headless server without polkit. ```sh sudo pushwarden protect --install # Linux and macOS pushwarden protect --install # Windows, from an elevated terminal pushwarden protect --status ``` ### A legitimate file was quarantined ```sh pushwarden history pushwarden history --allow PATH --note "what the file really is" ``` This restores the file and stops flagging that exact content. Please also open an issue with the file so the indicator can be tightened. ### A process is flagged repeatedly The alert in `pushwarden alerts` shows the indicator and the command line. Update the indicators first, since false positives are fixed there without a new release: ```sh pushwarden update-iocs systemctl --user restart pushwarden-guard.service # Linux ``` If it persists, open an issue with the alert. ### Clicking a macOS notification opens Script Editor Builds before 0.3 posted notifications through osascript. Run `pushwarden install` again: it builds the notification helper app, and macOS asks once whether PushWarden may send notifications. Allow it. ### `github-clean` says `push-failed` The branch is protected, the repository is archived, or the token lacks write access. Temporarily allow the push or add yourself to the bypass list, or unarchive the repository, then run again for that repository with `--repo owner/name --apply`. A non-fast-forward error means someone pushed between clone and push; run again. ### `github-clean` cannot find a token Pass `--token`, export `GITHUB_TOKEN`, or run `gh auth login`. A fine-grained token must list the repositories and grant *Contents: Read and write*. ### `github-clean` was interrupted Run the same command again. Finished branches are remembered and skipped. `pushwarden github-clean --progress` shows what is already verified. ### `pushwarden ui` says it needs an interactive terminal The guided screens need a real terminal window. They do not run inside a pipe, a CI job or an editor's output panel. Open a terminal and run `pushwarden ui`, use the Start menu or Applications shortcut, or use `pushwarden github-clean` in scripts. ### The PushWarden shortcut on macOS does nothing The shortcut opens Terminal, and macOS asks once whether PushWarden may control it. If that was declined, allow it under *System Settings, Privacy & Security, Automation*, or run `pushwarden ui` in Terminal yourself. ### I had ThreatScan installed PushWarden is the same program under its new name. Your settings and history in `~/.threatscan` keep being used. See [Moving from ThreatScan](moving-from-threatscan.md). ### The central upload shows events waiting `pushwarden status` says since when the server has not been reached and why. Common causes: the machine is offline, `upload_url` does not start with `https://`, the key is wrong, or the table was not created with `docs/supabase.sql`. Nothing is lost; the guard retries by itself. `pushwarden feedback --upload` tries immediately and prints the error. ### A setting I changed has no effect The guard reads its configuration when it starts. Restart the service. ### The data directory is large ```sh pushwarden cleanup --dry-run pushwarden cleanup ``` See [Disk use and cleanup](disk-and-cleanup.md). ### Uninstall completely ```sh pushwarden uninstall --unblock --purge ``` Then delete the install directory shown by `pushwarden status`. ## Frequently asked questions ### Does PushWarden replace my antivirus? No. It targets one malware family and knows it in depth. Keep your general antivirus. ### Does it need administrator rights? No, except for the optional firewall block, which asks once. ### Will it slow my machine down? The real-time layer is idle until a file is written. The 5-second check takes a few milliseconds. The full sweep every 6 hours takes seconds to a minute. Scanning `node_modules` is off by default because it is slow. ### Does it send my code or file names anywhere? No. Nothing leaves the machine unless you set a webhook, digest or upload URL. See [Security and privacy](security-and-privacy.md). ### Can it delete something I need? Every file is copied to quarantine before it is changed or removed, and `pushwarden history --restore PATH` puts it back. Quarantined copies are kept for 90 days by default. ### What if I want it to ask before touching anything? ```sh pushwarden config --set action=ask ``` Or `action=report` to only notify. ### How do I stop it flagging a file? `pushwarden history --allow PATH` for one file, or `pushwarden config --set exclude=DIR` for a directory. ### How quickly are new variants covered? Indicators are refreshed every 24 hours without a new release. `pushwarden update-iocs` forces it. ### Does it work offline? Yes. Protection needs no network. Indicators and updates are fetched when a connection is available. ### Does it work in CI? Yes. See [CI and automation](ci-and-automation.md). ### How do I clean the repositories of a second GitHub account? In the [guided screens](guided-screens.md#more-than-one-github-account), press `s` to switch accounts: every account logged in to `gh` or set up for git over SSH is listed, and *Use another token* takes a pasted token. On the command line, run `github-clean` once per account with `--token`. ### My SSH account is missing from the list, or shows too few repositories An SSH account is listed when `ssh -T git@HOST` answers with your login without asking anything. If the key has a passphrase, add it to your SSH agent first (`ssh-add`). The list of an SSH account holds only local clones, public repositories and names you add with `+`, because SSH cannot list repositories; see [SSH accounts](guided-screens.md#ssh-accounts). ### Does it support GitHub Enterprise? `github-clean` does, through `--api URL`. ### How do I see everything it did? `pushwarden history --all`. See [Activity history](activity-history.md). ### I found the malware. What now? Follow the checklist in [Response and recovery](response-and-recovery.md#if-an-infection-is-found). Removing the files is not enough: rotate every credential the machine held. ### Where do I report a bug or a false positive? . Attach the output of `pushwarden feedback`, which is redacted by default. --- # PushWarden command-line reference Source: https://faheemrafiq.github.io/pushwarden/reference.html PushWarden is a single binary. Every feature is a subcommand: ``` pushwarden [options] ``` This page documents each command, its options, exit codes, the configuration keys, the files PushWarden keeps, and the environment variables it reads. For the install one-liner and a description of the threat, see the [README](https://github.com/FaheemRafiq/pushwarden#readme) and the [documentation site](https://faheemrafiq.github.io/pushwarden/guide/overview.html). ## Contents - [Conventions](#conventions) - [Quick start](#quick-start) - [Commands](#commands) - [scan](#scan) · [status](#status) · [history and restore](#history-and-restore) · [alerts](#alerts) · [feedback](#feedback) · [cleanup](#cleanup) - [github-clean](#github-clean) · [ui](#ui) - [guard](#guard) · [install](#install) · [uninstall](#uninstall) - [update](#update) · [update-iocs](#update-iocs) - [harden](#harden) · [protect](#protect) · [config](#config) - [check-staged](#check-staged) · [version](#version) · [help](#help) - [Configuration keys](#configuration-keys) - [Files and directories](#files-and-directories) - [Environment variables](#environment-variables) - [Severities, actions and threat names](#severities-actions-and-threat-names) - [Recipes](#recipes) - [Troubleshooting](#troubleshooting) --- ## Conventions - **Flags** accept one or two dashes: `-fix` and `--fix` are the same. Flags may come before or after positional arguments. - **`pushwarden [dirs]`** with no command is short for `pushwarden scan [dirs]`. - **`pushwarden -h`** prints that command's options. **`pushwarden help `** prints this documentation for it, in the terminal, offline. - **Exit codes** follow one rule everywhere: `0` clean or done, `1` threats found or not fully fixed, `2` usage error, nothing scanned, or an internal failure. Commands that only display information return `0`. - **Nothing needs root** except `protect`, which edits the firewall and the hosts file. - **Every destructive step is reversible.** Files are copied to the quarantine before they are stripped or deleted, and `pushwarden history` puts them back. ## Quick start ```sh pushwarden install # background guard + editor hardening + first scan pushwarden status # is everything running? pushwarden scan --home # audit every project under your home folder now pushwarden github-clean # dry run: which of my GitHub repos and branches are infected? pushwarden github-clean --apply # fix and push them pushwarden ui # clean your GitHub repositories the easy way, on guided screens ``` --- ## Commands ### scan One-off scan of repositories and of this computer. ``` pushwarden scan [options] [directories] ``` With no directories it scans the current working directory. In a terminal a progress bar shows which repository is being scanned; in CI or a pipe, numbered progress lines are printed instead. Each directory is walked for git repositories and for projects with a `package.json`, `go.mod` or `composer.json`. Every script, config, font, image and `.vscode/*.json` file in them is checked. Then the host is checked: running processes, network connections to known command-and-control (C2) addresses, persistence (systemd units, LaunchAgents, scheduled tasks, cron, shell rc files), remote access tool footprints, editor injection and exposed credentials. | Option | Effect | |---|---| | `--home` | also scan the common project directories under your home folder (or `scan_roots` from the config) | | `--deep` | also descend into `node_modules` and `vendor` (slow) | | `--configs-only` | only check the known config and entry-file names, the old v4 scope | | `--no-system` | skip the host checks | | `--no-repos` | skip the repository checks | | `--verbose` | list every repository, including clean ones | | `--fix` | act on CRITICAL findings without asking: strip the payload or quarantine the file, remove PolinRider entries from `.gitignore`, kill malicious processes, remove persistence. Reversible | | `--dry-run` | show what `--fix` would do and change nothing | | `--gui` | ask about each malicious file with a native dialog instead of in the terminal | | `--no-prompt` | report only. Never ask, never change a file | | `--notify` | send a desktop notification and, if configured, a webhook when anything HIGH or above is found | | `--json FILE` | also write the report as JSON to `FILE` | | `--no-report` | do not save the report under the data directory | | `--ci` | CI mode: no colour, compact output, never prompt | | `--js-all` | accepted for compatibility; scanning every script is already the default | **What happens when something is found.** In an interactive terminal, a CRITICAL finding with strong evidence triggers a question per file: remove, or keep and allow. With `--gui` the question is a native dialog. With `--fix` the reversible action is taken without asking. With `--no-prompt` or `--ci` nothing is touched. Findings that PushWarden cannot fix on its own (for example a compromised package version in a lockfile) are printed with a remediation hint. **Exit codes:** `0` clean, `1` HIGH or CRITICAL findings, `2` error or nothing scanned. Examples: ```sh pushwarden scan # this directory pushwarden scan ~/code ~/work # specific directories pushwarden scan --home --no-system # repositories only pushwarden scan --home --fix # clean up without questions pushwarden scan --ci --json report.json # in a pipeline; exit 1 fails the job pushwarden --deep ~/proj # same as `pushwarden scan --deep ~/proj` ``` ### status Protection status: a score, what each layer of protection is doing, and the details. ``` pushwarden status ``` The output has three parts. **The verdict.** A score out of 100 with a bar, one word for it and a sentence on what it means: `PROTECTED` (90 or more), `PARTLY PROTECTED` (65 to 89) or `AT RISK` (below 65). When the latest sweep found something critical or high the word is `THREATS FOUND`, whatever the score. On a terminal the PushWarden shield is drawn beside it in the colour of the verdict; piped or in CI the same information is plain text. **Protection.** One line per layer, marked `OK`, ` !` (partly) or `!!` (missing), with the command that fixes it: | Layer | Points | Full marks when | |---|---|---| | Background guard | 25 | the guard is alive | | Real-time file protection | 15 | a native watcher is in use (8 for polling) | | Starts when you sign in | 10 | the user service is registered | | Malware servers blocked | 10 | the firewall block is active and persistent (5 if it is lost on restart) | | Editors cannot auto-run tasks | 10 | every VS Code-family editor found is hardened | | Indicators up to date | 5 | the indicator file is at most 30 days old | | Recent full sweep | 5 | a full sweep finished in the last 24 hours | | Nothing critical or high found | 20 | the latest sweep found none | The score measures how much of the protection is switched on. It is not a guarantee that the machine is clean; `scan --home` and `history` show what was actually found. **Details.** Whether the guard is alive and which real-time backend it uses (inotify, kqueue, ReadDirectoryChangesW, or polling); during a sweep the Guard line reads `scanning 7/17 repositories (name)` and updates as it goes. Then the service state, the installed binary, the time of the last full sweep, how many repositories are tracked, the indicator version, the configured action policy, webhook and firewall state, the data directory, the summary of the latest sweep, and each VS Code-family editor with its `task.allowAutomaticTasks` setting. Always exits `0`. ### history and restore Everything PushWarden has seen and done on this machine: every detection, every kill, quarantine and strip, every answer you gave in a dialog, every error. ``` pushwarden history [filters] [--all] [--details] [--json] pushwarden history --restore PATH pushwarden history --allow PATH [--note "what it really is"] pushwarden history --remove PATH pushwarden restore PATH # same as history --restore PATH ``` **The default view** has two parts. *Detections* lists each distinct finding once, with when it was last seen, how many times (`14x`), and where it stands: `handled`, `open`, `allowed`, `dry-run` or `FAILED`. *Actions and decisions* lists what was done, in order, with the reason. | Option | Effect | |---|---| | `--all` | every recorded event in time order, nothing collapsed: each sighting, sweep, update and error | | `--details` | also the exact matched text, the evidence, the command line and which version recorded it | | `--severity LEVEL` | only findings at `warning`, `high` or `critical` and above | | `--kind KIND` | only this kind of event: `finding`, `action`, `decision`, `sweep`, `guard`, `update`, `error`, `feedback`. Repeatable | | `--since WHEN` | only newer events: `30m`, `24h`, `7d`, `2w`, or a date such as `2026-10-01` | | `--path TEXT` | only events whose path, title or command line contains the text | | `--archive` | also read the rotated journal archives | | `--not-uploaded` | only the events still waiting to be uploaded to `upload_url`, see [feedback](#feedback). Implies `--all --archive` | | `--limit N` | number of rows per part (default 50) | | `--json` | the raw events as JSON | | `--restore PATH` | put the original file back at `PATH`. It is still malicious and will be flagged again | | `--allow PATH` | restore and stop flagging this exact file content, remembered for 30 days. With `--note` the reason is recorded as feedback | | `--remove PATH` | delete the quarantined copies permanently | `PATH` is the original location as shown in the list. **Where it comes from.** The journal, `journal.jsonl` in the data directory, records every sighting of every finding at WARNING or above, with no deduplication, plus every action, decision, sweep, update and error. At 10 MB it is rolled into a gzip archive beside it and a new file starts. The oldest archives are deleted once the archives pass 100 MB in total or are older than a year (`journal_keep_mb`, `journal_keep_days`; see [cleanup](#cleanup)). Turn it off with `pushwarden config --set journal=false`; history then shows only the actions kept in the quarantine index. Examples: ```sh pushwarden history --severity critical --since 7d # what was serious this week pushwarden history --all --kind sweep # every sweep with duration and counts pushwarden history --kind error # anything PushWarden itself got wrong pushwarden history --all --path A-Bot-Ledger --details ``` ### alerts Recent alerts with the reason for each one. This is what a click on a desktop notification opens. ``` pushwarden alerts [--last N] [--gui] [--json] ``` | Option | Effect | |---|---| | `--last N` | number of alerts to show (default 10) | | `--gui` | show them in a native dialog instead of the terminal | | `--json` | print the raw records from `alerts.log` | Each alert shows when it happened, the severity and threat name, the file or process, a `why:` line explaining what the evidence means and why it has that severity, and a `response:` line saying what was done and why (killed, stripped, quarantined, or left for you to decide). ### github-clean Remove PolinRider from every branch of every GitHub repository you can push to. ``` pushwarden github-clean [options] ``` PolinRider steals a token, rewrites your repositories and force-pushes the backdoor to every branch it can reach. `github-clean` undoes that at the same scale. For every repository: 1. bare-clone it once; 2. check out each branch in its own worktree; 3. run the repository scanner; 4. strip trailing payloads from config files, delete whole-file threats (fake fonts, malicious `.vscode/tasks.json`, propagation scripts) and remove the entries PolinRider adds to `.gitignore` to hide them, with the same reversible protector as `scan --fix`; 5. commit once per infected branch and push with an explicit refspec. History is never rewritten and nothing is force-pushed. A push the remote refuses (protected branch, archived repository, permission) is reported, not forced. **Dry run is the default.** Without `--apply` nothing is committed or pushed; the output says what would change on each branch. **Progress is remembered.** Every branch that is finished (clean, fixed and pushed, or flagged for manual review) is recorded right away, together with the commit it was verified at. If a run is interrupted with Ctrl-C, hangs, or the machine goes to sleep, run the same command again: branches whose tip is still that commit are skipped, and a repository where nothing moved is not even cloned. The summary counts what this run did and what earlier runs did. A branch is checked again when its tip changes, because the attacker can push again, and when PushWarden or its indicators are updated, because a newer version may find more. Dry-run findings, refused pushes and errors are never recorded as done. `--progress` shows what is remembered; `--fresh` forgets it and checks everything. **From dry run to fix without doing the work twice.** A dry run in a terminal that finds infected branches ends with a question: `Fix and push these 12 branches in 4 repositories now?` Answer `y` and the fixes are committed and pushed in the same run. Answer no, and a later `--apply` picks up from there: | After a dry run, `--apply` … | | |---|---| | branches found clean | are skipped, as long as their tip has not moved | | repositories where every branch was clean | are not cloned | | repositories with infected branches | are **not cloned again**: the dry run's copy is kept in the data directory and only new commits are fetched | | the infected branches | are scanned once more, then fixed, committed and pushed | The second scan of an infected branch is deliberate. The fix is made from the files as they are on the branch at that moment, so nothing stale is ever pushed, and it takes a moment per branch. A kept copy is a bare repository with no checked-out files and no token. It is deleted as soon as its repository has nothing left to fix, after 3 days without use (`clone_keep_days`), when the copies together pass 2 GB (`clone_keep_mb`, least recently used first), or by `--fresh`. A repository larger than that limit is not kept at all. | Option | Effect | |---|---| | `--apply` | commit and push the fixes | | `--token PAT` | the GitHub token. Also read from `GITHUB_TOKEN`, then `GH_TOKEN`, then the `gh` CLI login | | `--token-stdin` | read the token from standard input, for secret managers and CI | | `--select` | list the reachable repositories with numbers and pick which to clean and in which order | | `--repo owner/name` | only this repository. Repeatable; run in the order given | | `--owner NAME` | only repositories under this user or organisation. Repeatable | | `--branch GLOB` | only branches matching this glob, for example `release/*`. Repeatable. Default: all | | `--list` | print the repositories the token can push to and exit | | `--include-forks` | also clean forks (skipped by default) | | `--include-archived` | also clean archived repositories. GitHub rejects pushes to them until they are unarchived | | `--author "Name "` | identity for the fix commits. Default: your git config, then `PushWarden ` | | `--keep-clones DIR` | keep the clones under `DIR` for inspection instead of deleting them | | `--json FILE` | write the full result to `FILE` | | `--api URL` | GitHub Enterprise API base, default `https://api.github.com` | | `--ci` | no colour, no interactive prompts | | `--progress` | show what earlier runs already verified, per repository, and exit. Needs no token and no network | | `--fresh` | forget the progress of earlier runs, delete the kept copies and check every branch again | | `--no-ask` | after a dry run, do not offer to fix what was found. `--ci` implies it | **The token.** Create a fine-grained personal access token with *Repository permissions, Contents: Read and write* on the repositories you want cleaned (a classic token needs the `repo` scope). The token is handed to git through an askpass helper built into the binary and an environment variable; it is never written to disk, into a remote URL, or into the report. Stored git credential helpers, hooks and `core.fsmonitor` are disabled while git runs. **Selecting repositories.** `--select` prints a numbered list; type `3`, `1,4,2`, `5-9` or `all`. The order you type is the order the repositories are processed, so put the important one first. `--repo` does the same non-interactively. **Per-branch status** in the output and in the JSON: | Status | Meaning | |---|---| | `clean` | nothing to do | | `clean, unchanged since verified …` / `fixed earlier …` | taken from an earlier run: the branch is still at the commit that was verified. In the JSON: `"resumed": true` | | `infected` | dry run: this branch needs fixes | | `pushed` | fixed and pushed; the short commit hash is shown | | `push-failed` | fixed locally, the remote refused the push. The error is shown | | `manual` | HIGH or CRITICAL findings PushWarden does not fix automatically. Review them | | `error` | clone, worktree, commit or scan failure | Repository-wide findings about commits in history that touch payload code are listed under `history`. They are informational; removing them would need a force-push. **Exit codes:** `0` everything clean or fixed and pushed, `1` infected branches remain (dry run, refused pushes, errors), `2` no token, bad token, or an API failure. **Afterwards.** Rotate the token you used and every secret those repositories or their CI could read. Check *Settings, Applications* and *Deploy keys* on GitHub for anything you did not add. Tell collaborators to `git pull`. Quarantined originals are in `pushwarden history`. Examples: ```sh pushwarden github-clean --list pushwarden github-clean # dry run over everything pushwarden github-clean --select --apply # pick from a list pushwarden github-clean --apply # interrupted? the same command continues pushwarden github-clean --progress # what is already verified pushwarden github-clean --repo me/api --repo me/web --apply pushwarden github-clean --owner my-org --branch main --branch 'release/*' --apply GITHUB_TOKEN=... pushwarden github-clean --ci --apply --json clean.json op read op://Vault/GitHub/token | pushwarden github-clean --token-stdin --apply ``` ### ui The easy way to clean your GitHub repositories: guided screens walk you through what [github-clean](#github-clean) does. ``` pushwarden ui [options] ``` There are no commands to remember. The Windows installer adds a **PushWarden** entry to the Start menu and `pushwarden install` on macOS builds `~/Applications/PushWarden.app`; both open a terminal window on these screens. On Linux, run `pushwarden ui`. 1. **Sign in.** Accounts already on the machine are found: tokens from `GITHUB_TOKEN`, `GH_TOKEN` and each `gh` login, and the logins git has over SSH (hosts in `~/.ssh/config` that lead to github.com). With several you choose from a list. With none, the screen explains how to create a token and takes it in a masked field; it is used for this run only and never written to disk. `s` on the repository list or the final report switches to another account. 2. **Choose repositories.** A list with checkboxes, nothing ticked to begin with. Each row shows what earlier runs verified and whether the repository is cloned on this computer. Forks and archived repositories are hidden until you show them. A token account lists every repository it can push to. An SSH account cannot ask GitHub for that list: it shows the local clones that use its SSH host, the account's public repositories, and names you add with `+`. 3. **Check.** A dry run over what you chose. Nothing is changed. 4. **Review.** Every infected branch and the files that would be fixed. 5. **Fix and push,** only after you answer `y`. One normal commit per infected branch, never a force-push, exactly as `github-clean --apply`. Stopping with `q` or `Ctrl+C` keeps what is finished: the next run continues where this one stopped, and shares that progress with `github-clean`. | Option | Effect | |---|---| | `--api URL` | GitHub API base URL (GitHub Enterprise). | | `--fresh` | Forget the progress of earlier runs and check every branch again. | | `--pause` | If it cannot start, wait for Enter before closing. The shortcuts use this. | **Exit codes:** as [github-clean](#github-clean); `2` also when there is no interactive terminal. For scripts and CI use `github-clean`. ### feedback Package this machine's activity so whoever maintains PushWarden for your team can analyse it, or report that a finding was wrong. ``` pushwarden feedback [--days N] [--out FILE] [--no-redact] pushwarden feedback --false-positive PATH [--note "what it really is"] pushwarden feedback --digest pushwarden feedback --preview pushwarden feedback --upload ``` | Option | Effect | |---|---| | (none) | write `pushwarden-feedback-DATE.zip` in the current folder: the journal for the last 14 days, the tail of the guard log, the latest report, the configuration and a status summary | | `--days N` | how many days of activity to include | | `--out FILE` | where to write the bundle | | `--no-redact` | keep paths, user and host names. Token-shaped strings are masked regardless | | `--false-positive PATH` | record that the finding on `PATH` was wrong, with an optional `--note`. Does not change the file; `history --allow` does that | | `--digest` | print the daily digest that `feedback_url` would receive | | `--preview` | print the next events that `upload_url` would receive, exactly as they would be stored, and how many are waiting. Sends nothing | | `--upload` | send every waiting event to `upload_url` now instead of waiting for the guard | **Nothing is uploaded unless you opted in.** Without options this command writes a file and tells you what is in it, so you can look before you send it. The digest and the central event upload below are both off until their URL is set, and `pushwarden status` shows whether the upload is on. **Redaction, on by default.** The home folder is written as `~`, the user name and host name are removed, and anything shaped like a secret is masked: GitHub, AWS, Slack and API tokens, private keys, Supabase keys and other JSON Web Tokens, credentials inside URLs, and the values of `token`, `password`, `api_key`, `webhook_url`, `upload_key` and similar keys. **The optional daily digest.** Off unless `feedback_url` is set, for example by the team lead at install time with `PUSHWARDEN_FEEDBACK_URL`. Once a day the guard then posts a summary to that URL (a Slack or Discord webhook works). It contains a random machine id, the version, the OS and counts: findings per severity and category, actions taken and failed, dialog answers, sweep durations, errors, and the notes written with false-positive reports. It never contains file contents, command lines or paths; a false-positive report carries the file's base name only. The host name is included only with `feedback_identify=true`. `pushwarden feedback --digest` shows exactly what would be sent. **The optional central event upload.** Off unless `upload_url` is set. It gives whoever maintains PushWarden for a team the full record from every machine in one database table, so a false positive or a failed action on a colleague's machine can be diagnosed without asking for a bundle. Every 10 minutes the guard sends the journal events it has not sent yet: findings, actions, dialog answers, sweeps, guard starts, updates, errors and false-positive reports. No URL is built into the program: nothing is uploaded until `upload_url` is set on the machine. What is sent: each event with its severity, category, threat name, title, path, command line, matched text, evidence, the reason and response texts, the action and whether it worked, plus the PushWarden version, the OS and the random machine id. What is removed first, always: the home folder becomes `~`, the user name and host name are taken out, and secrets are masked as described under redaction above. This path has no switch to turn redaction off. File contents are never sent, apart from the matched text and evidence lines of a finding. `pushwarden feedback --preview` prints the rows before anything leaves the machine. Delivery: batches of 200 over HTTPS. A plain `http://` URL is refused unless it points at this machine, and redirects are not followed. **Every event is either `uploaded` or `waiting`.** An event counts as uploaded only after the server accepted the batch it was in. `pushwarden status` shows the totals and the time of the last upload, `pushwarden history --all` shows the state of each event in an `upload` column, `history --not-uploaded` lists what is still waiting, and `history --json` carries `"uploaded": true|false`. The state belongs to the URL that is set: after `upload_url` changes, every event is waiting again and the new destination receives the full record. The state is counted by an event's position in the journal, not by its timestamp, so a clock change cannot make an event look uploaded. **Offline.** Protection does not need the network, and every event is written to the local journal first. While the server cannot be reached the events stay `waiting`; `status` shows since when and why. The guard tries again after 1, 2, 4 and 8 minutes and then every 10 minutes, so a machine that is back online delivers its backlog within minutes, all of it in one run (up to 20000 events). `guard.log` gets one line when uploads start failing and one when they work again. If a batch was stored but the answer was lost, it is sent again; each event has a fixed id and the table skips ids it already holds, so nothing is stored twice. Journal archives that still hold unsent events are kept until twice the journal limits, so an offline period has to be very long before events are dropped unsent; if that happens `guard.log` says how many. Setting it up with [Supabase](https://supabase.com) (hosted Postgres, no server to run): 1. Create a project. Open the SQL editor, paste [`docs/supabase.sql`](https://github.com/FaheemRafiq/pushwarden/blob/main/docs/supabase.sql) and run it. It creates the table `pushwarden_events`, four views and the access rules. 2. In the project's API settings copy the project URL and the public key, named `anon` or `publishable`. Never use the `service_role` or `secret` key on a machine. 3. On each machine, at install time: ``` curl -fsSL https://raw.githubusercontent.com/FaheemRafiq/pushwarden/main/installers/install.sh | \ PUSHWARDEN_UPLOAD_URL=https://PROJECT.supabase.co/rest/v1/pushwarden_events PUSHWARDEN_UPLOAD_KEY=KEY sh ``` or on a machine that already runs PushWarden: ``` pushwarden config --set upload_url=https://PROJECT.supabase.co/rest/v1/pushwarden_events upload_key=KEY pushwarden feedback --preview pushwarden feedback --upload ``` The guard reads its settings when it starts, so restart it after `config --set` (`systemctl --user restart pushwarden-guard.service` on Linux, or sign out and in). 4. Read the data in the Supabase dashboard: | View | Shows | |---|---| | `pushwarden_false_positive_signals` | what users said was wrong: dialog answers "keep" and `--false-positive` reports, with their notes | | `pushwarden_findings_summary` | each distinct finding with sightings and how many machines see it. One machine only is a hint of a false positive | | `pushwarden_tool_errors` | failed actions, recovered panics, refused updates | | `pushwarden_machines` | per machine: last event, last upload, version, OS, indicator version, average sweep time | The key on the machines can only add rows. The SQL turns row level security on with a single insert policy for that key, so it cannot read, change or delete anything, and the views are closed to it as well. Someone who extracts the key from a machine can add junk rows, nothing more. Any other server works too if it accepts a JSON array by POST with the key in the `apikey` and `Authorization: Bearer` headers and answers 2xx. ### cleanup Show what PushWarden occupies on disk and remove what is past its limits. ``` pushwarden cleanup [--dry-run] ``` PushWarden is meant to run for months without attention, so nothing it stores may grow without bound. Every store in the data directory has a limit. The guard enforces them once a day; this command does the same on demand and prints the sizes. `--dry-run` shows what would be removed and removes nothing. `pushwarden status` shows the total in its `Disk use` line. | Store | Limit | Setting | |---|---|---| | journal archives (`journal-*.jsonl.gz`) | 100 MB in total and 365 days; oldest deleted first. The active `journal.jsonl` rotates at 10 MB | `journal_keep_mb`, `journal_keep_days` | | quarantined originals | 90 days and 500 MB; oldest deleted first. `history` then shows the entry as expired, and it can no longer be restored | `quarantine_keep_days`, `quarantine_keep_mb` | | repository copies kept by `github-clean` | 3 days without use and 2 GB in total | `clone_keep_days`, `clone_keep_mb` | | `github-clean` progress | repositories not seen for 90 days are forgotten | | | guard log | rotates at 5 MB, 5 archives kept | | | alerts log | rotates at 5 MB, 3 archives kept | | | reports | newest 60 | `report_keep` | | allow decisions | removed when they expire after 30 days | | | editor settings backups (`settings.json.pushwarden-*.bak`) | newest 2 per file | | | leftovers in the system temp folder from an interrupted `github-clean` | removed after a day | | Set a limit to `0` to turn it off, for example `pushwarden config --set quarantine_keep_days=0` to keep quarantined files until you remove them yourself. When the central upload is on, a journal archive whose events are not all uploaded yet is kept until twice the journal limits. Deleting an archive does not disturb the upload: a small marker (`journal-DATE.pruned`) keeps the count, so no event is sent twice and the uploaded/waiting state of the remaining events stays correct. ### guard Run the background protection loop in this terminal. `install` registers this as a user service; run it by hand to debug or on a machine without a service manager. ``` pushwarden guard [--once] [--dry-run] [--verbose] ``` | Option | Effect | |---|---| | `--once` | one full pass, then exit | | `--dry-run` | detect and alert but never kill or quarantine | | `--verbose` | print the log to the terminal as well as to `guard.log` | The loop runs four layers, each timed by a configuration key: | Layer | Work | Key | |---|---|---| | real-time | native file-system watcher over the project roots, `~/Downloads` and `~/Desktop`; each written file is scanned as it lands | `realtime` | | quick | processes and network connections to C2 addresses | `quick_interval` (5 s) | | full | every repository plus host persistence, RATs, editor injection, credentials | `full_interval` (6 h) | | definitions | download and validate `iocs.json` | `ioc_update_interval` (24 h) | Findings go through the `action` policy (see [Configuration keys](#configuration-keys)), then through desktop notification and webhook. Configuration is read at start: restart the guard after `pushwarden config --set`. ### install Install the background guard so it starts at sign-in, harden editors, run a first scan. ``` pushwarden install [options] ``` Everything is per user: the binary is copied to a per-user install directory and linked from `~/.local/bin`. When that folder is not on your PATH (the macOS default), one line is appended to your shell start-up file (`~/.zshrc` on macOS, `~/.bashrc` or `~/.profile` on Linux), marked `# added by pushwarden install`; open a new terminal afterwards. The guard is registered as a systemd `--user` service, a LaunchAgent or a Scheduled Task, and every VS Code-family editor found gets `task.allowAutomaticTasks = off` and workspace trust turned on. | Option | Effect | |---|---| | `--roots DIR` | project directory to watch. Repeatable. Default: auto-discover the usual places under your home folder | | `--webhook URL` | URL that receives JSON alerts (Slack, Discord, Teams or your own endpoint) | | `--feedback-url URL` | opt in to the daily digest of counts, see [feedback](#feedback) | | `--upload-url URL` | opt in to the central event upload: the table endpoint that receives redacted events, see [feedback](#feedback) | | `--upload-key KEY` | the insert-only key for `--upload-url` | | `--no-block-c2` | do not ask for administrator rights to block the C2 servers (default is to ask once; see [protect](#protect)) | | `--deep` | the guard also scans `node_modules` and `vendor` (slow) | | `--full-interval SECONDS` | seconds between full sweeps (default 21600) | | `--no-harden` | skip editor hardening | | `--npm-ignore-scripts` | also set `ignore-scripts=true` in `~/.npmrc` | | `--no-kill` | never kill processes automatically | | `--no-prompt` | never show dialogs; quarantine automatically | | `--no-clean` | when no dialog can be shown, leave files in place instead of quarantining | | `--unattended` | for installers: no questions, first scan in the background | | `--dry-run` | show what would be done and change nothing | On macOS it also builds `~/Applications/PushWarden.app`, a shortcut that opens [ui](#ui) in Terminal; the Windows installer adds the same shortcut to the Start menu. The one-line installer from the README calls this for you; its environment variables `PUSHWARDEN_ROOTS`, `PUSHWARDEN_WEBHOOK`, `PUSHWARDEN_FEEDBACK_URL`, `PUSHWARDEN_UPLOAD_URL`, `PUSHWARDEN_UPLOAD_KEY`, `PUSHWARDEN_VERSION` and `PUSHWARDEN_NO_INSTALL` map onto these options. ### uninstall Remove the background guard. ``` pushwarden uninstall [--unblock] [--purge] ``` | Option | Effect | |---|---| | `--unblock` | also remove the C2 block: the boot-time job, the firewall rules and the hosts entries (asks for administrator rights) | | `--purge` | also delete the data directory: config, reports, quarantine | Editor hardening is left in place because it is a safe default. ### update Update PushWarden to the latest release. ``` pushwarden update [--check] ``` `--check` only reports whether a newer release exists. On a terminal the download shows a progress bar with the megabytes received and the percentage. Releases are verified before they replace the binary: the checksums file is signed with the project's ed25519 key and the download's SHA-256 must match. The previous binary is kept; if the new one fails to start the guard rolls back. The guard does this on its own every `update_interval` seconds when `auto_update` is on. `update_channel` `beta` also considers pre-releases. ### update-iocs Download the latest indicator file. ``` pushwarden update-iocs [--url URL] ``` The default source is the file on the project's `main` branch. The download is parsed and validated (every regex must compile, every IP and hash must be well formed) before it replaces the previous copy, and a file whose version is not newer is ignored. A running guard picks a new file up at its next definitions check; restart the service to use it immediately. ### harden Apply preventive settings without installing the guard. ``` pushwarden harden [options] ``` | Option | Effect | |---|---| | (none) | harden every VS Code-family editor found: `task.allowAutomaticTasks = off`, workspace trust on | | `--npm-ignore-scripts` | set `ignore-scripts=true` in `~/.npmrc`, so installs never run lifecycle scripts | | `--undo-npm` | remove that setting again | | `--pre-commit REPO` | install a pre-commit hook in `REPO` that runs `pushwarden check-staged`. Repeatable. An existing hook is kept as `pre-commit.pre-pushwarden` | | `--dry-run` | show what would change | ### protect Block the PolinRider command-and-control (C2) servers system-wide and keep them blocked. ``` pushwarden protect [--install | --refresh | --uninstall | --status | --block-c2 | --unblock] [--dry-run] ``` `pushwarden install` does this for you: it asks once for administrator rights (native password dialog on macOS, polkit or sudo on Linux, UAC on Windows) and registers a small privileged job that re-applies the block at every boot and refreshes the address list once a day. You only need `protect` directly to check on it, to add it later, or to remove it. | Option | Effect | |---|---| | `--install` (default) | block now and register the boot-time job. Needs administrator rights; asks for them when run as a normal user | | `--status` | is the block active, and will it survive a reboot? No rights needed | | `--refresh` | what the job runs: re-apply the rules from the root-owned indicators, then fetch newer ones and re-apply | | `--uninstall` | remove the job, the firewall rules and the hosts entries | | `--block-c2` | one-shot block for this boot only, no job (the pre-0.3 behaviour) | | `--unblock` | remove the rules and hosts entries but keep the job, if any | | `--dry-run` | show what `--install` would do | **What gets installed.** Outgoing traffic to every IP on the C2 list is dropped: an `iptables` chain or `nftables` table named `pushwarden` on Linux, a `pf` anchor on macOS, a Windows Firewall rule named "PushWarden C2 block". The C2 hostnames are sinkholed in the hosts file between `# BEGIN PUSHWARDEN C2 SINKHOLE` and `# END` markers. The job that keeps this current is a systemd timer (`pushwarden-netblock.timer`, at boot and daily), a LaunchDaemon (`com.pushwarden.netblock`) or a Scheduled Task ("PushWarden NetBlock", runs as SYSTEM at boot and daily). **Why it uses its own copy of the indicators.** The job runs as root, and the `iocs.json` under your home folder is writable by anything running as you. A privileged job must not let a user-writable file decide what goes into the hosts file, so it keeps a root-owned copy under `/etc/pushwarden`, `/Library/Application Support/PushWarden` or `%ProgramData%\PushWarden`, refreshed from the project repository only, and runs a root-owned copy of the program. **Status values** shown by `pushwarden status` and `protect --status`: | Firewall | Meaning | |---|---| | `active (persistent; 25 IPs, 15 hosts; applied 3h ago; indicators …)` | all good | | `active until reboot (…)` | a one-shot block from `--block-c2`; run `protect --install` to keep it | | `not active (rules from a previous boot were lost)` | same, after a reboot | | `not active` | never installed, or removed | Opt out with `pushwarden install --no-block-c2` or `pushwarden config --set block_c2=false`; the guard then stops reminding you. Setting `PUSHWARDEN_NO_BLOCK=1` skips the administrator prompt for one run, which the package installers use because they already did the work as root. ### config Show or change settings. ``` pushwarden config pushwarden config --set key=value [--set key=value ...] ``` Without options it prints the current configuration. Keys are the JSON names listed under [Configuration keys](#configuration-keys). Booleans take `true`/`false`, lists are comma-separated. The guard reads the file when it starts, so restart the service after a change (`systemctl --user restart pushwarden-guard.service` on Linux). ```sh pushwarden config --set action=ask pushwarden config --set scan_roots=~/code,~/work pushwarden config --set webhook_url=https://hooks.slack.com/services/... pushwarden config --set auto_update=false ``` ### check-staged Pre-commit hook helper: scan the files staged for commit and refuse the commit if any carries a PolinRider indicator at HIGH or above. ``` pushwarden check-staged ``` Run it from inside the repository. Exit `1` blocks the commit, `0` allows it. Install it with `pushwarden harden --pre-commit .`, or add this to `.git/hooks/pre-commit` yourself: ```sh #!/bin/sh pushwarden check-staged || exit 1 ``` ### version ``` pushwarden version pushwarden --version ``` Prints the version. Release builds print the release number; development builds end in `-dev`. ### help Show this documentation in the terminal. ``` pushwarden help # command list pushwarden help # one command, for example: pushwarden help install pushwarden help # config-keys, files, environment, severities, recipes, troubleshooting pushwarden help all # the whole reference ``` The text is embedded in the binary, so it always matches the installed version and works offline. Long pages go through `$PAGER` (default `less`); set `PUSHWARDEN_NO_PAGER=1` to print straight to the terminal. --- ## Configuration keys Stored in `config.json` in the data directory. Change them with `pushwarden config --set`. | Key | Default | Meaning | |---|---|---| | `scan_roots` | `[]` | directories the guard watches and `scan --home` covers. Empty means auto-discover | | `exclude` | `[]` | directories never scanned | | `js_all` | `true` | scan every script file, not only known config names | | `deep` | `false` | also scan `node_modules` and `vendor` | | `realtime` | `true` | real-time file watcher on | | `quick_interval` | `5` | seconds between process and network checks | | `full_interval` | `21600` | seconds between full sweeps | | `ioc_update_interval` | `86400` | seconds between indicator downloads | | `ioc_update` | `true` | download indicators at all | | `ioc_update_url` | `""` | alternative indicator URL, for mirrors | | `action` | `quarantine` | what the guard does with a CRITICAL file, see below | | `auto_kill` | `true` | kill processes that match a kill-list indicator | | `auto_clean` | `true` | with `action=ask`, quarantine when nobody answers the dialog | | `prompt` | `true` | show native dialogs | | `prompt_timeout` | `180` | seconds a dialog waits before the default applies | | `notify_desktop` | `true` | desktop notifications | | `notify_min_severity` | `HIGH` | lowest severity that notifies | | `notify_sweeps` | `true` | desktop notification when a background full sweep starts and when it finishes, with the result | | `webhook_url` | `""` | receives a JSON POST per alert | | `webhook_min_severity` | `HIGH` | lowest severity that posts | | `block_c2` | `true` | keep the system-wide C2 block installed; `install` asks for administrator rights once and the guard reminds you daily while it is missing | | `report_keep` | `60` | number of reports kept | | `journal` | `true` | record every finding, action, decision, sweep and error in `journal.jsonl` | | `journal_min_severity` | `WARNING` | lowest finding severity recorded in the journal | | `journal_keep_mb` | `100` | journal archives kept, in total; the oldest are deleted first. `0` = no limit | | `journal_keep_days` | `365` | oldest journal archive kept. `0` = no limit | | `quarantine_keep_days` | `90` | quarantined originals are deleted after this many days. `0` = keep until removed by hand | | `quarantine_keep_mb` | `500` | size of the quarantine folder; the oldest copies are deleted first. `0` = no limit | | `clone_keep_days` | `3` | how long `github-clean` keeps a repository copy waiting for `--apply` | | `clone_keep_mb` | `2048` | those copies in total; a larger repository is not kept | | `feedback_url` | `""` | opt-in: the guard posts a daily digest of counts here (no paths, no command lines) | | `feedback_identify` | `false` | include the host name in the digest instead of only a random machine id | | `upload_url` | `""` | opt-in: the guard uploads redacted journal events to this table endpoint every 10 minutes and catches up after being offline, see [feedback](#feedback) | | `upload_key` | `""` | the insert-only API key sent with each upload | | `auto_update` | `true` | update the binary automatically | | `update_channel` | `stable` | `stable` or `beta` | | `update_interval` | `21600` | seconds between update checks | | `update_api_url` | `""` | alternative release API, for mirrors | **`action` values:** | Value | Behaviour | |---|---| | `quarantine` | move the file to quarantine first (reversible), then show a dialog: remove for good, or restore and allow | | `ask` | show the dialog first; on timeout quarantine if `auto_clean` is on | | `delete` | remove without asking. Stripped files keep a backup in quarantine; deleted files do not | | `report` | alert only, never touch files. Processes are still killed if `auto_kill` is on | ## Files and directories The data directory is `~/.pushwarden` (override with `PUSHWARDEN_HOME`). If only `~/.threatscan` exists, from when the program was called ThreatScan, that folder is used instead. | Path | Content | |---|---| | `config.json` | settings | | `iocs.json` | downloaded indicators; the binary carries an embedded copy as fallback | | `journal.jsonl` | the complete activity record: every finding at every sighting, every action, decision, sweep, update and error. Rotated at 10 MB into `journal-DATE.jsonl.gz`; the oldest archives are deleted past 100 MB or one year, leaving a tiny `journal-DATE.pruned` marker with their event count | | `guard.log` | the guard's text log, rotated at 5 MB into `guard-DATE.log.gz`; the newest 5 archives are kept | | `machine-id` | a random identifier used only in the optional feedback digest and event upload | | `github-clean-state.json` | what `github-clean` already verified: per repository and branch the commit, the result and the time. No token. Deleted by `github-clean --fresh` | | `github-clean-clones/` | bare copies of repositories in which a `github-clean` dry run found infected branches, kept so `--apply` does not download them again. Removed when fixed, after 3 days without use, past 2 GB in total, or by `github-clean --fresh` | | `upload-state.json` | when `upload_url` is set: how many events are uploaded to that URL, the time of the last attempt and success, and the last error. Deleting it sends everything again; the table stores nothing twice | | `alerts.log` | one JSON line per alert, including the `why` and `response` texts as worded at the time. Rotated at 5 MB; the newest 3 archives are kept | | `reports/` | timestamped JSON reports plus `latest.json` | | `quarantine/` | copies of every stripped or deleted file, and `index.jsonl`, the protection history. Copies are deleted after 90 days or past 500 MB | | `decisions.json` | files you chose to allow, with their content hash; entries are removed when they expire after 30 days | | `PushWarden Notifier.app` | macOS only, in the install directory: the helper that posts notifications so a click opens `pushwarden alerts --gui` | | `guard/` | heartbeat and state of the running guard | The privileged C2 block keeps its own root-owned files: `netblock-state.json` and a copy of `iocs.json` under `/etc/pushwarden` (Linux), `/Library/Application Support/PushWarden` (macOS) or `%ProgramData%\PushWarden` (Windows), plus a copy of the program under `/usr/local/lib/pushwarden`, `/usr/local/libexec/pushwarden` or `%ProgramFiles%\PushWarden`. The binary itself lives in a per-user install directory, on Linux `~/.local/share/pushwarden` with a link in `~/.local/bin` (override with `PUSHWARDEN_INSTALL_DIR`). ## Environment variables | Variable | Read by | Effect | |---|---|---| | `PUSHWARDEN_HOME` | every command | data directory instead of `~/.pushwarden` | | `PUSHWARDEN_INSTALL_DIR` | `install`, `update` | where the binary is installed | | `GITHUB_TOKEN`, `GH_TOKEN` | `github-clean` | token when `--token` is not given | | `NO_COLOR` | every command | disable coloured output | | `PAGER`, `PUSHWARDEN_NO_PAGER` | `help` | pager for long pages; set the second to disable paging | | `PUSHWARDEN_NO_BLOCK` | `install`, `protect`, `uninstall` | never ask for administrator rights (package installers, CI) | | `PUSHWARDEN_ROOTS` | install script | space-separated project directories to watch | | `PUSHWARDEN_WEBHOOK` | install script | webhook URL | | `PUSHWARDEN_FEEDBACK_URL` | install script | opt in to the daily digest, see [feedback](#feedback) | | `PUSHWARDEN_UPLOAD_URL` | install script | opt in to the central event upload, see [feedback](#feedback) | | `PUSHWARDEN_UPLOAD_KEY` | install script | the insert-only key for `PUSHWARDEN_UPLOAD_URL` | | `PUSHWARDEN_VERSION` | install script | install this release instead of the latest | | `PUSHWARDEN_NO_INSTALL` | install script | download the binary only, do not register the guard | | `PUSHWARDEN_BASE_URL` | install script | download from a mirror | ## Severities, actions and threat names | Severity | Meaning | Automatic action | |---|---|---| | CRITICAL | confirmed PolinRider artifact or activity | yes, per `action` policy | | HIGH | strong indicator that needs a human: compromised package version, exposed keys on an infected host | no, listed for review | | WARNING | context worth checking: suspicious git reflog, secrets file in an infected repo, files that arrived in the same commit as a loader (camouflage fonts, decoy README, `.vscode` set) | no | | INFO | informational | no | **Why-lines.** Every finding has a one-sentence reason for its severity, and every response (kill, strip, quarantine, or nothing) has a one-sentence reason too. They appear in scan output, in the dialogs, in `pushwarden alerts`, in `pushwarden history`, in desktop notifications, in the webhook JSON (`reasons` array) and in `guard.log`. A process is killed only when its command line holds a strict PolinRider marker that legitimate tools never use; a broad indicator alone is reported but not killed, and the reason says so. Dialogs, notifications and reports name threats Defender-style, for example `Trojan:JS/PolinRider.FakeFont` for a script disguised as a font, `Trojan:Script/PolinRider` for an injected loader and `Backdoor:Script/PolinRider.Persist` for a persistence entry. ## Recipes **CI gate.** Fail the pipeline when a PolinRider indicator lands in the repository: ```yaml - run: curl -fsSL https://raw.githubusercontent.com/FaheemRafiq/pushwarden/main/installers/install.sh | PUSHWARDEN_NO_INSTALL=1 sh - run: ~/.local/bin/pushwarden scan --ci --no-system --json pushwarden.json . ``` **Pre-commit hook for every repository you work on:** ```sh for r in ~/code/*/; do pushwarden harden --pre-commit "$r"; done ``` **Slack, Discord or Teams alerts** from every machine: ```sh pushwarden config --set webhook_url=https://hooks.slack.com/services/T000/B000/XXXX ``` The POST body contains `text` (Slack), `content` (Discord), `host`, `platform`, `context` and the `findings` array, so one URL works for all three and for your own endpoint. **Organisation-wide clean-up after a compromise**, from a machine with a fine-grained token that has Contents write on the organisation's repositories: ```sh pushwarden github-clean --owner my-org --list pushwarden github-clean --owner my-org --json dryrun.json # review dryrun.json pushwarden github-clean --owner my-org --apply --json applied.json ``` **Machine audit without touching anything:** ```sh pushwarden scan --home --no-prompt --verbose --json audit.json ``` ## Troubleshooting **Clicking a macOS notification opens Script Editor.** Builds before 0.3 posted notifications through osascript. Run `pushwarden install` again: it builds the notification helper app, and macOS will ask once whether PushWarden may send notifications. Allow it. **A legitimate file was quarantined.** `pushwarden history` shows it; `pushwarden history --allow PATH` restores it and stops flagging that exact content. Please also open an issue with the file so the indicator can be tightened. **A process is flagged repeatedly.** The alert in `alerts.log` shows the indicator and the command line. Update indicators first, since false positives are fixed there without a new release: `pushwarden update-iocs` and then restart the service (`systemctl --user restart pushwarden-guard.service` on Linux). If it persists, open an issue with the alert line. **Firewall says `not active` or `until reboot`.** The one-time administrator prompt was declined or could not be shown (for example a headless server without polkit). Run `sudo pushwarden protect --install` on Linux and macOS, or `pushwarden protect --install` from an elevated terminal on Windows. `pushwarden protect --status` confirms it afterwards. **`status` says the guard is not alive.** Check the service: `systemctl --user status pushwarden-guard.service` on Linux, `launchctl list | grep pushwarden` on macOS, Task Scheduler on Windows. Run `pushwarden guard --verbose --once` in a terminal to see errors directly. **Real-time shows `off` or `polling`.** On Linux the inotify watch limit may be exhausted with many large repositories; raise `fs.inotify.max_user_watches`. The guard falls back to polling and to the 5-second and 6-hour layers, so protection continues. **`github-clean` says `push-failed`.** The branch is protected, the repository is archived, or the token lacks write access. Temporarily allow the push (or add yourself to the bypass list), or unarchive the repository, then run again for that repository with `--repo owner/name --apply`. A non-fast-forward error means someone pushed between clone and push; simply run again. **`github-clean` cannot find a token.** Pass `--token`, export `GITHUB_TOKEN`, or run `gh auth login`. A fine-grained token must list the repositories and grant *Contents: Read and write*. **Uninstall completely:** `pushwarden uninstall --unblock --purge`, then delete the install directory shown by `pushwarden status`.