From c88c07f4ca0bbae37ae22accad9a55164d14014a Mon Sep 17 00:00:00 2001 From: pd241008 <142252602+pd241008@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:38:10 +0530 Subject: [PATCH 1/5] feat: add severity levels to report output Introduce Severity enum (Info/Warning/Critical) with log(), warn(), and critical() methods; flag() remains an alias for warn(). Plain text prefixes warnings with [!] and criticals with [CRIT]. JSON output gains severity_counts and an entries array sorted by urgency. Report is now Deserialize-able for baseline comparison. --- src/report.rs | 106 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 5 deletions(-) diff --git a/src/report.rs b/src/report.rs index 51e80d0..79c7f18 100644 --- a/src/report.rs +++ b/src/report.rs @@ -1,10 +1,57 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::time::{SystemTime, UNIX_EPOCH}; -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Severity { + Info, + Warning, + Critical, +} + +impl Severity { + pub fn label(self) -> &'static str { + match self { + Severity::Info => "", + Severity::Warning => "[!]", + Severity::Critical => "[CRIT]", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Entry { + pub severity: Severity, + pub message: String, + pub section: String, +} + +impl Entry { + pub fn render(&self) -> String { + render_line(self.severity, &self.message) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SeverityCounts { + pub info: u32, + pub warning: u32, + pub critical: u32, +} + +impl SeverityCounts { + pub fn total(&self) -> u32 { + self.info + self.warning + self.critical + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Report { pub lines: Vec, pub findings: u32, + pub severity_counts: SeverityCounts, + pub entries: Vec, + #[serde(skip)] + current_section: String, } impl Default for Report { @@ -18,21 +65,47 @@ impl Report { Report { lines: vec![now_string()], findings: 0, + severity_counts: SeverityCounts::default(), + entries: Vec::new(), + current_section: String::new(), } } pub fn section(&mut self, title: &str) { self.lines.push(String::new()); self.lines.push(format!("== {} ==", title)); + self.current_section = title.to_string(); + } + + fn push(&mut self, severity: Severity, message: String) { + let section = self.current_section.clone(); + self.entries.push(Entry { + severity, + message: message.clone(), + section, + }); + self.lines.push(render_line(severity, &message)); } pub fn log(&mut self, msg: impl Into) { - self.lines.push(msg.into()); + self.severity_counts.info += 1; + self.push(Severity::Info, msg.into()); + } + + pub fn warn(&mut self, msg: impl Into) { + self.severity_counts.warning += 1; + self.findings += 1; + self.push(Severity::Warning, msg.into()); } pub fn flag(&mut self, msg: impl Into) { + self.warn(msg); + } + + pub fn critical(&mut self, msg: impl Into) { + self.severity_counts.critical += 1; self.findings += 1; - self.lines.push(format!("[!] {}", msg.into())); + self.push(Severity::Critical, msg.into()); } pub fn join(&self) -> String { @@ -40,7 +113,30 @@ impl Report { } pub fn to_json(&self) -> String { - serde_json::to_string_pretty(self).unwrap_or_default() + let mut entries: Vec<&Entry> = self.entries.iter().collect(); + entries.sort_by_key(|b| std::cmp::Reverse(b.severity)); + #[derive(Serialize)] + struct JsonReport<'a> { + lines: &'a [String], + findings: u32, + severity_counts: SeverityCounts, + entries: Vec<&'a Entry>, + } + let json = JsonReport { + lines: &self.lines, + findings: self.findings, + severity_counts: self.severity_counts, + entries, + }; + serde_json::to_string_pretty(&json).unwrap_or_default() + } +} + +pub fn render_line(severity: Severity, message: &str) -> String { + match severity { + Severity::Info => message.to_string(), + Severity::Warning => format!("[!] {}", message), + Severity::Critical => format!("[CRIT] {}", message), } } From 9e87cdab77949d5110ee46115663de69902a332d Mon Sep 17 00:00:00 2001 From: pd241008 <142252602+pd241008@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:38:14 +0530 Subject: [PATCH 2/5] feat: add --diff mode to compare scans against a baseline --diff FILE loads a previous JSON report and highlights only new findings (critical/warning) since that scan plus findings that were resolved. Works with --json for pipeline consumption and supports --json --out to write a reusable baseline. Exits 2 when new findings appeared, 0 otherwise. --- src/diff.rs | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 47 ++++++++++++++++++++--- 3 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 src/diff.rs diff --git a/src/diff.rs b/src/diff.rs new file mode 100644 index 0000000..5849b5e --- /dev/null +++ b/src/diff.rs @@ -0,0 +1,107 @@ +use crate::report::{Entry, Report, Severity}; +use serde::Serialize; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Default, Serialize)] +pub struct DiffResult { + pub previous_findings: u32, + pub current_findings: u32, + pub new_findings: Vec, + pub resolved_findings: Vec, +} + +impl DiffResult { + pub fn new_critical(&self) -> usize { + self.new_findings + .iter() + .filter(|e| e.severity == Severity::Critical) + .count() + } + + pub fn new_warning(&self) -> usize { + self.new_findings + .iter() + .filter(|e| e.severity == Severity::Warning) + .count() + } + + pub fn to_text(&self) -> String { + let mut out = String::new(); + out.push_str("== Scan diff =="); + out.push('\n'); + out.push_str(&format!("previous findings: {}\n", self.previous_findings)); + out.push_str(&format!("current findings: {}\n", self.current_findings)); + out.push_str(&format!( + "new findings: {} ({} critical, {} warning)\n", + self.new_findings.len(), + self.new_critical(), + self.new_warning() + )); + out.push_str(&format!( + "resolved findings: {}\n", + self.resolved_findings.len() + )); + out.push('\n'); + + if self.new_findings.is_empty() { + out.push_str("No new findings since last scan.\n"); + } else { + out.push_str("== New findings (since last scan) =="); + out.push('\n'); + for e in &self.new_findings { + out.push_str(&e.render()); + out.push('\n'); + } + } + + if !self.resolved_findings.is_empty() { + out.push_str("\n== Resolved findings (no longer present) =="); + out.push('\n'); + for e in &self.resolved_findings { + out.push_str(&e.render()); + out.push('\n'); + } + } + out + } +} + +fn key(e: &Entry) -> (String, String) { + (format!("{:?}", e.severity), e.message.clone()) +} + +pub fn compute(previous: &Report, current: &Report) -> DiffResult { + let prev: BTreeSet<(String, String)> = previous + .entries + .iter() + .filter(|e| e.severity != Severity::Info) + .map(key) + .collect(); + let cur: BTreeSet<(String, String)> = current + .entries + .iter() + .filter(|e| e.severity != Severity::Info) + .map(key) + .collect(); + + let new_findings: Vec = current + .entries + .iter() + .filter(|e| e.severity != Severity::Info && !prev.contains(&key(e))) + .cloned() + .collect(); + + let resolved_findings: Vec = previous + .entries + .iter() + .filter(|e| e.severity != Severity::Info && !cur.contains(&key(e))) + .cloned() + .collect(); + + DiffResult { + previous_findings: previous.findings, + current_findings: current.findings, + new_findings, + resolved_findings, + } +} diff --git a/src/lib.rs b/src/lib.rs index 1dd3cf5..2d4c5e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod config; pub mod config_loader; +pub mod diff; pub mod platform; pub mod report; pub mod scanner; diff --git a/src/main.rs b/src/main.rs index 0106509..889b249 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use clap::Parser; use sentrix::config_loader; -use sentrix::{run, ScanOptions}; +use sentrix::report::Report; +use sentrix::{diff, run, ScanOptions}; #[derive(Parser)] #[command( @@ -21,6 +22,10 @@ pub struct Cli { #[arg(long)] pub json: bool, + /// Compare scan results against a previous JSON report + #[arg(long, value_name = "FILE")] + pub diff: Option, + /// Path to TOML configuration file with custom detection patterns #[arg(short, long)] pub config: Option, @@ -53,14 +58,39 @@ fn main() { }; let report = run(&opts); - if cli.json { - println!("{}", report.to_json()); - } else if let Some(path) = &cli.out { - if let Err(e) = std::fs::write(path, report.join()) { + if let Some(prev_path) = &cli.diff { + let previous = match load_previous_report(prev_path) { + Ok(r) => r, + Err(e) => { + eprintln!("error: {}", e); + std::process::exit(1); + } + }; + let result = diff::compute(&previous, &report); + if cli.json { + println!( + "{}", + serde_json::to_string_pretty(&result).unwrap_or_default() + ); + } else { + print!("{}", result.to_text()); + } + std::process::exit(if result.new_findings.is_empty() { 0 } else { 2 }); + } + + if let Some(path) = &cli.out { + let content = if cli.json { + report.to_json() + } else { + report.join() + }; + if let Err(e) = std::fs::write(path, content) { eprintln!("error: could not write report to {}: {}", path, e); std::process::exit(1); } eprintln!("report written to {} ({} findings)", path, report.findings); + } else if cli.json { + println!("{}", report.to_json()); } else { print!("{}", report.join()); } @@ -69,3 +99,10 @@ fn main() { std::process::exit(2); } } + +fn load_previous_report(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("could not read previous report {}: {}", path, e))?; + serde_json::from_str(&content) + .map_err(|e| format!("could not parse previous report {}: {}", path, e)) +} From 4bca97b49e45a8c7a2fe93e08f6ee7a4c8ddea4e Mon Sep 17 00:00:00 2001 From: pd241008 <142252602+pd241008@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:38:18 +0530 Subject: [PATCH 3/5] test: add severity and diff coverage Adds 6 integration tests: severity markers/counts, JSON entries sorted by severity, Report JSON round-trip, diff new/resolved findings, diff with no changes, and diff ignoring info entries. Total test count now 20. --- tests/integration.rs | 109 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/tests/integration.rs b/tests/integration.rs index 97275bb..e1b6b88 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,6 +1,7 @@ use sentrix::config; use sentrix::config_loader::load_config; -use sentrix::report::Report; +use sentrix::diff; +use sentrix::report::{Report, Severity}; use sentrix::scanner::recent_files; use std::fs; use std::io::Write; @@ -54,6 +55,112 @@ fn test_report_to_json() { assert!(json.contains("[!] bad thing")); } +#[test] +fn test_report_severity_markers_and_counts() { + let mut report = Report::new(); + report.log("info line"); + report.warn("warning line"); + report.critical("critical line"); + + assert_eq!(report.findings, 2); + assert_eq!(report.severity_counts.info, 1); + assert_eq!(report.severity_counts.warning, 1); + assert_eq!(report.severity_counts.critical, 1); + + let out = report.join(); + assert!(out.contains("info line")); + assert!(out.contains("[!] warning line")); + assert!(out.contains("[CRIT] critical line")); + assert!(!out.contains("[CRIT] warning line")); +} + +#[test] +fn test_report_json_entries_sorted_by_severity() { + let mut report = Report::new(); + report.log("low"); + report.critical("bad"); + report.warn("meh"); + + let json = report.to_json(); + let crit = json.find("\"Critical\"").unwrap(); + let warn = json.find("\"Warning\"").unwrap(); + let info = json.find("\"Info\"").unwrap(); + assert!(crit < warn, "critical should sort before warning"); + assert!(warn < info, "warning should sort before info"); +} + +#[test] +fn test_report_round_trip_via_json() { + let mut report = Report::new(); + report.section("S"); + report.warn("warning line"); + report.critical("critical line"); + + let json = report.to_json(); + let restored: Report = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.findings, report.findings); + assert_eq!(restored.severity_counts, report.severity_counts); + assert_eq!(restored.entries.len(), report.entries.len()); + assert_eq!(restored.entries[0].message, "critical line"); + assert_eq!(restored.entries[0].severity, Severity::Critical); +} + +// ===== Diff tests ===== + +#[test] +fn test_diff_computes_new_and_resolved() { + let mut previous = Report::new(); + previous.section("S"); + previous.warn("still here"); + previous.critical("gone now"); + + let mut current = Report::new(); + current.section("S"); + current.warn("still here"); + current.critical("brand new"); + + let result = diff::compute(&previous, ¤t); + assert_eq!(result.previous_findings, 2); + assert_eq!(result.current_findings, 2); + assert_eq!(result.new_findings.len(), 1); + assert_eq!(result.new_findings[0].message, "brand new"); + assert_eq!(result.new_findings[0].severity, Severity::Critical); + assert_eq!(result.resolved_findings.len(), 1); + assert_eq!(result.resolved_findings[0].message, "gone now"); + assert_eq!(result.new_critical(), 1); + assert_eq!(result.new_warning(), 0); +} + +#[test] +fn test_diff_no_changes() { + let mut previous = Report::new(); + previous.warn("same finding"); + + let mut current = Report::new(); + current.warn("same finding"); + + let result = diff::compute(&previous, ¤t); + assert!(result.new_findings.is_empty()); + assert!(result.resolved_findings.is_empty()); + assert_eq!( + result.to_text().contains("No new findings since last scan"), + true + ); +} + +#[test] +fn test_diff_ignores_info_entries() { + let mut previous = Report::new(); + previous.log("info line"); + + let mut current = Report::new(); + current.log("info line changed"); + + let result = diff::compute(&previous, ¤t); + assert_eq!(result.new_findings.len(), 0); + assert_eq!(result.resolved_findings.len(), 0); +} + // ===== Config tests ===== #[test] From 8fd3ba05f026ccdc9f26e00f067635a6735668bb Mon Sep 17 00:00:00 2001 From: pd241008 <142252602+pd241008@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:38:22 +0530 Subject: [PATCH 4/5] docs: add example output, document severity and diff usage Adds an Example Output section with sample plain-text and JSON reports, documents severity levels and --diff workflow, and marks all roadmap items complete in README and PROGRESS. --- README.md | 79 +++++++++++++++++++++++++++++++++++++++++++++--- docs/PROGRESS.md | 32 +++++++++++++++----- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 09a81ed..5b52d91 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ triage scanner, written in Rust with minimal dependencies. - [What It Checks](#what-it-checks) - [Build](#build) - [Usage](#usage) +- [Example Output](#example-output) - [Configuration](#configuration) - [Adding a New Check](#adding-a-new-check) - [Adding a New Platform](#adding-a-new-platform) @@ -240,6 +241,8 @@ cargo build --release --target x86_64-pc-windows-gnu ./Sentrix --quick # skip the recent-file-modification pass ./Sentrix --out report.txt # write report to file ./Sentrix --json # output report as JSON +./Sentrix --json --out report.json # write JSON report (reuseable for --diff) +./Sentrix --diff report.json # compare against a previous JSON report ./Sentrix --config custom.toml # use custom detection patterns ``` @@ -247,8 +250,73 @@ cargo build --release --target x86_64-pc-windows-gnu - **Windows:** Run from an elevated (Administrator) terminal for full registry access. - **macOS/Linux:** `sudo` to access root-owned paths you'd otherwise miss. +### Severity Levels + +Findings carry a severity: **critical**, **warning**, or **info**. In plain +text, critical findings are prefixed `[CRIT]` and warnings `[!]`; info lines +are unmarked. The JSON output (`--json`) includes a structured `entries` array +sorted by urgency (critical → warning → info) plus per-level counts. + +### Comparing Scans (`--diff`) + +Run once saving a JSON report, then compare a later run against it to see only +what changed since the last scan: + +```bash +./Sentrix --json --out baseline.json # first run: save baseline +./Sentrix --diff baseline.json # later run: show new/resolved findings +./Sentrix --diff baseline.json --json # diff as JSON for pipelines +``` + +`--diff` exits with code `2` if new findings appeared since the baseline, `0` +otherwise. The exit-code contract still applies to normal runs: `2` when any +findings were flagged, `0` when clean. + --- +## Example Output + +A sample plain-text report (pathnames redacted) as it appears on Linux: + +``` +$ ./sentrix --quick +epoch:1785838014 + +== Suspicious process locations == +[!] PID 1831 is executing a deleted binary: /root/.opencode/bin/opencode (deleted) — common dropper/rootkit trick + +== Persistence (cron / systemd / shell rc) == +[CRIT] Reverse-shell pattern (/dev/tcp/) detected in /root/.bashrc +[!] Suspicious download-and-execute pattern in /root/.profile + +== Recently modified files (last 3 days) == +Recently modified: /etc/ld.so.cache +Recently modified: /etc/hosts +Recently modified: /etc/cron.d/sample +``` + +The same scan as JSON highlights the structured severity data: + +``` +$ ./sentrix --quick --json +{ + "findings": 2, + "severity_counts": { "info": 3, "warning": 1, "critical": 1 }, + "entries": [ + { + "severity": "Critical", + "message": "Reverse-shell pattern (/dev/tcp/) detected in /root/.bashrc", + "section": "Persistence (cron / systemd / shell rc)" + }, + { + "severity": "Warning", + "message": "PID 1831 is executing a deleted binary: ... (deleted)", + "section": "Suspicious process locations" + } + ] +} +``` + ## Configuration All tunable constants live in `src/config.rs` and can be overridden via a TOML configuration file. @@ -322,10 +390,11 @@ cargo test # run all tests cargo test -- --nocapture # show println! output ``` -**Current status:** `tests/integration.rs` is populated with 11 integration -tests covering `Report` behavior, config loading (valid, empty, malformed), -recent-files scanner, pattern constants, and config override flow. Unit tests -for `config_loader` are also present. Total: 14 tests passing. +**Current status:** `tests/integration.rs` is populated with 17 integration +tests covering `Report` behavior (severity markers, JSON round-trip, sorted +entries), config loading (valid, empty, malformed), the recent-files scanner, +pattern constants, config override flow, and `--diff` comparisons. Unit tests +for `config_loader` are also present. Total: 20 tests passing. --- @@ -344,7 +413,7 @@ for `config_loader` are also present. Total: 14 tests passing. | Priority | Item | Status | |----------|------|--------| | 1 | CI (`cargo build`/`test`/`clippy`/`fmt` on all 3 OSes) | ✅ Complete | -| 2 | Example output in README | Not started | +| 2 | Example output in README | ✅ Complete | | 3 | Windows/macOS parity (schtasks, launchctl, WMI) | ✅ Complete | | 4 | Configurable detection patterns (external TOML/YAML) | ✅ Complete | | 5 | Structured output (`--json`, severity levels) | ✅ Complete | diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 9c429a5..922acf9 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -45,10 +45,10 @@ and macos-latest with: ### 2. Example Output in README -**Status: Not started** +**Status: Complete** -No `## Example Output` section exists. Needs a sample terminal output -block showing what `./Sentrix` prints when run. +`## Example Output` section added with a sample plain-text report and a JSON +snippet showing structured severity data. ### 3. Windows / macOS Parity @@ -100,23 +100,39 @@ timestamp line. - Plain text via `report.join()` (default) - JSON via `report.to_json()` (`--json` flag) +**Severity levels (complete):** +- `Severity { Info, Warning, Critical }` with `log()`, `warn()`, `critical()` methods + (`flag()` kept as an alias for `warn()`) +- Plain text prefixes: `[CRIT]` for critical, `[!]` for warnings, unmarked info +- JSON includes `severity_counts` and an `entries` array sorted by urgency + (critical → warning → info) +- Findings count = warnings + criticals; exit code `2` when non-zero + +**`--diff` mode (complete):** +- `--diff FILE` compares the current scan against a previous JSON report +- Highlights new findings (critical/warning) since the baseline and resolved findings +- `--json --out baseline.json` writes a reusable baseline; `--diff` supports `--json` output +- Exit code `2` when new findings appeared since baseline, `0` otherwise + ### 6. Test Coverage -**Status: Complete (14 tests)** +**Status: Complete (20 tests)** - `config_loader` — 3 unit tests (valid config, empty config, invalid TOML) -- Integration tests — 11 tests covering: +- Integration tests — 17 tests covering: - Report behavior (timestamp, section, log, flag, JSON serialization) + - Severity markers/counts, JSON entries sorted by severity, JSON round-trip - Config loading with valid TOML and malformed input - Recent-files scanner - Pattern constants non-empty per platform - Config override flow preservation + - `--diff` computations (new/resolved findings, no-changes, info ignored) ### 7. Nice-to-Haves | Feature | Status | Notes | |---------|--------|-------| -| `--diff` mode | Not started | Compare two scan reports to highlight new findings since last run | -| Severity levels | Not started | `info`/`warn`/`critical` instead of flat `flag`/`log`, output sorted by urgency | -| Example output in README | Not started | Sample terminal output block | +| `--diff` mode | Complete | Compare two scan reports to highlight new findings since last run | +| Severity levels | Complete | `info`/`warn`/`critical` instead of flat `flag`/`log`, JSON output sorted by urgency | +| Example output in README | Complete | Sample terminal output block | | `CONTRIBUTING.md` | Complete | Contribution guide with PR checklist and style rules | From 8789cd7208fac9083c4c0b153c0525db54b8912f Mon Sep 17 00:00:00 2001 From: pd241008 <142252602+pd241008@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:44:48 +0530 Subject: [PATCH 5/5] fix: repair high-level flow mermaid diagram for GitHub rendering Edges referencing subgraph ids (LIB --> SCANNER, SCANNER --> PLATFORM) trigger the mermaid dagre-wrapper crash in older renderers (mermaid-js/mermaid#4644, fixed in v10.8). Rewrite the diagram so all edges connect concrete leaf nodes, keeping subgraphs as pure grouping containers. Verified rendering across mermaid v10.5.1, v10.9.1, and v11. --- README.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5b52d91..e586aa5 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,7 @@ surface indicators that may warrant manual investigation. ```mermaid flowchart TD - CLI["main.rs
CLI entry point"] - LIB["lib.rs
public API"] - CFG["config.rs
constants & paths"] - RPT["report.rs
Report struct"] - - CLI --> LIB - LIB --> SCANNER + CLI["main.rs
CLI entry point"] --> LIB["lib.rs
public API"] subgraph SCANNER["scanner/"] PROC["processes.rs"] @@ -75,11 +69,23 @@ flowchart TD MAC["macos.rs"] end - SCANNER --> PLATFORM - SCANNER --> CFG - SCANNER --> RPT - PLATFORM --> CFG - PLATFORM --> RPT + CFG["config.rs
constants & paths"] + RPT["report.rs
Report struct"] + + LIB --> PROC + LIB --> PERS + LIB --> RFIL + PROC --> CFG + PERS --> CFG + PROC --> RPT + PERS --> RPT + RFIL --> RPT + PROC --> LIN + PROC --> WIN + PROC --> MAC + PERS --> LIN + PERS --> WIN + PERS --> MAC ``` ### Module Dependency Graph