From 3ad9b7a6d6145ed741c574759d2e0644063fd2b7 Mon Sep 17 00:00:00 2001 From: iamb4uc Date: Tue, 29 Sep 2026 01:22:22 +0530 Subject: [PATCH 01/12] document investigator interface --- DESIGN.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++ docs/design.md | 7 ++++-- 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 DESIGN.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..4a40877 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,65 @@ +# Investigator interface + +## 1. Purpose + +The terminal interface configures and follows a case run. Offline HTML pages +make its evidence and limits readable to people who did not operate the tool. +The case directory is the source of truth; neither view hides failed sources. + +## 2. Audience and tasks + +- An operator chooses the output, collection categories, extra logs, and capture duration. +- An investigator checks findings, the timeline, and the raw source behind each item. +- A reviewer opens the offline report without a running server. + +## 3. Visual direction + +Use a restrained operations surface: dark ink, slate panels, cyan for active +work, amber for gaps, and red for failed collection. The case status is the +first visual anchor. Color never carries status alone; every state has text. + +## 4. Tokens + +| Role | Value | +| --- | --- | +| page | `#101923` | +| panel | `#172633` | +| panel raised | `#1d3141` | +| border | `#355061` | +| text | `#e9f1f5` | +| muted | `#a7bcc8` | +| active | `#59d5d0` | +| warning | `#f1bd68` | +| failure | `#f07878` | +| type | system sans for prose, system monospace for evidence | +| spacing | multiples of 4 px | + +## 5. Primitives and states + +- TUI section row: idle, focused, selected, unavailable. +- TUI setting row: label, current value, short help. +- TUI run row: pending, running, collected, empty, failed, skipped. +- Web status badge: same named states, always with text. +- Web evidence table: sortable by time in source order, readable without scripts. +- Web source link: relative path back to raw or normalized evidence. + +## 6. Layout and navigation + +The TUI has a guided configuration screen, a review action, and a run view. +Keyboard navigation uses arrows, Space, Enter, and Escape; a narrow terminal +keeps one column. The dashboard has a shared header and links for Overview, +Exfiltration, Timeline, Downloads, and Coverage. Pages scroll normally and +tables can scroll horizontally on small screens. + +## 7. Accessibility and safety + +Use semantic HTML headings, tables, nav, and visible focus states. Keep the +dashboard useful without JavaScript or network access. Escape all collected +text before it reaches HTML. Distinguish observed evidence from inference and +show unparsed or unavailable sources near findings. + +## 8. Accepted limits + +No endpoint run can reconstruct events that were never logged. A displayed +historical range means the range of available evidence, not the lifetime of an +application. Imported logs retain their own provenance and time zone context. diff --git a/docs/design.md b/docs/design.md index b2a6ab9..35c361e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,7 +1,9 @@ # Design -opsforge is intentionally shell-only. The toolkit favors scripts that can -run on constrained incident-response hosts without installing a language runtime. +opsforge favors platform scripts that can run on constrained incident-response +hosts without installing a language runtime. The Linux investigator runner is +a prebuilt Rust binary for guided collection and offline reporting; it does not +require Rust on the investigated host. ## Principles @@ -15,6 +17,7 @@ run on constrained incident-response hosts without installing a language runtime ## Layout - `bin/` contains dispatch wrappers. +- `investigate/` contains the Linux guided collector and report generator. - `lib/` contains shared shell and PowerShell helpers. - `scripts/linux/` and `scripts/windows/` contain operational tools by domain. - `configs/` contains target lists and examples. From 43f7a246fa9f2a5f04094208ac5856782afff68f Mon Sep 17 00:00:00 2001 From: iamb4uc Date: Tue, 29 Sep 2026 08:35:25 +0530 Subject: [PATCH 02/12] add linux investigator collector --- bin/test | 4 +- investigate/.gitignore | 1 + investigate/Cargo.lock | 2025 +++++++++++++++++++++++++++++++++ investigate/Cargo.toml | 27 + investigate/src/browser.rs | 242 ++++ investigate/src/case.rs | 260 +++++ investigate/src/collect.rs | 705 ++++++++++++ investigate/src/config.rs | 113 ++ investigate/src/lib.rs | 6 + investigate/src/main.rs | 137 +++ investigate/src/report.rs | 232 ++++ investigate/src/tui.rs | 194 ++++ investigate/tests/case_run.rs | 44 + 13 files changed, 3989 insertions(+), 1 deletion(-) create mode 100644 investigate/.gitignore create mode 100644 investigate/Cargo.lock create mode 100644 investigate/Cargo.toml create mode 100644 investigate/src/browser.rs create mode 100644 investigate/src/case.rs create mode 100644 investigate/src/collect.rs create mode 100644 investigate/src/config.rs create mode 100644 investigate/src/lib.rs create mode 100644 investigate/src/main.rs create mode 100644 investigate/src/report.rs create mode 100644 investigate/src/tui.rs create mode 100644 investigate/tests/case_run.rs diff --git a/bin/test b/bin/test index 9a605ef..da22ad3 100755 --- a/bin/test +++ b/bin/test @@ -226,11 +226,12 @@ test_forbidden_files() { -path "$ROOT/.git" -prune -o \ -path "$ROOT/.ci-artifacts" -prune -o \ -path "$ROOT/output" -prune -o \ + -path "$ROOT/investigate/target" -prune -o \ -type f \( \ -name '*.py' -o -name '*.go' -o -name '*.rs' -o -name '*.js' -o \ -name '*.ts' -o -name '*.rb' -o -name '*.pl' -o -name '*.exe' -o \ -name '*.dll' -o -name '*.so' -o -name '*.dylib' \ - \) -print)" + \) ! \( -path "$ROOT/investigate/src/*.rs" -o -path "$ROOT/investigate/tests/*.rs" \) -print)" [ -z "$files" ] || fail "forbidden core language or binary files found: $files" } @@ -247,6 +248,7 @@ test_readability() { -path "$ROOT/.git" -prune -o \ -path "$ROOT/.ci-artifacts" -prune -o \ -path "$ROOT/output" -prune -o \ + -path "$ROOT/investigate/target" -prune -o \ -path "$ROOT/examples" -prune -o \ -type f \( \ -name '*.md' -o -name '*.sh' -o -name '*.ps1' -o -name '*.yml' -o \ diff --git a/investigate/.gitignore b/investigate/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/investigate/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/investigate/Cargo.lock b/investigate/Cargo.lock new file mode 100644 index 0000000..642fae1 --- /dev/null +++ b/investigate/Cargo.lock @@ -0,0 +1,2025 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "finl_unicode" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.21", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opsforge-investigate" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "crossterm", + "jiff", + "ratatui", + "rusqlite", + "serde", + "serde_json", + "sha2", + "walkdir", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pest" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.21", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.2", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.2", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/investigate/Cargo.toml b/investigate/Cargo.toml new file mode 100644 index 0000000..4adf999 --- /dev/null +++ b/investigate/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "opsforge-investigate" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +license = "MIT" +description = "Guided Linux evidence collection and offline case reporting" + +[dependencies] +anyhow = "1" +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" +walkdir = "2" + +[lints.rust] +unsafe_code = "forbid" + +[profile.release] +lto = true +codegen-units = 1 +strip = "symbols" diff --git a/investigate/src/browser.rs b/investigate/src/browser.rs new file mode 100644 index 0000000..c39d310 --- /dev/null +++ b/investigate/src/browser.rs @@ -0,0 +1,242 @@ +use crate::case::{Case, Coverage, CoverageState, Event, EvidenceLevel}; +use anyhow::Result; +use rusqlite::{Connection, OpenFlags, backup::Backup}; +use std::{ + fs, + path::{Path, PathBuf}, + time::Duration, +}; +use walkdir::WalkDir; + +pub fn collect(case: &mut Case, progress: &impl Fn(&str)) -> Result<()> { + let mut found = 0_u64; + let mut failed = 0_u64; + for (user, home) in user_homes()? { + if !home.is_dir() { + continue; + } + for entry in WalkDir::new(&home).follow_links(false).max_depth(8) { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + failed = failed.saturating_add(1); + case.coverage(&Coverage { + source: home.display().to_string(), + state: CoverageState::Failed, + detail: error.to_string(), + })?; + continue; + } + }; + if !entry.file_type().is_file() { + continue; + } + let filename = entry.file_name().to_string_lossy(); + let firefox = filename == "places.sqlite"; + if !firefox && filename != "History" { + continue; + } + if firefox { + case.coverage(&Coverage { + source: format!("{}:downloads", entry.path().display()), + state: CoverageState::Unsupported, + detail: "Firefox download metadata is not decoded by this version".into(), + })?; + } + found = found.saturating_add(1); + progress(&format!("Browser profile: {}", entry.path().display())); + let raw = format!("browser-{found:05}.sqlite"); + let result = snapshot(entry.path(), &case.root.join("raw").join(&raw)).and_then(|db| { + case.record_existing(&entry.path().display().to_string(), &raw)?; + parse_history(case, &db, &user, &raw, firefox) + }); + match result { + Ok(count) => case.coverage(&Coverage { + source: entry.path().display().to_string(), + state: if count == 0 { + CoverageState::Empty + } else { + CoverageState::Collected + }, + detail: format!("{count} history and download records"), + })?, + Err(error) => { + failed = failed.saturating_add(1); + case.coverage(&Coverage { + source: entry.path().display().to_string(), + state: CoverageState::Failed, + detail: format!("snapshot or parser failed: {error}"), + })?; + } + } + } + } + case.coverage(&Coverage { + source: "browser-profiles".into(), + state: if failed > 0 { + CoverageState::Failed + } else if found == 0 { + CoverageState::Empty + } else { + CoverageState::Collected + }, + detail: format!("{found} profiles found; {failed} failed"), + })?; + Ok(()) +} + +fn user_homes() -> Result> { + let passwd = fs::read_to_string("/etc/passwd")?; + let mut homes = Vec::new(); + for line in passwd.lines() { + let parts: Vec<_> = line.split(':').collect(); + if let (Some(user), Some(home)) = (parts.first(), parts.get(5)) { + let home = PathBuf::from(home); + if home.is_dir() && !homes.iter().any(|(_, known)| known == &home) { + homes.push(((*user).to_owned(), home)); + } + } + } + Ok(homes) +} + +fn snapshot(source: &Path, destination: &Path) -> Result { + let from = Connection::open_with_flags(source, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + let mut to = Connection::open(destination)?; + let backup = Backup::new(&from, &mut to)?; + backup.run_to_completion(100, Duration::from_millis(50), None)?; + drop(backup); + Ok(to) +} + +fn parse_history( + case: &mut Case, + db: &Connection, + user: &str, + raw: &str, + firefox: bool, +) -> Result { + let query = if firefox { + "SELECT v.visit_date, p.url, COALESCE(p.title, '') FROM moz_historyvisits v JOIN moz_places p ON p.id = v.place_id ORDER BY v.visit_date" + } else { + "SELECT v.visit_time, u.url, COALESCE(u.title, '') FROM visits v JOIN urls u ON u.id = v.url ORDER BY v.visit_time" + }; + let mut statement = db.prepare(query)?; + let mut rows = statement.query([])?; + let mut count = 0_u64; + while let Some(row) = rows.next()? { + let time: i64 = row.get(0)?; + let url: String = row.get(1)?; + let title: String = row.get(2)?; + let microseconds = if firefox { + Some(time) + } else { + time.checked_sub(11_644_473_600_000_000) + }; + let timestamp = microseconds + .and_then(|value| jiff::Timestamp::from_microsecond(value).ok()) + .map(|value| value.to_string()); + case.event(&Event { + timestamp, + source: "browser-history".into(), + kind: "browser-visit".into(), + application: Some( + if firefox { + "Firefox" + } else { + "Chromium-family" + } + .into(), + ), + user: Some(user.into()), + destination: Some(url), + detail: title, + evidence: format!("raw/{raw}"), + level: EvidenceLevel::Lead, + })?; + count = count.saturating_add(1); + } + if !firefox { + let rich = "SELECT d.start_time, COALESCE(d.target_path, d.current_path, ''), d.received_bytes, d.total_bytes, COALESCE((SELECT url FROM downloads_url_chains WHERE id=d.id ORDER BY chain_index LIMIT 1), '') FROM downloads d ORDER BY d.start_time"; + let basic = "SELECT start_time, COALESCE(target_path, current_path, ''), received_bytes, total_bytes, '' FROM downloads ORDER BY start_time"; + let (mut downloads, source_urls) = match db.prepare(rich) { + Ok(statement) => (statement, true), + Err(_) => match db.prepare(basic) { + Ok(statement) => (statement, false), + Err(error) => { + case.coverage(&Coverage { + source: format!("raw/{raw}:downloads"), + state: CoverageState::Unsupported, + detail: format!("download schema not decoded: {error}"), + })?; + return Ok(count); + } + }, + }; + if !source_urls { + case.coverage(&Coverage { + source: format!("raw/{raw}:download-urls"), + state: CoverageState::Unsupported, + detail: "source URL table unavailable in this profile".into(), + })?; + } + let mut rows = downloads.query([])?; + let mut download_count = 0_u64; + while let Some(row) = rows.next()? { + let time: i64 = row.get(0)?; + let path: String = row.get(1)?; + let received: i64 = row.get(2)?; + let total: i64 = row.get(3)?; + let url: String = row.get(4)?; + let timestamp = time + .checked_sub(11_644_473_600_000_000) + .and_then(|value| jiff::Timestamp::from_microsecond(value).ok()) + .map(|value| value.to_string()); + case.event(&Event { + timestamp, + source: "browser-downloads".into(), + kind: "download".into(), + application: Some("Chromium-family".into()), + user: Some(user.into()), + destination: Some(path), + detail: format!("source {url}; received {received} of {total} bytes"), + evidence: format!("raw/{raw}"), + level: EvidenceLevel::Recorded, + })?; + download_count = download_count.saturating_add(1); + count = count.saturating_add(1); + } + case.coverage(&Coverage { + source: format!("raw/{raw}:downloads"), + state: if download_count == 0 { + CoverageState::Empty + } else { + CoverageState::Collected + }, + detail: format!("{download_count} records"), + })?; + } + Ok(count) +} + +#[cfg(test)] +mod tests { + use super::parse_history; + use crate::case::Case; + use rusqlite::Connection; + use std::fs; + + #[test] + fn chromium_download_keeps_origin_and_local_path() { + let base = std::env::temp_dir().join(format!("opsforge-browser-{}", std::process::id())); + fs::create_dir_all(&base).expect("base"); + let mut case = Case::new(&base).expect("case"); + let db = Connection::open_in_memory().expect("database"); + db.execute_batch("CREATE TABLE visits(visit_time INTEGER,url INTEGER); CREATE TABLE urls(id INTEGER,url TEXT,title TEXT); CREATE TABLE downloads(id INTEGER,start_time INTEGER,target_path TEXT,current_path TEXT,received_bytes INTEGER,total_bytes INTEGER); CREATE TABLE downloads_url_chains(id INTEGER,chain_index INTEGER,url TEXT); INSERT INTO downloads VALUES(1,11644473600000000,'/tmp/file.bin','/tmp/file.part',123,123); INSERT INTO downloads_url_chains VALUES(1,0,'https://example.test/file.bin');").expect("schema"); + parse_history(&mut case, &db, "operator", "browser-00001.sqlite", false).expect("parse"); + let events = fs::read_to_string(case.root.join("normalized/events.jsonl")).expect("events"); + assert!(events.contains("https://example.test/file.bin")); + assert!(events.contains("/tmp/file.bin")); + fs::remove_dir_all(base).expect("remove fixture"); + } +} diff --git a/investigate/src/case.rs b/investigate/src/case.rs new file mode 100644 index 0000000..f9a9758 --- /dev/null +++ b/investigate/src/case.rs @@ -0,0 +1,260 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, DirBuilder, File, OpenOptions}, + io::{BufReader, BufWriter, Read, Write}, + os::unix::fs::DirBuilderExt, + path::{Path, PathBuf}, +}; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceLevel { + Recorded, + Observed, + Lead, + Unattributed, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Event { + pub timestamp: Option, + pub source: String, + pub kind: String, + pub application: Option, + pub user: Option, + pub destination: Option, + pub detail: String, + pub evidence: String, + pub level: EvidenceLevel, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CoverageState { + Collected, + Empty, + Unsupported, + Failed, + Skipped, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Coverage { + pub source: String, + pub state: CoverageState, + pub detail: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct EvidenceFile { + pub source: String, + pub path: String, + pub bytes: u64, + pub sha256: String, + pub acquired_at: String, +} + +pub struct Case { + pub root: PathBuf, + events: BufWriter, + coverage: BufWriter, + 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)?, + )) +} + +impl Case { + pub fn new(output_base: &Path) -> Result { + fs::create_dir_all(output_base) + .with_context(|| format!("creating {}", output_base.display()))?; + let base = fs::canonicalize(output_base)?; + let host = std::process::Command::new("hostname") + .output() + .context("reading hostname")?; + let host = String::from_utf8_lossy(&host.stdout) + .trim() + .chars() + .filter(|character| { + character.is_ascii_alphanumeric() || *character == '-' || *character == '_' + }) + .collect::(); + let stamp = jiff::Timestamp::now().strftime("%Y%m%d-%H%M%S"); + let name = format!( + "{}-investigate-{stamp}", + if host.is_empty() { "unknown" } else { &host } + ); + let root = (0..1_000) + .find_map(|attempt| { + let candidate = base.join(if attempt == 0 { + name.clone() + } else { + format!("{name}-{attempt}") + }); + match DirBuilder::new().mode(0o700).create(&candidate) { + Ok(()) => Some(Ok(candidate)), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None, + Err(error) => Some(Err(error)), + } + }) + .context("could not reserve a unique case directory")??; + for part in ["raw", "normalized", "dashboard"] { + private_dir(&root.join(part))?; + } + let events = append_file(&root.join("normalized/events.jsonl"))?; + let coverage = append_file(&root.join("normalized/coverage.jsonl"))?; + let manifest = append_file(&root.join("manifest.jsonl"))?; + Ok(Self { + root, + events, + coverage, + manifest, + }) + } + + pub fn event(&mut self, event: &Event) -> Result<()> { + serde_json::to_writer(&mut self.events, event)?; + self.events.write_all(b"\n")?; + self.events.flush()?; + Ok(()) + } + + pub fn coverage(&mut self, row: &Coverage) -> Result<()> { + serde_json::to_writer(&mut self.coverage, row)?; + self.coverage.write_all(b"\n")?; + self.coverage.flush()?; + Ok(()) + } + + pub fn copy_evidence(&mut self, source: &Path, name: &str) -> Result { + if name.contains('/') || name == "." || name == ".." { + anyhow::bail!("invalid evidence name"); + } + let destination = self.root.join("raw").join(name); + let mut input = BufReader::new( + File::open(source).with_context(|| format!("opening {}", source.display()))?, + ); + let mut output = BufWriter::new( + OpenOptions::new() + .create_new(true) + .write(true) + .open(&destination)?, + ); + let mut hash = Sha256::new(); + let mut bytes = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = input.read(&mut buffer)?; + if count == 0 { + break; + } + output.write_all(&buffer[..count])?; + hash.update(&buffer[..count]); + bytes = bytes.saturating_add(u64::try_from(count)?); + } + output.flush()?; + let row = EvidenceFile { + source: source.display().to_string(), + path: format!("raw/{name}"), + bytes, + sha256: format!("{:x}", hash.finalize()), + acquired_at: jiff::Timestamp::now().to_string(), + }; + serde_json::to_writer(&mut self.manifest, &row)?; + self.manifest.write_all(b"\n")?; + self.manifest.flush()?; + Ok(destination) + } + + pub fn record_existing(&mut self, source: &str, name: &str) -> Result<()> { + if name.contains('/') || name == "." || name == ".." { + anyhow::bail!("invalid evidence name"); + } + let path = self.root.join("raw").join(name); + let mut input = BufReader::new(File::open(&path)?); + let mut hash = Sha256::new(); + let mut bytes = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = input.read(&mut buffer)?; + if count == 0 { + break; + } + hash.update(&buffer[..count]); + bytes = bytes.saturating_add(u64::try_from(count)?); + } + let row = EvidenceFile { + source: source.to_owned(), + path: format!("raw/{name}"), + bytes, + sha256: format!("{:x}", hash.finalize()), + acquired_at: jiff::Timestamp::now().to_string(), + }; + serde_json::to_writer(&mut self.manifest, &row)?; + self.manifest.write_all(b"\n")?; + self.manifest.flush()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{Case, Coverage, CoverageState, Event, EvidenceLevel}; + use std::fs; + + #[test] + fn preserves_incremental_evidence_and_status() { + let base = std::env::temp_dir().join(format!("opsforge-test-{}", std::process::id())); + fs::create_dir_all(&base).expect("test directory"); + let source = base.join("sample.log"); + fs::write(&source, b"network activity\n").expect("test source"); + let mut case = Case::new(&base).expect("case"); + case.copy_evidence(&source, "sample.log").expect("copy"); + case.coverage(&Coverage { + source: "sample".into(), + state: CoverageState::Collected, + detail: "ok".into(), + }) + .expect("status"); + case.event(&Event { + timestamp: None, + source: "sample".into(), + kind: "log".into(), + application: None, + user: None, + destination: None, + detail: "network activity".into(), + evidence: "raw/sample.log".into(), + level: EvidenceLevel::Unattributed, + }) + .expect("event"); + assert_eq!( + fs::read(case.root.join("raw/sample.log")).expect("evidence"), + b"network activity\n" + ); + assert!( + fs::read_to_string(case.root.join("normalized/events.jsonl")) + .expect("events") + .contains("network activity") + ); + assert!( + fs::read_to_string(case.root.join("manifest.jsonl")) + .expect("manifest") + .contains("sha256") + ); + fs::remove_dir_all(&base).expect("remove fixture"); + } +} diff --git a/investigate/src/collect.rs b/investigate/src/collect.rs new file mode 100644 index 0000000..1b07a4c --- /dev/null +++ b/investigate/src/collect.rs @@ -0,0 +1,705 @@ +use crate::{ + case::{Case, Coverage, CoverageState, Event, EvidenceLevel}, + config::RunConfig, +}; +use anyhow::{Context, Result}; +use serde_json::Value; +use std::{ + fs::{self, File}, + io::{BufRead, BufReader, BufWriter, Write}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + thread, + time::{Duration, Instant}, +}; +use walkdir::WalkDir; + +pub fn run(config: &RunConfig, progress: &impl Fn(&str)) -> Result { + let mut case = Case::new(&config.output_base)?; + let root = case.root.clone(); + fs::write(root.join("config.json"), serde_json::to_vec_pretty(config)?)?; + fs::write( + root.join("case-info.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "tool": "opsforge-investigate", + "version": env!("CARGO_PKG_VERSION"), + "started_at": jiff::Timestamp::now().to_string(), + "operator": std::env::var("SUDO_USER").or_else(|_| std::env::var("USER")).unwrap_or_else(|_| "unknown".into()), + }))?, + )?; + progress(&format!("Case created: {}", root.display())); + let mut live = if config.live_capture { + progress(&format!( + "Starting live traffic capture for {}", + config.capture_duration + )); + match start_capture(&mut case, config.capture_duration.seconds()) { + Ok(capture) => capture, + Err(error) => { + case.coverage(&Coverage { + source: "live-traffic".into(), + state: CoverageState::Failed, + detail: error.to_string(), + })?; + None + } + } + } else { + case.coverage(&Coverage { + source: "live-traffic".into(), + state: CoverageState::Skipped, + detail: "operator deselected live capture".into(), + })?; + None + }; + + for (enabled, source) in [ + (config.exfil, "exfiltration"), + (config.timeline, "device-timeline"), + (config.downloads, "downloads"), + ] { + if !enabled { + case.coverage(&Coverage { + source: source.into(), + state: CoverageState::Skipped, + detail: "operator deselected category".into(), + })?; + } + } + if config.imports.is_empty() { + case.coverage(&Coverage { + source: "imported-logs".into(), + state: CoverageState::Skipped, + detail: "no paths supplied".into(), + })?; + } + + if config.exfil || config.timeline { + progress("Collecting active sockets and installed applications"); + source(&mut case, "active-sockets", |case| { + run_command(case, "active-sockets", "ss", &["-tunap"]) + })?; + source(&mut case, "active-socket-normalization", normalize_sockets)?; + for (name, program, args) in [ + ("packages-dpkg", "dpkg-query", vec!["-W"]), + ("packages-rpm", "rpm", vec!["-qa"]), + ("packages-xbps", "xbps-query", vec!["-l"]), + ("packages-pacman", "pacman", vec!["-Q"]), + ("apps-flatpak", "flatpak", vec!["list", "--app"]), + ("apps-snap", "snap", vec!["list"]), + ] { + source(&mut case, name, |case| { + run_command(case, name, program, &args) + })?; + } + progress("Collecting retained system journal"); + source(&mut case, "journal", |case| collect_journal(case, progress))?; + progress("Preserving retained system logs"); + source(&mut case, "system-logs", |case| { + collect_logs(case, progress) + })?; + } + if config.exfil || config.downloads { + progress("Collecting browser activity and downloads"); + source(&mut case, "browser-profiles", |case| { + crate::browser::collect(case, progress) + })?; + case.coverage(&Coverage { source: "browser-upload-transactions".into(), state: CoverageState::Unsupported, detail: "browser history does not retain upload payloads or prove that a visit uploaded data".into() })?; + } + for (index, path) in config.imports.iter().enumerate() { + progress(&format!("Importing {}", path.display())); + source(&mut case, &path.display().to_string(), |case| { + import_path(case, path, index, progress) + })?; + } + if config.deep_inventory { + progress("Inventorying local files"); + source(&mut case, "file-inventory", |case| { + inventory(case, Path::new("/"), progress) + })?; + } else { + case.coverage(&Coverage { + source: "file-inventory".into(), + state: CoverageState::Skipped, + detail: "operator deselected deep inventory".into(), + })?; + } + if let Some(capture) = &mut live { + source(&mut case, "live-traffic", |case| { + finish_capture(case, capture, progress) + })?; + } + progress("Building offline dashboard"); + crate::report::generate(&root)?; + fs::write( + root.join("completion.json"), + serde_json::to_vec_pretty( + &serde_json::json!({"finished_at": jiff::Timestamp::now().to_string(), "status": "finished_review_coverage"}), + )?, + )?; + crate::report::write_checksums(&root)?; + progress(&format!( + "Case saved; review source coverage: {}", + root.display() + )); + Ok(root) +} + +fn source( + case: &mut Case, + name: &str, + collect: impl FnOnce(&mut Case) -> Result<()>, +) -> Result<()> { + if let Err(error) = collect(case) { + case.coverage(&Coverage { + source: name.into(), + state: CoverageState::Failed, + detail: error.to_string(), + })?; + } + Ok(()) +} + +fn run_command(case: &mut Case, name: &str, program: &str, args: &[&str]) -> Result<()> { + if !command_exists(program) { + case.coverage(&Coverage { + source: name.into(), + state: CoverageState::Unsupported, + detail: format!("{program} is not installed"), + })?; + return Ok(()); + } + let raw = format!("{name}.txt"); + let file = File::create(case.root.join("raw").join(&raw))?; + let error_file = File::create(case.root.join("raw").join(format!("{name}.stderr.txt")))?; + let status = Command::new(program) + .args(args) + .stdout(file) + .stderr(error_file) + .status()?; + case.record_existing(&format!("{program} {}", args.join(" ")), &raw)?; + case.record_existing(&format!("{program} stderr"), &format!("{name}.stderr.txt"))?; + let bytes = fs::metadata(case.root.join("raw").join(raw))?.len(); + let state = if !status.success() { + CoverageState::Failed + } else if bytes == 0 { + CoverageState::Empty + } else { + CoverageState::Collected + }; + case.coverage(&Coverage { + source: name.into(), + state, + detail: format!("exit status: {status}"), + })?; + Ok(()) +} + +fn command_exists(program: &str) -> bool { + std::env::var_os("PATH") + .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(program).is_file())) +} + +fn normalize_sockets(case: &mut Case) -> Result<()> { + let path = case.root.join("raw/active-sockets.txt"); + if !path.exists() { + return Ok(()); + } + let timestamp = jiff::Timestamp::now().to_string(); + for line in BufReader::new(File::open(path)?).lines().skip(1) { + let line = line?; + let fields: Vec<_> = line.split_whitespace().collect(); + if fields.len() < 6 { + continue; + } + let application = line + .split("users:((\"") + .nth(1) + .and_then(|rest| rest.split('"').next()) + .map(str::to_owned); + case.event(&Event { + timestamp: Some(timestamp.clone()), + source: "active-sockets".into(), + kind: if fields[1] == "LISTEN" { + "inbound-listener" + } else { + "active-connection" + } + .into(), + application, + user: None, + destination: Some(fields[5].into()), + detail: line, + evidence: "raw/active-sockets.txt".into(), + level: EvidenceLevel::Observed, + })?; + } + Ok(()) +} + +fn collect_journal(case: &mut Case, progress: &impl Fn(&str)) -> Result<()> { + if !command_exists("journalctl") { + case.coverage(&Coverage { + source: "journal".into(), + state: CoverageState::Unsupported, + detail: "journalctl is not installed".into(), + })?; + return Ok(()); + } + let mut child = Command::new("journalctl") + .args(["--no-pager", "-o", "json"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + let stdout = child.stdout.take().context("journal stdout")?; + let mut raw = BufWriter::new(File::create(case.root.join("raw/journal.jsonl"))?); + let mut count = 0_u64; + for line in BufReader::new(stdout).lines() { + let line = line?; + writeln!(raw, "{line}")?; + raw.flush()?; + let Ok(record) = serde_json::from_str::(&line) else { + continue; + }; + let message = record.get("MESSAGE").and_then(Value::as_str).unwrap_or(""); + let app = record + .get("_COMM") + .and_then(Value::as_str) + .map(str::to_owned); + let timestamp = record + .get("__REALTIME_TIMESTAMP") + .and_then(Value::as_str) + .and_then(|micros| micros.parse::().ok()) + .and_then(|micros| jiff::Timestamp::from_microsecond(micros).ok()) + .map(|stamp| stamp.to_string()); + let network = [ + "connect", "upload", "download", "network", "firewall", "vpn", "dns", "ssh", "http", + "sync", + ] + .iter() + .any(|needle| message.to_ascii_lowercase().contains(needle)); + case.event(&Event { + timestamp, + source: "journal".into(), + kind: if network { "network-lead" } else { "journal" }.into(), + application: app, + user: record + .get("_UID") + .and_then(Value::as_str) + .map(str::to_owned), + destination: None, + detail: message.to_owned(), + evidence: "raw/journal.jsonl".into(), + level: EvidenceLevel::Lead, + })?; + count = count.saturating_add(1); + if count.is_multiple_of(10_000) { + progress(&format!("Journal: {count} entries saved")); + } + } + raw.flush()?; + let status = child.wait()?; + case.record_existing("journalctl --no-pager -o json", "journal.jsonl")?; + case.coverage(&Coverage { + source: "journal".into(), + state: if !status.success() { + CoverageState::Failed + } else if count == 0 { + CoverageState::Empty + } else { + CoverageState::Collected + }, + detail: format!("{count} entries; {status}"), + })?; + Ok(()) +} + +fn collect_logs(case: &mut Case, progress: &impl Fn(&str)) -> Result<()> { + let mut count = 0_u64; + let mut attempted = 0_u64; + let mut failed = 0_u64; + for entry in WalkDir::new("/var/log").follow_links(false).max_depth(4) { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + failed = failed.saturating_add(1); + case.coverage(&Coverage { + source: "system-logs".into(), + state: CoverageState::Failed, + detail: error.to_string(), + })?; + continue; + } + }; + if !entry.file_type().is_file() { + continue; + } + let name = format!("system-log-{attempted:05}"); + attempted = attempted.saturating_add(1); + match case.copy_evidence(entry.path(), &name) { + Ok(_) => { + count = count.saturating_add(1); + let raw = case.root.join("raw").join(&name); + let parsed = normalize_text_log(case, &raw, &name, "system-log"); + case.coverage(&Coverage { + source: entry.path().display().to_string(), + state: if parsed.is_ok() { + CoverageState::Collected + } else { + CoverageState::Unsupported + }, + detail: match parsed { + Ok(lines) => format!("{lines} text records normalized"), + Err(error) => format!("raw preserved; text parser unavailable: {error}"), + }, + })?; + } + Err(error) => { + failed = failed.saturating_add(1); + case.coverage(&Coverage { + source: entry.path().display().to_string(), + state: CoverageState::Failed, + detail: error.to_string(), + })?; + } + } + if count > 0 && count.is_multiple_of(100) { + progress(&format!("System logs: {count} files saved")); + } + } + case.coverage(&Coverage { + source: "system-logs".into(), + state: if failed > 0 { + CoverageState::Failed + } else if count == 0 { + CoverageState::Empty + } else { + CoverageState::Collected + }, + detail: format!("{count} files saved; {failed} failed; scan depth 4"), + })?; + Ok(()) +} + +fn import_path(case: &mut Case, path: &Path, index: usize, progress: &impl Fn(&str)) -> Result<()> { + if !path.exists() { + case.coverage(&Coverage { + source: path.display().to_string(), + state: CoverageState::Failed, + detail: "path does not exist".into(), + })?; + return Ok(()); + } + let mut count = 0_u64; + let mut failed = 0_u64; + let mut attempted = 0_u64; + for entry in WalkDir::new(path).follow_links(false) { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + failed = failed.saturating_add(1); + case.coverage(&Coverage { + source: path.display().to_string(), + state: CoverageState::Failed, + detail: error.to_string(), + })?; + continue; + } + }; + if !entry.file_type().is_file() { + continue; + } + let name = format!("import-{index:03}-{attempted:06}"); + attempted = attempted.saturating_add(1); + if let Err(error) = case.copy_evidence(entry.path(), &name) { + failed = failed.saturating_add(1); + case.coverage(&Coverage { + source: entry.path().display().to_string(), + state: CoverageState::Failed, + detail: error.to_string(), + })?; + continue; + } + let raw = case.root.join("raw").join(&name); + let parsed = normalize_text_log(case, &raw, &name, "imported-log"); + case.coverage(&Coverage { + source: entry.path().display().to_string(), + state: if parsed.is_ok() { + CoverageState::Collected + } else { + CoverageState::Unsupported + }, + detail: match parsed { + Ok(lines) => format!("{lines} text records normalized"), + Err(error) => format!("raw preserved; text parser unavailable: {error}"), + }, + })?; + count = count.saturating_add(1); + if count.is_multiple_of(100) { + progress(&format!("Import {index}: {count} files saved")); + } + } + case.coverage(&Coverage { + source: path.display().to_string(), + state: if failed > 0 { + CoverageState::Failed + } else if count == 0 { + CoverageState::Empty + } else { + CoverageState::Collected + }, + detail: format!( + "{count} raw files saved; {failed} failed; unrecognized formats remain leads" + ), + })?; + Ok(()) +} + +fn normalize_text_log(case: &mut Case, raw: &Path, name: &str, source: &str) -> Result { + let mut count = 0_u64; + for line in BufReader::new(File::open(raw)?).lines() { + let line = line?; + let lower = line.to_ascii_lowercase(); + let network = [ + "upload", "download", "post ", "put ", "connect", "dns", "vpn", "src=", "dst=", + "outbound", "egress", + ] + .iter() + .any(|word| lower.contains(word)); + case.event(&Event { + timestamp: None, + source: source.into(), + kind: if network { + "network-lead" + } else { + "imported-record" + } + .into(), + application: None, + user: None, + destination: None, + detail: line, + evidence: format!("raw/{name}"), + level: EvidenceLevel::Lead, + })?; + count = count.saturating_add(1); + } + Ok(count) +} + +fn inventory(case: &mut Case, root: &Path, progress: &impl Fn(&str)) -> Result<()> { + let case_root = case.root.clone(); + let mut output = BufWriter::new(File::create( + case.root.join("normalized/file-inventory.jsonl"), + )?); + let mut count = 0_u64; + let mut failed = 0_u64; + for entry in WalkDir::new(root) + .follow_links(false) + .into_iter() + .filter_entry(|entry| { + let path = entry.path(); + !["/proc", "/sys", "/dev", "/run"] + .iter() + .any(|prefix| path == Path::new(prefix)) + && !path.starts_with(&case_root) + }) + { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + failed = failed.saturating_add(1); + case.coverage(&Coverage { + source: "file-inventory".into(), + state: CoverageState::Failed, + detail: error.to_string(), + })?; + continue; + } + }; + if !entry.file_type().is_file() { + continue; + } + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(error) => { + failed = failed.saturating_add(1); + case.coverage(&Coverage { + source: entry.path().display().to_string(), + state: CoverageState::Failed, + detail: error.to_string(), + })?; + continue; + } + }; + serde_json::to_writer( + &mut output, + &serde_json::json!({ + "path": entry.path().display().to_string(), "bytes": metadata.len(), + "modified": metadata.modified().ok().and_then(|stamp| jiff::Timestamp::try_from(stamp).map(|value| value.to_string()).ok()), + }), + )?; + output.write_all(b"\n")?; + count = count.saturating_add(1); + if count.is_multiple_of(1_000) { + output.flush()?; + } + if count.is_multiple_of(10_000) { + progress(&format!("File inventory: {count} files saved")); + } + } + output.flush()?; + case.coverage(&Coverage { + source: "file-inventory".into(), + state: if failed > 0 { CoverageState::Failed } else if count == 0 { CoverageState::Empty } else { CoverageState::Collected }, + detail: format!("{count} files; {failed} unreadable entries; excludes proc, sys, dev, run, and case directory"), + })?; + Ok(()) +} + +struct LiveCapture { + child: Child, + started: Instant, + started_at: String, + seconds: u64, +} + +impl Drop for LiveCapture { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _ = Command::new("kill") + .args(["-TERM", &self.child.id().to_string()]) + .status(); + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + } + let _ = self.child.wait(); + } + } +} + +fn start_capture(case: &mut Case, seconds: u64) -> Result> { + if !command_exists("tcpdump") || !command_exists("timeout") { + case.coverage(&Coverage { + source: "live-traffic".into(), + state: CoverageState::Unsupported, + detail: "tcpdump or timeout is not installed".into(), + })?; + return Ok(None); + } + let pcap = case.root.join("raw/live-traffic.pcap"); + let log = File::create(case.root.join("raw/live-traffic.log"))?; + let child = Command::new("timeout") + .args([ + "-s", + "INT", + &format!("{seconds}s"), + "tcpdump", + "-i", + "any", + "-nn", + "-s", + "0", + "-U", + "-w", + ]) + .arg(&pcap) + .stderr(log) + .stdout(Stdio::null()) + .spawn()?; + Ok(Some(LiveCapture { + child, + started: Instant::now(), + started_at: jiff::Timestamp::now().to_string(), + seconds, + })) +} + +fn finish_capture( + case: &mut Case, + capture: &mut LiveCapture, + progress: &impl Fn(&str), +) -> Result<()> { + let status = loop { + if let Some(status) = capture.child.try_wait()? { + break status; + } + progress(&format!( + "Live traffic: {}/{}s", + capture.started.elapsed().as_secs(), + capture.seconds + )); + thread::sleep(Duration::from_secs(1)); + }; + case.record_existing("tcpdump -i any", "live-traffic.log")?; + let pcap = case.root.join("raw/live-traffic.pcap"); + let bytes = if pcap.exists() { + case.record_existing("tcpdump -i any", "live-traffic.pcap")?; + fs::metadata(&pcap)?.len() + } else { + 0 + }; + let code = status.code(); + case.coverage(&Coverage { + source: "live-traffic".into(), + state: if code != Some(124) && !status.success() { + CoverageState::Failed + } else if bytes <= 24 { + CoverageState::Empty + } else { + CoverageState::Collected + }, + detail: format!( + "started {}; ended {}; elapsed {}s; {status}", + capture.started_at, + jiff::Timestamp::now(), + capture.started.elapsed().as_secs() + ), + })?; + if bytes > 24 { + normalize_pcap(case, &pcap)?; + } + Ok(()) +} + +fn normalize_pcap(case: &mut Case, pcap: &Path) -> Result<()> { + let mut child = Command::new("tcpdump") + .args(["-nn", "-tttt", "-r"]) + .arg(pcap) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + let stdout = child.stdout.take().context("tcpdump decode stdout")?; + let mut raw = File::create(case.root.join("raw/live-traffic-summary.txt"))?; + let mut count = 0_u64; + for line in BufReader::new(stdout).lines() { + let line = line?; + writeln!(raw, "{line}")?; + case.event(&Event { + timestamp: None, + source: "live-traffic".into(), + kind: "packet".into(), + application: None, + user: None, + destination: None, + detail: line, + evidence: "raw/live-traffic-summary.txt".into(), + level: EvidenceLevel::Observed, + })?; + count = count.saturating_add(1); + } + let status = child.wait()?; + case.record_existing("tcpdump -nn -tttt -r", "live-traffic-summary.txt")?; + case.coverage(&Coverage { + source: "live-traffic-decode".into(), + state: if !status.success() { + CoverageState::Failed + } else if count == 0 { + CoverageState::Empty + } else { + CoverageState::Collected + }, + detail: format!("{count} packet summaries; {status}"), + })?; + Ok(()) +} diff --git a/investigate/src/config.rs b/investigate/src/config.rs new file mode 100644 index 0000000..a3b9a16 --- /dev/null +++ b/investigate/src/config.rs @@ -0,0 +1,113 @@ +use serde::{Deserialize, Serialize}; +use std::{fmt, path::PathBuf, str::FromStr, time::Duration}; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct CaptureDuration { + seconds: u64, + label: String, +} + +impl CaptureDuration { + #[must_use] + pub fn seconds(&self) -> u64 { + self.seconds + } +} + +impl From for Duration { + fn from(value: CaptureDuration) -> Self { + Self::from_secs(value.seconds) + } +} + +impl fmt::Display for CaptureDuration { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.label) + } +} + +impl FromStr for CaptureDuration { + type Err = &'static str; + + fn from_str(input: &str) -> Result { + let (digits, unit) = + input.split_at(input.len().checked_sub(1).ok_or("use 1s, 5m, 1h, or 1d")?); + if digits.is_empty() || !digits.bytes().all(|digit| digit.is_ascii_digit()) { + return Err("use a positive integer followed by s, m, h, or d"); + } + let count: u64 = digits + .parse() + .map_err(|_| "capture duration is too large")?; + if count == 0 { + return Err("capture duration must be positive"); + } + let multiplier = match unit { + "s" => 1, + "m" => 60, + "h" => 3_600, + "d" => 86_400, + _ => return Err("capture duration unit must be s, m, h, or d"), + }; + let seconds = count + .checked_mul(multiplier) + .ok_or("capture duration is too large")?; + Ok(Self { + seconds, + label: input.to_owned(), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RunConfig { + pub output_base: PathBuf, + pub exfil: bool, + pub timeline: bool, + pub downloads: bool, + pub deep_inventory: bool, + pub live_capture: bool, + pub capture_duration: CaptureDuration, + pub imports: Vec, +} + +impl RunConfig { + #[must_use] + pub fn default_for(output_base: PathBuf) -> Self { + Self { + output_base, + exfil: true, + timeline: true, + downloads: true, + deep_inventory: true, + live_capture: true, + capture_duration: CaptureDuration { + seconds: 300, + label: "5m".to_owned(), + }, + imports: Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::CaptureDuration; + use std::time::Duration; + + #[test] + fn parses_requested_capture_units() { + for (input, seconds) in [("1s", 1), ("5m", 300), ("2h", 7200), ("1d", 86400)] { + assert_eq!( + input.parse::().map(Duration::from), + Ok(Duration::from_secs(seconds)) + ); + } + } + + #[test] + fn rejects_missing_or_unbounded_capture_duration() { + for input in ["", "0s", "1", "1w", "1.5h", "999999999999999999999d"] { + assert!(input.parse::().is_err(), "{input}"); + } + } +} diff --git a/investigate/src/lib.rs b/investigate/src/lib.rs new file mode 100644 index 0000000..bbcce37 --- /dev/null +++ b/investigate/src/lib.rs @@ -0,0 +1,6 @@ +pub mod browser; +pub mod case; +pub mod collect; +pub mod config; +pub mod report; +pub mod tui; diff --git a/investigate/src/main.rs b/investigate/src/main.rs new file mode 100644 index 0000000..0515cf4 --- /dev/null +++ b/investigate/src/main.rs @@ -0,0 +1,137 @@ +use anyhow::{Context, Result, bail}; +use clap::Parser; +use opsforge_investigate::{ + collect, + config::{CaptureDuration, RunConfig}, + tui, +}; +use std::{ + path::PathBuf, + process::{Command, Stdio}, +}; + +#[derive(Parser)] +#[command(about = "Collect a Linux investigation case with an offline dashboard")] +struct Args { + #[arg(long, help = "Run without the setup TUI")] + non_interactive: bool, + #[arg(short, long, help = "Parent directory for the timestamped case")] + output: Option, + #[arg( + long, + default_value = "5m", + help = "Live capture duration: positive s, m, h, or d" + )] + duration: CaptureDuration, + #[arg( + long = "import", + help = "Additional proxy, firewall, VPN, DNS, or other logs" + )] + imports: Vec, + #[arg(long)] + no_exfil: bool, + #[arg(long)] + no_timeline: bool, + #[arg(long)] + no_downloads: bool, + #[arg(long)] + no_inventory: bool, + #[arg(long)] + no_capture: bool, + #[arg(long, hide = true)] + config_stdin: bool, +} + +fn main() -> Result<()> { + let args = Args::parse(); + if args.config_stdin { + if !is_root()? { + bail!("--config-stdin requires root"); + } + let config: RunConfig = serde_json::from_reader(std::io::stdin())?; + return execute(&config, !args.non_interactive); + } + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .context("HOME is not set; pass --output")?; + let mut config = + RunConfig::default_for(args.output.unwrap_or_else(|| home.join("opsforge-cases"))); + config.capture_duration = args.duration; + config.imports = args.imports; + config.exfil = !args.no_exfil; + config.timeline = !args.no_timeline; + config.downloads = !args.no_downloads; + config.deep_inventory = !args.no_inventory; + config.live_capture = !args.no_capture; + if !args.non_interactive { + let Some(selected) = tui::configure(config)? else { + return Ok(()); + }; + config = selected; + } + if is_root()? { + return execute(&config, !args.non_interactive); + } + if !command_exists("sudo") { + bail!("root access is required and sudo is unavailable"); + } + if !Command::new("sudo") + .args(["-n", "true"]) + .status()? + .success() + { + eprintln!( + "Root access is required to collect all available evidence. Requesting sudo now." + ); + if !Command::new("sudo").arg("-v").status()?.success() { + bail!("sudo authentication failed; no collection started"); + } + } + let executable = std::env::current_exe()?; + let mut command = Command::new("sudo"); + command + .arg("--") + .arg(executable) + .arg("--config-stdin") + .stdin(Stdio::piped()); + if args.non_interactive { + command.arg("--non-interactive"); + } + let mut child = command.spawn()?; + serde_json::to_writer( + child.stdin.take().context("sudo input unavailable")?, + &config, + )?; + let status = child.wait()?; + if !status.success() { + bail!("elevated collection failed: {status}"); + } + Ok(()) +} + +fn execute(config: &RunConfig, interactive: bool) -> Result<()> { + let root = if interactive { + tui::run_progress(config)? + } else { + collect::run(config, &|message| println!("{message}"))? + }; + println!("Case: {}", root.display()); + println!("Dashboard: {}", root.join("dashboard/index.html").display()); + Ok(()) +} + +fn is_root() -> Result { + let output = Command::new("id") + .arg("-u") + .output() + .context("checking effective user")?; + if !output.status.success() { + bail!("id -u failed"); + } + Ok(output.stdout == b"0\n") +} + +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/report.rs b/investigate/src/report.rs new file mode 100644 index 0000000..48697c6 --- /dev/null +++ b/investigate/src/report.rs @@ -0,0 +1,232 @@ +use crate::case::{Coverage, Event}; +use anyhow::Result; +use sha2::{Digest, Sha256}; +use std::{ + collections::VecDeque, + fs::{self, File}, + io::{BufRead, BufReader, Read, Write}, + path::Path, +}; +use walkdir::WalkDir; + +const LIMIT: usize = 2_000; + +pub fn generate(root: &Path) -> Result<()> { + let events = read_events(&root.join("normalized/events.jsonl"))?; + let coverage = read_lines::(&root.join("normalized/coverage.jsonl"))?; + let failures = coverage + .iter() + .filter(|row| format!("{:?}", row.state) == "Failed") + .count(); + let leads = events.leads; + let downloads = events.downloads; + let other = events.count.saturating_sub(leads.saturating_add(downloads)); + let graph_max = events.count.max(1); + fs::write(root.join("findings.json"), b"[]\n")?; + fs::write(root.join("normalized/findings.json"), b"[]\n")?; + fs::write( + root.join("summary.txt"), + format!( + "Output: {}\nFindings: 0\nEvents: {}\nNetwork leads: {leads}\nDownloads: {downloads}\nFailed sources: {failures}\n", + root.display(), + events.count + ), + )?; + fs::write( + root.join("report.md"), + format!( + "# Linux investigator case\n\nCase: `{}`\n\n- Events: {}\n- Network leads: {leads}\n- Downloads: {downloads}\n- Failed sources: {failures}\n\nNo automated exfiltration conclusion is made from browser visits, journal keywords, or a packet capture alone. Check the raw evidence and source coverage before reaching a conclusion.\n\nOpen `dashboard/index.html` for the offline report. Full records are in `normalized/events.jsonl` and `normalized/coverage.jsonl`.\n", + root.display(), + events.count + ), + )?; + fs::write( + root.join("dashboard/style.css"), + format!("{STYLE}{GRAPH_STYLE}{TABLE_STYLE}"), + )?; + + let overview = format!( + "
{}events
{leads}network leads
{downloads}downloads
{failures}failed sources

Evidence shape

These counts show available evidence, not complete device history or confirmed exfiltration.

All normalized events · Evidence hashes

", + events.count + ); + page(root, "index", "Case overview", &overview)?; + + let network: Vec<_> = events.network.iter().collect(); + page( + root, + "exfil", + "Exfiltration review", + &format!( + "

Network and browser activity are leads. A visit or keyword does not prove an upload. Compare these records with packet and imported network logs.

{}", + event_table(&network, events.network_count) + ), + )?; + let mut timeline: Vec<_> = events.timeline.iter().collect(); + timeline.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); + page( + root, + "timeline", + "Device timeline", + &format!( + "

Times are recorded in source form where available. Missing times sort first.

{}", + event_table(&timeline, events.count) + ), + )?; + let downloaded: Vec<_> = events.download_rows.iter().collect(); + page( + root, + "downloads", + "Downloads", + &format!( + "

Browser download records are historical metadata. Inspect the referenced database and file before classifying a download.

{}", + event_table(&downloaded, downloads) + ), + )?; + let mut rows = String::from( + "", + ); + for row in &coverage { + rows.push_str(&format!( + "", + escape(&row.source), + row.state, + escape(&row.detail) + )); + } + rows.push_str( + "
SourceStatusDetail
{}{:?}{}

Full coverage log

", + ); + page(root, "collection", "Collection coverage", &rows)?; + Ok(()) +} + +pub fn write_checksums(root: &Path) -> Result<()> { + let mut output = File::create(root.join("checksums.sha256"))?; + for entry in WalkDir::new(root).sort_by_file_name() { + let entry = entry?; + if !entry.file_type().is_file() || entry.file_name() == "checksums.sha256" { + continue; + } + let mut file = File::open(entry.path())?; + let mut hash = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let bytes = file.read(&mut buffer)?; + if bytes == 0 { + break; + } + hash.update(&buffer[..bytes]); + } + writeln!( + output, + "{:x} {}", + hash.finalize(), + entry.path().strip_prefix(root)?.display() + )?; + } + Ok(()) +} + +fn read_lines(path: &Path) -> Result> { + let mut result = Vec::new(); + for line in BufReader::new(File::open(path)?).lines() { + result.push(serde_json::from_str(&line?)?); + } + Ok(result) +} + +#[derive(Default)] +struct EventSamples { + count: usize, + leads: usize, + downloads: usize, + network_count: usize, + timeline: VecDeque, + network: VecDeque, + download_rows: VecDeque, +} + +fn read_events(path: &Path) -> Result { + let mut samples = EventSamples::default(); + for line in BufReader::new(File::open(path)?).lines() { + let event: Event = serde_json::from_str(&line?)?; + samples.count = samples.count.saturating_add(1); + if event.kind == "network-lead" { + samples.leads = samples.leads.saturating_add(1); + } + if event.kind == "download" { + samples.downloads = samples.downloads.saturating_add(1); + retain(&mut samples.download_rows, event.clone()); + } + if matches!( + event.kind.as_str(), + "network-lead" | "active-connection" | "inbound-listener" | "packet" + ) || event.source == "browser-history" + { + samples.network_count = samples.network_count.saturating_add(1); + retain(&mut samples.network, event.clone()); + } + retain(&mut samples.timeline, event); + } + Ok(samples) +} + +fn retain(rows: &mut VecDeque, event: Event) { + if rows.len() == LIMIT { + rows.pop_front(); + } + rows.push_back(event); +} + +fn event_table(events: &[&Event], total: usize) -> String { + let mut body = format!( + "

Showing the last {} collected records of {}. The JSONL file contains every record.

", + events.len(), + total + ); + for event in events { + let raw = if event.evidence.starts_with("raw/") && !event.evidence.contains("..") { + format!("source", escape(&event.evidence)) + } else { + String::new() + }; + body.push_str(&format!("", + escape(event.timestamp.as_deref().unwrap_or("unknown")), escape(&event.source), escape(&event.kind), escape(event.application.as_deref().unwrap_or("unattributed")), escape(event.destination.as_deref().unwrap_or("")), escape(&event.detail), event.level)); + } + body.push_str("
TimeSourceKindApplicationDestination or pathDetailEvidence levelRaw
{}{}{}{}{}{}{:?}{raw}
"); + body +} + +fn page(root: &Path, name: &str, title: &str, content: &str) -> Result<()> { + let mut file = File::create(root.join("dashboard").join(format!("{name}.html")))?; + write!( + file, + "{} · opsforge

OPSFORGE / INVESTIGATOR

LINUX CASE REPORT

{}

{}
Offline case report · keep this directory with its raw and normalized evidence.
", + escape(title), + escape(title), + content + )?; + Ok(()) +} + +fn escape(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +const STYLE: &str = "*{box-sizing:border-box}body{margin:0;background:#101923;color:#e9f1f5;font:16px/1.5 system-ui,sans-serif}header,main,footer{padding:24px max(24px,calc((100vw - 1200px)/2))}header{border-bottom:1px solid #355061;background:#172633}.brand,.eyebrow{color:#59d5d0;font:700 12px/1.4 ui-monospace,monospace;letter-spacing:.15em}nav{display:flex;flex-wrap:wrap;gap:8px 24px}a{color:#59d5d0}a:focus-visible{outline:3px solid #f1bd68;outline-offset:3px}h1{font-size:clamp(2rem,5vw,4rem);line-height:1.1;margin:.25em 0 .8em}p{max-width:75ch;color:#a7bcc8}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.cards div{background:#172633;border:1px solid #355061;border-radius:8px;padding:20px}.cards strong{display:block;color:#e9f1f5;font-size:2rem}.cards span{color:#a7bcc8}.scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;min-width:750px}th,td{text-align:left;vertical-align:top;padding:10px;border-bottom:1px solid #355061;overflow-wrap:anywhere}th{background:#1d3141;color:#e9f1f5}tr:nth-child(even){background:#172633}td{font:13px/1.5 ui-monospace,monospace}footer{border-top:1px solid #355061;color:#a7bcc8;font-size:13px}"; +const GRAPH_STYLE: &str = ".breakdown{margin:32px 0;padding:24px;background:#172633;border:1px solid #355061;border-radius:8px}.breakdown h2{margin:0 0 16px}.breakdown label{display:grid;grid-template-columns:1fr auto;gap:4px 20px;margin:12px 0;color:#a7bcc8}.breakdown label span{color:#e9f1f5;font-family:ui-monospace,monospace}.breakdown meter{grid-column:1/-1;width:100%;height:18px}.breakdown meter::-webkit-meter-bar{background:#1d3141;border:1px solid #355061}.breakdown meter::-webkit-meter-optimum-value{background:#59d5d0}"; +const TABLE_STYLE: &str = ".evidence-table{min-width:1600px;table-layout:fixed}.evidence-table th:nth-child(1){width:190px}.evidence-table th:nth-child(2){width:150px}.evidence-table th:nth-child(3){width:160px}.evidence-table th:nth-child(4){width:160px}.evidence-table th:nth-child(5){width:250px}.evidence-table th:nth-child(6){width:480px}.evidence-table th:nth-child(7){width:130px}.evidence-table th:nth-child(8){width:80px}"; + +#[cfg(test)] +mod tests { + use super::escape; + #[test] + fn escapes_collected_text() { + assert_eq!(escape("\n", + ) + .expect("fixture"); + let mut config = RunConfig::default_for(base.clone()); + config.exfil = false; + config.timeline = false; + config.downloads = false; + config.deep_inventory = false; + config.live_capture = false; + config.imports.push(imported); + let root = collect::run(&config, &|_| {}).expect("case run"); + let events = fs::read_to_string(root.join("normalized/events.jsonl")).expect("events"); + let page = fs::read_to_string(root.join("dashboard/exfil.html")).expect("dashboard"); + assert!(events.contains("network-lead")); + assert!(page.contains("<script>")); + assert!(!page.contains("