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
25 changes: 24 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,33 @@ jobs:
run: cargo fmt --check

- name: Run clippy
run: cargo clippy -- -D warnings
run: cargo clippy --all-targets -- -D warnings

- name: Run tests
run: cargo test

- name: Build release
run: cargo build --release

coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@stable

- name: Cache dependencies
uses: Swatinem/rust-cache@v2

- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov

- name: Generate coverage
run: cargo llvm-cov --workspace --lcov --output-path lcov.info

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: lcov.info
fail_ci_if_error: false
39 changes: 22 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,8 @@ cargo build --release
```

Output binary:
- Linux/macOS: `target/release/Sentrix`
- Windows: `target\release\Sentrix.exe`
- Linux/macOS: `target/release/sentrix`
- Windows: `target\release\sentrix.exe`

### Cross-Compilation

Expand All @@ -243,13 +243,13 @@ cargo build --release --target x86_64-pc-windows-gnu
## Usage

```
./Sentrix # full scan, prints to stdout
./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
./sentrix # full scan, prints to stdout
./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:**
Expand All @@ -269,9 +269,9 @@ 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
./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`
Expand All @@ -286,7 +286,7 @@ A sample plain-text report (pathnames redacted) as it appears on Linux:

```
$ ./sentrix --quick
epoch:1785838014
scan time: 2026-09-22T15:33:00Z (epoch:1785838014)

== Suspicious process locations ==
[!] PID 1831 is executing a deleted binary: /root/.opencode/bin/opencode (deleted) — common dropper/rootkit trick
Expand Down Expand Up @@ -337,6 +337,8 @@ All tunable constants live in `src/config.rs` and can be overridden via a TOML c
| `SUSPICIOUS_PLIST_PATTERNS` | macOS plist content patterns to flag | 4 patterns |
| `SUSPICIOUS_CRON_PATTERNS` | macOS crontab entry patterns to flag | 4 patterns |
| `SUSPICIOUS_LAUNCHCTL_OUTPUT` | macOS launchctl label patterns to flag | 6 patterns |
| `MACOS_NETWORK_EXT_PATTERNS` | macOS network/system extension name patterns to flag | 7 patterns |
| `MACOS_NETWORK_EXT_ALLOWLIST` | macOS extension identifier prefixes exempt from flagging | 21 prefixes |
| `SHELL_RC_FILES` | Linux shell rc files to inspect | `.bashrc`, `.profile` |
| `PERSISTENCE_SCAN_DIRS` | Linux dirs to scan for recent modifications | `/etc`, `/usr/local/bin` |

Expand Down Expand Up @@ -398,9 +400,12 @@ cargo test -- --nocapture # show println! output

**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.
entries), config loading (valid, empty, malformed), the recent-files scanner
(including nested directories and the depth cap), pattern constants, config
override flow, and `--diff` comparisons. Unit tests also cover `config_loader`,
`Report` rendering/round-trips, the ISO-8601 timestamp conversion, and the
Windows output helpers (UTF-16 decoding, quote-aware CSV). Total: 28 tests
passing. CI measures coverage with `cargo-llvm-cov` and uploads it to Codecov.

---

Expand All @@ -423,7 +428,7 @@ for `config_loader` are also present. Total: 20 tests passing.
| 3 | Windows/macOS parity (schtasks, launchctl, WMI) | ✅ Complete |
| 4 | Configurable detection patterns (external TOML/YAML) | ✅ Complete |
| 5 | Structured output (`--json`, severity levels) | ✅ Complete |
| 6 | Test coverage (unit tests, tarpaulin/grcov, badge) | ✅ Complete |
| 6 | Test coverage (unit tests, cargo-llvm-cov in CI, Codecov upload) | ✅ Complete |
| 7 | Nice-to-haves (`--diff`, `CONTRIBUTING.md`) | ✅ Complete |

See [docs/PROGRESS.md](docs/PROGRESS.md#roadmap-status) for detailed status,
Expand Down
30 changes: 12 additions & 18 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,34 +168,28 @@ The tradeoff is more code (e.g., manual timestamp formatting instead of

---

## Why `Report` Uses `Vec<String>` Instead of Structured Data?
## Why `Report` Stores Structured Entries, Not Rendered Strings

Findings are stored as formatted strings, not as structured enums/structs.

**Why (for now):** The original design was a simple text reporter. This
was kept for v0.1.0 to avoid over-engineering before the check set is
stable.

**Future improvement:** Once the check set stabilizes, `Report` should use
structured findings:
Findings are stored once, as structured `Entry` values (severity, message,
section), plus an ordered list of section titles. The plain-text view
(`join()`) and the JSON view (`to_json()`) are *derived* at output time from
that single source of truth, so the rendered text can never drift out of
sync with the entries it came from.

```rust
enum Severity { Info, Warning, Critical }

struct Finding {
struct Entry {
severity: Severity,
category: String,
section: String,
message: String,
source: String,
}
```

This enables JSON/SARIF output, filtering by severity, and programmatic
consumption. The current string-based approach is a placeholder.

> **Roadmap:** Structured output (`--json`) is tracked as priority #5 in
> [PROGRESS.md](PROGRESS.md#5-structured-output---json). The `Severity`
> enum and `Finding` struct shown above are the planned implementation.
consumption. (An earlier iteration stored pre-rendered `Vec<String>` lines
alongside the entries — duplicated state that could and did drift; it was
removed once the entry set stabilized.)

---

Expand Down Expand Up @@ -237,5 +231,5 @@ a triage scanner should be predictable and debuggable.
| `platform/` with `#[cfg]` | Clean compile-time platform dispatch |
| `scanner/` orchestration | Thin layer, easy to add/remove checks |
| Zero deps (except `winreg`) | Security, auditability, tiny binary |
| String-based `Report` | Simplicity for v0.1.0, structured later |
| Structured `Report` entries | Single source of truth; text/JSON derived |
| Linear scan flow | Predictable, debuggable, no concurrency bugs |
24 changes: 23 additions & 1 deletion docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ All three platforms have process metadata, persistence, and recent file detectio

**Note:** Neither Windows nor macOS can detect deleted-but-running binaries (Linux `/proc` advantage).

**Windows tooling notes:** `wmic` is deprecated/removed on recent Windows 11
builds, so the services, process, and WMI checks fall back to PowerShell CIM
(`Get-CimInstance` / `Get-ScheduledTask`) when `wmic` is unavailable. All
command output is decoded BOM-aware (wmic emits UTF-16LE), and CSV parsing is
quote-aware with header-based column lookup (`win_helpers.rs`), so paths
containing commas or non-ASCII text are handled correctly.

### 4. Configurable Detection Patterns

**Status: Complete (100%)**
Expand All @@ -85,6 +92,10 @@ All detection patterns can now be overridden via an external TOML configuration
- `--config path/to/config.toml` CLI flag
- Optional external TOML config file that overrides built-in defaults
- Support for all platform-specific patterns (Windows, macOS, Linux)
- macOS network-extension patterns and allowlist (`network_extension_patterns`,
`network_extension_allowlist`) — the allowlist prevents well-known vendors
(Apple, Microsoft, CrowdStrike, ...) from being flagged merely for using
standard reverse-DNS bundle IDs
- Uses `toml` + `serde` crates for robust parsing with proper error messages
- Example config file: `sentrix.example.toml`

Expand Down Expand Up @@ -116,9 +127,16 @@ timestamp line.

### 6. Test Coverage

**Status: Complete (20 tests)**
**Status: Complete (28 tests)**

- `config_loader` — 3 unit tests (valid config, empty config, invalid TOML)
- `report` — 6 unit tests (ISO-8601 timestamp conversion incl. leap-day and
century edge cases, timestamp line format, section rendering and merge
behavior, legacy-JSON round-trip for old `--diff` baselines, JSON lines)
- `scanner::recent_files` — 2 unit tests (nested subdirectories are scanned,
depth cap stops runaway walks)
- `platform::win_helpers` (Windows target) — 9 unit tests (UTF-16LE/BE and
BOM handling, quote-aware CSV, header-based CSV table lookup)
- Integration tests — 17 tests covering:
- Report behavior (timestamp, section, log, flag, JSON serialization)
- Severity markers/counts, JSON entries sorted by severity, JSON round-trip
Expand All @@ -128,6 +146,10 @@ timestamp line.
- Config override flow preservation
- `--diff` computations (new/resolved findings, no-changes, info ignored)

**Coverage measurement:** CI runs `cargo llvm-cov` on ubuntu-latest and
uploads the LCOV report to Codecov (`coverage` job in `.github/workflows/ci.yml`).
Add a repo badge linking to the Codecov page once enabled there.

### 7. Nice-to-Haves

| Feature | Status | Notes |
Expand Down
31 changes: 31 additions & 0 deletions sentrix.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,37 @@ wmi_event_consumer_patterns = [
suspicious_plist_patterns = ["curl", "wget", "/tmp/", "base64"]
suspicious_cron_patterns = ["curl", "wget", "base64", "/tmp/"]
suspicious_launchctl_output = ["curl", "wget", "/tmp/", "base64", "mshta", "powershell"]

# Network/system extension name patterns that trigger a flag.
network_extension_patterns = ["filter", "proxy", "dns", "vpn", "firewall", "monitor", "capture"]

# Identifier prefixes that exempt an extension from flagging. Without an
# allowlist, standard macOS bundle IDs (almost all starting with "com.")
# would flood the report with false positives.
network_extension_allowlist = [
"com.apple.",
"com.cisco.",
"com.crowdstrike.",
"com.dtna.",
"com.egnyte.",
"com.google.",
"com.cloudflare.",
"com.jamf.",
"com.kandji.",
"com.malwarebytes.",
"com.microsoft.",
"com.netskope.",
"com.paloaltonetworks.",
"com.sentinelone.",
"com.sophos.",
"com.1e.",
"com.1password.",
"com.zscaler.",
"org.mozilla.",
"ch.protonvpn.",
"net.tunnelblick.",
]

shell_rc_files = [".zshrc", ".bash_profile", ".zprofile"]

# Linux-specific settings
Expand Down
34 changes: 34 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,40 @@ pub const MACOS_KEXT_SCAN_DIRS: &[&str] = &["/Library/Extensions", "/System/Libr
pub const MACOS_NETWORK_EXTENSION_DIRS: &[&str] =
&["/Library/SystemExtensions", "/Library/NetworkExtensions"];

#[cfg(target_os = "macos")]
pub const MACOS_NETWORK_EXT_PATTERNS: &[&str] = &[
"filter", "proxy", "dns", "vpn", "firewall", "monitor", "capture",
];

/// Identifier prefixes (bundle IDs, vendor names) of well-known legitimate
/// network/system extensions. Matching entries are logged, not flagged.
/// The old heuristic flagged anything containing "com.", which matches
/// virtually every macOS bundle ID and produced mass false positives.
#[cfg(target_os = "macos")]
pub const MACOS_NETWORK_EXT_ALLOWLIST: &[&str] = &[
"com.apple.",
"com.cisco.",
"com.crowdstrike.",
"com.dtna.",
"com.egnyte.",
"com.google.",
"com.cloudflare.",
"com.jamf.",
"com.kandji.",
"com.malwarebytes.",
"com.microsoft.",
"com.netskope.",
"com.paloaltonetworks.",
"com.sentinelone.",
"com.sophos.",
"com.1e.",
"com.1password.",
"com.zscaler.",
"org.mozilla.",
"ch.protonvpn.",
"net.tunnelblick.",
];

#[cfg(target_os = "windows")]
pub const SUSPICIOUS_SERVICE_PATTERNS: &[&str] = &[
"\\temp\\",
Expand Down
5 changes: 5 additions & 0 deletions src/config_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ pub struct PlatformConfig {
pub suspicious_plist_patterns: Option<Vec<String>>,
pub suspicious_cron_patterns: Option<Vec<String>>,
pub suspicious_launchctl_output: Option<Vec<String>>,
/// macOS only: name patterns that flag a network/system extension.
pub network_extension_patterns: Option<Vec<String>>,
/// macOS only: identifier prefixes that exempt a network/system extension
/// from flagging (e.g. "com.apple.", "com.microsoft.").
pub network_extension_allowlist: Option<Vec<String>>,
pub shell_rc_files: Option<Vec<String>>,
pub persistence_scan_dirs: Option<Vec<String>>,
}
Expand Down
12 changes: 6 additions & 6 deletions src/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,35 +72,35 @@ fn key(e: &Entry) -> (String, String) {

pub fn compute(previous: &Report, current: &Report) -> DiffResult {
let prev: BTreeSet<(String, String)> = previous
.entries
.entries()
.iter()
.filter(|e| e.severity != Severity::Info)
.map(key)
.collect();
let cur: BTreeSet<(String, String)> = current
.entries
.entries()
.iter()
.filter(|e| e.severity != Severity::Info)
.map(key)
.collect();

let new_findings: Vec<Entry> = current
.entries
.entries()
.iter()
.filter(|e| e.severity != Severity::Info && !prev.contains(&key(e)))
.cloned()
.collect();

let resolved_findings: Vec<Entry> = previous
.entries
.entries()
.iter()
.filter(|e| e.severity != Severity::Info && !cur.contains(&key(e)))
.cloned()
.collect();

DiffResult {
previous_findings: previous.findings,
current_findings: current.findings,
previous_findings: previous.findings(),
current_findings: current.findings(),
new_findings,
resolved_findings,
}
Expand Down
8 changes: 6 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,18 @@ fn main() {
eprintln!("error: could not write report to {}: {}", path, e);
std::process::exit(1);
}
eprintln!("report written to {} ({} findings)", path, report.findings);
eprintln!(
"report written to {} ({} findings)",
path,
report.findings()
);
} else if cli.json {
println!("{}", report.to_json());
} else {
print!("{}", report.join());
}

if report.findings > 0 {
if report.findings() > 0 {
std::process::exit(2);
}
}
Expand Down
Loading
Loading