From ac2fd8a3156a63ff2635bca06bdedb1a84c73534 Mon Sep 17 00:00:00 2001 From: edgecasehuman <309310929+edgecasehuman@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:59:56 +0000 Subject: [PATCH 1/2] Report the version instead of refusing to be asked for it CARGO_PKG_VERSION appeared nowhere in the source. The version existed only in the Windows file properties winresource stamps into the executable, so the running program could not say which build it was -- not in the window, not on the command line, nowhere a person could see it. Worse, asking produced an error. Every argument except the internal --replace-instance fell through to the unknown-argument branch, so `ProcDrift --version` answered "unknown internal argument: --version" in an error dialog. For a tool whose release notes ask you to match a SHA-256 before trusting the binary, being unable to state its own version is the wrong end of that bargain. --version, -v, --help, -h and /? are now answered. In a dialog, because this is a windows-subsystem binary: it is never attached to the console that launched it, so anything printed would go nowhere, and a dialog is the only channel that reaches the person who typed the argument. The window title carries the version as well, so a screenshot identifies the build as precisely as the command line does. Parsing stays strict, because --replace-instance makes this process wait on a PID another process named, and an argument list that is not exactly understood should be refused rather than guessed at. `--version extra` is still an error, and so is a trailing argument after the --replace-instance PID -- which the previous test did not cover. Two documentation consequences. The bug report template asked for "1.0.0, or the commit SHA if built from source", a placeholder that goes stale at every release; it now asks for the line --version reports. And the README documents installing from crates.io, where this crate has been since 1.0.1, including what differs about a binary compiled locally: no mark-of-the-web, so no SmartScreen notice, but it embeds your own build paths and should not be redistributed. 34 tests to 37. --- .github/ISSUE_TEMPLATE/bug_report.yml | 5 +- README.md | 22 ++++ src/main.rs | 144 +++++++++++++++++++++++--- 3 files changed, 155 insertions(+), 16 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f17b8aa..4d84dee 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/README.md b/README.md index 9d6a871..a7ef92b 100644 --- a/README.md +++ b/README.md @@ -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?** @@ -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. @@ -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. diff --git a/src/main.rs b/src/main.rs index 1e27735..94c6caa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { @@ -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), @@ -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) -> Result, 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), + /// 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) -> Result { 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() @@ -125,7 +194,7 @@ fn replacement_pid(args: impl IntoIterator) -> Result 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> { @@ -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::::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())); } } From 8f24f433cc19195bc522d2d2ea23501a12e51713 Mon Sep 17 00:00:00 2001 From: edgecasehuman <309310929+edgecasehuman@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:59:56 +0000 Subject: [PATCH 2/2] Attest the release binary, and take its notes from a changelog ProcDrift ships one unsigned executable that SmartScreen will warn on, and its only integrity check is a SHA-256 written into the release notes by the same workflow that built it. That hash says the file has not changed since it was hashed. It cannot say what produced it, because the thing publishing the hash is the thing being vouched for. attest-build-provenance binds the binary to this workflow, this commit, and this repository, in a store the release page cannot rewrite, checkable with `gh attestation verify ProcDrift.exe --repo edgecasehuman/procdrift`. It matters more here than it would elsewhere: the binary is unsigned, so a downloader has nothing else to go on. It runs before the release exists, so a failure leaves the tag re-runnable rather than leaving an unattested asset published. v1.0.1's notes were written by hand afterwards with `gh release edit`, splicing the generated hash line through byte for byte, because the workflow produced only that hash and the unsigned-binary warning. The reason to upgrade was the whole point of that page and it was the one part the release could not produce. Notes now come from a CHANGELOG section matching the tag, with the hash and the verification command appended. A tag whose version has no section fails the run rather than publishing an empty page. The changelog is reconstructed from the tags and the commits rather than from memory: 1.0.0 as released, 1.0.1's baseline-adoption fix as the reason that release existed, and the post-tag CodeQL and approval-gate work under Unreleased. Verified by executing the workflow's own run: script -- read out of the YAML rather than retyped -- against the real changelog, for both released versions and for a version that does not exist, which must fail the run. --- .github/workflows/release.yml | 50 +++++++++++++++++++++++-- CHANGELOG.md | 70 +++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 906cac9..4fef405 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..09b8251 --- /dev/null +++ b/CHANGELOG.md @@ -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