diff --git a/README.md b/README.md
index 09a81ed..e586aa5 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)
@@ -54,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"]
@@ -74,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
@@ -240,6 +247,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 +256,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 +396,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 +419,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 |
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))
+}
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),
}
}
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]