Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ body:
id: version
attributes:
label: ProcDrift version
placeholder: "1.0.0, or the commit SHA if built from source"
description: >-
The line reported by `ProcDrift --version`, which is also the window
title. If you built it yourself, the commit SHA instead.
placeholder: "ProcDrift 1.2.3"
validations:
required: true

Expand Down
50 changes: 47 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ jobs:
timeout-minutes: 30
permissions:
contents: write
# attest-build-provenance signs with a short-lived OIDC token and records
# the result in the repository's attestation store. Neither is a secret
# this repository holds.
id-token: write
attestations: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
Expand Down Expand Up @@ -52,17 +57,56 @@ jobs:
- name: Build
run: cargo build --locked --release

# The binary is unsigned, so the hash is the only thing a downloader can
# check. Publish it in the release notes rather than asking for trust.
# v1.0.1's notes were written by hand after the fact, because the workflow
# generated only the hash and the unsigned-binary warning. The reason to
# upgrade is the one thing a release page has to carry, and it is known
# when the change is made, not when the tag is pushed. An absent section
# fails here, while the tag is still re-runnable.
- name: Take the release notes from the changelog
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
awk -v want="$version" '
$0 ~ ("^## \\[" want "\\]") { inside = 1; next }
inside && /^## / { exit }
inside { print }
' CHANGELOG.md > notes.md
if ! grep -q '[^[:space:]]' notes.md; then
echo "::error::CHANGELOG.md has no section for $version" >&2
exit 1
fi
echo "" >> notes.md
cat notes.md

# The binary is unsigned, so publish the hash rather than asking for
# trust. It is no longer the only check available -- see the attestation
# below -- but it is the one that needs no tooling.
- name: Record SHA-256
shell: pwsh
run: |
$hash = (Get-FileHash target\release\ProcDrift.exe -Algorithm SHA256).Hash
"SHA-256: ``$hash``" | Out-File -FilePath notes.md -Encoding utf8
"SHA-256: ``$hash``" | Out-File -FilePath notes.md -Append -Encoding utf8
"" | Out-File -FilePath notes.md -Append -Encoding utf8
"This binary is unsigned. SmartScreen will warn on first run until the build earns reputation." | Out-File -FilePath notes.md -Append -Encoding utf8
"" | Out-File -FilePath notes.md -Append -Encoding utf8
"Verify what produced it with ``gh attestation verify ProcDrift.exe --repo $env:GITHUB_REPOSITORY``." | Out-File -FilePath notes.md -Append -Encoding utf8
Write-Output "ProcDrift.exe $hash"

# The published hash says the file has not changed since it was hashed.
# It cannot say what produced it, because the same workflow computes and
# publishes it. The attestation binds this binary to this workflow, this
# commit, and this repository, and is checked with `gh attestation verify`
# against a store the release page cannot rewrite. That matters more here
# than usual: the binary is unsigned, so a downloader has nothing else.
#
# Deliberately before the release exists. A failure here leaves the tag
# re-runnable rather than leaving an unattested asset published.
- name: Attest the binary
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: target/release/ProcDrift.exe

- name: Publish release
shell: pwsh
env:
Expand Down
70 changes: 70 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Changelog

All notable changes to ProcDrift are recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

The release workflow reads the section matching the tag it is building and
publishes it as the release notes, appending the SHA-256 of the binary it just
produced. A tag whose version has no section here fails the release rather than
publishing an empty page, so this file is edited in the same commit that bumps
`Cargo.toml`.

## [Unreleased]

### Added

- `--version` and `--help`. Both are answered in a dialog, because a
windows-subsystem binary is never attached to the console that started it and
anything printed would go nowhere. Previously every argument except the
internal `--replace-instance` produced an error dialog, so asking a program
its version was indistinguishable from a mistake.
- The window title now carries the version, so a screenshot identifies the
build as precisely as the command line does.
- Release binaries carry a build provenance attestation, verifiable with
`gh attestation verify ProcDrift.exe --repo edgecasehuman/procdrift`. The
published SHA-256 says a file has not changed since it was hashed; the
attestation says which workflow, commit, and repository produced it.

### Changed

- The bug report template asks for the line `--version` reports instead of
naming a release that goes stale at every tag.
- The README documents installing from crates.io, and what differs about a
binary you compiled yourself: no mark-of-the-web, so no SmartScreen notice,
but it embeds your own build paths and should not be redistributed.

### Security

- CodeQL analysis, and a release environment that requires an approval before a
tag can publish.
- Repository secret and personal-data safeguards tightened.

## [1.0.1] - 2026-08-03

### Fixed

- A `baseline.json` placed anywhere in the working directory or its parents was
adopted as the reference snapshot, `{}` was accepted as a valid one, and
onboarding was then marked complete, so the machine's idea of normal could be
set by a planted file without anyone being told. The snapshot is now read only
from the executable's own directory, the file must actually look like a
baseline, and the first-run prompt names what it read.

## [1.0.0] - 2026-07-26

### Added

- Initial public release. A single Windows executable that records a reference
snapshot of every executable path it can see, then shows each later scan as a
difference against it: Trust states describing where a process stands relative
to the snapshot, and Findings describing what is true about it now.
- Offline Authenticode verification, catalog signatures first, distinguishing an
absent signature from one that fails to verify.
- Process control that re-checks process identity before acting, so a recycled
PID cannot be acted on by mistake.
- Findings phrased as observations rather than verdicts, enforced by a test.

[Unreleased]: https://github.com/edgecasehuman/procdrift/compare/v1.0.1...HEAD
[1.0.1]: https://github.com/edgecasehuman/procdrift/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/edgecasehuman/procdrift/releases/tag/v1.0.0
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
[![CI](https://github.com/edgecasehuman/procdrift/actions/workflows/ci.yml/badge.svg)](https://github.com/edgecasehuman/procdrift/actions/workflows/ci.yml)
[![Security audit](https://github.com/edgecasehuman/procdrift/actions/workflows/audit.yml/badge.svg)](https://github.com/edgecasehuman/procdrift/actions/workflows/audit.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![crates.io](https://img.shields.io/crates/v/procdrift.svg)](https://crates.io/crates/procdrift)

A single-executable Windows process monitor that answers one question: **what is
running now that was not running when this machine was known-good?**
Expand Down Expand Up @@ -70,6 +71,21 @@ Download `ProcDrift.exe` from [Releases] and run it. That is the whole install.

[Releases]: https://github.com/edgecasehuman/procdrift/releases

If you would rather compile it than download it, the crate is on [crates.io]:

```bash
cargo install procdrift
```

That needs rustup and the Visual Studio Build Tools, and produces the same
single executable -- see [Build](#build). Two differences are worth knowing. A
binary you compiled locally carries no mark-of-the-web, so the SmartScreen
notice below does not apply to it. It also embeds your own build paths, because
panic locations from dependencies survive `strip`, so it is fine to run and not
fine to redistribute.

[crates.io]: https://crates.io/crates/procdrift

State is written to `%LOCALAPPDATA%\ProcDrift\state.json`, and only after you
answer the first-run prompt, capture a snapshot, or change an allowance. Writes
are atomic. Nothing is written on a timer.
Expand All @@ -95,6 +111,12 @@ Keyboard: `F5` refresh, `Ctrl+F` or `Ctrl+K` search, `Ctrl+C` copy selected
rows, `Enter` details, `Space` toggle suspend, `F2` allow the selected path,
`Delete` end process, `Escape` clear search. Click a column heading to sort.

The window title carries the version, and `ProcDrift --version` reports it
without opening the window. No argument changes how it runs; `--help` says so
and names where state is kept. Because this is a windows-subsystem binary it is
never attached to the console that started it, so both answers arrive as a
dialog rather than as printed text.

Process control (suspend, resume, end, end captured tree) acts only on the
selected process and re-checks the process identity, so a recycled PID cannot be
acted on by mistake.
Expand Down
144 changes: 129 additions & 15 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,24 @@ use windows_sys::Win32::UI::Controls::InitCommonControls;
use windows_sys::Win32::UI::Shell::ShellExecuteW;
use windows_sys::Win32::UI::WindowsAndMessaging::{
CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DestroyWindow, DispatchMessageW,
GetMessageW, IDC_ARROW, LoadCursorW, MB_ICONERROR, MB_OK, MSG, RegisterClassW, SW_SHOW,
SW_SHOWNORMAL, ShowWindow, TranslateMessage, WNDCLASSW, WS_CLIPCHILDREN, WS_OVERLAPPEDWINDOW,
GetMessageW, IDC_ARROW, LoadCursorW, MB_ICONERROR, MB_ICONINFORMATION, MB_OK, MSG,
RegisterClassW, SW_SHOW, SW_SHOWNORMAL, ShowWindow, TranslateMessage, WNDCLASSW,
WS_CLIPCHILDREN, WS_OVERLAPPEDWINDOW,
};

fn run() -> Result<(), String> {
if let Some(pid) = replacement_pid(std::env::args().skip(1))? {
// Reported through a dialog rather than stdout. This is a windows-subsystem
// binary, so it is never attached to the console that launched it and
// anything printed would go nowhere; a dialog is the only channel that
// reaches the person who typed the argument.
let replaces = match invocation(std::env::args().skip(1))? {
Invocation::Report(text) => {
message(null_mut(), &text, "ProcDrift", MB_ICONINFORMATION | MB_OK);
return Ok(());
}
Invocation::Open(pid) => pid,
};
if let Some(pid) = replaces {
let old = unsafe { OpenProcess(SYNCHRONIZE_ACCESS, 0, pid) };
if !old.is_null() {
unsafe {
Expand All @@ -55,7 +67,7 @@ fn run() -> Result<(), String> {
unsafe { InitCommonControls() };
let instance: HINSTANCE = unsafe { GetModuleHandleW(null()) };
let class_name = wide("ProcDriftNativeWindow");
let title = wide("ProcDrift");
let title = wide(version_line());
let class = WNDCLASSW {
style: CS_HREDRAW | CS_VREDRAW,
lpfnWndProc: Some(window_proc),
Expand Down Expand Up @@ -106,16 +118,73 @@ fn run() -> Result<(), String> {
Ok(())
}

// `replacement_pid` and `restart_elevated` are the two ends of one handoff:
// the elevated copy parses the argument the unelevated copy wrote. They stay in
// the same file so the `--replace-instance` spelling cannot drift on one side.
fn replacement_pid(args: impl IntoIterator<Item = String>) -> Result<Option<u32>, String> {
/// What the command line asked this process to do.
#[derive(Debug, PartialEq, Eq)]
enum Invocation {
/// Open the window. `Some(pid)` is an elevated copy waiting on the
/// unelevated one it replaces.
Open(Option<u32>),
/// Show one dialog and exit without opening a window.
Report(String),
}

/// The name and version, in one line. This is the window title as well as the
/// answer to `--version`, so a screenshot of the running program identifies the
/// build as precisely as the command line does. The version is compiled in from
/// the manifest, which the release workflow has already checked against the tag.
fn version_line() -> String {
format!("ProcDrift {}", env!("CARGO_PKG_VERSION"))
}

/// There are no options that change how `ProcDrift` runs -- everything happens in
/// the window -- so this exists to name the build and to make a mistyped
/// argument recoverable rather than a bare error.
fn help_text() -> String {
let version = version_line();
[
version.as_str(),
"",
"Compares the processes running now against a reference snapshot taken",
"when the machine was known-good, and shows what changed.",
"",
"Usage: ProcDrift [--version | --help]",
"",
"No option changes how it runs: it opens one window, and everything is",
"done from there. The keys are listed in the README.",
"",
"State is kept in %LOCALAPPDATA%\\ProcDrift\\state.json, and only after",
"you answer the first-run prompt, capture a snapshot, or change an allowance.",
]
.join("\n")
}

// `invocation` and `restart_elevated` are the two ends of one handoff: the
// elevated copy parses the argument the unelevated copy wrote. They stay in the
// same file so the `--replace-instance` spelling cannot drift on one side.
//
// Parsing stays strict. `--replace-instance` makes this process wait on a PID
// another one named, so an argument list that is not exactly understood is
// refused rather than guessed at.
fn invocation(args: impl IntoIterator<Item = String>) -> Result<Invocation, String> {
let mut args = args.into_iter();
let Some(argument) = args.next() else {
return Ok(None);
return Ok(Invocation::Open(None));
};
let report = match argument.as_str() {
"--version" | "-v" => Some(version_line()),
"--help" | "-h" | "/?" => Some(help_text()),
_ => None,
};
if let Some(text) = report {
if args.next().is_some() {
return Err(format!("{argument} takes no further arguments."));
}
return Ok(Invocation::Report(text));
}
if argument != "--replace-instance" {
return Err(format!("unknown internal argument: {argument}"));
return Err(format!(
"Unrecognized argument: {argument}\n\nRun ProcDrift --help for the ones it accepts."
));
}
let pid = args
.next()
Expand All @@ -125,7 +194,7 @@ fn replacement_pid(args: impl IntoIterator<Item = String>) -> Result<Option<u32>
if args.next().is_some() {
return Err("unexpected arguments after --replace-instance PID".to_owned());
}
Ok(Some(pid))
Ok(Invocation::Open(Some(pid)))
}

fn restart_elevated(owner: HWND) -> Result<(), String> {
Expand Down Expand Up @@ -163,10 +232,55 @@ mod tests {
#[test]
fn elevated_replacement_argument_is_strictly_parsed() {
assert_eq!(
replacement_pid(["--replace-instance".into(), "42".into()]).unwrap(),
Some(42)
invocation(["--replace-instance".into(), "42".into()]).unwrap(),
Invocation::Open(Some(42))
);
assert!(invocation(["--replace-instance".into()]).is_err());
assert!(invocation(["--replace-instance".into(), "x".into()]).is_err());
assert!(
invocation(["--replace-instance".into(), "42".into(), "43".into()]).is_err(),
"a trailing argument must not be ignored"
);
assert!(invocation(["--other".into()]).is_err());
}

#[test]
fn no_arguments_opens_the_window() {
assert_eq!(
invocation(Vec::<String>::new()).unwrap(),
Invocation::Open(None)
);
}

#[test]
fn version_and_help_are_answered_instead_of_refused() {
// Every one of these used to reach the unknown-argument branch and
// produce an error dialog, which is the wrong answer to a fair question.
assert_eq!(
invocation(["--version".into()]).unwrap(),
Invocation::Report(version_line())
);
assert_eq!(
invocation(["-v".into()]).unwrap(),
Invocation::Report(version_line())
);
assert!(replacement_pid(["--replace-instance".into()]).is_err());
assert!(replacement_pid(["--other".into()]).is_err());
for spelling in ["--help", "-h", "/?"] {
let Ok(Invocation::Report(text)) = invocation([spelling.to_owned()]) else {
panic!("{spelling} was not answered");
};
assert!(text.starts_with(&version_line()), "{spelling}: {text}");
assert!(text.contains("--version"), "{spelling} omits --version");
}
// Strictness survives: these are still parsed, not merely recognized.
assert!(invocation(["--version".into(), "extra".into()]).is_err());
assert!(invocation(["--help".into(), "extra".into()]).is_err());
}

#[test]
fn the_reported_version_is_the_compiled_in_one() {
// The window title and --version are the same string, so a screenshot
// and a command line cannot disagree about which build is running.
assert!(version_line().ends_with(env!("CARGO_PKG_VERSION")));
assert!(help_text().starts_with(&version_line()));
}
}