diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 2b721be..334327a 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -8,6 +8,37 @@ permissions: contents: read jobs: + investigator: + name: windows investigator build and saved-case tests + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: checkout + uses: actions/checkout@v4 + - name: Rust toolchain + shell: pwsh + run: rustup toolchain install 1.93.0 --profile minimal --component clippy,rustfmt + - name: Rust checks + shell: pwsh + run: | + cargo +1.93.0 fmt --manifest-path investigate/Cargo.toml --check + if ($LASTEXITCODE) { exit $LASTEXITCODE } + cargo +1.93.0 clippy --locked --manifest-path investigate/Cargo.toml --all-targets -- -D warnings + if ($LASTEXITCODE) { exit $LASTEXITCODE } + cargo +1.93.0 test --locked --manifest-path investigate/Cargo.toml + if ($LASTEXITCODE) { exit $LASTEXITCODE } + cargo +1.93.0 build --locked --manifest-path investigate/Cargo.toml + if ($LASTEXITCODE) { exit $LASTEXITCODE } + .\investigate\target\debug\opsforge-investigate.exe --help + if ($LASTEXITCODE) { exit $LASTEXITCODE } + - name: private-directory helper parser + shell: powershell + run: | + $tokens = $null + $parseErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path investigate/src/private-directory.ps1), [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count) { throw ($parseErrors | Out-String) } + powershell: name: powershell validation runs-on: windows-latest diff --git a/docs/compatibility.md b/docs/compatibility.md index a9158b8..f9ae0ce 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -2,8 +2,9 @@ opsforge combines a Rust investigator with native Bash and PowerShell checks. Scripts degrade clearly when optional commands are missing. The existing -investigator is Linux x86_64; Windows investigator support is planned and must -not be inferred from Windows script CI results. +collector is Linux x86_64. Windows saved-case commands are implemented, with +native CI validation pending. Windows collection remains planned and must not +be inferred from Windows script CI results. The [approved roadmap](investigator-roadmap.md) targets x64 Windows 10 22H2, Windows 11, and Windows Server 2022/2025 alongside the Linux validation matrix. diff --git a/docs/investigator-roadmap.md b/docs/investigator-roadmap.md index c343c94..3484770 100644 --- a/docs/investigator-roadmap.md +++ b/docs/investigator-roadmap.md @@ -10,7 +10,7 @@ This file records implementation work, not a support claim. | Slice | Implementation | Validation | |---|---|---| | Policy and architecture alignment | documentation updated | diff reviewed | -| Portable case/runtime foundation | pending | pending | +| Portable case/runtime foundation | native directory permissions, portable artifact paths, Windows job containment and Linux collection separation implemented | Linux regressions and Windows cross-compilation passed; native Windows CI pending | | Saved-case verification and rebuilding | Linux CLI and review TUI implemented | eleven saved-case tests, three UI tests, PTY verify/bundle/linked workflows and all nine browser pages in both modes passed; cross-platform gates pending | | Windows collection and check parity | pending | pending | | Disk acquisition and recovery | pending | pending | @@ -71,6 +71,21 @@ Isolate native discovery, elevation, private directories, and child cancellation from shared parsers/reporting. Windows requires private ACLs and owned child-tree cleanup; saved-case commands never enter collection or request elevation. +The Windows binary currently exposes saved-case commands only. Linux collection, +embedded Bash checks and their integration fixtures remain Linux-only; this is +not Windows collection parity. New Linux acquisitions record format version, +platform and architecture; old cases remain readable without these fields. + +Windows rebuilding uses the installed Windows PowerShell 5.1 to create a private +directory with a protected current-user ACL before writing evidence. It checks +that the filesystem retained that ACL and refuses reparse-point ancestors. +Files are streamed into new destinations so source ACLs are not copied. The +`process-wrap` backend assigns child processes to a job before they run; normal +completion, cancellation and guard cleanup retain ownership of descendants. +Forced termination of the investigator is not a validated cleanup guarantee. +Cases and output parents must remain stable throughout review. Cross-volume +linked output and Windows device/ambiguous artifact names are rejected. + Windows must cover browser profiles (Chromium/Edge/Firefox), existing application and transfer formats, EVTX and structured Event Logs, PowerShell activity, TCP/UDP owners, software inventory, retained DNS/VPN/proxy/firewall records, diff --git a/investigate/Cargo.lock b/investigate/Cargo.lock index 4c1d049..4f9be65 100644 --- a/investigate/Cargo.lock +++ b/investigate/Cargo.lock @@ -139,6 +139,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -319,6 +328,17 @@ dependencies = [ "phf", ] +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.3", + "windows-sys", +] + [[package]] name = "darling" version = "0.20.11" @@ -429,6 +449,18 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + [[package]] name = "document-features" version = "0.2.12" @@ -653,6 +685,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "indoc" version = "2.0.7" @@ -858,7 +900,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix", + "nix 0.29.0", "winapi", ] @@ -914,6 +956,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -959,6 +1013,21 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + [[package]] name = "once_cell" version = "1.21.4" @@ -978,7 +1047,9 @@ dependencies = [ "anyhow", "clap", "crossterm", + "ctrlc", "jiff", + "process-wrap", "ratatui", "rusqlite", "serde", @@ -1189,6 +1260,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "10.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f21b97672d2dc848e7b25701ab4535618b92f4861c13cc3f7f7bed52ad3c8da" +dependencies = [ + "indexmap", + "nix 0.31.3", + "windows", +] + [[package]] name = "quote" version = "1.0.47" @@ -1660,7 +1742,7 @@ dependencies = [ "libc", "log", "memmem", - "nix", + "nix 0.29.0", "num-derive", "num-traits", "ordered-float", @@ -1998,12 +2080,107 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2013,6 +2190,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/investigate/Cargo.toml b/investigate/Cargo.toml index f659cfb..a7eb41d 100644 --- a/investigate/Cargo.toml +++ b/investigate/Cargo.toml @@ -12,13 +12,19 @@ clap = { version = "4", features = ["derive"] } crossterm = "0.29" jiff = "0.2" ratatui = "0.30" -rusqlite = { version = "0.37", features = ["bundled", "backup"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" -signal-hook = "0.3.18" walkdir = "2" +[target.'cfg(unix)'.dependencies] +rusqlite = { version = "0.37", features = ["bundled", "backup"] } +signal-hook = "0.3.18" + +[target.'cfg(windows)'.dependencies] +ctrlc = "3.5" +process-wrap = { version = "10.0.1", default-features = false, features = ["std", "job-object"] } + [lints.rust] unsafe_code = "forbid" diff --git a/investigate/src/case.rs b/investigate/src/case.rs index 4b49a51..24c720e 100644 --- a/investigate/src/case.rs +++ b/investigate/src/case.rs @@ -1,11 +1,16 @@ +use crate::platform::private_dir; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +#[cfg(unix)] +use std::collections::BTreeSet; +#[cfg(unix)] +use std::io::BufRead; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; use std::{ - collections::BTreeSet, - fs::{self, DirBuilder, File, OpenOptions}, - io::{BufRead, BufReader, BufWriter, Read, Write}, - os::unix::fs::{DirBuilderExt, MetadataExt}, + fs::{self, File, OpenOptions}, + io::{BufReader, BufWriter, Read, Write}, path::{Path, PathBuf}, }; @@ -63,6 +68,7 @@ pub enum CoverageState { } impl CoverageState { + #[cfg(unix)] pub(crate) fn from_io(error: &std::io::Error) -> Self { match error.kind() { std::io::ErrorKind::NotFound => Self::Unavailable, @@ -72,6 +78,7 @@ impl CoverageState { } } + #[cfg(unix)] pub(crate) fn from_error(error: &anyhow::Error) -> Self { error .downcast_ref::() @@ -102,20 +109,13 @@ pub struct Case { manifest: BufWriter, } -fn private_dir(path: &Path) -> Result<()> { - DirBuilder::new() - .mode(0o700) - .create(path) - .with_context(|| format!("creating {}", path.display()))?; - Ok(()) -} - fn append_file(path: &Path) -> Result> { Ok(BufWriter::new( OpenOptions::new().create_new(true).write(true).open(path)?, )) } +#[cfg(unix)] fn validate_root_output_base(base: &Path) -> Result<()> { for ancestor in base.ancestors() { let metadata = fs::metadata(ancestor)?; @@ -133,13 +133,18 @@ impl Case { pub fn new(output_base: &Path) -> Result { fs::create_dir_all(output_base) .with_context(|| format!("creating {}", output_base.display()))?; + #[cfg(windows)] + crate::platform::reject_link_ancestors(output_base)?; let base = fs::canonicalize(output_base)?; - let uid = std::process::Command::new("id").arg("-u").output()?; - if !uid.status.success() { - anyhow::bail!("id -u failed"); - } - if uid.stdout == b"0\n" { - validate_root_output_base(&base)?; + #[cfg(unix)] + { + let uid = std::process::Command::new("id").arg("-u").output()?; + if !uid.status.success() { + anyhow::bail!("id -u failed"); + } + if uid.stdout == b"0\n" { + validate_root_output_base(&base)?; + } } let host = std::process::Command::new("hostname") .output() @@ -163,9 +168,15 @@ impl Case { } else { format!("{name}-{attempt}") }); - match DirBuilder::new().mode(0o700).create(&candidate) { + match private_dir(&candidate) { Ok(()) => Some(Ok(candidate)), - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None, + Err(error) + if error.downcast_ref::().is_some_and(|error| { + error.kind() == std::io::ErrorKind::AlreadyExists + }) => + { + None + } Err(error) => Some(Err(error)), } }) @@ -200,9 +211,7 @@ impl Case { } pub fn copy_evidence(&mut self, source: &Path, name: &str) -> Result { - if name.split('/').any(|part| matches!(part, "" | "." | "..")) { - anyhow::bail!("invalid evidence name"); - } + crate::verify::valid_path(name)?; let destination = self.root.join("raw").join(name); let mut input = BufReader::new( File::open(source).with_context(|| format!("opening {}", source.display()))?, @@ -244,6 +253,7 @@ impl Case { self.record_existing_inner(source, name, true) } + #[cfg(unix)] pub(crate) fn record_partial_files(&mut self) -> Result<()> { let recorded = BufReader::new(File::open(self.root.join("manifest.jsonl"))?) .lines() @@ -286,9 +296,7 @@ impl Case { name: &str, interruptible: bool, ) -> Result<()> { - if name.split('/').any(|part| matches!(part, "" | "." | "..")) { - anyhow::bail!("invalid evidence name"); - } + crate::verify::valid_path(name)?; let path = self.root.join("raw").join(name); let mut input = BufReader::new(File::open(&path)?); let mut hash = Sha256::new(); @@ -319,7 +327,7 @@ impl Case { } } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::{Case, Coverage, CoverageState, Event, EvidenceLevel, validate_root_output_base}; diff --git a/investigate/src/checks.rs b/investigate/src/checks.rs index 51e6b1a..f221151 100644 --- a/investigate/src/checks.rs +++ b/investigate/src/checks.rs @@ -1,110 +1,10 @@ -use crate::{ - case::{Case, Coverage, CoverageState, Event, EvidenceLevel}, - config::{CheckConfig, LinuxCheck}, - runtime::{self, OwnedChild}, -}; -use anyhow::{Context, Result, bail}; +use crate::case::CoverageState; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::{ - fs::{self, DirBuilder, File, OpenOptions}, - io::Write, - os::unix::fs::{DirBuilderExt, MetadataExt}, - path::{Path, PathBuf}, - process::{Command, Stdio}, -}; -use walkdir::WalkDir; -macro_rules! asset { - ($path:literal) => { - ($path, include_str!(concat!("../../", $path))) - }; -} - -const LIBRARIES: &[(&str, &str)] = &[ - asset!("lib/common.sh"), - asset!("lib/logging.sh"), - asset!("lib/output.sh"), - asset!("lib/archive.sh"), - asset!("lib/checks.sh"), -]; -const SCRIPTS: &[(LinuxCheck, &str, &str)] = &[ - ( - LinuxCheck::Triage, - "scripts/linux/endpoint/linux-triage-collector.sh", - include_str!("../../scripts/linux/endpoint/linux-triage-collector.sh"), - ), - ( - LinuxCheck::Persistence, - "scripts/linux/persistence/linux-persistence-hunter.sh", - include_str!("../../scripts/linux/persistence/linux-persistence-hunter.sh"), - ), - ( - LinuxCheck::DeletedBinaries, - "scripts/linux/forensic/deleted-binary-detector.sh", - include_str!("../../scripts/linux/forensic/deleted-binary-detector.sh"), - ), - ( - LinuxCheck::ProcessTree, - "scripts/linux/endpoint/process-tree-anomaly.sh", - include_str!("../../scripts/linux/endpoint/process-tree-anomaly.sh"), - ), - ( - LinuxCheck::Suid, - "scripts/linux/hardening/suid-drift-monitor.sh", - include_str!("../../scripts/linux/hardening/suid-drift-monitor.sh"), - ), - ( - LinuxCheck::PrivilegeSurface, - "scripts/linux/hardening/linux-privilege-surface-audit.sh", - include_str!("../../scripts/linux/hardening/linux-privilege-surface-audit.sh"), - ), - ( - LinuxCheck::Ssh, - "scripts/linux/hardening/ssh-hardening-audit.sh", - include_str!("../../scripts/linux/hardening/ssh-hardening-audit.sh"), - ), - ( - LinuxCheck::ConfigDrift, - "scripts/linux/hardening/config-drift-monitor.sh", - include_str!("../../scripts/linux/hardening/config-drift-monitor.sh"), - ), - ( - LinuxCheck::NetworkPath, - "scripts/linux/network/network-path-drift.sh", - include_str!("../../scripts/linux/network/network-path-drift.sh"), - ), - ( - LinuxCheck::DiskPressure, - "scripts/linux/noc/disk-pressure-rca.sh", - include_str!("../../scripts/linux/noc/disk-pressure-rca.sh"), - ), - ( - LinuxCheck::Tls, - "scripts/linux/network/tls-inventory-scanner.sh", - include_str!("../../scripts/linux/network/tls-inventory-scanner.sh"), - ), - ( - LinuxCheck::Firewall, - "scripts/linux/network/firewall-rule-analyzer.sh", - include_str!("../../scripts/linux/network/firewall-rule-analyzer.sh"), - ), - ( - LinuxCheck::LogSilence, - "scripts/linux/siem/log-source-silence-detector.sh", - include_str!("../../scripts/linux/siem/log-source-silence-detector.sh"), - ), - ( - LinuxCheck::Timeline, - "scripts/linux/forensic/timeline-builder.sh", - include_str!("../../scripts/linux/forensic/timeline-builder.sh"), - ), - ( - LinuxCheck::WebTriage, - "scripts/linux/forensic/web-compromise-triage.sh", - include_str!("../../scripts/linux/forensic/web-compromise-triage.sh"), - ), -]; +#[cfg(unix)] +mod linux; +#[cfg(unix)] +pub(crate) use linux::collect; #[derive(Clone, Debug, Serialize, Deserialize)] pub(crate) struct Finding { @@ -128,384 +28,3 @@ pub(crate) struct CheckResult { pub evidence: String, pub findings: Vec, } - -struct StagedTools(PathBuf); - -impl Drop for StagedTools { - fn drop(&mut self) { - if let Err(error) = fs::remove_dir_all(&self.0) - && error.kind() != std::io::ErrorKind::NotFound - { - eprintln!( - "could not remove staged checks at {}: {error}", - self.0.display() - ); - } - } -} - -fn stage(root: &Path) -> Result { - let path = root.join(".check-tools"); - DirBuilder::new().mode(0o700).create(&path)?; - let stage = StagedTools(path); - for (name, contents) in LIBRARIES - .iter() - .copied() - .chain(SCRIPTS.iter().map(|(_, path, contents)| (*path, *contents))) - { - let file = stage.0.join(name); - fs::create_dir_all(file.parent().context("asset parent")?)?; - let mut output = OpenOptions::new().create_new(true).write(true).open(file)?; - output.write_all(contents.as_bytes())?; - } - Ok(stage) -} - -pub(crate) fn collect( - case: &mut Case, - selected: &[CheckConfig], - progress: &impl Fn(&str), -) -> Result<()> { - let mut records = OpenOptions::new() - .create_new(true) - .write(true) - .open(case.root.join("normalized/checks.jsonl"))?; - let tools = if selected.is_empty() { - None - } else { - Some(stage(&case.root)?) - }; - if tools.is_some() { - let assets = LIBRARIES.iter().copied().chain(SCRIPTS.iter().map(|(_, path, contents)| (*path, *contents))).map(|(path, contents)|serde_json::json!({"path":path,"sha256":format!("{:x}",Sha256::digest(contents.as_bytes())),"bytes":contents.len()})).collect::>(); - fs::write( - case.root.join("raw/check-assets.json"), - serde_json::to_vec_pretty(&assets)?, - )?; - case.record_existing( - "embedded operational check assets; content hashes", - "check-assets.json", - )?; - } - for tool in LinuxCheck::ALL { - runtime::check_cancelled()?; - let name = tool.name(); - let Some(config) = selected.iter().find(|config| config.tool == tool) else { - case.coverage(&Coverage { - source: format!("check-{name}"), - state: CoverageState::Skipped, - detail: "operator did not select this operational check".into(), - })?; - continue; - }; - progress(&format!("Running Linux check: {name}")); - let started_at = jiff::Timestamp::now().to_string(); - let evidence = format!("raw/checks/{name}/command.json"); - let result = run_one(case, config, &tools.as_ref().context("staged checks")?.0); - let (state, exit_code, detail, findings) = match result { - Ok(result) => result, - Err(error) => { - let exit_code = fs::read(case.root.join(&evidence)) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - .and_then(|audit| audit.get("exit_code").and_then(serde_json::Value::as_i64)) - .and_then(|code| i32::try_from(code).ok()); - ( - CoverageState::from_error(&error), - exit_code, - format!("{error:#}"), - Vec::new(), - ) - } - }; - let result = CheckResult { - tool: name.into(), - state, - started_at, - finished_at: jiff::Timestamp::now().to_string(), - exit_code, - detail, - evidence, - findings, - }; - serde_json::to_writer(&mut records, &result)?; - records.write_all(b"\n")?; - records.flush()?; - case.coverage(&Coverage { - source: format!("check-{name}"), - state, - detail: result.detail.clone(), - })?; - case.event(&Event { - timestamp: Some(result.finished_at.clone()), - source: format!("check-{name}"), - kind: "operational-check".into(), - application: Some("opsforge".into()), - user: None, - destination: None, - detail: format!( - "{}; {} finding(s); these are check results, not proof of compromise", - result.detail, - result.findings.len() - ), - evidence: result.evidence.clone(), - level: EvidenceLevel::Observed, - transfer: None, - evidence_line: None, - })?; - runtime::check_cancelled()?; - } - if let Some(tools) = tools { - fs::remove_dir_all(&tools.0).context("removing owned staged checks")?; - case.coverage(&Coverage { - source: "check-tools-cleanup".into(), - state: CoverageState::Collected, - detail: "removed this run's staged scripts and tool state; retained case evidence" - .into(), - })?; - } - Ok(()) -} - -fn run_one( - case: &mut Case, - config: &CheckConfig, - tools: &Path, -) -> Result<(CoverageState, Option, String, Vec)> { - let name = config.tool.name(); - let parent = case.root.join("raw/checks").join(name); - fs::create_dir_all(&parent)?; - let script = SCRIPTS - .iter() - .find(|(tool, _, _)| *tool == config.tool) - .context("embedded check")?; - let mut args = vec![ - tools.join(script.1).into_os_string(), - "--output".into(), - parent.as_os_str().to_owned(), - "--json".into(), - "--markdown".into(), - "--verbose".into(), - ]; - if let Some(input) = &config.input { - let input = case.copy_evidence(input, &format!("check-{name}-input.conf"))?; - args.push( - if config.tool == LinuxCheck::LogSilence { - "--config" - } else { - "--targets" - } - .into(), - ); - args.push(input.into_os_string()); - } - if config.tool.needs_baseline() { - args.push( - if config.create_baseline { - "--baseline" - } else { - "--check" - } - .into(), - ); - args.push( - if config.tool == LinuxCheck::Suid { - "--baseline-file" - } else { - "--baseline-dir" - } - .into(), - ); - args.push(if config.create_baseline { - parent.join("baseline").into_os_string() - } else { - preserve_baseline( - case, - config.baseline.as_ref().context("existing baseline")?, - name, - )? - .into_os_string() - }); - } else if let Some(baseline) = &config.baseline { - args.push("--baseline".into()); - args.push(preserve_baseline(case, baseline, name)?.into_os_string()); - } - let audit = parent.join("command.json"); - let started = jiff::Timestamp::now().to_string(); - let uid = fs::metadata(&case.root)?.uid().to_string(); - let passwd = fs::read_to_string("/etc/passwd")?; - let home = passwd - .lines() - .find_map(|line| { - let fields = line.split(':').collect::>(); - (fields.get(2) == Some(&uid.as_str())) - .then(|| fields.get(5).copied()) - .flatten() - }) - .filter(|home| Path::new(home).is_absolute()) - .context("collection account home is missing from /etc/passwd")?; - let mut command = Command::new("bash"); - command - .env_clear() - .env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin") - .env("LC_ALL", "C") - .env("HOME", home) - .env("OPSFORGE_SKIP_ARCHIVE", "1") - .env("OPSFORGE_FALLBACK_OUTPUT", &parent) - .args(&args) - .current_dir(tools) - .stdin(Stdio::null()) - .stdout(File::create(parent.join("stdout.log"))?) - .stderr(File::create(parent.join("stderr.log"))?); - let mut record = serde_json::json!({"program":"bash", "args":args.iter().map(|arg|arg.to_string_lossy()).collect::>(), "started_at":started, "script_sha256":format!("{:x}",Sha256::digest(script.2.as_bytes())), "environment":"cleared; fixed system PATH; LC_ALL=C; no archive", "home":home, "status":"started"}); - fs::write(&audit, serde_json::to_vec_pretty(&record)?)?; - let status = OwnedChild::spawn(&mut command)?.wait()?; - record["status"] = serde_json::json!(if status.success() { "exited" } else { "failed" }); - record["exit_code"] = serde_json::json!(status.code()); - record["finished_at"] = serde_json::json!(jiff::Timestamp::now().to_string()); - fs::write(&audit, serde_json::to_vec_pretty(&record)?)?; - for entry in WalkDir::new(&parent).follow_links(false) { - let entry = entry?; - if entry.file_type().is_file() { - let path = entry - .path() - .strip_prefix(case.root.join("raw"))? - .to_str() - .context("check output path")? - .to_owned(); - case.record_existing(&format!("opsforge {name}; generated check output"), &path)?; - } - } - let mut outputs = fs::read_dir(&parent)? - .filter_map(|entry| entry.ok()) - .filter(|entry| { - entry.file_type().is_ok_and(|kind| kind.is_dir()) && entry.file_name() != "baseline" - }) - .collect::>(); - if outputs.len() != 1 { - bail!( - "check produced {} output directories; exit {:?}; inspect stdout/stderr", - outputs.len(), - status.code() - ); - } - let output = outputs.remove(0).path(); - for required in [ - "raw", - "normalized", - "findings.json", - "summary.txt", - "report.md", - ] { - if !output.join(required).exists() { - bail!("check output missing {required}; exit {:?}", status.code()); - } - } - let mut findings: Vec = - serde_json::from_reader(File::open(output.join("findings.json"))?)?; - let finding_path = output - .join("findings.json") - .strip_prefix(&case.root)? - .to_str() - .context("finding path")? - .to_owned(); - for finding in &mut findings { - if !["critical", "high", "medium", "low", "info"].contains(&finding.severity.as_str()) { - bail!("invalid check finding severity"); - } - finding.evidence = finding_path.clone(); - } - let collection = output.join("normalized/collection-status.tsv"); - let failed_commands = if collection.exists() { - fs::read_to_string(collection)? - .lines() - .skip(1) - .filter(|line| { - line.split('\t') - .nth(3) - .is_some_and(|state| state != "collected") - }) - .count() - } else { - 0 - }; - let state = if status.success() && failed_commands == 0 { - CoverageState::Collected - } else { - CoverageState::Failed - }; - Ok(( - state, - status.code(), - format!( - "exit {:?}; {failed_commands} failed or unavailable commands; original reports retained", - status.code() - ), - findings, - )) -} - -fn preserve_baseline(case: &mut Case, input: &Path, name: &str) -> Result { - let destination = format!("check-{name}-baseline"); - if input.is_file() { - return case.copy_evidence(input, &destination); - } - if !input.is_dir() { - bail!("baseline is not a file or directory: {}", input.display()); - } - let root = case.root.join("raw").join(&destination); - fs::create_dir(&root)?; - for entry in WalkDir::new(input).follow_links(false) { - runtime::check_cancelled()?; - let entry = entry?; - if entry.file_type().is_symlink() { - bail!("baseline contains a symlink: {}", entry.path().display()); - } - if !entry.file_type().is_file() { - continue; - } - let relative = entry - .path() - .strip_prefix(input)? - .to_str() - .context("baseline path is not UTF-8")?; - let name = format!("{destination}/{relative}"); - fs::create_dir_all(root.join(relative).parent().context("baseline parent")?)?; - case.copy_evidence(entry.path(), &name)?; - } - Ok(root) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn staged_tools_cover_all_checks_and_ignore_shell_startup_code() { - let root = - std::env::temp_dir().join(format!("opsforge-staged-checks-{}", std::process::id())); - fs::create_dir(&root).expect("fixture"); - let marker = root.join("startup-ran"); - let startup = root.join("startup.sh"); - fs::write(&startup, format!("touch '{}'\n", marker.display())).expect("startup fixture"); - let staged = stage(&root).expect("stage"); - assert_eq!(SCRIPTS.len(), LinuxCheck::ALL.len()); - for (tool, path, _) in SCRIPTS { - assert!(LinuxCheck::ALL.contains(tool)); - let status = Command::new("bash") - .env("BASH_ENV", &startup) - .env_clear() - .env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin") - .arg(staged.0.join(path)) - .arg("--help") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .expect("help"); - assert!(status.success(), "{} help", tool.name()); - } - assert!(!marker.exists()); - drop(staged); - assert!(!root.join(".check-tools").exists()); - fs::remove_dir_all(root).expect("cleanup"); - } -} diff --git a/investigate/src/checks/linux.rs b/investigate/src/checks/linux.rs new file mode 100644 index 0000000..0b2fda0 --- /dev/null +++ b/investigate/src/checks/linux.rs @@ -0,0 +1,488 @@ +use super::{CheckResult, Finding}; +use crate::{ + case::{Case, Coverage, CoverageState, Event, EvidenceLevel}, + config::{CheckConfig, LinuxCheck}, + runtime::{self, OwnedChild}, +}; +use anyhow::{Context, Result, bail}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, DirBuilder, File, OpenOptions}, + io::Write, + os::unix::fs::{DirBuilderExt, MetadataExt}, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; +use walkdir::WalkDir; + +macro_rules! asset { + ($path:literal) => { + ($path, include_str!(concat!("../../../", $path))) + }; +} + +const LIBRARIES: &[(&str, &str)] = &[ + asset!("lib/common.sh"), + asset!("lib/logging.sh"), + asset!("lib/output.sh"), + asset!("lib/archive.sh"), + asset!("lib/checks.sh"), +]; +const SCRIPTS: &[(LinuxCheck, &str, &str)] = &[ + ( + LinuxCheck::Triage, + "scripts/linux/endpoint/linux-triage-collector.sh", + include_str!("../../../scripts/linux/endpoint/linux-triage-collector.sh"), + ), + ( + LinuxCheck::Persistence, + "scripts/linux/persistence/linux-persistence-hunter.sh", + include_str!("../../../scripts/linux/persistence/linux-persistence-hunter.sh"), + ), + ( + LinuxCheck::DeletedBinaries, + "scripts/linux/forensic/deleted-binary-detector.sh", + include_str!("../../../scripts/linux/forensic/deleted-binary-detector.sh"), + ), + ( + LinuxCheck::ProcessTree, + "scripts/linux/endpoint/process-tree-anomaly.sh", + include_str!("../../../scripts/linux/endpoint/process-tree-anomaly.sh"), + ), + ( + LinuxCheck::Suid, + "scripts/linux/hardening/suid-drift-monitor.sh", + include_str!("../../../scripts/linux/hardening/suid-drift-monitor.sh"), + ), + ( + LinuxCheck::PrivilegeSurface, + "scripts/linux/hardening/linux-privilege-surface-audit.sh", + include_str!("../../../scripts/linux/hardening/linux-privilege-surface-audit.sh"), + ), + ( + LinuxCheck::Ssh, + "scripts/linux/hardening/ssh-hardening-audit.sh", + include_str!("../../../scripts/linux/hardening/ssh-hardening-audit.sh"), + ), + ( + LinuxCheck::ConfigDrift, + "scripts/linux/hardening/config-drift-monitor.sh", + include_str!("../../../scripts/linux/hardening/config-drift-monitor.sh"), + ), + ( + LinuxCheck::NetworkPath, + "scripts/linux/network/network-path-drift.sh", + include_str!("../../../scripts/linux/network/network-path-drift.sh"), + ), + ( + LinuxCheck::DiskPressure, + "scripts/linux/noc/disk-pressure-rca.sh", + include_str!("../../../scripts/linux/noc/disk-pressure-rca.sh"), + ), + ( + LinuxCheck::Tls, + "scripts/linux/network/tls-inventory-scanner.sh", + include_str!("../../../scripts/linux/network/tls-inventory-scanner.sh"), + ), + ( + LinuxCheck::Firewall, + "scripts/linux/network/firewall-rule-analyzer.sh", + include_str!("../../../scripts/linux/network/firewall-rule-analyzer.sh"), + ), + ( + LinuxCheck::LogSilence, + "scripts/linux/siem/log-source-silence-detector.sh", + include_str!("../../../scripts/linux/siem/log-source-silence-detector.sh"), + ), + ( + LinuxCheck::Timeline, + "scripts/linux/forensic/timeline-builder.sh", + include_str!("../../../scripts/linux/forensic/timeline-builder.sh"), + ), + ( + LinuxCheck::WebTriage, + "scripts/linux/forensic/web-compromise-triage.sh", + include_str!("../../../scripts/linux/forensic/web-compromise-triage.sh"), + ), +]; + +struct StagedTools(PathBuf); + +impl Drop for StagedTools { + fn drop(&mut self) { + if let Err(error) = fs::remove_dir_all(&self.0) + && error.kind() != std::io::ErrorKind::NotFound + { + eprintln!( + "could not remove staged checks at {}: {error}", + self.0.display() + ); + } + } +} + +fn stage(root: &Path) -> Result { + let path = root.join(".check-tools"); + DirBuilder::new().mode(0o700).create(&path)?; + let stage = StagedTools(path); + for (name, contents) in LIBRARIES + .iter() + .copied() + .chain(SCRIPTS.iter().map(|(_, path, contents)| (*path, *contents))) + { + let file = stage.0.join(name); + fs::create_dir_all(file.parent().context("asset parent")?)?; + let mut output = OpenOptions::new().create_new(true).write(true).open(file)?; + output.write_all(contents.as_bytes())?; + } + Ok(stage) +} + +pub(crate) fn collect( + case: &mut Case, + selected: &[CheckConfig], + progress: &impl Fn(&str), +) -> Result<()> { + let mut records = OpenOptions::new() + .create_new(true) + .write(true) + .open(case.root.join("normalized/checks.jsonl"))?; + let tools = if selected.is_empty() { + None + } else { + Some(stage(&case.root)?) + }; + if tools.is_some() { + let assets = LIBRARIES.iter().copied().chain(SCRIPTS.iter().map(|(_, path, contents)| (*path, *contents))).map(|(path, contents)|serde_json::json!({"path":path,"sha256":format!("{:x}",Sha256::digest(contents.as_bytes())),"bytes":contents.len()})).collect::>(); + fs::write( + case.root.join("raw/check-assets.json"), + serde_json::to_vec_pretty(&assets)?, + )?; + case.record_existing( + "embedded operational check assets; content hashes", + "check-assets.json", + )?; + } + for tool in LinuxCheck::ALL { + runtime::check_cancelled()?; + let name = tool.name(); + let Some(config) = selected.iter().find(|config| config.tool == tool) else { + case.coverage(&Coverage { + source: format!("check-{name}"), + state: CoverageState::Skipped, + detail: "operator did not select this operational check".into(), + })?; + continue; + }; + progress(&format!("Running Linux check: {name}")); + let started_at = jiff::Timestamp::now().to_string(); + let evidence = format!("raw/checks/{name}/command.json"); + let result = run_one(case, config, &tools.as_ref().context("staged checks")?.0); + let (state, exit_code, detail, findings) = match result { + Ok(result) => result, + Err(error) => { + let exit_code = fs::read(case.root.join(&evidence)) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|audit| audit.get("exit_code").and_then(serde_json::Value::as_i64)) + .and_then(|code| i32::try_from(code).ok()); + ( + CoverageState::from_error(&error), + exit_code, + format!("{error:#}"), + Vec::new(), + ) + } + }; + let result = CheckResult { + tool: name.into(), + state, + started_at, + finished_at: jiff::Timestamp::now().to_string(), + exit_code, + detail, + evidence, + findings, + }; + serde_json::to_writer(&mut records, &result)?; + records.write_all(b"\n")?; + records.flush()?; + case.coverage(&Coverage { + source: format!("check-{name}"), + state, + detail: result.detail.clone(), + })?; + case.event(&Event { + timestamp: Some(result.finished_at.clone()), + source: format!("check-{name}"), + kind: "operational-check".into(), + application: Some("opsforge".into()), + user: None, + destination: None, + detail: format!( + "{}; {} finding(s); these are check results, not proof of compromise", + result.detail, + result.findings.len() + ), + evidence: result.evidence.clone(), + level: EvidenceLevel::Observed, + transfer: None, + evidence_line: None, + })?; + runtime::check_cancelled()?; + } + if let Some(tools) = tools { + fs::remove_dir_all(&tools.0).context("removing owned staged checks")?; + case.coverage(&Coverage { + source: "check-tools-cleanup".into(), + state: CoverageState::Collected, + detail: "removed this run's staged scripts and tool state; retained case evidence" + .into(), + })?; + } + Ok(()) +} + +fn run_one( + case: &mut Case, + config: &CheckConfig, + tools: &Path, +) -> Result<(CoverageState, Option, String, Vec)> { + let name = config.tool.name(); + let parent = case.root.join("raw/checks").join(name); + fs::create_dir_all(&parent)?; + let script = SCRIPTS + .iter() + .find(|(tool, _, _)| *tool == config.tool) + .context("embedded check")?; + let mut args = vec![ + tools.join(script.1).into_os_string(), + "--output".into(), + parent.as_os_str().to_owned(), + "--json".into(), + "--markdown".into(), + "--verbose".into(), + ]; + if let Some(input) = &config.input { + let input = case.copy_evidence(input, &format!("check-{name}-input.conf"))?; + args.push( + if config.tool == LinuxCheck::LogSilence { + "--config" + } else { + "--targets" + } + .into(), + ); + args.push(input.into_os_string()); + } + if config.tool.needs_baseline() { + args.push( + if config.create_baseline { + "--baseline" + } else { + "--check" + } + .into(), + ); + args.push( + if config.tool == LinuxCheck::Suid { + "--baseline-file" + } else { + "--baseline-dir" + } + .into(), + ); + args.push(if config.create_baseline { + parent.join("baseline").into_os_string() + } else { + preserve_baseline( + case, + config.baseline.as_ref().context("existing baseline")?, + name, + )? + .into_os_string() + }); + } else if let Some(baseline) = &config.baseline { + args.push("--baseline".into()); + args.push(preserve_baseline(case, baseline, name)?.into_os_string()); + } + let audit = parent.join("command.json"); + let started = jiff::Timestamp::now().to_string(); + let uid = fs::metadata(&case.root)?.uid().to_string(); + let passwd = fs::read_to_string("/etc/passwd")?; + let home = passwd + .lines() + .find_map(|line| { + let fields = line.split(':').collect::>(); + (fields.get(2) == Some(&uid.as_str())) + .then(|| fields.get(5).copied()) + .flatten() + }) + .filter(|home| Path::new(home).is_absolute()) + .context("collection account home is missing from /etc/passwd")?; + let mut command = Command::new("bash"); + command + .env_clear() + .env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin") + .env("LC_ALL", "C") + .env("HOME", home) + .env("OPSFORGE_SKIP_ARCHIVE", "1") + .env("OPSFORGE_FALLBACK_OUTPUT", &parent) + .args(&args) + .current_dir(tools) + .stdin(Stdio::null()) + .stdout(File::create(parent.join("stdout.log"))?) + .stderr(File::create(parent.join("stderr.log"))?); + let mut record = serde_json::json!({"program":"bash", "args":args.iter().map(|arg|arg.to_string_lossy()).collect::>(), "started_at":started, "script_sha256":format!("{:x}",Sha256::digest(script.2.as_bytes())), "environment":"cleared; fixed system PATH; LC_ALL=C; no archive", "home":home, "status":"started"}); + fs::write(&audit, serde_json::to_vec_pretty(&record)?)?; + let status = OwnedChild::spawn(&mut command)?.wait()?; + record["status"] = serde_json::json!(if status.success() { "exited" } else { "failed" }); + record["exit_code"] = serde_json::json!(status.code()); + record["finished_at"] = serde_json::json!(jiff::Timestamp::now().to_string()); + fs::write(&audit, serde_json::to_vec_pretty(&record)?)?; + for entry in WalkDir::new(&parent).follow_links(false) { + let entry = entry?; + if entry.file_type().is_file() { + let path = entry + .path() + .strip_prefix(case.root.join("raw"))? + .to_str() + .context("check output path")? + .to_owned(); + case.record_existing(&format!("opsforge {name}; generated check output"), &path)?; + } + } + let mut outputs = fs::read_dir(&parent)? + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry.file_type().is_ok_and(|kind| kind.is_dir()) && entry.file_name() != "baseline" + }) + .collect::>(); + if outputs.len() != 1 { + bail!( + "check produced {} output directories; exit {:?}; inspect stdout/stderr", + outputs.len(), + status.code() + ); + } + let output = outputs.remove(0).path(); + for required in [ + "raw", + "normalized", + "findings.json", + "summary.txt", + "report.md", + ] { + if !output.join(required).exists() { + bail!("check output missing {required}; exit {:?}", status.code()); + } + } + let mut findings: Vec = + serde_json::from_reader(File::open(output.join("findings.json"))?)?; + let finding_path = output + .join("findings.json") + .strip_prefix(&case.root)? + .to_str() + .context("finding path")? + .to_owned(); + for finding in &mut findings { + if !["critical", "high", "medium", "low", "info"].contains(&finding.severity.as_str()) { + bail!("invalid check finding severity"); + } + finding.evidence = finding_path.clone(); + } + let collection = output.join("normalized/collection-status.tsv"); + let failed_commands = if collection.exists() { + fs::read_to_string(collection)? + .lines() + .skip(1) + .filter(|line| { + line.split('\t') + .nth(3) + .is_some_and(|state| state != "collected") + }) + .count() + } else { + 0 + }; + let state = if status.success() && failed_commands == 0 { + CoverageState::Collected + } else { + CoverageState::Failed + }; + Ok(( + state, + status.code(), + format!( + "exit {:?}; {failed_commands} failed or unavailable commands; original reports retained", + status.code() + ), + findings, + )) +} + +fn preserve_baseline(case: &mut Case, input: &Path, name: &str) -> Result { + let destination = format!("check-{name}-baseline"); + if input.is_file() { + return case.copy_evidence(input, &destination); + } + if !input.is_dir() { + bail!("baseline is not a file or directory: {}", input.display()); + } + let root = case.root.join("raw").join(&destination); + fs::create_dir(&root)?; + for entry in WalkDir::new(input).follow_links(false) { + runtime::check_cancelled()?; + let entry = entry?; + if entry.file_type().is_symlink() { + bail!("baseline contains a symlink: {}", entry.path().display()); + } + if !entry.file_type().is_file() { + continue; + } + let relative = entry + .path() + .strip_prefix(input)? + .to_str() + .context("baseline path is not UTF-8")?; + let name = format!("{destination}/{relative}"); + fs::create_dir_all(root.join(relative).parent().context("baseline parent")?)?; + case.copy_evidence(entry.path(), &name)?; + } + Ok(root) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn staged_tools_cover_all_checks_and_ignore_shell_startup_code() { + let root = + std::env::temp_dir().join(format!("opsforge-staged-checks-{}", std::process::id())); + fs::create_dir(&root).expect("fixture"); + let marker = root.join("startup-ran"); + let startup = root.join("startup.sh"); + fs::write(&startup, format!("touch '{}'\n", marker.display())).expect("startup fixture"); + let staged = stage(&root).expect("stage"); + assert_eq!(SCRIPTS.len(), LinuxCheck::ALL.len()); + for (tool, path, _) in SCRIPTS { + assert!(LinuxCheck::ALL.contains(tool)); + let status = Command::new("bash") + .env("BASH_ENV", &startup) + .env_clear() + .env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin") + .arg(staged.0.join(path)) + .arg("--help") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("help"); + assert!(status.success(), "{} help", tool.name()); + } + assert!(!marker.exists()); + drop(staged); + assert!(!root.join(".check-tools").exists()); + fs::remove_dir_all(root).expect("cleanup"); + } +} diff --git a/investigate/src/collect.rs b/investigate/src/collect.rs index fa08ac1..13b8641 100644 --- a/investigate/src/collect.rs +++ b/investigate/src/collect.rs @@ -31,6 +31,9 @@ pub fn run(config: &RunConfig, progress: &impl Fn(&str)) -> Result { "tool": "opsforge-investigate", "case_id": root.file_name().unwrap_or_default().to_string_lossy(), "version": env!("CARGO_PKG_VERSION"), + "format_version": 1, + "platform": std::env::consts::OS, + "architecture": std::env::consts::ARCH, "started_at": jiff::Timestamp::now().to_string(), "operator": std::env::var("SUDO_USER").or_else(|_| std::env::var("USER")).unwrap_or_else(|_| "unknown".into()), }))?, diff --git a/investigate/src/lib.rs b/investigate/src/lib.rs index e35177b..5f50707 100644 --- a/investigate/src/lib.rs +++ b/investigate/src/lib.rs @@ -1,14 +1,23 @@ +#[cfg(unix)] mod applications; +#[cfg(unix)] pub mod browser; pub mod case; mod checks; +#[cfg(unix)] pub mod collect; pub mod config; +#[cfg(unix)] mod exports; +mod platform; pub mod rebuild; pub mod report; pub mod review_ui; mod runtime; +#[cfg(windows)] +pub use runtime::install_signal_handlers; +#[cfg(unix)] pub mod transfers; +#[cfg(unix)] pub mod tui; pub mod verify; diff --git a/investigate/src/main.rs b/investigate/src/main.rs index e391338..a79392d 100644 --- a/investigate/src/main.rs +++ b/investigate/src/main.rs @@ -1,15 +1,18 @@ -use anyhow::{Context, Result, bail}; +#[cfg(unix)] +use anyhow::Context; +use anyhow::{Result, bail}; use clap::Parser; +#[cfg(unix)] use opsforge_investigate::{ collect, config::{CaptureDuration, CheckConfig, LinuxCheck, RunConfig}, tui, }; -use std::{ - path::PathBuf, - process::{Command, Stdio}, -}; +use std::path::PathBuf; +#[cfg(unix)] +use std::process::{Command, Stdio}; +#[cfg(unix)] #[derive(Parser)] #[command( about = "Collect a Linux investigation case with an offline dashboard", @@ -58,6 +61,8 @@ struct Args { } fn main() -> Result<()> { + #[cfg(windows)] + opsforge_investigate::install_signal_handlers()?; if std::env::args_os().nth(1).as_deref() == Some(std::ffi::OsStr::new("review")) { #[derive(Parser)] #[command(about = "Review a saved case interactively without collection")] @@ -126,6 +131,25 @@ fn main() -> Result<()> { } } } + collect_main() +} + +#[cfg(not(unix))] +fn collect_main() -> Result<()> { + if std::env::args_os() + .nth(1) + .is_none_or(|arg| arg == "--help" || arg == "-h") + { + println!( + "opsforge-investigate\n\nSaved-case commands:\n verify CASE [--json]\n rebuild CASE --output DIRECTORY [--mode bundle|linked] [--allow-unverified]\n review\n\nCollection is not available on Windows yet." + ); + return Ok(()); + } + bail!("collection is not available on this platform yet; use verify, rebuild, or review"); +} + +#[cfg(unix)] +fn collect_main() -> Result<()> { let mut arguments = std::env::args_os().collect::>(); if arguments.get(1).is_some_and(|arg| arg == "collect") { arguments.remove(1); @@ -224,6 +248,7 @@ fn main() -> Result<()> { Ok(()) } +#[cfg(unix)] fn execute(config: &RunConfig, interactive: bool) -> Result<()> { collect::install_signal_handlers()?; let root = if interactive { @@ -236,6 +261,7 @@ fn execute(config: &RunConfig, interactive: bool) -> Result<()> { Ok(()) } +#[cfg(unix)] fn is_root() -> Result { let output = Command::new("id") .arg("-u") @@ -247,6 +273,7 @@ fn is_root() -> Result { Ok(output.stdout == b"0\n") } +#[cfg(unix)] fn command_exists(program: &str) -> bool { std::env::var_os("PATH") .is_some_and(|paths| std::env::split_paths(&paths).any(|path| path.join(program).is_file())) diff --git a/investigate/src/platform.rs b/investigate/src/platform.rs new file mode 100644 index 0000000..788c894 --- /dev/null +++ b/investigate/src/platform.rs @@ -0,0 +1,126 @@ +use anyhow::{Context, Result, bail}; +use std::{ + fs, + path::{Component, Path}, +}; + +pub(crate) fn artifact_path(path: &Path) -> Result { + path.components() + .map(|part| match part { + Component::Normal(value) => value.to_str().context("non-UTF-8 artifact path"), + _ => bail!("artifact path must be relative"), + }) + .collect::>>() + .map(|parts| parts.join("/")) +} + +pub(crate) fn reject_link(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path)?; + if metadata.is_symlink() { + bail!("links are not allowed: {}", path.display()); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + bail!("reparse points are not allowed: {}", path.display()); + } + } + Ok(()) +} + +pub(crate) fn reject_link_ancestors(path: &Path) -> Result<()> { + for ancestor in std::path::absolute(path)?.ancestors() { + reject_link(ancestor)?; + } + Ok(()) +} + +#[cfg(unix)] +pub(crate) fn private_dir(path: &Path) -> Result<()> { + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new().mode(0o700).create(path)?; + Ok(()) +} + +#[cfg(windows)] +pub(crate) fn private_dir(path: &Path) -> Result<()> { + use std::{io::Read, process::Stdio}; + crate::verify::valid_path( + path.file_name() + .and_then(|name| name.to_str()) + .context("invalid output name")?, + )?; + let parent = path.parent().context("output parent")?; + reject_link_ancestors(parent)?; + if fs::symlink_metadata(path).is_ok() { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "output already exists", + ) + .into()); + } + let mut command = powershell()?; + command + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + include_str!("private-directory.ps1"), + ]) + .env("OPSFORGE_PRIVATE_DIRECTORY", path) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + let child = crate::runtime::OwnedChild::spawn(&mut command)?; + let mut output = String::new(); + child.take_stdout()?.read_to_string(&mut output)?; + if !child.wait()?.success() { + bail!("could not create a private directory: {}", path.display()); + } + reject_link(path)?; + Ok(()) +} + +#[cfg(windows)] +pub(crate) fn powershell() -> Result { + let root = std::env::var_os("SystemRoot").context("SystemRoot is missing")?; + Ok(std::process::Command::new( + Path::new(&root).join("System32/WindowsPowerShell/v1.0/powershell.exe"), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn artifact_names_use_slashes_and_reject_escape() { + assert_eq!( + artifact_path(&Path::new("raw").join("file # name")).unwrap(), + "raw/file # name" + ); + assert!(artifact_path(Path::new("../escape")).is_err()); + } + + #[cfg(windows)] + #[test] + fn private_directory_refuses_overwrite_and_junctions() { + let parent = std::env::temp_dir().join(format!("opsforge-acl-{}", std::process::id())); + fs::create_dir(&parent).unwrap(); + let output = parent.join("private # ' ü directory"); + private_dir(&output).unwrap(); + fs::write(output.join("marker"), "keep").unwrap(); + assert!(private_dir(&output).is_err()); + assert_eq!(fs::read_to_string(output.join("marker")).unwrap(), "keep"); + let status = powershell().unwrap().args(["-NoProfile", "-NonInteractive", "-Command", + "New-Item -ItemType Junction -Path (Join-Path $env:OPSFORGE_TEST_PARENT junction) -Target $env:OPSFORGE_TEST_TARGET | Out-Null"]) + .env("OPSFORGE_TEST_PARENT", &parent).env("OPSFORGE_TEST_TARGET", &output).status().unwrap(); + assert!(status.success()); + let junction = parent.join("junction"); + assert!(reject_link_ancestors(&junction).is_err()); + assert!(private_dir(&junction.join("blocked")).is_err()); + assert!(!output.join("blocked").exists()); + fs::remove_dir(junction).unwrap(); + fs::remove_dir_all(parent).unwrap(); + } +} diff --git a/investigate/src/private-directory.ps1 b/investigate/src/private-directory.ps1 new file mode 100644 index 0000000..2e9d052 --- /dev/null +++ b/investigate/src/private-directory.ps1 @@ -0,0 +1,30 @@ +#Requires -Version 5.1 +[CmdletBinding()] +param() +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$destination = $env:OPSFORGE_PRIVATE_DIRECTORY +$parent = [IO.Path]::GetDirectoryName($destination) +$identity = [Security.Principal.WindowsIdentity]::GetCurrent().User +$security = New-Object Security.AccessControl.DirectorySecurity +$security.SetOwner($identity) +$security.SetAccessRuleProtection($true, $false) +$rule = New-Object Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') +$security.AddAccessRule($rule) +# Create with the private ACL, then reserve the requested name without overwrite. +$staging = [IO.Path]::Combine($parent, '.opsforge-private-' + [Guid]::NewGuid().ToString('N')) +[void][IO.Directory]::CreateDirectory($staging, $security) +try { + $actual = [IO.Directory]::GetAccessControl($staging) + $rules = @($actual.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) + if (-not $actual.AreAccessRulesProtected -or $actual.GetOwner([Security.Principal.SecurityIdentifier]) -ne $identity -or + $rules.Count -ne 1 -or $rules[0].IdentityReference -ne $identity -or + $rules[0].AccessControlType -ne 'Allow' -or $rules[0].FileSystemRights -ne 'FullControl') { + throw 'Filesystem did not retain the private ACL' + } + [IO.Directory]::Move($staging, $destination) +} catch { + Write-Error "Private empty staging directory retained at ${staging}: $_" + exit 1 +} diff --git a/investigate/src/rebuild.rs b/investigate/src/rebuild.rs index ea21be4..f6f1d60 100644 --- a/investigate/src/rebuild.rs +++ b/investigate/src/rebuild.rs @@ -2,7 +2,6 @@ use anyhow::{Context, Result, bail}; use serde::Serialize; use std::{ fs, - os::unix::fs::DirBuilderExt, path::{Path, PathBuf}, }; @@ -39,6 +38,7 @@ pub fn rebuild( .parent() .filter(|p| !p.as_os_str().is_empty()) .unwrap_or(Path::new(".")); + crate::platform::reject_link_ancestors(parent)?; let parent = fs::canonicalize(parent).context("output parent must already exist")?; let name = output .file_name() @@ -51,12 +51,10 @@ pub fn rebuild( ReviewMode::Bundle => "../".to_owned(), ReviewMode::Linked => relative_url(&output.join("dashboard"), &source)?, }; - fs::DirBuilder::new() - .mode(0o700) - .create(&output) - .context("output must not exist")?; + crate::platform::private_dir(&output).context("output must not exist")?; let mut review = serde_json::json!({"status":"in_progress", "mode":mode,"source":source, "tool_version":env!("CARGO_PKG_VERSION"),"rebuilt_at":jiff::Timestamp::now().to_string(), + "review_platform":std::env::consts::OS, "verification":verification,"evidence_base":evidence_base}); fs::write( output.join("review.json"), @@ -65,6 +63,7 @@ pub fn rebuild( let result = (|| -> Result<()> { for entry in walkdir::WalkDir::new(&source).follow_links(false) { let entry = entry?; + crate::platform::reject_link(entry.path())?; if entry.file_type().is_dir() { continue; } @@ -72,8 +71,8 @@ pub fn rebuild( bail!("source changed: non-regular file"); } let relative = entry.path().strip_prefix(&source)?; - let name = relative.to_str().context("non-UTF-8 path")?; - if crate::verify::generated(name) + let name = crate::platform::artifact_path(relative)?; + if crate::verify::generated(&name) || matches!(mode, ReviewMode::Linked) && name.starts_with("raw/") { continue; @@ -87,7 +86,14 @@ pub fn rebuild( bail!("reserved or duplicate review artifact: {name}"); } fs::create_dir_all(target.parent().context("target parent")?)?; - fs::copy(entry.path(), &target)?; + // New files inherit the private output ACL instead of copying source ACLs. + let mut input = fs::File::open(entry.path())?; + let mut copy = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&target)?; + std::io::copy(&mut input, &mut copy)?; + drop(copy); if crate::verify::digest(entry.path())? != crate::verify::digest(&target)? { bail!("source changed while copying: {name}"); } @@ -129,6 +135,7 @@ fn fingerprints(root: &Path) -> Result Result { } Ok(result) } + +#[cfg(all(test, windows))] +mod tests { + use super::*; + #[test] + fn linked_paths_refuse_other_volumes_and_encode_names() { + assert!(relative_url(Path::new(r"C:\review\dashboard"), Path::new(r"D:\source")).is_err()); + assert_eq!( + relative_url( + Path::new(r"C:\review\dashboard"), + Path::new(r"C:\source # case") + ) + .unwrap(), + "../../source%20%23%20case/" + ); + } +} diff --git a/investigate/src/report.rs b/investigate/src/report.rs index 13297ac..1678a77 100644 --- a/investigate/src/report.rs +++ b/investigate/src/report.rs @@ -260,7 +260,7 @@ pub fn write_checksums(root: &Path) -> Result<()> { output, "{:x} {}", hash.finalize(), - entry.path().strip_prefix(root)?.display() + crate::platform::artifact_path(entry.path().strip_prefix(root)?)? )?; } output.flush()?; @@ -279,6 +279,7 @@ fn write_atomic(path: impl AsRef, contents: impl AsRef<[u8]>) -> Result<() Ok(()) } +#[cfg(unix)] pub(crate) fn write_completion(root: &Path, status: &str, error: Option<&str>) -> Result<()> { let completion = serde_json::json!({ "status": status, diff --git a/investigate/src/runtime.rs b/investigate/src/runtime.rs index 54af356..e462bfd 100644 --- a/investigate/src/runtime.rs +++ b/investigate/src/runtime.rs @@ -1,14 +1,22 @@ +#[cfg(unix)] +use std::time::Instant; +#[cfg(unix)] +use std::{ + os::unix::process::CommandExt, + process::{Child, Stdio}, +}; +#[cfg(windows)] +type Child = Box; use anyhow::{Context, Result}; use std::{ io, - os::unix::process::CommandExt, - process::{Child, ChildStdout, Command, ExitStatus, Stdio}, + process::{ChildStdout, Command, ExitStatus}, sync::{ Arc, Mutex, OnceLock, atomic::{AtomicBool, AtomicUsize, Ordering}, }, thread::{self, JoinHandle}, - time::{Duration, Instant}, + time::Duration, }; static CANCELLED: OnceLock> = OnceLock::new(); @@ -17,6 +25,7 @@ fn flag() -> &'static Arc { CANCELLED.get_or_init(|| Arc::new(AtomicUsize::new(0))) } +#[cfg(unix)] pub fn install_signal_handlers() -> Result<()> { for signal in [signal_hook::consts::SIGINT, signal_hook::consts::SIGTERM] { signal_hook::flag::register_usize(signal, Arc::clone(flag()), signal as usize)?; @@ -24,6 +33,13 @@ pub fn install_signal_handlers() -> Result<()> { Ok(()) } +#[cfg(windows)] +pub fn install_signal_handlers() -> Result<()> { + ctrlc::set_handler(|| flag().store(2, Ordering::Relaxed))?; + Ok(()) +} + +#[cfg(unix)] pub(crate) fn cancel() { flag().store(1, Ordering::Relaxed); } @@ -39,6 +55,7 @@ pub(crate) fn check_cancelled() -> Result<()> { Err(io::Error::new(io::ErrorKind::Interrupted, reason).into()) } +#[cfg(unix)] fn signal_group(pid: u32, signal: &str) -> bool { Command::new("kill") .args([signal, "--", &format!("-{pid}")]) @@ -63,7 +80,15 @@ impl OwnedChild { } fn start(command: &mut Command, cancelled: Arc) -> Result { - let child = Arc::new(Mutex::new(command.process_group(0).spawn()?)); + #[cfg(unix)] + let child = command.process_group(0).spawn()?; + #[cfg(windows)] + let child = { + use process_wrap::std::{CommandWrap, JobObject}; + let command = std::mem::replace(command, Command::new("")); + CommandWrap::from(command).wrap(JobObject).spawn()? + }; + let child = Arc::new(Mutex::new(child)); let finished = Arc::new(AtomicBool::new(false)); let mut owned = Self { child, @@ -77,11 +102,14 @@ impl OwnedChild { let cancelled = Arc::clone(&owned.cancelled); let terminated = Arc::clone(&owned.terminated); let watcher = thread::Builder::new().spawn(move || { + #[cfg(unix)] let mut started = None; let mut previous = None; while !watched_finished.load(Ordering::Relaxed) { if cancelled.load(Ordering::Relaxed) != 0 { + #[cfg(unix)] let elapsed = started.get_or_insert_with(Instant::now).elapsed(); + #[cfg(unix)] let signal = if elapsed >= Duration::from_secs(2) { "-KILL" } else if elapsed >= Duration::from_secs(1) { @@ -89,6 +117,8 @@ impl OwnedChild { } else { "-INT" }; + #[cfg(windows)] + let signal = "-KILL"; if previous != Some(signal) { // The lock prevents reaping and PID reuse before signalling the owned group. let mut child = watched_child @@ -97,9 +127,7 @@ impl OwnedChild { if watched_finished.load(Ordering::Relaxed) { break; } - if !signal_group(child.id(), signal) { - let _ = child.kill(); - } + stop_child(&mut child, signal); if signal == "-KILL" { terminated.store(true, Ordering::Relaxed); } @@ -114,12 +142,12 @@ impl OwnedChild { } pub(crate) fn take_stdout(&self) -> Result { - self.child - .lock() - .unwrap_or_else(|error| error.into_inner()) - .stdout - .take() - .context("collector child stdout") + let mut child = self.child.lock().unwrap_or_else(|error| error.into_inner()); + #[cfg(unix)] + let stdout = child.stdout.take(); + #[cfg(windows)] + let stdout = child.stdout().take(); + stdout.context("collector child stdout") } pub(crate) fn try_wait(&self) -> io::Result> { @@ -151,11 +179,13 @@ impl Drop for OwnedChild { fn drop(&mut self) { { let mut child = self.child.lock().unwrap_or_else(|error| error.into_inner()); + // A Windows group leader can exit while descendants still belong to its job. + #[cfg(windows)] + stop_child(&mut child, "-KILL"); if !self.finished.swap(true, Ordering::Relaxed) { - signal_group(child.id(), "-TERM"); + stop_child(&mut child, "-TERM"); thread::sleep(Duration::from_millis(100)); - signal_group(child.id(), "-KILL"); - let _ = child.kill(); + stop_child(&mut child, "-KILL"); let _ = child.wait(); } } @@ -165,7 +195,7 @@ impl Drop for OwnedChild { } } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; use std::io::{BufRead, BufReader}; @@ -273,3 +303,72 @@ mod tests { std::fs::remove_file(path).expect("remove owned pid fixture"); } } + +fn stop_child(child: &mut Child, signal: &str) { + #[cfg(unix)] + if !signal_group(child.id(), signal) { + let _ = child.kill(); + } + #[cfg(windows)] + { + let _ = signal; + let _ = child.start_kill(); + } +} + +#[cfg(all(test, windows))] +mod windows_tests { + use super::*; + use std::{ + io::{BufRead, BufReader}, + process::Stdio, + time::Instant, + }; + + #[test] + fn cancellation_stops_owned_descendants_and_leaves_other_jobs_running() { + let mut other_command = crate::platform::powershell().unwrap(); + other_command.args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Start-Sleep 60", + ]); + let other = OwnedChild::start(&mut other_command, Arc::new(AtomicUsize::new(0))).unwrap(); + let cancelled = Arc::new(AtomicUsize::new(0)); + let mut command = crate::platform::powershell().unwrap(); + command.args(["-NoProfile", "-NonInteractive", "-Command", + "$p = Start-Process (Join-Path $PSHOME powershell.exe) -ArgumentList '-NoProfile -NonInteractive -Command Start-Sleep 60' -PassThru; Write-Output $p.Id; Start-Sleep 60"]) + .stdout(Stdio::piped()); + let child = OwnedChild::start(&mut command, Arc::clone(&cancelled)).unwrap(); + let (send, receive) = std::sync::mpsc::channel(); + let stdout = child.take_stdout().unwrap(); + let reader = thread::spawn(move || { + let mut line = String::new(); + BufReader::new(stdout).read_line(&mut line).unwrap(); + send.send(line).unwrap(); + }); + let pid: u32 = receive + .recv_timeout(Duration::from_secs(20)) + .unwrap() + .trim() + .parse() + .unwrap(); + reader.join().unwrap(); + cancelled.store(1, Ordering::Relaxed); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!(!status.success()); + break; + } + assert!(Instant::now() < deadline, "owned job did not stop"); + thread::sleep(Duration::from_millis(50)); + } + let status = crate::platform::powershell().unwrap().args(["-NoProfile", "-NonInteractive", "-Command", + "if (Get-Process -Id $env:OPSFORGE_TEST_PID -ErrorAction SilentlyContinue) { exit 1 }"]) + .env("OPSFORGE_TEST_PID", pid.to_string()).status().unwrap(); + assert!(status.success(), "owned descendant survived cancellation"); + assert!(other.try_wait().unwrap().is_none()); + } +} diff --git a/investigate/src/verify.rs b/investigate/src/verify.rs index 77b6d1e..5ef93b8 100644 --- a/investigate/src/verify.rs +++ b/investigate/src/verify.rs @@ -42,16 +42,59 @@ pub(crate) fn generated(path: &str) -> bool { ) } -fn valid_path(path: &str) -> Result<()> { +pub(crate) fn valid_path(path: &str) -> Result<()> { if path.is_empty() || path.contains(['\\', ':', '\n', '\r', '\0']) || path.split('/').any(|s| matches!(s, "" | "." | "..")) { bail!("unsafe case-relative path: {path:?}"); } + #[cfg(windows)] + for part in path.split('/') { + let stem = part + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + if part.ends_with([' ', '.']) + || part + .chars() + .any(|c| c.is_control() || "<>\"|?*".contains(c)) + || matches!( + stem.as_str(), + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" + ) + || (stem.starts_with("COM") || stem.starts_with("LPT")) + && matches!( + &stem[3..], + "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³" + ) + { + bail!("artifact name cannot be represented safely on Windows: {part:?}"); + } + } Ok(()) } +#[cfg(all(test, windows))] +#[test] +fn windows_artifact_names_reject_devices_and_aliases() { + for path in [ + "raw/CON", + "raw/nul.txt", + "raw/COM1", + "raw/LPT².txt", + "raw/file.", + "raw/file ", + "raw/a:b", + "raw/a\\b", + "raw/a?b", + ] { + assert!(valid_path(path).is_err(), "accepted {path}"); + } + assert!(valid_path("raw/normal # file.txt").is_ok()); +} + fn valid_hash(hash: &str) -> Result<()> { if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { bail!("invalid SHA-256 digest"); @@ -80,12 +123,14 @@ pub(crate) fn digest(path: &Path) -> Result<(String, u64)> { /// Verify a quiescent saved case without writing to it. This checks consistency, /// not authenticity; callers must keep the directory unchanged during the read. pub fn verify(root: &Path) -> Result { + crate::platform::reject_link_ancestors(root)?; if !fs::symlink_metadata(root)?.is_dir() { bail!("case must be a real directory"); } let mut files = BTreeSet::new(); for entry in walkdir::WalkDir::new(root).follow_links(false) { let entry = entry?; + crate::platform::reject_link(entry.path())?; let kind = entry.file_type(); if !kind.is_file() && !kind.is_dir() { bail!( @@ -94,12 +139,7 @@ pub fn verify(root: &Path) -> Result { ); } if kind.is_file() { - let path = entry - .path() - .strip_prefix(root)? - .to_str() - .context("non-UTF-8 case path")? - .to_owned(); + let path = crate::platform::artifact_path(entry.path().strip_prefix(root)?)?; valid_path(&path)?; files.insert(path); } diff --git a/investigate/tests/azure_case.rs b/investigate/tests/azure_case.rs index 5f3583e..4d41aad 100644 --- a/investigate/tests/azure_case.rs +++ b/investigate/tests/azure_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use std::fs; diff --git a/investigate/tests/cancel_case.rs b/investigate/tests/cancel_case.rs index bc73a29..88a8ecd 100644 --- a/investigate/tests/cancel_case.rs +++ b/investigate/tests/cancel_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use std::{cell::RefCell, fs, path::PathBuf, process::Command}; diff --git a/investigate/tests/case_run.rs b/investigate/tests/case_run.rs index 3d05a89..7cf11b5 100644 --- a/investigate/tests/case_run.rs +++ b/investigate/tests/case_run.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use std::fs; @@ -25,6 +27,11 @@ fn imported_log_reaches_offline_report_with_escaped_text() { config.live_capture = false; config.imports.push(imported); let root = collect::run(&config, &|_| {}).expect("case run"); + let info: serde_json::Value = + serde_json::from_slice(&fs::read(root.join("case-info.json")).unwrap()).unwrap(); + assert_eq!(info["format_version"], 1); + assert_eq!(info["platform"], std::env::consts::OS); + assert_eq!(info["architecture"], std::env::consts::ARCH); let events = fs::read_to_string(root.join("normalized/events.jsonl")).expect("events"); let page = fs::read_to_string(root.join("dashboard/network.html")).expect("dashboard"); let data = fs::read_to_string(root.join("dashboard/events.js")).expect("event data"); diff --git a/investigate/tests/check_case.rs b/investigate/tests/check_case.rs index cefc8f7..d760588 100644 --- a/investigate/tests/check_case.rs +++ b/investigate/tests/check_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{ collect, config::{CheckConfig, LinuxCheck, RunConfig}, diff --git a/investigate/tests/compressed_logs.rs b/investigate/tests/compressed_logs.rs index cc1de6d..ccb3720 100644 --- a/investigate/tests/compressed_logs.rs +++ b/investigate/tests/compressed_logs.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use std::{fs, process::Command}; diff --git a/investigate/tests/config_cli.rs b/investigate/tests/config_cli.rs index 744acd6..30edf00 100644 --- a/investigate/tests/config_cli.rs +++ b/investigate/tests/config_cli.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use std::{fs, process::Command}; #[test] diff --git a/investigate/tests/coverage_states.rs b/investigate/tests/coverage_states.rs index 24267ee..f64fdeb 100644 --- a/investigate/tests/coverage_states.rs +++ b/investigate/tests/coverage_states.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{ case::{Coverage, CoverageState}, collect, diff --git a/investigate/tests/dnsmasq_case.rs b/investigate/tests/dnsmasq_case.rs index 200d594..78efeea 100644 --- a/investigate/tests/dnsmasq_case.rs +++ b/investigate/tests/dnsmasq_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig, transfers::dnsmasq_event}; use serde_json::Value; use std::fs; diff --git a/investigate/tests/docker_case.rs b/investigate/tests/docker_case.rs index 742d748..e942b38 100644 --- a/investigate/tests/docker_case.rs +++ b/investigate/tests/docker_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use serde_json::Value; use std::fs; diff --git a/investigate/tests/element_case.rs b/investigate/tests/element_case.rs index f221f62..f31f39e 100644 --- a/investigate/tests/element_case.rs +++ b/investigate/tests/element_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use serde_json::{Value, json}; use std::fs; diff --git a/investigate/tests/filezilla_case.rs b/investigate/tests/filezilla_case.rs index 6410351..dbb5654 100644 --- a/investigate/tests/filezilla_case.rs +++ b/investigate/tests/filezilla_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use std::fs; diff --git a/investigate/tests/firewall_case.rs b/investigate/tests/firewall_case.rs index e268b29..60b6eb9 100644 --- a/investigate/tests/firewall_case.rs +++ b/investigate/tests/firewall_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{ collect, config::RunConfig, diff --git a/investigate/tests/gcloud_case.rs b/investigate/tests/gcloud_case.rs index 76a0101..4b58618 100644 --- a/investigate/tests/gcloud_case.rs +++ b/investigate/tests/gcloud_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use std::fs; diff --git a/investigate/tests/log_times.rs b/investigate/tests/log_times.rs index c593170..7de6ec7 100644 --- a/investigate/tests/log_times.rs +++ b/investigate/tests/log_times.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{case::Event, collect, config::RunConfig}; use std::fs; diff --git a/investigate/tests/nextcloud_case.rs b/investigate/tests/nextcloud_case.rs index 247dc0c..b42724d 100644 --- a/investigate/tests/nextcloud_case.rs +++ b/investigate/tests/nextcloud_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use std::fs; diff --git a/investigate/tests/openvpn_case.rs b/investigate/tests/openvpn_case.rs index a6f6f71..dc02328 100644 --- a/investigate/tests/openvpn_case.rs +++ b/investigate/tests/openvpn_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use serde_json::Value; use std::fs; diff --git a/investigate/tests/partial_case.rs b/investigate/tests/partial_case.rs index ad3f66b..86d01f7 100644 --- a/investigate/tests/partial_case.rs +++ b/investigate/tests/partial_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use std::{cell::RefCell, fs, io::Write, path::PathBuf}; diff --git a/investigate/tests/rsync_case.rs b/investigate/tests/rsync_case.rs index 9f30789..a1630cb 100644 --- a/investigate/tests/rsync_case.rs +++ b/investigate/tests/rsync_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use std::fs; diff --git a/investigate/tests/sftp_case.rs b/investigate/tests/sftp_case.rs index 1b0a567..3e2241b 100644 --- a/investigate/tests/sftp_case.rs +++ b/investigate/tests/sftp_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use std::fs; diff --git a/investigate/tests/slack_case.rs b/investigate/tests/slack_case.rs index 56bccda..1baf6de 100644 --- a/investigate/tests/slack_case.rs +++ b/investigate/tests/slack_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use serde_json::{Value, json}; use std::fs; diff --git a/investigate/tests/squid_case.rs b/investigate/tests/squid_case.rs index 1fa4a73..9e02fac 100644 --- a/investigate/tests/squid_case.rs +++ b/investigate/tests/squid_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig, transfers::Parser}; use serde_json::Value; use std::fs; diff --git a/investigate/tests/sync_case.rs b/investigate/tests/sync_case.rs index 0cfb601..62a72ed 100644 --- a/investigate/tests/sync_case.rs +++ b/investigate/tests/sync_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use std::fs; diff --git a/investigate/tests/thunderbird_case.rs b/investigate/tests/thunderbird_case.rs index a957d52..7020421 100644 --- a/investigate/tests/thunderbird_case.rs +++ b/investigate/tests/thunderbird_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use rusqlite::Connection; use std::fs; diff --git a/investigate/tests/transfer_case.rs b/investigate/tests/transfer_case.rs index cc86cb3..95f1f2b 100644 --- a/investigate/tests/transfer_case.rs +++ b/investigate/tests/transfer_case.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use opsforge_investigate::{collect, config::RunConfig}; use serde_json::Value; use std::fs; diff --git a/investigate/tests/verify_case.rs b/investigate/tests/verify_case.rs index cc48219..760a4e3 100644 --- a/investigate/tests/verify_case.rs +++ b/investigate/tests/verify_case.rs @@ -202,3 +202,31 @@ fn rebuild_requires_override_for_missing_checksums_and_refuses_overlap() { assert!(rebuild(&fixture.0, &output, ReviewMode::Bundle, true).is_err()); fs::remove_dir_all(output).unwrap(); } + +#[cfg(windows)] +#[test] +fn rebuilt_files_inherit_private_acl_and_collection_stays_disabled() { + use opsforge_investigate::rebuild::{ReviewMode, rebuild}; + let fixture = Fixture::new("windows-acl"); + let output = fixture.0.with_extension("review"); + rebuild(&fixture.0, &output, ReviewMode::Bundle, false).unwrap(); + let powershell = PathBuf::from(std::env::var_os("SystemRoot").unwrap()) + .join("System32/WindowsPowerShell/v1.0/powershell.exe"); + let result = Command::new(powershell).args(["-NoProfile", "-NonInteractive", "-Command", + "$ErrorActionPreference='Stop'; $sid=[Security.Principal.WindowsIdentity]::GetCurrent().User; $acl=[IO.File]::GetAccessControl($env:OPSFORGE_TEST_FILE); $rules=@($acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])); if ($rules.Count -ne 1 -or $rules[0].IdentityReference -ne $sid -or -not $rules[0].IsInherited) { throw 'copied file did not inherit private ACL' }"]) + .env("OPSFORGE_TEST_FILE", output.join("raw/file with spaces")).output().unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!(verify(&output).unwrap().exit_code(), 0); + let command = Command::new(env!("CARGO_BIN_EXE_opsforge-investigate")) + .args(["collect", "--non-interactive"]) + .env("PATH", "") + .output() + .unwrap(); + assert!(!command.status.success()); + assert!(String::from_utf8_lossy(&command.stderr).contains("collection is not available")); + fs::remove_dir_all(output).unwrap(); +}