Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 92 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -54,13 +55,7 @@ surface indicators that may warrant manual investigation.

```mermaid
flowchart TD
CLI["main.rs<br/>CLI entry point"]
LIB["lib.rs<br/>public API"]
CFG["config.rs<br/>constants & paths"]
RPT["report.rs<br/>Report struct"]

CLI --> LIB
LIB --> SCANNER
CLI["main.rs<br/>CLI entry point"] --> LIB["lib.rs<br/>public API"]

subgraph SCANNER["scanner/"]
PROC["processes.rs"]
Expand All @@ -74,11 +69,23 @@ flowchart TD
MAC["macos.rs"]
end

SCANNER --> PLATFORM
SCANNER --> CFG
SCANNER --> RPT
PLATFORM --> CFG
PLATFORM --> RPT
CFG["config.rs<br/>constants & paths"]
RPT["report.rs<br/>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
Expand Down Expand Up @@ -240,15 +247,82 @@ 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
```

**Privileges:**
- **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.
Expand Down Expand Up @@ -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.

---

Expand All @@ -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 |
Expand Down
32 changes: 24 additions & 8 deletions docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
107 changes: 107 additions & 0 deletions src/diff.rs
Original file line number Diff line number Diff line change
@@ -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<Entry>,
pub resolved_findings: Vec<Entry>,
}

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<Entry> = current
.entries
.iter()
.filter(|e| e.severity != Severity::Info && !prev.contains(&key(e)))
.cloned()
.collect();

let resolved_findings: Vec<Entry> = 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,
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod config;
pub mod config_loader;
pub mod diff;
pub mod platform;
pub mod report;
pub mod scanner;
Expand Down
Loading
Loading