diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eeefb2a..edb6157 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,12 @@ name: CI +# Three operating systems, but not on every commit. +# +# A push to main runs one job, on the platform a Windows development machine cannot check +# for itself. The full matrix runs on a pull request, on the Monday schedule, when asked, +# and — through `workflow_call` — as the gate a release passes before it publishes +# anything. That is the whole policy: the check that must never be skipped is the one at +# the cut, and it is the cut that now pays for it. on: push: branches: [main] @@ -14,6 +21,13 @@ on: - 'docs/**' - 'assets/logo/**' - 'assets/screenshots/**' + schedule: + # Monday, 04:17 UTC. Early enough in the week that a platform-only break has six days + # to be found before the next one, and off the hour so it does not join the queue of + # every other project's nightly. + - cron: '17 4 * * 1' + workflow_dispatch: + workflow_call: # A superseded push has nothing to say. Cancel it and let the cache serve the next one. concurrency: @@ -28,15 +42,33 @@ env: CARGO_INCREMENTAL: '0' jobs: + # The matrix is chosen by the event rather than written twice, so the caller and the + # push cannot disagree about what "checked" means. + plan: + name: Choose the matrix + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + os: ${{ steps.pick.outputs.os }} + steps: + - id: pick + run: | + if [ "${{ github.event_name }}" = "push" ]; then + echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT" + else + echo 'os=["ubuntu-latest","windows-latest","macos-latest"]' >> "$GITHUB_OUTPUT" + fi + check: name: ${{ matrix.os }} + needs: plan runs-on: ${{ matrix.os }} timeout-minutes: 30 strategy: fail-fast: false matrix: # Ubuntu latest for the quick check; the release workflow uses 22.04 for glibc. - os: [windows-latest, ubuntu-latest, macos-latest] + os: ${{ fromJSON(needs.plan.outputs.os) }} steps: - uses: actions/checkout@v4 with: @@ -64,3 +96,22 @@ jobs: # core and the native collectors, and type-checks the UI and shell. - name: Check run: cargo xtask ci + + # A `#[cfg]` bug lives on the platform that is not being compiled, and this repository + # has just had two: a path that only resolves on Unix, and a helper only Windows called. + # The Windows runner is the one that can type-check the Linux code without spending a + # matrix slot on it. + - name: Type-check the other two platforms from Windows + if: runner.os == 'Windows' + run: | + rustup target add x86_64-unknown-linux-gnu x86_64-apple-darwin + cargo clippy --workspace --all-targets --target x86_64-unknown-linux-gnu -- -D warnings + # The macOS collectors compile without a Mac; only the linking and the running + # need one, and `check` does neither. + cargo clippy --package vnr-native --all-targets --target x86_64-apple-darwin -- -D warnings + + # The optional features are deliberately not in the default build, which is exactly + # why they need their own step: code behind a feature flag is code no default check + # compiles. This is the content scan, and it pulls a pattern engine with it. + - name: Optional features + run: cargo xtask features diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2fa818..cbdb56d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,10 +20,20 @@ env: GH_REPO: ${{ github.repository }} jobs: + # A tag is a promise that the commit it names was checked on three platforms. The gate + # calls the same workflow a pull request runs rather than a copy of it, so the two cannot + # drift: a red check stops the release here, before a draft exists to be published. + gate: + name: Gate + uses: ./.github/workflows/ci.yml + permissions: + contents: read + # Create the release once, as a draft. It becomes visible only after every archive # and SHA256SUMS.txt are attached, so a half-finished release cannot be installed. prepare: name: Prepare release + needs: gate runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.gitignore b/.gitignore index 3ca3288..7f5785b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,12 @@ desktop.ini /scratch/ *.local.toml -# Scratch profile for a local test run: the data directory the harness writes -/.tmp-ci/ +# Scratch profiles for a local test run: the data directory a harness writes, and the +# throwaway profile a report is written into. They hold this machine's facts - process +# paths, listeners, packages - so they never belong in a commit. +# `probe`, `bench` and any other scratch directory a test run invents start with this +# prefix, so one rule covers what three did and the next one is covered before it exists. +/.tmp-*/ # A commit message written to a file, so a long one survives the shell /.git-commit-msg.txt diff --git a/AGENTS.md b/AGENTS.md index ead39dd..5832aff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,12 +29,15 @@ crates/ core vnr-core pure and IO-free: facts, snapshots, timelines, diffs, rules, findings, hash signatures, reports, time arithmetic, journal records, recovery verdicts, pack schema and matcher, - clean policy, schedule arithmetic, the Sigma converter + the allowlist table and its refusals (`action.rs`), the field + vocabulary and the pack schema (`fields.rs`, `pack/`), + schedule arithmetic native vnr-native the only crate that touches the world: collect/{processes,autoruns,listeners,packages,tasks,agentstack}, hash, signatures, elevation, paths, store, watch, scan, - journal, action, quarantine, clean, pack, tray, autostart - ui vnr-ui egui: theme, fonts, icons, widgets, the views, World/Action + journal, act, action, quarantine, clean, pack, tray, autostart + ui vnr-ui egui: theme, fonts, icons, widgets, the views, World/Action, + and the clean card's selection state apps/ vn-ranger the eframe shell: owns the World, runs the worker, scheduler and tray, carries out actions, and the headless @@ -45,7 +48,7 @@ assets/ fonts/ Inter and JetBrains Mono, OFL texts, type notes screenshots/ the picture the README uses packs/ the first-party rule pack, signed like any other (from 0.1.6) -docs/ PLAN, RELEASES, BENCHMARKS, and RULES (from 0.1.6) +docs/ PLAN, RELEASES, BENCHMARKS and RULES ``` **Layering is a rule, not a suggestion.** @@ -123,9 +126,11 @@ act on — never a panic. not a trade-off to be weighed; it is out of scope. - Rule packs and signature databases are local files. They cannot fetch, and rules are data, not code. -- One local socket is allowed, and only this one: the Linux tray's status-item connection - to the session bus, in `native/src/tray/linux.rs`. `xtask` asserts that it is the only - socket use in the tree, so "local IPC" cannot quietly become something else. +- One local socket exists anywhere near the product, and it belongs to the tray's Linux + backend: the status-item connection to the session bus, opened by `ksni` (pure-Rust + `zbus`), which is reached through `tray-icon`'s `ksni` feature. No file in this tree + opens a socket, `xtask` asserts that, and the dependency that does is the one named in + §8 — so "local IPC" cannot quietly become something else. ### Never hide @@ -170,6 +175,9 @@ cargo xtask language # the §5.5 language audit, as a check cargo run --release # the ranger cargo run --release -- --report report.json # scan without a window cargo run --release -- --soak soak.ndjson # sample a 24-hour run (from 0.1.6) +cargo run --release -- --diagnose # where the files are, and whether this + # program may write them +cargo run --release -- --sigma rule.yml # import one Sigma rule as a pack to sign ``` The first build is the slow one: egui, wgpu and winit are most of the tree. After that, @@ -206,8 +214,11 @@ capability report. ## 5. Platform rules -CI builds on three platforms, and two of them will disagree with your machine. An -attribute on a **statement** deletes it on the other platforms, which can leave a +CI builds on three platforms, and two of them will disagree with your machine. A push to +`main` checks Linux only; a pull request, the Monday run and the release gate check all +three. So a `#[cfg]` bug can live on `main` until the next Monday or until the cut, and +the target check below is how you find it on the day you write it rather than the day you +tag. An attribute on a **statement** deletes it on the other platforms, which can leave a binding unused, a variable unread or a branch unreachable: ```rust @@ -291,11 +302,15 @@ Every new dependency is a licence, a supply chain and a size decision. - Not allowed: GPL, AGPL, LGPL without a written exception, CDLA, SSPL, BUSL, or anything whose licence text has to be guessed. - No network crate in v0.x: `reqwest`, `ureq`, `hyper`, `h2`, `curl`, `isahc`, `surf`, - `attohttpc`, `socket2` and raw socket code are banned. The one local socket the Linux - tray needs is named in §2 and asserted by `xtask`. + `attohttpc`, `socket2` and raw socket code are banned. The one local socket the tray + needs is named in §2 and asserted by `xtask`. - No C or C++ build dependency in the default build. A crate that needs one has to justify it; `netstat2` is Windows-only here for exactly that reason, and the Linux tray - uses `ksni` rather than `tray-icon` precisely to avoid GTK3 and `libappindicator`. + takes `tray-icon` with `default-features = false, features = ["ksni"]` precisely so that + no GTK3 and no `libappindicator` is compiled in. Both halves of that are a decision: + `tray-icon` (MIT OR Apache-2.0) for one menu API on three platforms, and its `ksni` + backend, which speaks StatusNotifierItem through the pure-Rust `zbus` and needs no + toolkit installed on the user's machine. - OS access goes through a safe wrapper (`winreg`, `netstat2`, `is_elevated`, `runas`, `sysinfo`, `plist`, `cap-std`), never through a hand-written FFI block. - **The action path's dependency is `cap-std`**, and it is not negotiable for @@ -346,10 +361,10 @@ A change is done when: | Rules and findings | `core/src/rules.rs` | A rule that cannot name a benign explanation is not ready; every finding carries the evidence it fired on and a stable subject for suppressions. | | The action path | `native/src/action.rs`, `quarantine.rs`, `clean.rs` | The only code that can destroy data. `cap-std`, four refusals, a journal line before every act, and a test seam for TOCTOU — never a race. | | The journal | `native/src/journal.rs`, `core/src/journal.rs` | Append-only, `sync_data`, a torn tail tolerated, a rotation that keeps seq monotone, and a recovery verdict for four on-disk states. | -| The allowlist | `native/src/clean.rs`, `core/src/clean.rs` | A table, not a heuristic; the walk never follows a link or crosses a device, and a file changed since the preview is skipped. | +| The allowlist | `native/src/clean.rs`, `core/src/action.rs` | A table, not a heuristic; the walk never follows a link or crosses a device, and a file changed since the preview is skipped. | | Packs | `core/src/pack/`, `native/src/pack/` | Verified before parsed; a closed vocabulary; an unknown field is an error; activation and rollback are journal entries. | | The Sigma reader | `core/src/sigma/` | YAML is user input: a restricted reader that refuses anchors, tags and multi-document files rather than guessing. | -| The tray | `native/src/tray/` | `tray-icon` on Windows and macOS, `ksni` on Linux, an event loop per platform, and no icon is a supported state. | +| The tray | `native/src/tray.rs` | One crate for three desktops, so the subtlety is not a per-platform module: it is that the icon must be created on the frame's thread, that a click arrives on a channel this process has to wake the interface for, and that "no status area" is a state rather than an error. | | Elevation | `native/src/elevation.rs`, `apps/vn-ranger/src/worker.rs` | The elevated copy is one-shot; the unelevated window must not hand it a path to act on. | | Listener ownership | `native/src/collect/listeners/` | The socket table and the fd table are read at different times; other users' sockets need privilege. | | Autorun parsing | `native/src/collect/autoruns/` | Registry views, 32/64-bit keys, quoted paths, binary plists, systemd user units, and a surface table read through one trait. | diff --git a/CHANGELOG.md b/CHANGELOG.md index 58bd6c8..352e896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to vn-ranger are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.1.6] - 2026-10-18 ### Added @@ -37,8 +37,186 @@ All notable changes to vn-ranger are recorded here. The format follows - A resident schedule: six hours ±10 % from a seeded jitter, and a scan when the machine wakes from sleep. +### Added — rule packs + +- **A pack now fires.** `vnr_core::pack::evaluate` takes a rule and a fact view and returns a + `Finding`: `all` and `any`, text compared case-insensitively, an absent field matching + nothing, `glob` as a two-pointer walk instead of a regular expression, and evidence that + names the value the fact actually had. Before this, a pack could be proved valid and could + never say anything. +- **The field vocabulary is typed.** Every field of every subject is text, a number or + true/false, and the validator refuses an operator that cannot hold for it (`gt` on a path, + `prefix` on a port) at load time, with the operators that would work in the refusal. +- **Places instead of paths.** A rule may name `{TEMP}`, `{HOME}`, `{DOWNLOADS}`, + `{DESKTOP}`, `{APPDATA}`, `{LOCALAPPDATA}`, `{PROGRAMDATA}` or `{STARTUP}`; the core resolves + none of them, so the caller hands the paths in and a build on a machine that cannot name a + place does not guess at one. +- **Activation and rollback.** `packs/active.json` names the pack, its digest and the key that + signed it; the pair of writes is journalled with an intent before and a result after, and + `previous.json` is written first so a crash leaves a way back. Rolling back swaps the two + records and touches nothing else. +- **The first-party pack is compiled in** with `include_str!`, so the catalogue travels with + the build rather than with a file a person could swap. +- **The Rule packs page**: what is loaded and who signed it, every pack in the folder with + what verification says about it, keys added only when the typed fingerprint is the key's + own, and a key removed by fingerprint — which unloads the packs it signed, because the + record is verified again every time it is read. +- **The Sigma importer** (`--sigma rule.yml`): a restricted YAML reader that refuses anchors, + aliases, tags and multi-document files by line; `Image`, `CommandLine` and `User` map with + three modifiers; everything else is counted with a reason that names the field or the + condition. The output is a pack that still has to be signed. +- **The optional content scan** (`--features content-scan`): `yara-x`, behind a flag, off in + the default build, with the walk bounded by files, depth and size, no link followed, and + nothing but a rule name and a path kept. `cargo xtask ci` proves the default build contains + no YARA-X with `cargo tree`. + +### Changed — the interface + +- The overview opens with a line of counts rather than four tiles, and says how long the last + scan ago was, how long it took and how many collectors answered, where a person reads about + the scan. +- "No network. No telemetry." moved from the overview to Settings, with the data folder beside + it, because it is a statement about the program rather than a fact about the machine. +- Dialogs have one header size and one padding: a dialog titled at a page's size reads as a + page in a box, and the margin now comes from the theme rather than from each dialog. +- The controls above every table are chips and a 24-pixel search field, sized with the table + header, instead of links at body size. +- The journal's failure sentence now names the folder and the two things that cause it — + a security program, or a policy — and the variable that moves it. + +### Fixed + +- A journal that could not be opened said `Access is denied` and nothing a person could act + on. It now says which of the three problems it is: this program may not write in the folder + (a security program, a folder policy or a sandbox all look like this, and + `VN_RANGER_DATA_DIR` moves the folder), the journal file itself cannot be opened (it is + moved aside as `journal.kept-