diff --git a/.agent/PROJECT_STATE.md b/.agent/PROJECT_STATE.md index 8e9a3fb..47465c1 100644 --- a/.agent/PROJECT_STATE.md +++ b/.agent/PROJECT_STATE.md @@ -4,7 +4,7 @@ Last updated: 2026-08-13 ## Current phase -The public foundation, pre-collector hardening, and first real Windows product slice are on `main`. PR #6 is merged: SystemDiff can capture Registry Run/RunOnce startup evidence, write a Snapshot, and compare a real before/after pair. The current `feat/human-readable-diff` work for Issue #7 is the first product-presentation pass: default human-readable Diff output, an explicit technical text mode, and a truthful stranger-first repository front page. v0.1 remains incomplete. +The public foundation, pre-collector hardening, first real Windows product slice, and stranger-first Diff presentation are on `main`. PR #8 is merged: SystemDiff has default human-readable Registry Diff output, exact `--technical` evidence, deterministic `--json`, and a truthful Registry-only front page. The current `build/portable-developer-preview` work for Issue #9 adds an unsigned, CI-generated Windows x64 package so the existing product slice can be exercised without a Rust development environment. v0.1 remains incomplete. ## Implemented components @@ -14,15 +14,16 @@ The public foundation, pre-collector hardening, and first real Windows product s - Coverage-aware comparison: incomplete, unavailable, unsupported, or permission-denied scope coverage cannot silently become a Removed finding. - `windows.registry.startup` v1 using query-only Win32 Registry APIs, explicit Registry views, scoped diagnostics, bounded mutation/resource handling, strict native-data decoding, lossless UTF-16 value names, and complete-value SHA-256. - `systemdiff snapshot -o ` with canonical UTC metadata, bounded serialization, and create-new output semantics. -- Default human-readable Registry Diff output, explicit `--technical` evidence output, and unchanged `--json` machine output on the current Issue #7 feature branch. +- Default human-readable Registry Diff output, explicit `--technical` evidence output, and unchanged `--json` machine output. +- On the current Issue #9 branch, a release-mode Windows x64 Developer Preview pipeline with packaging-only static MSVC CRT, an explicit `asInvoker` manifest, exact package/checksum verification, and a later artifact-download smoke job. - Registry-only synthetic before/after fixtures and a dual-gated test-only real HKCU E2E. The real E2E observed exactly one expected Added startup value, zero Removed changes, and verified exact-data cleanup; production Rust has no Registry write path. - A truthful Registry-only README demo whose transcript is regression-tested and whose static visual is derived from that exact output. - Project-scoped Codex agents, three repeated-workflow skills, living ExecPlans, architecture/format/Collector/threat-model documentation, and synthetic cross-platform tests. ## Known limitations -- Only Registry Run/RunOnce collection is implemented. Services, Scheduled Tasks, rules/explanations, sanitization, installation/package delivery, and the desktop app are unavailable. -- There is no official binary release. Current users must build the development CLI from source. +- Only Registry Run/RunOnce collection is implemented. Services, Scheduled Tasks, rules/explanations, sanitization, an installer, and the desktop app are unavailable. +- There is no official binary Release or Authenticode signing. The Developer Preview is an expiring GitHub Actions artifact that requires GitHub sign-in, and clean-machine validation remains a gate for an official alpha. - The current minimum is Windows 10 version 1709 or Windows Server 2016 version 1709. ARM64 v1 collects HKCU Shared scopes but reports HKLM alternate-view coverage as unsupported until those views are represented and tested. - Snapshot files are unredacted and can contain usernames in paths, command strings, software details, and other sensitive host evidence. They must be reviewed before sharing. - Draft v0.1 diffs assume the same Windows installation and the same user/principal context. Cross-host and cross-user identity are intentionally out of scope. @@ -41,15 +42,17 @@ The public foundation, pre-collector hardening, and first real Windows product s - Unknown cross-version comparisons for the same Collector ID are rejected by default. A future verified compatible pair remains possible, but no migration framework exists. - Registry views, RunOnce prefixes, and value names retain their documented/evidence semantics; no command parsing, environment expansion, executable resolution, signature check, or risk inference occurs. - Normal changes to `main` go through pull requests and the two required checks: `Rust (windows-latest)` and `Rust (ubuntu-latest)`. +- Developer Preview packaging runs only after those gates on trusted upstream `push` events, uploads an exact ZIP/checksum pair for 14 days, and verifies the downloaded artifact in a fresh Windows job. Normal artifacts come from `main`; the exact Issue #9 branch temporarily produces a clearly named candidate for pre-merge validation. Fork pull requests cannot enter this upload path. +- The portable build alone uses static MSVC CRT and remains at version `0.0.0`. The package is commit-linked and hashed but is not claimed to be reproducible, signed, released, or permanently downloadable. ## Next milestone -Finish review and CI for Issue #7 without merging automatically. After that, plan the lightest credible portable developer preview so strangers can try the real Registry workflow without a Rust toolchain. Windows Services remains the next Collector candidate, but it is not started in the presentation PR. +Finish implementation, independent review, remote CI, and downloaded-artifact verification for Issue #9 without merging automatically. A signed, permanent public download remains future release work. Services remains the next Collector candidate but is not started in this productization PR. ## Major unresolved questions - What genuine, monitored private channel should receive Code of Conduct reports? -- What publisher-signing and portable-build process can support a trustworthy first Windows developer preview? +- What publisher-signing, clean-machine validation, and immutable Release process should support the first official Windows alpha? - What explicitly versioned identity upgrade should eventually address Registry value-name casing without hiding raw evidence or coupling Diff to mutable Windows NLS behavior? - What bounded/archive policy should apply to Scheduled Task raw XML before that Collector is implemented? - What minimum supported Rust version will be tested and documented? diff --git a/.agent/plans/portable-windows-developer-preview.md b/.agent/plans/portable-windows-developer-preview.md new file mode 100644 index 0000000..aa69c2f --- /dev/null +++ b/.agent/plans/portable-windows-developer-preview.md @@ -0,0 +1,157 @@ +# Portable Windows developer preview + +Status: Complete +Owner: primary agent +Last updated: 2026-08-13 + +## Goal + +Issue #9 removes the Rust/MSVC development-environment barrier for the current Registry-only product slice. The repository should produce a short-lived GitHub Actions artifact containing a release-mode Windows x64 CLI, a small end-user quick start, license, build provenance, and a checksum, then verify the uploaded bytes in a later clean job. + +This is productization of the existing CLI, not an official SystemDiff release. + +## User-visible outcome + +A signed-in GitHub user can download the successful CI run artifact, verify `systemdiff-windows-x86_64.zip`, extract it, and run `systemdiff.exe` without Cargo or a Rust toolchain. The packaged executable supports the already implemented `--help`, `collectors`, `snapshot`, and Registry-only `diff` workflows. Documentation clearly labels the artifact as an unsigned, ephemeral Developer Preview rather than a release asset. + +## Current architecture and context + +- The workspace version remains `0.0.0`; `systemdiff-cli` declares the binary name `systemdiff`. +- The existing `rust` CI matrix provides the required `Rust (windows-latest)` and `Rust (ubuntu-latest)` correctness checks with repository contents read-only permission. +- The CLI has no runtime network feature, account, telemetry, installer, updater, persistence, or Registry write path. Real snapshot collection is query-only and does not require administrator elevation; denied coverage is reported. +- The current minimum collection platform is Windows 10 version 1709 or Windows Server 2016 version 1709. This preview is x86_64 only. +- A real default release build on 2026-08-13 produced an x64 CUI PE of 1,652,224 bytes. `dumpbin /DEPENDENTS` showed `VCRUNTIME140.dll` and UCRT API-set imports, and `mt.exe` confirmed the file had no manifest resource. +- A controlled release build with `-C target-feature=+crt-static` produced an x64 CUI PE of 1,751,040 bytes. Its dependency list contained only Windows system DLL/API-set imports (`advapi32`, `ntdll`, `kernel32`, and `api-ms-win-core-synch-l1-2-0`), removing the separate VC runtime dependency. +- GitHub Actions artifacts are run-scoped, expire, and are not GitHub Release assets. Browser download access requires GitHub sign-in; documentation must preserve that friction rather than call this a public release download. + +## Constraints + +- Do not create a GitHub Release, tag, semantic version, signing infrastructure, installer, package-manager entry, GUI, session/baseline workflow, or Collector. +- Preserve `permissions: contents: read`; do not use `pull_request_target`, secrets, or attacker-controlled GitHub context in shell commands or packaged metadata. +- Pull requests continue to run correctness checks but do not upload a trusted-looking developer-preview executable. Normal packaging is limited to upstream `main` pushes. The exact Issue #9 branch has a narrow pre-merge push exception and a distinct `-candidate` artifact name; a fork pull request cannot generate either upstream event. +- The archive is built from an explicit allowlist. It must not include PDBs, snapshots, credentials, runner paths, repository internals, or staging directories. +- The executable remains unsigned. Documentation may describe possible SmartScreen/reputation warnings, checksum verification, and source inspection, but must never recommend disabling or bypassing Windows security controls. +- Snapshots and every report mode remain unredacted and sensitive by default. +- Static CRT removes the observed VC redistributable imports; it does not make Windows system DLLs disappear and is not proof of compatibility on every clean machine. +- Do not claim reproducible builds. The actual guarantees are a locked Cargo graph, immutable Action pins, commit-linked metadata, exact artifact verification, and a SHA-256 checksum. + +## Implementation steps + +1. Add a minimal Windows MSVC executable manifest for the `systemdiff` binary with `requestedExecutionLevel="asInvoker"` and `uiAccess="false"`. Link it through a standard-library-only Cargo build script and verify the final PE resource; do not add a resource crate. +2. Add `packaging/windows/QUICKSTART.md` for non-Rust users. Cover preview status, x64/minimum OS, Registry-only scope, first commands, before/after workflow, privacy, unsigned status, no-admin expectation, and deletion-based uninstall. +3. Add a focused PowerShell packaging script that: + - builds `systemdiff-cli` with `--locked --release --target x86_64-pc-windows-msvc`; + - enables static MSVC CRT only for this portable build; + - constructs an allowlisted staging root containing `systemdiff.exe`, `QUICKSTART.md`, `LICENSE`, `THIRD_PARTY_LICENSES.txt`, and `BUILD_INFO.txt`; + - writes only non-sensitive commit/target/Rust/profile/CRT metadata; + - creates `systemdiff-windows-x86_64.zip` and a standard external `SHA256SUMS` entry for that ZIP. +4. Add a focused PowerShell verifier that accepts explicit artifact and fixture paths, then: + - enforces exact artifact and ZIP contents and verifies SHA-256; + - checks PE x64/CUI identity, direct imported DLLs, no observed dynamic VC runtime imports, and no common direct network DLL imports; + - extracts and parses the embedded manifest, requiring `asInvoker` and `uiAccess=false`; + - reports the Authenticode state and requires the documented unsigned-preview state; + - launches only the extracted artifact executable for `--help`, `collectors`, human/technical/JSON fixture diffs, and a real read-only Snapshot; + - parses the Snapshot header and Collector presence, then deletes only its exact temporary files. +5. Extend CI after the existing matrix gate: + - a Windows package job builds and verifies the preview, then uploads only the ZIP and checksum with immutable action pins and 14-day retention; + - a later fresh Windows job downloads that workflow artifact, checks out fixtures, and runs the same artifact-only verifier without building through Cargo; + - package/verify jobs run only for upstream pushes to `main` and the exact Issue #9 branch, never for a pull-request event; the feature-branch artifact is visibly labeled as a candidate. +6. Update English and Chinese READMEs, project state, roadmap, and the supply-chain portion of the threat model. Keep the hero focused on product value, maintain factual parity, and remove the stale source-build-only statement without implying an official release. +7. Run local validation, independent review, final-diff inspection, and documentation checks. Fix only material findings. +8. Commit, push `build/portable-developer-preview`, open a ready PR closing Issue #9, wait for every remote check, inspect the actual uploaded artifact metadata, download it back, and rerun verification. Do not merge. + +## Affected files and modules + +- `.agent/plans/portable-windows-developer-preview.md` +- `.agent/PROJECT_STATE.md` +- `.github/workflows/ci.yml` +- `crates/systemdiff-cli/build.rs` +- `crates/systemdiff-cli/systemdiff.manifest` +- `THIRD_PARTY_LICENSES.txt` +- `packaging/windows/QUICKSTART.md` +- `scripts/package-windows-preview.ps1` +- `scripts/verify-windows-preview.ps1` +- `README.md` +- `README.zh-CN.md` +- `docs/roadmap.md` +- `docs/threat-model.md` + +No Rust source, public schema, fixture, Cargo dependency, Collector, or product runtime behavior should change. + +## Test strategy + +- Existing gates: `cargo fmt --all --check`, locked workspace Clippy with warnings denied, and locked workspace all-target tests. +- Build: a real locked `release` build for explicit `x86_64-pc-windows-msvc`, with the portable path's static CRT setting. +- Package integrity: exact allowlists at artifact and archive roots, normalized checksum format, checksum re-computation, no PDB or nested staging content. +- PE inspection: actual `dumpbin` headers/dependencies and `mt.exe` extraction of the final packaged executable; fail closed on wrong architecture, forbidden runtime imports, missing/wrong manifest, or unexpected signing state. +- Artifact-only CLI: packaged `--help`, `collectors`, human/technical/JSON Registry fixture diffs, and read-only `snapshot -o` with parsed v1 header and `windows.registry.startup` presence. +- CI chain: the smoke job downloads the artifact created by the preceding upload job on a fresh runner and never runs Cargo. +- Post-push: use GitHub API/CLI to confirm run, job results, artifact name/size/expiry; download remote artifact and repeat checksum/archive/binary/smoke verification locally. +- Manual: inspect README links, EN/ZH factual parity, stale source-build language, `git diff --check`, final tracked file list, and absence of machine paths/secrets. + +## Risks + +- A workflow artifact can be mistaken for a release. Mitigation: Developer Preview naming in the artifact, package, quick start, workflow jobs, and both READMEs; no tag/release/version bump. +- Fork code could publish an executable with a trusted-looking name. Mitigation: job-level same-repository guard; fork PRs run only the existing correctness matrix; no `pull_request_target` or privileged token. +- Dynamic CRT imports could make a supposedly portable executable fail on a fresh system. Mitigation: packaging-only static CRT plus inspection of the exact zipped executable; still retain a clean-machine manual-validation caveat. +- An implicit/default manifest could trigger Windows installer detection or obscure privilege intent. Mitigation: explicit embedded manifest verified from the packaged PE. +- Packaging could leak host/repository files. Mitigation: exact five-file ZIP allowlist and two-file outer artifact allowlist, verified before and after upload. +- CI can prove artifact-only execution on a hosted runner but not an arbitrary clean supported Windows installation. Mitigation: precise documentation and a deferred clean-machine release gate. +- Static CRT fixes require rebuilding to receive CRT security fixes. Mitigation: previews are short-lived CI artifacts; dependency/toolchain updates rebuild the executable. Revisit for a signed official release. + +## Rollback and compatibility + +The change is isolated to build metadata, scripts, CI, and documentation. Reverting it removes Developer Preview artifact production without affecting source builds, Snapshot/Diff schemas, fixtures, CLI arguments, or runtime evidence. Existing required check names remain unchanged. The explicit `asInvoker` manifest makes the intended existing privilege model reliable but does not add an elevation path. + +## Progress + +- [x] 2026-08-13: synchronized clean `main` at `b9c6a981ef7f45c2f4645066a050f4e97b5a9863`; latest `main` CI was green. +- [x] 2026-08-13: created Issue #9 and branch `build/portable-developer-preview`. +- [x] 2026-08-13: built and inspected default and static-CRT release executables; recorded exact sizes, imports, signature state, and missing manifest. +- [x] 2026-08-13: implemented packaging, binary verification, upstream-push-only CI upload/download verification, and English/Chinese end-user documentation. +- [x] 2026-08-13: local locked format, Clippy, and 88-test workspace gates passed; the packaged artifact-only smoke passed with the toolchain removed from `PATH`. +- [x] 2026-08-13: independent review found no High issues and five Medium/one Low across publication scope, upload allowlisting, timeouts, manifest namespace validation, and import validation; all were addressed and the focused package verifier reran successfully. +- [x] 2026-08-13: pushed implementation commit `986f5f03a4253e32a570c0d47227e45be2e71f4d`, opened PR #10, observed all PR/push jobs pass, and downloaded/reverified the real GitHub artifact. + +## Discoveries + +- The default Rust MSVC release executable is not sufficient evidence for “no VC++ Redistributable”: it directly imports `VCRUNTIME140.dll` and UCRT API-set libraries on the actual maintainer machine. +- Static CRT is a small measured size tradeoff here: +98,816 bytes (about 6%) before adding the manifest, while removing the observed redistributable imports. +- The default executable contains no resource section, so privilege intent is not currently explicit or inspectable. A manifest is a correctness change to build metadata, not packaging decoration. +- A ZIP cannot contain a checksum of itself. `SHA256SUMS` therefore belongs beside the ZIP in the outer Actions artifact; the inner ZIP remains the direct download/extract/run payload. +- The implementation commit's local manifest-bearing portable executable was 1,752,064 bytes. Its commit-linked local ZIP was 705,117 bytes with SHA-256 `61f303ea459599394d75e802b334124d7b0683fef708d0b846cd97b08f72fe79`. The GitHub-hosted runner produced different, separately verified bytes, as expected because reproducible builds are not claimed. + +## Decisions + +- Keep Cargo version `0.0.0`. A CI Developer Preview is not a semantic release promise. +- Use static CRT only in the portable packaging command, leaving normal development and existing CI compilation behavior unchanged. +- Add a standard-library-only `build.rs` plus source manifest rather than a new resource dependency. +- Package exactly five inner files: executable, quick start, project license, third-party license notices, and concise build info. Upload exactly the ZIP and its checksum. +- Defer artifact attestations. They require additional `id-token`/attestation write permissions and workflow complexity that is disproportionate for an unsigned, expiring CI preview; checksums and exact download-back verification are the focused controls here. +- Use immutable SHA pins for official upload/download Artifact actions. Keep global workflow permissions at `contents: read`. +- Do not call the artifact a download or release. Document browser sign-in, run-scoped retention, and the eventual need for a signed Release asset. + +## Final validation + +Local validation completed on 2026-08-13: + +- `cargo build --locked --release -p systemdiff-cli`: passed; produced `systemdiff.exe` with the explicit `asInvoker`, `uiAccess=false` manifest. +- `cargo fmt --all --check`: passed. +- `cargo clippy --locked --workspace --all-targets -- -D warnings`: passed. +- `cargo test --locked --workspace --all-targets`: passed, 88 tests and no failures. +- `scripts/package-windows-preview.ps1`: passed with explicit x64 target, release profile, and packaging-only static CRT. +- `scripts/verify-windows-preview.ps1 -RemoveToolchainFromPath`: passed checksum, exact contents, PE parser, normal/delay import, manifest, unsigned-state, `--help`, `collectors`, human/technical/JSON Diff, and real read-only Snapshot checks. +- Independent `dumpbin`: confirmed AMD64 CUI, a manifest resource, no delay-import directory, and only `advapi32.dll`, `ntdll.dll`, `kernel32.dll`, and `api-ms-win-core-synch-l1-2-0.dll` direct dependencies. A binary string search found no local workspace path. +- `git diff --check`, PowerShell syntax parsing, local Markdown link targets, stale wording, and machine-path/secret scans: passed. + +Remote validation completed on 2026-08-13 for implementation commit `986f5f03a4253e32a570c0d47227e45be2e71f4d`: + +- PR run `31674781709`: `Rust (windows-latest)` and `Rust (ubuntu-latest)` passed; artifact jobs correctly skipped on the pull-request event. +- Upstream push run `31674761702`: Windows/Ubuntu Rust jobs, `Package Windows developer preview`, and `Verify downloaded Windows developer preview` all passed. +- Artifact `systemdiff-windows-x86_64-developer-preview-candidate` (ID `9171147964`) was created with 14-day retention, 728,642-byte GitHub wrapper size, and expiration at `2026-08-27T06:44:00Z`. +- `gh run download` retrieved the real upload. The downloaded portable ZIP was 728,262 bytes with SHA-256 `69603984f0749b454442b271d947aa883ce6c59e46ad6d705408c50616d4edce`. +- The downloaded executable was 1,750,528 bytes with SHA-256 `9dddfb75e8fa07627b0015decbbe4bfacb48bf936022f8a3f4bbdd7aa0a463df`. +- The downloaded artifact again passed the exact two-file outer allowlist, five-file ZIP allowlist, checksum, BUILD_INFO commit/hash binding, AMD64/PE32+/CUI checks, exact four-DLL import allowlist, empty delay imports, namespace-aware `asInvoker`/`uiAccess=false` manifest check, and unsigned-state check. +- With Rust/MSVC commands removed from `PATH`, the downloaded executable passed `--help`, `collectors`, human/technical/JSON Registry fixture Diff, and a real read-only Snapshot smoke. The real Snapshot remained in a GUID temporary directory, was not logged or uploaded, and was removed afterward. + +PR #10 remains intentionally unmerged. Clean-machine manual validation, Authenticode signing, an immutable Release, and permanent anonymous download remain future release gates. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd87945..095a5e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,8 @@ on: push: branches: - main + # Narrow pre-merge candidate validation for Issue #9. + - build/portable-developer-preview pull_request: permissions: @@ -53,3 +55,86 @@ jobs: - name: List Collector status run: cargo run --locked -p systemdiff-cli -- collectors + + package-windows-preview: + name: Package Windows developer preview + if: github.event_name == 'push' + needs: rust + runs-on: windows-latest + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install stable Rust + run: rustup toolchain install stable --profile minimal + + - name: Select stable Rust + run: rustup default stable + + - name: Install Windows x64 Rust target + run: rustup target add x86_64-pc-windows-msvc + + - name: Build portable package + shell: pwsh + env: + SYSTEMDIFF_COMMIT_SHA: ${{ github.sha }} + SYSTEMDIFF_ARTIFACT_DIR: ${{ runner.temp }}\systemdiff-preview-artifact + run: >- + ./scripts/package-windows-preview.ps1 + -RepositoryRoot $env:GITHUB_WORKSPACE + -OutputDirectory $env:SYSTEMDIFF_ARTIFACT_DIR + -CommitSha $env:SYSTEMDIFF_COMMIT_SHA + + - name: Verify package before upload + shell: pwsh + env: + SYSTEMDIFF_ARTIFACT_DIR: ${{ runner.temp }}\systemdiff-preview-artifact + run: >- + ./scripts/verify-windows-preview.ps1 + -ArtifactDirectory $env:SYSTEMDIFF_ARTIFACT_DIR + -RepositoryRoot $env:GITHUB_WORKSPACE + -ExpectedCommitSha $env:GITHUB_SHA + -RemoveToolchainFromPath + + - name: Upload portable preview + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ github.ref_name == 'main' && 'systemdiff-windows-x86_64-developer-preview' || 'systemdiff-windows-x86_64-developer-preview-candidate' }} + path: | + ${{ runner.temp }}\systemdiff-preview-artifact\systemdiff-windows-x86_64.zip + ${{ runner.temp }}\systemdiff-preview-artifact\SHA256SUMS + if-no-files-found: error + compression-level: 0 + include-hidden-files: false + overwrite: false + retention-days: 14 + + verify-windows-preview: + name: Verify downloaded Windows developer preview + if: github.event_name == 'push' + needs: package-windows-preview + runs-on: windows-latest + timeout-minutes: 15 + + steps: + - name: Check out repository fixtures + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download portable preview + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ github.ref_name == 'main' && 'systemdiff-windows-x86_64-developer-preview' || 'systemdiff-windows-x86_64-developer-preview-candidate' }} + path: ${{ runner.temp }}\downloaded-systemdiff-preview + + - name: Verify downloaded artifact without Cargo + shell: pwsh + env: + SYSTEMDIFF_DOWNLOADED_ARTIFACT: ${{ runner.temp }}\downloaded-systemdiff-preview + run: >- + ./scripts/verify-windows-preview.ps1 + -ArtifactDirectory $env:SYSTEMDIFF_DOWNLOADED_ARTIFACT + -RepositoryRoot $env:GITHUB_WORKSPACE + -ExpectedCommitSha $env:GITHUB_SHA + -RemoveToolchainFromPath diff --git a/README.md b/README.md index 61a547e..c81d000 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ SystemDiff takes a before Snapshot and an after Snapshot, then explains the evidence that changed. It is for questions like: “I installed this program—what did it add to startup?” > [!IMPORTANT] -> SystemDiff is pre-release, source-build-only software. Today it captures and compares the documented Windows Registry Run/RunOnce startup locations. Services, Scheduled Tasks, rules, redaction, releases, and the desktop app are not implemented. +> SystemDiff is pre-release software. Today it captures and compares the documented Windows Registry Run/RunOnce startup locations. An unsigned, short-lived Windows x64 Developer Preview is available from eligible CI runs, but there is no official binary Release. Services, Scheduled Tasks, rules, redaction, releases, and the desktop app are not implemented. -[Try the sample](#try-the-registry-demo) · [Build from source](#build-from-source) · [Inspect the data format](docs/data-format.md) +[Try the sample](#try-the-registry-demo) · [Developer Preview builds](#developer-preview-builds) · [Build from source](#build-from-source) · [Inspect the data format](docs/data-format.md) ![SystemDiff showing one synthetic Registry startup entry added](docs/assets/registry-startup-demo.svg) @@ -32,6 +32,19 @@ _Verified output from the committed synthetic Registry-only fixtures. No real ho SystemDiff reports facts such as “Added to current-user startup.” It does not currently decide whether an entry is malicious, safe, signed, or worthy of removal. +## Developer Preview builds + +Successful `main` CI runs attach `systemdiff-windows-x86_64-developer-preview` for 14 days. This is an ephemeral GitHub Actions artifact, not a GitHub Release or a supported version. To get it: + +1. sign in to GitHub and open a successful [CI workflow run](https://github.com/XiaojuCH/SystemDiff/actions/workflows/ci.yml); +2. find **Artifacts** at the bottom of the run and download `systemdiff-windows-x86_64-developer-preview`; +3. extract GitHub's outer download, verify `systemdiff-windows-x86_64.zip` against the adjacent `SHA256SUMS`, then extract the portable ZIP; +4. read `QUICKSTART.md` and run `.\systemdiff.exe --help`. + +The x64 executable is built in Cargo's `release` profile. CI checks its PE architecture and imports, verifies an embedded `asInvoker` / `uiAccess=false` manifest, and runs the downloaded artifact without Cargo. The current portable build statically links the MSVC CRT, so inspection shows no dynamic VC/UCRT runtime import; ordinary Windows system DLLs remain dependencies. Clean-machine validation on every supported Windows baseline is still required before an official alpha. + +The preview is not Authenticode-signed. Windows may show a SmartScreen or reputation warning. Verify the checksum and public source; SystemDiff does not ask users to disable or bypass Windows security controls. Browser artifact downloads require GitHub sign-in and expire, so this is intentionally not presented as the final public download experience. + ## Try the Registry demo With a stable Rust MSVC toolchain installed: @@ -98,7 +111,7 @@ cargo test --locked --workspace --all-targets cargo run --locked -p systemdiff-cli -- collectors ``` -There is no official binary release yet. The existing synthetic HKCU write-based E2E harness is test-only, requires two explicit gates, refuses to overwrite an existing value, performs exact-data guarded cleanup, and is not run by default CI. +There is no official binary Release yet. The CI Developer Preview above is unsigned and temporary. The existing synthetic HKCU write-based E2E harness is test-only, requires two explicit gates, refuses to overwrite an existing value, performs exact-data guarded cleanup, and is not run by default CI. ## Architecture and roadmap @@ -118,4 +131,4 @@ SystemDiff is defensive auditing software. Credential dumping, token/cookie extr ## License -Licensed under the [Apache License 2.0](LICENSE). +SystemDiff is licensed under the [Apache License 2.0](LICENSE). The portable binary's dependency notices are listed in [THIRD_PARTY_LICENSES.txt](THIRD_PARTY_LICENSES.txt). diff --git a/README.zh-CN.md b/README.zh-CN.md index 90ad018..446a1ee 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -11,9 +11,9 @@ SystemDiff 会分别创建变更前后的 Snapshot,再说明两者之间有哪些证据发生了变化。它要回答的是这类问题:“我刚安装了这个程序,它往启动项里加了什么?” > [!IMPORTANT] -> SystemDiff 仍处于预发布阶段,目前只能从源码构建。现阶段真正支持的是采集并比较 Windows Registry 中有官方文档的 Run/RunOnce 启动项。Windows 服务、计划任务、规则、脱敏、正式发布包和桌面应用均尚未实现。 +> SystemDiff 仍处于预发布阶段。现阶段真正支持的是采集并比较 Windows Registry 中有官方文档的 Run/RunOnce 启动项。符合条件的 CI run 会提供短期有效、未签名的 Windows x64 Developer Preview,但目前没有正式二进制 Release。Windows 服务、计划任务、规则、脱敏、正式发布包和桌面应用均尚未实现。 -[试用示例](#试用-registry-示例) · [从源码构建](#从源码构建) · [查看数据格式](docs/data-format.md) +[试用示例](#试用-registry-示例) · [Developer Preview 构建](#developer-preview-构建) · [从源码构建](#从源码构建) · [查看数据格式](docs/data-format.md) ![SystemDiff 显示新增的一条 synthetic Registry 启动项](docs/assets/registry-startup-demo.svg) @@ -32,6 +32,19 @@ _图中是仓库内 Registry-only synthetic fixtures 生成并经过验证的真 SystemDiff 目前只陈述“已加入当前用户启动项”这类事实,不会判断某个条目是否恶意、安全、已签名或应该删除。 +## Developer Preview 构建 + +`main` 的 CI 成功运行后,会附带保存 14 天的 `systemdiff-windows-x86_64-developer-preview`。这是会过期的 GitHub Actions artifact,不是 GitHub Release,也不代表受支持的正式版本。获取步骤如下: + +1. 登录 GitHub,打开一次成功的 [CI workflow run](https://github.com/XiaojuCH/SystemDiff/actions/workflows/ci.yml); +2. 在页面底部找到 **Artifacts**,下载 `systemdiff-windows-x86_64-developer-preview`; +3. 解压 GitHub 下载的外层压缩包,用旁边的 `SHA256SUMS` 校验 `systemdiff-windows-x86_64.zip`,再解压 portable ZIP; +4. 阅读 `QUICKSTART.md`,运行 `.\systemdiff.exe --help`。 + +x64 executable 使用 Cargo `release` profile 构建。CI 会检查 PE 架构和 imports,验证内嵌的 `asInvoker` / `uiAccess=false` manifest,并在不调用 Cargo 的情况下运行下载后的 artifact。当前 portable build 静态链接 MSVC CRT,因此实际检查未发现动态 VC/UCRT runtime import;它仍会依赖正常的 Windows system DLL。正式 alpha 前仍需在每个受支持的 Windows 基线环境中做 clean-machine 验证。 + +此预览没有 Authenticode 签名,Windows 可能显示 SmartScreen 或 reputation 警告。请核对 checksum 和公开源码;SystemDiff 不会要求用户关闭或绕过 Windows 安全保护。通过浏览器下载 Actions artifact 需要登录 GitHub,而且 artifact 会过期,因此这里不会把它包装成最终的公开下载体验。 + ## 试用 Registry 示例 安装 stable Rust MSVC toolchain 后运行: @@ -98,7 +111,7 @@ cargo test --locked --workspace --all-targets cargo run --locked -p systemdiff-cli -- collectors ``` -目前还没有官方二进制 Release。现有的 synthetic HKCU 写入型 E2E harness 只用于测试,需要两个显式 gate,会拒绝覆盖已有 value,并使用 exact-data guarded cleanup;默认 CI 不会运行它。 +目前还没有官方二进制 Release。上文的 CI Developer Preview 未签名且会过期。现有的 synthetic HKCU 写入型 E2E harness 只用于测试,需要两个显式 gate,会拒绝覆盖已有 value,并使用 exact-data guarded cleanup;默认 CI 不会运行它。 ## 架构与路线图 @@ -118,4 +131,4 @@ SystemDiff 是防御性审计软件。凭据转储、token/cookie 提取、键 ## 许可证 -本项目采用 [Apache License 2.0](LICENSE) 授权。 +SystemDiff 采用 [Apache License 2.0](LICENSE) 授权;portable binary 的依赖许可说明见 [THIRD_PARTY_LICENSES.txt](THIRD_PARTY_LICENSES.txt)。 diff --git a/THIRD_PARTY_LICENSES.txt b/THIRD_PARTY_LICENSES.txt new file mode 100644 index 0000000..d9fac15 --- /dev/null +++ b/THIRD_PARTY_LICENSES.txt @@ -0,0 +1,186 @@ +SystemDiff third-party licenses +================================ + +This file covers the third-party Rust packages in the locked dependency +graph used to build the SystemDiff Windows x64 Developer Preview. + +Apache-2.0 selections +--------------------- + +The following packages are offered under `MIT OR Apache-2.0` (or the +equivalent reversed expression). SystemDiff selects Apache-2.0 for this +distribution. The complete Apache License 2.0 text is provided in the +adjacent LICENSE file. + +- anstream 1.0.0 +- anstyle 1.0.14 +- anstyle-parse 1.0.0 +- anstyle-query 1.1.5 +- anstyle-wincon 3.0.11 +- block-buffer 0.12.1 +- cfg-if 1.0.4 +- clap 4.6.6 +- clap_builder 4.6.6 +- clap_derive 4.6.4 +- clap_lex 1.1.0 +- colorchoice 1.0.5 +- cpufeatures 0.3.0 +- crypto-common 0.2.2 +- deranged 0.5.8 +- digest 0.11.3 +- heck 0.5.0 +- hybrid-array 0.4.14 +- is_terminal_polyfill 1.70.2 +- itoa 1.0.18 +- libc 0.2.189 +- num-conv 0.2.2 +- once_cell_polyfill 1.70.2 +- powerfmt 0.2.0 +- proc-macro2 1.0.107 +- quote 1.0.47 +- serde 1.0.229 +- serde_core 1.0.229 +- serde_derive 1.0.229 +- serde_json 1.0.151 +- sha2 0.11.0 +- syn 2.0.119 +- syn 3.0.3 +- time 0.3.55 +- time-core 0.1.9 +- time-macros 0.2.32 +- typenum 1.20.1 +- utf8parse 0.2.2 +- windows 0.62.2 +- windows-collections 0.3.2 +- windows-core 0.62.2 +- windows-future 0.3.2 +- windows-implement 0.60.2 +- windows-interface 0.59.3 +- windows-link 0.2.1 +- windows-numerics 0.3.1 +- windows-result 0.4.1 +- windows-strings 0.5.1 +- windows-sys 0.61.2 +- windows-threading 0.2.1 +- windows-version 0.1.7 + +Other license selections in the locked graph: + +- memchr 2.8.3 +- strsim 0.11.1 +- unicode-ident 1.0.24 +- zmij 1.0.23 + +memchr 2.8.3 — Unlicense +------------------------ + +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or distribute +this software, either in source code form or as a compiled binary, for any +purpose, commercial or non-commercial, and by any means. + +In jurisdictions that recognize copyright laws, the author or authors of +this software dedicate any and all copyright interest in the software to +the public domain. We make this dedication for the benefit of the public +at large and to the detriment of our heirs and successors. We intend this +dedication to be an overt act of relinquishment in perpetuity of all +present and future rights to this software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to . + +strsim 0.11.1 — MIT License +-------------------------- + +Copyright (c) 2015 Danny Guo +Copyright (c) 2016 Titus Wormer +Copyright (c) 2018 Akash Kurdekar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +zmij 1.0.23 — MIT License +------------------------- + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +unicode-ident 1.0.24 — Unicode License v3 +----------------------------------------- + +unicode-ident is licensed under `(MIT OR Apache-2.0) AND Unicode-3.0`. +SystemDiff selects Apache-2.0 for the first part; the additional Unicode +License v3 follows. + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY DOWNLOADING, +INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR SOFTWARE, YOU +UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE TERMS AND CONDITIONS +OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT DOWNLOAD, INSTALL, COPY, +DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of data files and any associated documentation (the "Data Files") or software +and any associated documentation (the "Software") to deal in the Data Files +or Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, and/or sell copies of the Data +Files or Software, and to permit persons to whom the Data Files or Software +are furnished to do so, provided that either (a) this copyright and permission +notice appear with all copies of the Data Files or Software, or (b) this +copyright and permission notice appear in associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD +PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE BE +LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY +DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall not +be used in advertising or otherwise to promote the sale, use or other dealings +in these Data Files or Software without prior written authorization of the +copyright holder. diff --git a/crates/systemdiff-cli/build.rs b/crates/systemdiff-cli/build.rs new file mode 100644 index 0000000..2d170c8 --- /dev/null +++ b/crates/systemdiff-cli/build.rs @@ -0,0 +1,18 @@ +use std::env; +use std::path::PathBuf; + +fn main() { + let manifest = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("manifest directory")) + .join("systemdiff.manifest"); + println!("cargo:rerun-if-changed={}", manifest.display()); + + if env::var_os("CARGO_CFG_TARGET_OS").as_deref() == Some("windows".as_ref()) + && env::var_os("CARGO_CFG_TARGET_ENV").as_deref() == Some("msvc".as_ref()) + { + println!("cargo:rustc-link-arg-bin=systemdiff=/MANIFEST:EMBED"); + println!( + "cargo:rustc-link-arg-bin=systemdiff=/MANIFESTINPUT:{}", + manifest.display() + ); + } +} diff --git a/crates/systemdiff-cli/systemdiff.manifest b/crates/systemdiff-cli/systemdiff.manifest new file mode 100644 index 0000000..2becf2a --- /dev/null +++ b/crates/systemdiff-cli/systemdiff.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/docs/roadmap.md b/docs/roadmap.md index 1c8df83..cb593fb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -29,6 +29,7 @@ Required scope: - Scheduled Tasks 2.0 configuration. - Versioned snapshot and diff JSON. - Human-readable terminal report. +- An unsigned, expiring Windows x64 CI Developer Preview that proves the existing CLI can run from a downloaded portable package without Cargo. **In progress; this is not an official release.** - Independent collector failures and clear privilege/coverage reporting. - Deterministic fixtures and snapshot-to-diff integration tests that do not need administrator privileges. @@ -62,3 +63,5 @@ Product engineering precedes promotion: 2. A short hero demo should make the value obvious without security hype. 3. README installation and trust claims must match released artifacts. 4. Broader launch waits for a result worth sharing, not merely a completed scaffold. + +The first CI Developer Preview intentionally stops short of a release: browser download requires GitHub sign-in, artifacts expire, and the executable is unsigned. A public alpha additionally requires clean-machine validation, a publisher-signing decision, stable version semantics, and a durable Release channel. diff --git a/docs/threat-model.md b/docs/threat-model.md index 40d55aa..a4e71dd 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -57,7 +57,7 @@ Open questions that may change rankings: - Core evidence -> diff/rules: typed in-process values; compatibility, coverage, deterministic identity, and no evidence execution are the guarantees. - Diff/findings -> report files/terminal: privacy-sensitive local output; destination choice is user-controlled, and future sanitization must be explicit. - Future WebView -> Tauri core: typed local IPC; bundled origin, allowlisted commands, least-privilege capabilities, and input validation are required. -- Repository/dependencies -> CI/release artifacts: developer-controlled source plus third-party actions/crates; read-only token permissions, immutable action references, lockfiles, review, and dependency updates reduce risk. +- Repository/dependencies -> CI artifacts: developer-controlled source plus third-party actions/crates; read-only token permissions, immutable action references, the committed Cargo lockfile, review, exact package allowlists, checksums, and artifact-download verification reduce risk. #### Diagram @@ -120,7 +120,7 @@ flowchart LR | Report destination | CLI output path/stdout | Process -> filesystem/user | Sensitive output and overwrite behavior | `docs/product-principles.md` | | Rule inputs | Parsed changes | Evidence -> judgment | Rules must reference, not rewrite, evidence | `docs/architecture.md` | | Future Tauri IPC | WebView commands | Web content -> native core | Narrow commands/capabilities only | `docs/adr/0003-desktop-stack.md` | -| CI dependencies/actions | Pull requests and dependency updates | External supply chain -> build | No secrets on fork code; immutable pins | `.github/workflows/ci.yml` | +| CI dependencies/actions | Pull requests and dependency updates | External supply chain -> build | Fork PRs cannot enter the upstream-push artifact upload path; no secrets; immutable pins | `.github/workflows/ci.yml` | ## Top abuse paths @@ -142,7 +142,7 @@ flowchart LR | TM-004 | User/workflow mistake | Real report is shared publicly | Publish sensitive raw evidence | Lasting privacy disclosure | Snapshot/report confidentiality | Sensitive-by-default and redaction metadata (`docs/product-principles.md`) | Sanitizer absent | Blocking share warning in UI, documented manual review, policy-versioned pure sanitizer, synthetic issue fixtures | Scan project issues for accidental reports; sanitizer golden tests | High | High | High | | TM-005 | Future compromised WebView | Desktop exposes broad command/capability | Invoke native execution/write or read excess data | Privilege misuse and boundary violation | Token, host integrity, evidence confidentiality | Proposed narrow IPC (`docs/adr/0003-desktop-stack.md`) | Desktop not yet threat-tested | Bundled content, restrictive CSP, explicit commands, no generic shell/fs/http plugins, command authorization tests | Log command IDs without sensitive payloads; capability review in CI | Low pre-v0.2 | High | Medium | | TM-006 | Rule author or malformed evidence | Rule sees ambiguous command/path data | Overstate heuristic or detach finding from evidence | Misleading/fearmongering output | Finding integrity and user trust | Evidence-before-judgment principle; findings reference changes (`docs/architecture.md`) | No real rule corpus/reviewer rubric | Stable reason IDs, calibrated classifications, explanation keys, counterexample fixtures, independent review | Golden finding snapshots and rule precision review | Medium | Medium | Medium | -| TM-007 | Supply-chain attacker | Dependency/action update reaches CI | Execute during build or replace artifact | Malicious binaries under project identity | Build/release integrity | Minimal CI permissions and dependency policy (`docs/architecture.md`) | No lockfile/signing/release pipeline yet | Commit lockfiles, pin actions by full SHA, review Dependabot PRs, audit before release, sign Windows artifacts | Dependency review, reproducible checksums, provenance later | Low | High | Medium | +| TM-007 | Supply-chain attacker | Dependency/action update reaches CI | Execute during build or replace artifact | Malicious binaries under project identity | Build/release integrity | Minimal CI permissions, committed lockfile, immutable action pins, an upstream-push-only preview upload path, exact archive allowlists, SHA-256, PE/manifest inspection, and downloaded-artifact smoke verification (`.github/workflows/ci.yml`) | Preview artifacts are unsigned and expiring; no immutable Release or publisher identity exists | Review Dependabot changes, retain download-back verification, add Authenticode and provenance to a future immutable Release | Dependency review, checksum mismatch failures, artifact inventory, signing verification later | Low | High | Medium | | TM-008 | Feature contributor | Maintainer accepts scope expansion | Add remediation, evidence execution, credential access, or evasion | System modification or dual-use abuse | Host integrity and project trust | Explicit prohibited boundary (`AGENTS.md`, `docs/product-principles.md`) | Policy is review-enforced | Stop and require maintainer decision, ADR and new threat model; keep remediation separate if ever approved | PR checklist and independent security review | Medium | High | High | ## Criticality calibration diff --git a/packaging/windows/QUICKSTART.md b/packaging/windows/QUICKSTART.md new file mode 100644 index 0000000..8489b94 --- /dev/null +++ b/packaging/windows/QUICKSTART.md @@ -0,0 +1,39 @@ +# SystemDiff Windows x64 Developer Preview + +This is an **unsigned, pre-release Developer Preview**, not an official SystemDiff release. It currently observes only the documented Windows Registry `Run` and `RunOnce` startup locations. Windows Services and Scheduled Tasks are not implemented. + +SystemDiff runs locally, requires no account, includes no telemetry, and its product behavior is read-only. This package targets Windows x64. The current minimum collection platform is Windows 10 version 1709 or Windows Server 2016 version 1709. + +## First run + +Open PowerShell in the extracted directory: + +```powershell +.\systemdiff.exe --help +.\systemdiff.exe collectors +``` + +Administrator privileges are not required. If Windows limits access to a Registry scope, SystemDiff reports the coverage gap instead of silently treating missing evidence as a removal. + +## Compare before and after + +```powershell +.\systemdiff.exe snapshot -o before.json + +# Install or run the software you want to observe. + +.\systemdiff.exe snapshot -o after.json +.\systemdiff.exe diff before.json after.json +``` + +Use `diff --technical` for exact text evidence or `diff --json` for the versioned machine-readable document. Compare Snapshots from the same Windows installation and the same user/principal context. + +## Privacy + +Snapshots and all report modes are unredacted. They may contain command strings, usernames in paths, hashes, and other host details. Review every file before sharing it, and never attach an unreviewed real Snapshot or report to a public Issue. + +## Trust and removal + +This preview is not Authenticode-signed, so Windows may show a SmartScreen or reputation warning. Verify the ZIP SHA-256 against the adjacent `SHA256SUMS` file and inspect the public source. SystemDiff does not ask you to disable or bypass Windows security controls. + +The portable preview installs no service, driver, Scheduled Task, updater, or `PATH` entry. Delete the extracted directory to remove the program. Snapshot and report files you created elsewhere are not deleted automatically. diff --git a/scripts/package-windows-preview.ps1 b/scripts/package-windows-preview.ps1 new file mode 100644 index 0000000..4c0ff86 --- /dev/null +++ b/scripts/package-windows-preview.ps1 @@ -0,0 +1,179 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$RepositoryRoot, + + [Parameter(Mandatory = $true)] + [string]$OutputDirectory, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{40}$')] + [string]$CommitSha +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Resolve-FullPath { + param([Parameter(Mandatory = $true)][string]$Path) + return [System.IO.Path]::GetFullPath($Path) +} + +if ($env:OS -ne 'Windows_NT') { + throw 'The portable Windows preview must be built on Windows.' +} + +$repository = Resolve-FullPath -Path $RepositoryRoot +$output = Resolve-FullPath -Path $OutputDirectory +$cargoManifest = Join-Path $repository 'Cargo.toml' +$quickStart = Join-Path $repository 'packaging\windows\QUICKSTART.md' +$license = Join-Path $repository 'LICENSE' +$thirdPartyLicenses = Join-Path $repository 'THIRD_PARTY_LICENSES.txt' + +foreach ($required in @($cargoManifest, $quickStart, $license, $thirdPartyLicenses)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { + throw "Required packaging input does not exist: $required" + } +} + +$metadata = & cargo metadata --locked --format-version 1 --manifest-path $cargoManifest | + ConvertFrom-Json +if ($LASTEXITCODE -ne 0) { + throw "Cargo metadata failed with exit code $LASTEXITCODE." +} +$workspaceMembers = @($metadata.workspace_members) +$lockedPackages = @( + $metadata.packages | + Where-Object { $workspaceMembers -notcontains $_.id } | + ForEach-Object { "$($_.name) $($_.version)" } | + Sort-Object -Unique +) +$licenseText = [System.IO.File]::ReadAllText($thirdPartyLicenses, [System.Text.Encoding]::UTF8) +$documentedPackages = @( + [regex]::Matches($licenseText, '(?m)^- ([a-zA-Z0-9_-]+ [0-9][^\r\n ]*)$') | + ForEach-Object { $_.Groups[1].Value } | + Sort-Object -Unique +) +$missingLicenses = @($lockedPackages | Where-Object { $documentedPackages -notcontains $_ }) +$staleLicenses = @($documentedPackages | Where-Object { $lockedPackages -notcontains $_ }) +if ($missingLicenses.Count -ne 0 -or $staleLicenses.Count -ne 0) { + throw "THIRD_PARTY_LICENSES.txt does not match Cargo.lock. Missing: $($missingLicenses -join ', '); stale: $($staleLicenses -join ', ')" +} + +if (Test-Path -LiteralPath $output) { + if ((Get-ChildItem -LiteralPath $output -Force | Measure-Object).Count -ne 0) { + throw "Output directory must not already contain files: $output" + } +} else { + New-Item -ItemType Directory -Path $output | Out-Null +} + +$targetTriple = 'x86_64-pc-windows-msvc' +$targetDirectory = Join-Path $repository 'target\portable-preview' +$executable = Join-Path $targetDirectory "$targetTriple\release\systemdiff.exe" +$rustFlagsName = 'CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS' +$previousRustFlags = [Environment]::GetEnvironmentVariable($rustFlagsName, 'Process') +$previousTargetDirectory = [Environment]::GetEnvironmentVariable('CARGO_TARGET_DIR', 'Process') + +try { + [Environment]::SetEnvironmentVariable( + $rustFlagsName, + '-C target-feature=+crt-static', + 'Process' + ) + $env:CARGO_TARGET_DIR = $targetDirectory + + & cargo build ` + --locked ` + --release ` + --target $targetTriple ` + -p systemdiff-cli ` + --bin systemdiff ` + --manifest-path $cargoManifest + if ($LASTEXITCODE -ne 0) { + throw "Cargo release build failed with exit code $LASTEXITCODE." + } +} finally { + [Environment]::SetEnvironmentVariable($rustFlagsName, $previousRustFlags, 'Process') + [Environment]::SetEnvironmentVariable( + 'CARGO_TARGET_DIR', + $previousTargetDirectory, + 'Process' + ) +} + +if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { + throw "Cargo did not produce the expected executable: $executable" +} + +$rustcVersion = (& rustc --version).Trim() +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($rustcVersion)) { + throw 'Could not record the Rust compiler version.' +} + +$stage = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'SystemDiff-package-' + [Guid]::NewGuid().ToString('N') +) +$zipPath = Join-Path $output 'systemdiff-windows-x86_64.zip' +$checksumPath = Join-Path $output 'SHA256SUMS' + +try { + New-Item -ItemType Directory -Path $stage | Out-Null + Copy-Item -LiteralPath $executable -Destination (Join-Path $stage 'systemdiff.exe') + Copy-Item -LiteralPath $quickStart -Destination (Join-Path $stage 'QUICKSTART.md') + Copy-Item -LiteralPath $license -Destination (Join-Path $stage 'LICENSE') + Copy-Item -LiteralPath $thirdPartyLicenses -Destination (Join-Path $stage 'THIRD_PARTY_LICENSES.txt') + $executableHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $executable).Hash.ToLowerInvariant() + + $buildInfo = @( + 'SystemDiff Windows x64 Developer Preview' + "Commit: $($CommitSha.ToLowerInvariant())" + "Target: $targetTriple" + 'Cargo profile: release' + 'MSVC CRT linkage: static' + "Rust compiler: $rustcVersion" + "Executable SHA-256: $executableHash" + 'Official release: no' + 'Authenticode signed: no' + ) -join "`n" + [System.IO.File]::WriteAllText( + (Join-Path $stage 'BUILD_INFO.txt'), + $buildInfo + "`n", + [System.Text.UTF8Encoding]::new($false) + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::CreateFromDirectory( + $stage, + $zipPath, + [System.IO.Compression.CompressionLevel]::Optimal, + $false + ) +} finally { + if (Test-Path -LiteralPath $stage) { + Remove-Item -LiteralPath $stage -Recurse -Force + } +} + +$zipHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $zipPath).Hash.ToLowerInvariant() +[System.IO.File]::WriteAllText( + $checksumPath, + "$zipHash systemdiff-windows-x86_64.zip`n", + [System.Text.UTF8Encoding]::new($false) +) + +$outerFiles = @(Get-ChildItem -LiteralPath $output -Force | Sort-Object Name) +if ($outerFiles.Count -ne 2 -or + $outerFiles[0].PSIsContainer -or + $outerFiles[1].PSIsContainer -or + (($outerFiles[0].Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) -or + (($outerFiles[1].Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) -or + $outerFiles[0].Name -ne 'SHA256SUMS' -or + $outerFiles[1].Name -ne 'systemdiff-windows-x86_64.zip') { + throw 'Packaging output did not match the two-file allowlist.' +} + +Write-Output "Created: $zipPath" +Write-Output "SHA-256: $zipHash" +Write-Output "ZIP bytes: $((Get-Item -LiteralPath $zipPath).Length)" +Write-Output "EXE bytes: $((Get-Item -LiteralPath $executable).Length)" diff --git a/scripts/verify-windows-preview.ps1 b/scripts/verify-windows-preview.ps1 new file mode 100644 index 0000000..76dae95 --- /dev/null +++ b/scripts/verify-windows-preview.ps1 @@ -0,0 +1,407 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ArtifactDirectory, + + [Parameter(Mandatory = $true)] + [string]$RepositoryRoot, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{40}$')] + [string]$ExpectedCommitSha, + + [switch]$RemoveToolchainFromPath +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Assert-Condition { + param( + [Parameter(Mandatory = $true)][bool]$Condition, + [Parameter(Mandatory = $true)][string]$Message + ) + if (-not $Condition) { + throw $Message + } +} + +function Read-AsciiString { + param( + [Parameter(Mandatory = $true)][System.IO.BinaryReader]$Reader, + [Parameter(Mandatory = $true)][long]$Offset, + [Parameter(Mandatory = $true)][long]$FileLength + ) + Assert-Condition ($Offset -ge 0 -and $Offset -lt $FileLength) 'PE string offset is outside the file.' + $Reader.BaseStream.Position = $Offset + $bytes = [System.Collections.Generic.List[byte]]::new() + while ($Reader.BaseStream.Position -lt $FileLength -and $bytes.Count -lt 4096) { + $value = $Reader.ReadByte() + if ($value -eq 0) { + return [System.Text.Encoding]::ASCII.GetString($bytes.ToArray()) + } + $bytes.Add($value) + } + throw 'PE string is unterminated or exceeds the inspection limit.' +} + +function Read-PeMetadata { + param([Parameter(Mandatory = $true)][string]$ExecutablePath) + + $stream = [System.IO.File]::Open( + $ExecutablePath, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read + ) + $reader = [System.IO.BinaryReader]::new($stream) + try { + $length = $stream.Length + Assert-Condition ($length -ge 512) 'Executable is too small to be a valid PE file.' + Assert-Condition ($reader.ReadUInt16() -eq 0x5a4d) 'Executable does not begin with the DOS MZ signature.' + + $stream.Position = 0x3c + $peOffset = [long]$reader.ReadUInt32() + Assert-Condition ($peOffset -ge 0x40 -and $peOffset + 24 -le $length) 'PE header offset is invalid.' + $stream.Position = $peOffset + Assert-Condition ($reader.ReadUInt32() -eq 0x00004550) 'Executable does not contain the PE signature.' + + $machine = $reader.ReadUInt16() + $sectionCount = [int]$reader.ReadUInt16() + $stream.Position = $peOffset + 20 + $optionalHeaderSize = [int]$reader.ReadUInt16() + $optionalHeaderOffset = $peOffset + 24 + Assert-Condition ($optionalHeaderSize -ge 120) 'PE optional header is too small.' + Assert-Condition ($optionalHeaderOffset + $optionalHeaderSize -le $length) 'PE optional header exceeds the file.' + + $stream.Position = $optionalHeaderOffset + $optionalMagic = $reader.ReadUInt16() + Assert-Condition ($optionalMagic -eq 0x020b) 'Executable is not a PE32+ image.' + $stream.Position = $optionalHeaderOffset + 24 + $imageBase = $reader.ReadUInt64() + $stream.Position = $optionalHeaderOffset + 68 + $subsystem = $reader.ReadUInt16() + $stream.Position = $optionalHeaderOffset + 108 + $directoryCount = $reader.ReadUInt32() + + $sectionTableOffset = $optionalHeaderOffset + $optionalHeaderSize + Assert-Condition ($sectionTableOffset + (40 * $sectionCount) -le $length) 'PE section table exceeds the file.' + $sections = @() + for ($sectionIndex = 0; $sectionIndex -lt $sectionCount; $sectionIndex++) { + $stream.Position = $sectionTableOffset + (40 * $sectionIndex) + 8 + $virtualSize = $reader.ReadUInt32() + $virtualAddress = $reader.ReadUInt32() + $rawSize = $reader.ReadUInt32() + $rawPointer = $reader.ReadUInt32() + $sections += [pscustomobject]@{ + VirtualSize = [uint64]$virtualSize + VirtualAddress = [uint64]$virtualAddress + RawSize = [uint64]$rawSize + RawPointer = [uint64]$rawPointer + } + } + + $rvaToOffset = { + param([uint64]$Rva) + foreach ($section in $sections) { + $span = [Math]::Max($section.VirtualSize, $section.RawSize) + if ($Rva -ge $section.VirtualAddress -and $Rva -lt $section.VirtualAddress + $span) { + $offset = $section.RawPointer + ($Rva - $section.VirtualAddress) + Assert-Condition ($offset -lt [uint64]$length) 'PE RVA maps outside the file.' + return [long]$offset + } + } + throw "PE RVA 0x$($Rva.ToString('x')) does not map to a section." + } + + $readDirectory = { + param([int]$Index) + if ($directoryCount -le [uint32]$Index) { + return [pscustomobject]@{ Rva = [uint32]0; Size = [uint32]0 } + } + $entryOffset = $optionalHeaderOffset + 112 + (8 * $Index) + Assert-Condition ($entryOffset + 8 -le $optionalHeaderOffset + $optionalHeaderSize) 'PE data directory exceeds the optional header.' + $stream.Position = $entryOffset + return [pscustomobject]@{ Rva = $reader.ReadUInt32(); Size = $reader.ReadUInt32() } + } + + $imports = [System.Collections.Generic.List[string]]::new() + $importDirectory = & $readDirectory 1 + if ($importDirectory.Rva -ne 0) { + $descriptorOffset = & $rvaToOffset ([uint64]$importDirectory.Rva) + for ($descriptorIndex = 0; $descriptorIndex -lt 4096; $descriptorIndex++) { + $offset = $descriptorOffset + (20 * $descriptorIndex) + Assert-Condition ($offset + 20 -le $length) 'PE import descriptor exceeds the file.' + $stream.Position = $offset + $originalFirstThunk = $reader.ReadUInt32() + $timeDateStamp = $reader.ReadUInt32() + $forwarderChain = $reader.ReadUInt32() + $nameRva = $reader.ReadUInt32() + $firstThunk = $reader.ReadUInt32() + if (($originalFirstThunk -bor $timeDateStamp -bor $forwarderChain -bor $nameRva -bor $firstThunk) -eq 0) { + break + } + Assert-Condition ($nameRva -ne 0) 'PE import descriptor has no DLL name.' + $imports.Add((Read-AsciiString -Reader $reader -Offset (& $rvaToOffset ([uint64]$nameRva)) -FileLength $length)) + if ($descriptorIndex -eq 4095) { + throw 'PE import descriptor count exceeds the inspection limit.' + } + } + } + + $delayImports = [System.Collections.Generic.List[string]]::new() + $delayDirectory = & $readDirectory 13 + if ($delayDirectory.Rva -ne 0) { + $descriptorOffset = & $rvaToOffset ([uint64]$delayDirectory.Rva) + for ($descriptorIndex = 0; $descriptorIndex -lt 4096; $descriptorIndex++) { + $offset = $descriptorOffset + (32 * $descriptorIndex) + Assert-Condition ($offset + 32 -le $length) 'PE delay-load descriptor exceeds the file.' + $stream.Position = $offset + $attributes = $reader.ReadUInt32() + $nameValue = $reader.ReadUInt32() + $moduleHandle = $reader.ReadUInt32() + $delayIat = $reader.ReadUInt32() + $delayInt = $reader.ReadUInt32() + $boundIat = $reader.ReadUInt32() + $unloadIat = $reader.ReadUInt32() + $timestamp = $reader.ReadUInt32() + if (($attributes -bor $nameValue -bor $moduleHandle -bor $delayIat -bor $delayInt -bor $boundIat -bor $unloadIat -bor $timestamp) -eq 0) { + break + } + Assert-Condition ($nameValue -ne 0) 'PE delay-load descriptor has no DLL name.' + $nameRva = if (($attributes -band 1) -eq 1) { + [uint64]$nameValue + } else { + Assert-Condition ([uint64]$nameValue -ge $imageBase) 'PE delay-load name address is below the image base.' + [uint64]$nameValue - $imageBase + } + $delayImports.Add((Read-AsciiString -Reader $reader -Offset (& $rvaToOffset $nameRva) -FileLength $length)) + if ($descriptorIndex -eq 4095) { + throw 'PE delay-load descriptor count exceeds the inspection limit.' + } + } + } + + return [pscustomobject]@{ + Machine = $machine + OptionalMagic = $optionalMagic + Subsystem = $subsystem + Imports = @($imports | Sort-Object -Unique) + DelayImports = @($delayImports | Sort-Object -Unique) + } + } finally { + $reader.Dispose() + $stream.Dispose() + } +} + +function Find-ManifestTool { + $fromPath = Get-Command mt.exe -ErrorAction SilentlyContinue + if ($fromPath) { + return $fromPath.Source + } + + $kitsRoot = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + if (-not (Test-Path -LiteralPath $kitsRoot -PathType Container)) { + throw 'Could not locate the Windows SDK manifest tool (mt.exe).' + } + $candidate = Get-ChildItem -LiteralPath $kitsRoot -Filter mt.exe -Recurse -File | + Where-Object { $_.FullName -match '\\x64\\mt\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (-not $candidate) { + throw 'Could not locate an x64 Windows SDK manifest tool (mt.exe).' + } + return $candidate.FullName +} + +function Invoke-PreviewCommand { + param( + [Parameter(Mandatory = $true)][string]$Executable, + [Parameter(Mandatory = $true)][string[]]$Arguments + ) + $lines = @(& $Executable @Arguments 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "Packaged command failed ($($Arguments -join ' ')): $($lines -join [Environment]::NewLine)" + } + return $lines -join "`n" +} + +$artifact = [System.IO.Path]::GetFullPath($ArtifactDirectory) +$repository = [System.IO.Path]::GetFullPath($RepositoryRoot) +$fixtures = Join-Path $repository 'fixtures' +Assert-Condition (Test-Path -LiteralPath $artifact -PathType Container) 'Artifact directory does not exist.' +Assert-Condition (Test-Path -LiteralPath $repository -PathType Container) 'Repository directory does not exist.' +Assert-Condition (Test-Path -LiteralPath $fixtures -PathType Container) 'Repository fixture directory does not exist.' + +$outerEntries = @(Get-ChildItem -LiteralPath $artifact -Force | Sort-Object Name) +Assert-Condition ($outerEntries.Count -eq 2) 'Artifact directory must contain exactly two entries.' +Assert-Condition (-not $outerEntries[0].PSIsContainer -and -not $outerEntries[1].PSIsContainer) 'Artifact directory must not contain directories.' +Assert-Condition ((($outerEntries[0].Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) -and (($outerEntries[1].Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0)) 'Artifact directory must not contain reparse points.' +$outerNames = @($outerEntries | ForEach-Object Name) +Assert-Condition ($outerNames[0] -ceq 'SHA256SUMS') 'Artifact is missing the exact SHA256SUMS filename.' +Assert-Condition ($outerNames[1] -ceq 'systemdiff-windows-x86_64.zip') 'Artifact is missing the expected ZIP filename.' + +$zipPath = Join-Path $artifact 'systemdiff-windows-x86_64.zip' +$checksumPath = Join-Path $artifact 'SHA256SUMS' +$checksumText = [System.IO.File]::ReadAllText($checksumPath, [System.Text.Encoding]::UTF8) +$checksumMatch = [regex]::Match( + $checksumText, + '\A([0-9a-f]{64}) systemdiff-windows-x86_64\.zip\n\z', + [System.Text.RegularExpressions.RegexOptions]::CultureInvariant +) +Assert-Condition $checksumMatch.Success 'SHA256SUMS must contain one lowercase SHA-256 line with LF termination.' +$expectedZipHash = $checksumMatch.Groups[1].Value +$actualZipHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $zipPath).Hash.ToLowerInvariant() +Assert-Condition ($actualZipHash -ceq $expectedZipHash) 'Portable ZIP SHA-256 does not match SHA256SUMS.' + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$archive = [System.IO.Compression.ZipFile]::OpenRead($zipPath) +try { + $entryNames = @($archive.Entries | ForEach-Object FullName | Sort-Object) + $expectedEntries = @('BUILD_INFO.txt', 'LICENSE', 'QUICKSTART.md', 'systemdiff.exe', 'THIRD_PARTY_LICENSES.txt') | Sort-Object + Assert-Condition ($entryNames.Count -eq $expectedEntries.Count) 'Portable ZIP contains an unexpected number of entries.' + for ($index = 0; $index -lt $expectedEntries.Count; $index++) { + Assert-Condition ($entryNames[$index] -ceq $expectedEntries[$index]) "Unexpected ZIP entry: $($entryNames[$index])" + } + $caseInsensitiveNames = @($entryNames | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) + Assert-Condition ($caseInsensitiveNames.Count -eq $entryNames.Count) 'Portable ZIP contains case-insensitive duplicate paths.' +} finally { + $archive.Dispose() +} + +$verificationRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'SystemDiff-preview-verification-' + [Guid]::NewGuid().ToString('N') +) +$previousPath = $env:PATH +$extractDirectory = Join-Path $verificationRoot 'package' +$snapshotPath = Join-Path $verificationRoot 'snapshot.json' +$manifestPath = Join-Path $verificationRoot 'systemdiff.manifest.xml' + +try { + New-Item -ItemType Directory -Path $verificationRoot | Out-Null + [System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $extractDirectory) + $executable = Join-Path $extractDirectory 'systemdiff.exe' + Assert-Condition (Test-Path -LiteralPath $executable -PathType Leaf) 'Extracted executable is missing.' + Assert-Condition ((Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $extractDirectory 'QUICKSTART.md')).Hash -ceq (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $repository 'packaging\windows\QUICKSTART.md')).Hash) 'Packaged QUICKSTART.md does not match the reviewed repository file.' + Assert-Condition ((Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $extractDirectory 'LICENSE')).Hash -ceq (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $repository 'LICENSE')).Hash) 'Packaged LICENSE does not match the reviewed repository file.' + Assert-Condition ((Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $extractDirectory 'THIRD_PARTY_LICENSES.txt')).Hash -ceq (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $repository 'THIRD_PARTY_LICENSES.txt')).Hash) 'Packaged third-party licenses do not match the reviewed repository file.' + + $executableHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $executable).Hash.ToLowerInvariant() + $buildInfo = [System.IO.File]::ReadAllText((Join-Path $extractDirectory 'BUILD_INFO.txt'), [System.Text.Encoding]::UTF8) + Assert-Condition ($buildInfo -match "(?m)^Commit: $([regex]::Escape($ExpectedCommitSha.ToLowerInvariant()))$") 'BUILD_INFO.txt does not identify the expected commit.' + Assert-Condition ($buildInfo -match '(?m)^Target: x86_64-pc-windows-msvc$') 'BUILD_INFO.txt does not identify the expected target.' + Assert-Condition ($buildInfo -match '(?m)^Cargo profile: release$') 'BUILD_INFO.txt does not identify the release profile.' + Assert-Condition ($buildInfo -match '(?m)^MSVC CRT linkage: static$') 'BUILD_INFO.txt does not identify static CRT linkage.' + Assert-Condition ($buildInfo -match "(?m)^Executable SHA-256: $executableHash$") 'BUILD_INFO.txt executable hash does not match the packaged executable.' + Assert-Condition ($buildInfo -match '(?m)^Official release: no$') 'BUILD_INFO.txt must state that this is not an official release.' + Assert-Condition ($buildInfo -match '(?m)^Authenticode signed: no$') 'BUILD_INFO.txt must state the expected unsigned status.' + + $pe = Read-PeMetadata -ExecutablePath $executable + Assert-Condition ($pe.Machine -eq 0x8664) 'Packaged executable is not AMD64/x86_64.' + Assert-Condition ($pe.OptionalMagic -eq 0x020b) 'Packaged executable is not PE32+.' + Assert-Condition ($pe.Subsystem -eq 3) 'Packaged executable is not a Windows console application.' + + foreach ($import in @($pe.Imports + $pe.DelayImports)) { + Assert-Condition ($import.IndexOfAny([char[]]@('/', '\')) -lt 0) "PE import name contains a path separator: $import" + } + Assert-Condition ($pe.DelayImports.Count -eq 0) 'Packaged executable has an unexpected delay-loaded DLL.' + $normalizedImports = @($pe.Imports | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) + $expectedImports = @( + 'advapi32.dll' + 'api-ms-win-core-synch-l1-2-0.dll' + 'kernel32.dll' + 'ntdll.dll' + ) + Assert-Condition ($normalizedImports.Count -eq $expectedImports.Count) "Packaged executable import set changed: $($normalizedImports -join ', ')" + for ($importIndex = 0; $importIndex -lt $expectedImports.Count; $importIndex++) { + Assert-Condition ($normalizedImports[$importIndex] -ceq $expectedImports[$importIndex]) "Packaged executable import set changed: $($normalizedImports -join ', ')" + } + + $manifestTool = Find-ManifestTool + & $manifestTool "-inputresource:$executable;#1" "-out:$manifestPath" + if ($LASTEXITCODE -ne 0) { + throw "mt.exe could not extract RT_MANIFEST #1 (exit $LASTEXITCODE)." + } + [xml]$manifest = Get-Content -Raw -LiteralPath $manifestPath + $namespaceManager = [System.Xml.XmlNamespaceManager]::new($manifest.NameTable) + $namespaceManager.AddNamespace('asmv1', 'urn:schemas-microsoft-com:asm.v1') + $namespaceManager.AddNamespace('asmv3', 'urn:schemas-microsoft-com:asm.v3') + $executionLevels = @($manifest.SelectNodes('/asmv1:assembly/asmv3:trustInfo/asmv3:security/asmv3:requestedPrivileges/asmv3:requestedExecutionLevel', $namespaceManager)) + $allExecutionLevelNames = @($manifest.SelectNodes("//*[local-name()='requestedExecutionLevel']")) + Assert-Condition ($executionLevels.Count -eq 1 -and $allExecutionLevelNames.Count -eq 1) 'Manifest must contain exactly one correctly namespaced requestedExecutionLevel.' + Assert-Condition ($executionLevels[0].level -ceq 'asInvoker') 'Manifest must request asInvoker.' + Assert-Condition ($executionLevels[0].uiAccess -ceq 'false') 'Manifest must set uiAccess=false.' + Assert-Condition (@($manifest.SelectNodes("//*[local-name()='autoElevate']")).Count -eq 0) 'Manifest must not request autoElevate.' + + $signature = Get-AuthenticodeSignature -LiteralPath $executable + Assert-Condition ($signature.Status -eq [System.Management.Automation.SignatureStatus]::NotSigned) 'Developer Preview executable must match its documented unsigned state.' + + if ($RemoveToolchainFromPath) { + $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot" + foreach ($tool in @('cargo.exe', 'rustc.exe', 'link.exe', 'dumpbin.exe', 'mt.exe')) { + Assert-Condition (-not (Get-Command $tool -ErrorAction SilentlyContinue)) "Toolchain command remains on the smoke-test PATH: $tool" + } + } + + $help = Invoke-PreviewCommand -Executable $executable -Arguments @('--help') + foreach ($command in @('snapshot', 'diff', 'collectors')) { + Assert-Condition ($help.IndexOf($command, [System.StringComparison]::Ordinal) -ge 0) "Help output does not mention $command." + } + + $collectors = Invoke-PreviewCommand -Executable $executable -Arguments @('collectors') + Assert-Condition ($collectors.IndexOf('windows.registry.startup v1: Implemented', [System.StringComparison]::Ordinal) -ge 0) 'Collector output does not report the Registry startup Collector as implemented.' + + $before = Join-Path $fixtures 'snapshots\registry-before-v1.json' + $after = Join-Path $fixtures 'snapshots\registry-after-v1.json' + Assert-Condition (Test-Path -LiteralPath $before -PathType Leaf) 'Registry before fixture is missing.' + Assert-Condition (Test-Path -LiteralPath $after -PathType Leaf) 'Registry after fixture is missing.' + + $humanDiff = Invoke-PreviewCommand -Executable $executable -Arguments @('diff', $before, $after) + Assert-Condition ($humanDiff.IndexOf('1 confirmed change', [System.StringComparison]::Ordinal) -ge 0) 'Human Diff did not report exactly one confirmed change.' + Assert-Condition ($humanDiff.IndexOf('SystemDiffSyntheticE2E', [System.StringComparison]::Ordinal) -ge 0) 'Human Diff did not display the synthetic Registry value.' + + $technicalDiff = Invoke-PreviewCommand -Executable $executable -Arguments @('diff', '--technical', $before, $after) + Assert-Condition ($technicalDiff.IndexOf('windows.registry.startup', [System.StringComparison]::Ordinal) -ge 0) 'Technical Diff did not expose the Collector identity.' + Assert-Condition ($technicalDiff.IndexOf('SHA-256:', [System.StringComparison]::Ordinal) -ge 0) 'Technical Diff did not expose the evidence hash.' + + $jsonDiff = Invoke-PreviewCommand -Executable $executable -Arguments @('diff', '--json', $before, $after) + $diffDocument = $jsonDiff | ConvertFrom-Json + Assert-Condition ($diffDocument.document_type -ceq 'systemdiff.diff') 'JSON Diff document type is not canonical.' + Assert-Condition ($diffDocument.schema_version -eq 1) 'JSON Diff schema version is not 1.' + Assert-Condition (@($diffDocument.changes).Count -eq 1) 'JSON Diff did not contain exactly one change.' + Assert-Condition ($diffDocument.changes[0].change.change -ceq 'added') 'JSON Diff did not classify the synthetic change as Added.' + + $null = Invoke-PreviewCommand -Executable $executable -Arguments @('snapshot', '-o', $snapshotPath) + Assert-Condition (Test-Path -LiteralPath $snapshotPath -PathType Leaf) 'Packaged executable did not create a Snapshot.' + $snapshotText = [System.IO.File]::ReadAllText($snapshotPath, [System.Text.Encoding]::UTF8) + Assert-Condition ($snapshotText -match '"document_type"\s*:\s*"systemdiff\.snapshot"') 'Snapshot document type is not canonical.' + Assert-Condition ($snapshotText -match '"schema_version"\s*:\s*1\s*,') 'Snapshot schema version is not 1.' + Assert-Condition ($snapshotText -match '"enabled_collectors"\s*:\s*\[\s*"windows\.registry\.startup"\s*\]') 'Snapshot does not enable the Registry startup Collector.' + Assert-Condition ($snapshotText -match '"collectors"\s*:\s*\[\s*\{\s*"id"\s*:\s*"windows\.registry\.startup"') 'Snapshot does not contain the Registry startup Collector run.' + $null = Invoke-PreviewCommand -Executable $executable -Arguments @('diff', '--json', $snapshotPath, $snapshotPath) + + Write-Output 'Artifact-only smoke: --help passed' + Write-Output 'Artifact-only smoke: collectors passed' + Write-Output 'Artifact-only smoke: human Diff passed' + Write-Output 'Artifact-only smoke: technical Diff passed' + Write-Output 'Artifact-only smoke: JSON Diff passed' + Write-Output 'Artifact-only smoke: read-only Snapshot passed' + Write-Output "PE machine: AMD64 (0x$($pe.Machine.ToString('x4')))" + Write-Output "PE imports: $($pe.Imports -join ', ')" + Write-Output "PE delay imports: $($pe.DelayImports -join ', ')" + Write-Output 'Manifest: asInvoker, uiAccess=false' + Write-Output "Authenticode: $($signature.Status)" + Write-Output "EXE SHA-256: $executableHash" + Write-Output "EXE bytes: $((Get-Item -LiteralPath $executable).Length)" + Write-Output "ZIP bytes: $((Get-Item -LiteralPath $zipPath).Length)" + Write-Output "ZIP SHA-256: $actualZipHash" + Write-Output "Artifact contents: $($outerNames -join ', ')" + Write-Output "ZIP contents: $($entryNames -join ', ')" + Write-Output "Build commit: $($ExpectedCommitSha.ToLowerInvariant())" +} finally { + $env:PATH = $previousPath + if (Test-Path -LiteralPath $verificationRoot) { + Remove-Item -LiteralPath $verificationRoot -Recurse -Force + } +}