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/.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 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())); } }