diff --git a/.copilot-instructions.md b/.copilot-instructions.md index 88f9eeb..ce53a6f 100644 --- a/.copilot-instructions.md +++ b/.copilot-instructions.md @@ -4,7 +4,8 @@ You are an expert Rust developer working on the `HaH` project. Follow these proj ## Project Architecture - `hah-core`: The base logic, models, and rule engine. -- `hah-checks`: Implementations of specific system checks (Apt, Boot, Network, etc.). +- `hah-dsl`: YAML rule engine, pipeline evaluator, and capability functions. +- `hah-utils`: Low-level shared utilities and library facades. - `hah`: The CLI entry point. ## Coding Patterns diff --git a/Cargo.toml b/Cargo.toml index 4888650..01b751b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,8 @@ members = [ "crates/hah", "crates/hah-core", + "crates/hah-caps", "crates/hah-dsl", - "crates/hah-checks", "crates/hah-utils", ] resolver = "2" diff --git a/crates/hah-checks/Cargo.toml b/crates/hah-caps/Cargo.toml similarity index 77% rename from crates/hah-checks/Cargo.toml rename to crates/hah-caps/Cargo.toml index 637ab8a..b842285 100644 --- a/crates/hah-checks/Cargo.toml +++ b/crates/hah-caps/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "hah-checks" +name = "hah-caps" version = "0.1.0" edition = "2024" @@ -12,6 +12,7 @@ hah-utils = { path = "../hah-utils" } anyhow = "1" [dev-dependencies] +hah-core = { path = "../hah-core", features = ["mock"] } mockall = { workspace = true } tempfile = "3" filetime = "0.2" diff --git a/crates/hah-caps/src/apt.rs b/crates/hah-caps/src/apt.rs new file mode 100644 index 0000000..2ec09d1 --- /dev/null +++ b/crates/hah-caps/src/apt.rs @@ -0,0 +1,98 @@ +//! APT package capabilities: installed denylist. + +use std::collections::HashSet; + +use anyhow::{Result, anyhow}; +use hah_core::{config::DenylistEntry, runner::CommandRunner}; + +use crate::CapValue; + +/// Return a list of `"name|reason"` strings for denylist packages that are +/// currently installed. +pub fn installed_denylist( + runner: &dyn CommandRunner, + packages: &[DenylistEntry], +) -> Result { + if packages.is_empty() { + return Ok(CapValue::List(vec![])); + } + let out = runner + .run("dpkg-query", &["-W", "-f=${Package}\n"]) + .map_err(|e| anyhow!("dpkg-query: {e}"))?; + + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let installed: HashSet<&str> = stdout.lines().collect(); + + let matches: Vec = packages + .iter() + .filter(|p| installed.contains(p.name.as_str())) + .map(|p| format!("{}|{}", p.name, p.reason)) + .collect(); + + Ok(CapValue::List(matches)) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use hah_core::runner::{CommandOutput, MockCommandRunner}; + use std::io; + + fn ok_out(stdout: &str) -> io::Result { + Ok(CommandOutput { + stdout: stdout.as_bytes().to_vec(), + stderr: vec![], + success: true, + }) + } + + #[test] + fn empty_packages_returns_empty() { + let mock = MockCommandRunner::new(); + let result = installed_denylist(&mock, &[]).unwrap(); + assert_eq!(result, CapValue::List(vec![])); + } + + #[test] + fn finds_matching_package() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| ok_out("bash\nbad-pkg\nvim\n")); + let packages = vec![DenylistEntry { + name: "bad-pkg".into(), + reason: "insecure".into(), + }]; + let result = installed_denylist(&mock, &packages).unwrap(); + let CapValue::List(items) = result else { + panic!("expected list"); + }; + assert_eq!(items.len(), 1); + assert!(items[0].contains("bad-pkg")); + assert!(items[0].contains("insecure")); + } + + #[test] + fn no_match_returns_empty() { + let mut mock = MockCommandRunner::new(); + mock.expect_run().returning(|_, _| ok_out("bash\nvim\n")); + let packages = vec![DenylistEntry { + name: "bad-pkg".into(), + reason: "insecure".into(), + }]; + let result = installed_denylist(&mock, &packages).unwrap(); + assert_eq!(result, CapValue::List(vec![])); + } + + #[test] + fn propagates_error() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| Err(io::Error::new(io::ErrorKind::NotFound, "not found"))); + let packages = vec![DenylistEntry { + name: "x".into(), + reason: "y".into(), + }]; + assert!(installed_denylist(&mock, &packages).is_err()); + } +} diff --git a/crates/hah-caps/src/files.rs b/crates/hah-caps/src/files.rs new file mode 100644 index 0000000..284b379 --- /dev/null +++ b/crates/hah-caps/src/files.rs @@ -0,0 +1,244 @@ +//! Filesystem scan capabilities: old files and broken symlinks. + +use std::path::Path; + +use anyhow::Result; + +use crate::CapValue; + +const DEFAULT_CRASH_DIRS: &[&str] = &["/var/crash", "/var/lib/systemd/coredump"]; +const DEFAULT_SYMLINK_DIRS: &[&str] = &["/etc", "/usr/lib", "/var/lib"]; + +fn effective_dirs<'a>(dirs: &'a [String], defaults: &'a [&'a str]) -> Vec<&'a str> { + if dirs.is_empty() { + defaults.to_vec() + } else { + dirs.iter().map(String::as_str).collect() + } +} + +/// Return a list of file paths that have not been modified for at least +/// `older_than_days` days. +/// +/// Scans `/var/crash` and `/var/lib/systemd/coredump` when `dirs` is empty. +pub fn old_files(dirs: &[String], older_than_days: u64) -> Result { + let effective = effective_dirs(dirs, DEFAULT_CRASH_DIRS); + let files: Vec = hah_utils::fs::scan_old_files(&effective, older_than_days) + .into_iter() + .map(|f| f.path.to_string_lossy().into_owned()) + .collect(); + Ok(CapValue::List(files)) +} + +/// Return a list of paths that are broken symbolic links. +/// +/// Scans `/etc`, `/usr/lib`, and `/var/lib` when `dirs` is empty. +pub fn broken_symlinks(dirs: &[String]) -> Result { + let effective = effective_dirs(dirs, DEFAULT_SYMLINK_DIRS); + let broken: Vec = hah_utils::fs::broken_symlinks(&effective) + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + Ok(CapValue::List(broken)) +} + +/// Return a list of file paths using the legacy one-line `deb`/`deb-src` +/// APT source format. +/// +/// Checks `/etc/apt/sources.list` and all `*.list` files in +/// `/etc/apt/sources.list.d/`. +pub fn legacy_apt_sources() -> Result { + collect_legacy_sources( + Path::new("/etc/apt/sources.list"), + Path::new("/etc/apt/sources.list.d"), + ) +} + +pub(crate) fn collect_legacy_sources(sources_list: &Path, sources_d: &Path) -> Result { + use std::fs; + + let mut legacy: Vec = Vec::new(); + + if sources_list.exists() + && fs::read_to_string(sources_list).is_ok_and(|content| { + content + .lines() + .any(|l| l.starts_with("deb ") || l.starts_with("deb-src ")) + }) + { + legacy.push(sources_list.to_string_lossy().into_owned()); + } + + if let Ok(entries) = fs::read_dir(sources_d) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("list") + && fs::read_to_string(&path).is_ok_and(|content| { + content + .lines() + .any(|l| l.starts_with("deb ") || l.starts_with("deb-src ")) + }) + { + legacy.push(path.to_string_lossy().into_owned()); + } + } + } + + Ok(CapValue::List(legacy)) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use std::time::{Duration, SystemTime}; + use tempfile::TempDir; + + // ── old_files ───────────────────────────────────────────────────────────── + + #[test] + fn old_files_empty_dir_returns_empty_list() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let result = old_files(&[dir], 30).unwrap(); + assert_eq!(result, CapValue::List(vec![])); + } + + #[test] + fn old_files_nonexistent_dir_returns_empty_list() { + let result = old_files(&["/nonexistent/path/xyz".to_string()], 30).unwrap(); + assert_eq!(result, CapValue::List(vec![])); + } + + #[test] + fn old_files_recent_file_not_included() { + let tmp = TempDir::new().unwrap(); + let file = tmp.path().join("recent.log"); + std::fs::write(&file, b"data").unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let result = old_files(&[dir], 30).unwrap(); + assert_eq!(result, CapValue::List(vec![])); + } + + #[test] + fn old_files_old_file_included() { + use filetime::{FileTime, set_file_mtime}; + let tmp = TempDir::new().unwrap(); + let file = tmp.path().join("old.log"); + std::fs::write(&file, b"data").unwrap(); + let old_time = SystemTime::now() + .checked_sub(Duration::from_secs(60 * 86_400)) + .unwrap(); + set_file_mtime(&file, FileTime::from_system_time(old_time)).unwrap(); + + let dir = tmp.path().to_string_lossy().to_string(); + let result = old_files(&[dir], 30).unwrap(); + let CapValue::List(items) = result else { + panic!("expected list"); + }; + assert_eq!(items.len(), 1); + assert!(items[0].contains("old.log")); + } + + #[test] + fn old_files_uses_default_dirs_when_empty() { + let result = old_files(&[], 30); + assert!(result.is_ok()); + } + + // ── broken_symlinks ─────────────────────────────────────────────────────── + + #[test] + fn broken_symlinks_empty_dir_returns_empty_list() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let result = broken_symlinks(&[dir]).unwrap(); + assert_eq!(result, CapValue::List(vec![])); + } + + #[test] + fn broken_symlinks_detects_dangling_symlink() { + let tmp = TempDir::new().unwrap(); + let link = tmp.path().join("dangling"); + std::os::unix::fs::symlink("/nonexistent/target", &link).unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let result = broken_symlinks(&[dir]).unwrap(); + let CapValue::List(items) = result else { + panic!("expected list"); + }; + assert_eq!(items.len(), 1); + assert!(items[0].contains("dangling")); + } + + #[test] + fn broken_symlinks_valid_symlink_not_included() { + let tmp = TempDir::new().unwrap(); + let target = tmp.path().join("target.txt"); + std::fs::write(&target, b"x").unwrap(); + let link = tmp.path().join("valid_link"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let result = broken_symlinks(&[dir]).unwrap(); + assert_eq!(result, CapValue::List(vec![])); + } + + #[test] + fn broken_symlinks_uses_default_dirs_when_empty() { + let result = broken_symlinks(&[]); + assert!(result.is_ok()); + } + + // ── legacy_apt_sources ──────────────────────────────────────────────────── + + #[test] + fn legacy_apt_sources_returns_ok() { + let result = legacy_apt_sources(); + assert!(result.is_ok()); + } + + #[test] + fn collect_legacy_sources_detects_deb_line() { + let tmp = TempDir::new().unwrap(); + let list = tmp.path().join("sources.list"); + std::fs::write(&list, "deb http://archive.ubuntu.com/ubuntu focal main\n").unwrap(); + let d = tmp.path().join("sources.list.d"); + std::fs::create_dir_all(&d).unwrap(); + let result = collect_legacy_sources(&list, &d).unwrap(); + let CapValue::List(items) = result else { + panic!("expected list"); + }; + assert_eq!(items.len(), 1); + } + + #[test] + fn collect_legacy_sources_detects_list_files_in_dir() { + let tmp = TempDir::new().unwrap(); + let list = tmp.path().join("sources.list"); + std::fs::write(&list, "# no deb lines\n").unwrap(); + let d = tmp.path().join("sources.list.d"); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write( + d.join("extra.list"), + "deb-src http://example.com/ stable main\n", + ) + .unwrap(); + std::fs::write(d.join("modern.sources"), "Types: deb\n").unwrap(); + let result = collect_legacy_sources(&list, &d).unwrap(); + let CapValue::List(items) = result else { + panic!("expected list"); + }; + assert_eq!(items.len(), 1); + assert!(items[0].contains("extra.list")); + } + + #[test] + fn collect_legacy_sources_empty_when_no_deb_lines() { + let tmp = TempDir::new().unwrap(); + let list = tmp.path().join("sources.list"); + std::fs::write(&list, "# comment only\n").unwrap(); + let d = tmp.path().join("sources.list.d"); + std::fs::create_dir_all(&d).unwrap(); + let result = collect_legacy_sources(&list, &d).unwrap(); + assert_eq!(result, CapValue::List(vec![])); + } +} diff --git a/crates/hah-caps/src/initramfs.rs b/crates/hah-caps/src/initramfs.rs new file mode 100644 index 0000000..c1658d4 --- /dev/null +++ b/crates/hah-caps/src/initramfs.rs @@ -0,0 +1,30 @@ +//! Large initramfs detection capability. + +use std::fs; + +use anyhow::{Result, anyhow}; + +use crate::CapValue; + +/// Return a list of `"filename size_mb"` strings for initramfs images in +/// `/boot` that exceed `threshold_mb`. +pub fn large_initramfs(threshold_mb: u64) -> Result { + let threshold_bytes = threshold_mb * 1024 * 1024; + let entries = fs::read_dir("/boot").map_err(|e| anyhow!("read_dir /boot: {e}"))?; + let mut large = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy().into_owned(); + if !name_str.starts_with("initrd.img-") && !name_str.starts_with("initramfs-") { + continue; + } + if let Ok(meta) = entry.metadata() { + let size = meta.len(); + if size > threshold_bytes { + let size_mb = size / 1024 / 1024; + large.push(format!("{name_str} {size_mb}")); + } + } + } + Ok(CapValue::List(large)) +} diff --git a/crates/hah-caps/src/journal.rs b/crates/hah-caps/src/journal.rs new file mode 100644 index 0000000..389acc4 --- /dev/null +++ b/crates/hah-caps/src/journal.rs @@ -0,0 +1,99 @@ +//! Journal disk usage capability. + +use anyhow::{Result, anyhow}; +use hah_core::runner::CommandRunner; + +use crate::CapValue; + +/// Return the total systemd journal disk usage in megabytes. +/// +/// Returns `Int(0)` when the output cannot be parsed. +pub fn journal_usage_mb(runner: &dyn CommandRunner) -> Result { + let out = runner + .run("journalctl", &["--disk-usage"]) + .map_err(|e| anyhow!("journalctl: {e}"))?; + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let bytes = hah_utils::size::parse_journal_disk_usage(&stdout).unwrap_or(0); + Ok(CapValue::Int((bytes / 1_000_000) as i64)) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use hah_core::runner::{CommandOutput, MockCommandRunner}; + use std::io; + + fn ok_out(stdout: &str) -> io::Result { + Ok(CommandOutput { + stdout: stdout.as_bytes().to_vec(), + stderr: vec![], + success: true, + }) + } + + #[test] + fn parses_correctly() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| ok_out("Archived and active journals take up 600.0M.\n")); + let result = journal_usage_mb(&mock).unwrap(); + assert_eq!(result, CapValue::Int(600)); + } + + #[test] + fn returns_zero_on_unparseable_output() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| ok_out("something unexpected\n")); + let result = journal_usage_mb(&mock).unwrap(); + assert_eq!(result, CapValue::Int(0)); + } + + #[test] + fn propagates_command_error() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| Err(io::Error::new(io::ErrorKind::NotFound, "not found"))); + assert!(journal_usage_mb(&mock).is_err()); + } + + #[test] + fn parse_journal_gigabytes() { + assert_eq!( + hah_utils::size::parse_journal_disk_usage( + "Archived and active journals take up 1.5G in the file system." + ), + Some(1_500_000_000) + ); + } + + #[test] + fn parse_journal_megabytes() { + assert_eq!( + hah_utils::size::parse_journal_disk_usage( + "Archived and active journals take up 512.0M." + ), + Some(512_000_000) + ); + } + + #[test] + fn parse_journal_kilobytes() { + assert_eq!( + hah_utils::size::parse_journal_disk_usage( + "Archived and active journals take up 256K in the file system." + ), + Some(256_000) + ); + } + + #[test] + fn parse_journal_unrecognized_returns_none() { + assert_eq!( + hah_utils::size::parse_journal_disk_usage("no match here"), + None + ); + assert_eq!(hah_utils::size::parse_bytes("42XB"), None); + } +} diff --git a/crates/hah-caps/src/kernel.rs b/crates/hah-caps/src/kernel.rs new file mode 100644 index 0000000..9e81e68 --- /dev/null +++ b/crates/hah-caps/src/kernel.rs @@ -0,0 +1,163 @@ +//! Kernel package capabilities: unused kernels and stale headers. + +use anyhow::{Result, anyhow}; +use hah_core::runner::CommandRunner; + +use crate::CapValue; + +/// Return a list of installed `linux-image-*` package names that do **not** +/// contain the currently running kernel version string. +pub fn kernel_inventory(runner: &dyn CommandRunner) -> Result { + let out = runner + .run("uname", &["-r"]) + .map_err(|e| anyhow!("uname: {e}"))?; + let running = String::from_utf8_lossy(&out.stdout).trim().to_string(); + + let out = runner + .run( + "dpkg-query", + &["--show", "--showformat=${Package}\n", "linux-image-*"], + ) + .map_err(|e| anyhow!("dpkg-query (kernels): {e}"))?; + + let unused: Vec = String::from_utf8_lossy(&out.stdout) + .lines() + .filter(|pkg| !pkg.is_empty() && !pkg.contains(running.as_str())) + .map(str::to_string) + .collect(); + + Ok(CapValue::List(unused)) +} + +/// Return a list of `linux-headers-*` packages whose version string has no +/// matching `linux-image-*` package installed. +/// +/// Meta-packages (e.g., `linux-headers-generic`) that have no numeric version +/// suffix are skipped. +pub fn stale_kernel_headers(runner: &dyn CommandRunner) -> Result { + let out_headers = runner + .run( + "dpkg-query", + &["--show", "--showformat=${Package}\n", "linux-headers-*"], + ) + .map_err(|e| anyhow!("dpkg-query (headers): {e}"))?; + + let out_kernels = runner + .run( + "dpkg-query", + &["--show", "--showformat=${Package}\n", "linux-image-*"], + ) + .map_err(|e| anyhow!("dpkg-query (kernels): {e}"))?; + let kernels: Vec = String::from_utf8_lossy(&out_kernels.stdout) + .lines() + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect(); + + let stale: Vec = String::from_utf8_lossy(&out_headers.stdout) + .lines() + .filter(|l| !l.is_empty()) + .map(str::to_string) + .filter(|hdr| { + let version = hdr.trim_start_matches("linux-headers-"); + version.chars().next().is_some_and(char::is_numeric) + && !kernels.iter().any(|k| k.contains(version)) + }) + .collect(); + + Ok(CapValue::List(stale)) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use hah_core::runner::{CommandOutput, MockCommandRunner}; + use std::io; + + fn ok_out(stdout: &str) -> io::Result { + Ok(CommandOutput { + stdout: stdout.as_bytes().to_vec(), + stderr: vec![], + success: true, + }) + } + + #[test] + fn kernel_inventory_excludes_running_kernel() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .withf(|prog, _| *prog == *"uname") + .returning(|_, _| ok_out("6.5.0-35-generic\n")); + mock.expect_run() + .withf(|prog, _| *prog == *"dpkg-query") + .returning(|_, _| { + ok_out("linux-image-6.5.0-35-generic\nlinux-image-6.5.0-27-generic\nlinux-image-6.5.0-28-generic\n") + }); + let result = kernel_inventory(&mock).unwrap(); + let CapValue::List(items) = result else { + panic!("expected list"); + }; + assert_eq!(items.len(), 2); + assert!(!items.iter().any(|i| i.contains("35"))); + } + + #[test] + fn kernel_inventory_all_match_running() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .withf(|prog, _| *prog == *"uname") + .returning(|_, _| ok_out("6.5.0-35-generic\n")); + mock.expect_run() + .withf(|prog, _| *prog == *"dpkg-query") + .returning(|_, _| ok_out("linux-image-6.5.0-35-generic\n")); + let result = kernel_inventory(&mock).unwrap(); + assert_eq!(result, CapValue::List(vec![])); + } + + #[test] + fn kernel_inventory_propagates_uname_error() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| Err(io::Error::new(io::ErrorKind::NotFound, "not found"))); + assert!(kernel_inventory(&mock).is_err()); + } + + #[test] + fn stale_kernel_headers_detects_stale() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .withf(|_, args| args.contains(&"linux-headers-*")) + .returning(|_, _| ok_out("linux-headers-6.5.0-27-generic\nlinux-headers-generic\n")); + mock.expect_run() + .withf(|_, args| args.contains(&"linux-image-*")) + .returning(|_, _| ok_out("linux-image-6.5.0-35-generic\n")); + let result = stale_kernel_headers(&mock).unwrap(); + let CapValue::List(items) = result else { + panic!("expected list"); + }; + assert_eq!(items.len(), 1); + assert!(items[0].contains("6.5.0-27")); + } + + #[test] + fn stale_kernel_headers_skips_meta_packages() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .withf(|_, args| args.contains(&"linux-headers-*")) + .returning(|_, _| ok_out("linux-headers-generic\n")); + mock.expect_run() + .withf(|_, args| args.contains(&"linux-image-*")) + .returning(|_, _| ok_out("linux-image-6.5.0-35-generic\n")); + let result = stale_kernel_headers(&mock).unwrap(); + assert_eq!(result, CapValue::List(vec![])); + } + + #[test] + fn stale_kernel_headers_propagates_error() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| Err(io::Error::new(io::ErrorKind::NotFound, "not found"))); + assert!(stale_kernel_headers(&mock).is_err()); + } +} diff --git a/crates/hah-caps/src/lib.rs b/crates/hah-caps/src/lib.rs new file mode 100644 index 0000000..c34d3de --- /dev/null +++ b/crates/hah-caps/src/lib.rs @@ -0,0 +1,39 @@ +//! Capability functions for the HaH rule engine. +//! +//! Each capability gathers system data (via commands, filesystem scans, etc.) +//! and returns a [`CapValue`] — a simple typed result that the DSL engine +//! converts into its internal pipeline value. +//! +//! Capabilities are split into modules by domain: +//! +//! | Module | Capabilities | +//! |--------------|-------------------------------------------------------| +//! | [`journal`] | `journal_usage_mb` — systemd journal disk usage | +//! | [`files`] | `old_files`, `broken_symlinks` — filesystem scans | +//! | [`sysctl`] | `sysctl_conflicts` — sysctl.d key conflicts | +//! | [`kernel`] | `kernel_inventory`, `stale_kernel_headers` | +//! | [`initramfs`]| `large_initramfs` — oversized initramfs images | +//! | [`apt`] | `legacy_apt_sources`, `installed_denylist` | +//! | [`network`] | `legacy_network_interfaces` — ifupdown overlap | + +pub mod apt; +pub mod files; +pub mod initramfs; +pub mod journal; +pub mod kernel; +pub mod network; +pub mod sysctl; + +/// A typed value returned by capability functions. +/// +/// Deliberately simple and independent of the DSL's internal `RuleValue`. +/// The DSL engine converts `CapValue` into its own pipeline type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CapValue { + /// A single integer (e.g., megabytes of disk usage). + Int(i64), + /// A single string (e.g., a status description). + Str(String), + /// A list of strings (e.g., file paths, package names). + List(Vec), +} diff --git a/crates/hah-caps/src/network.rs b/crates/hah-caps/src/network.rs new file mode 100644 index 0000000..51477c6 --- /dev/null +++ b/crates/hah-caps/src/network.rs @@ -0,0 +1,158 @@ +//! Legacy network interfaces detection capability. + +use std::{fs, path::Path}; + +use anyhow::{Result, anyhow}; +use hah_core::runner::CommandRunner; + +use crate::CapValue; + +/// Return a status string describing legacy `/etc/network/interfaces` state. +/// +/// Returns: +/// - `""` (empty) when no non-loopback entries exist or file is absent +/// - `"overlap::"` when a modern manager is also active +/// - `"legacy:"` when only ifupdown is in use +pub fn legacy_network_interfaces(runner: &dyn CommandRunner) -> Result { + evaluate_network_interfaces( + Path::new("/etc/network/interfaces"), + Path::new("/etc/netplan"), + runner, + ) +} + +pub(crate) fn evaluate_network_interfaces( + interfaces_path: &Path, + netplan_dir: &Path, + runner: &dyn CommandRunner, +) -> Result { + if !interfaces_path.exists() { + return Ok(CapValue::Str(String::new())); + } + let content = fs::read_to_string(interfaces_path) + .map_err(|e| anyhow!("{}: {e}", interfaces_path.display()))?; + + let non_lo_count = content + .lines() + .filter(|l| { + let t = l.trim(); + (t.starts_with("iface ") && !t.starts_with("iface lo ")) + || (t.starts_with("auto ") && t.trim_start_matches("auto").trim() != "lo") + }) + .count(); + + if non_lo_count == 0 { + return Ok(CapValue::Str(String::new())); + } + + let netplan_active = + netplan_dir.exists() && fs::read_dir(netplan_dir).is_ok_and(|mut d| d.next().is_some()); + let nm_active = runner + .run("systemctl", &["is-active", "--quiet", "NetworkManager"]) + .is_ok_and(|o| o.success); + + if netplan_active || nm_active { + let managers = match (netplan_active, nm_active) { + (true, true) => "Netplan and NetworkManager", + (true, false) => "Netplan", + _ => "NetworkManager", + }; + Ok(CapValue::Str(format!("overlap:{non_lo_count}:{managers}"))) + } else { + Ok(CapValue::Str(format!("legacy:{non_lo_count}"))) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use hah_core::runner::{CommandOutput, MockCommandRunner}; + use tempfile::TempDir; + + #[test] + fn returns_ok_on_real_system() { + let mock = MockCommandRunner::new(); + let result = legacy_network_interfaces(&mock); + assert!(result.is_ok()); + } + + #[test] + fn absent_returns_empty() { + let mock = MockCommandRunner::new(); + let result = evaluate_network_interfaces( + Path::new("/nonexistent"), + Path::new("/nonexistent"), + &mock, + ) + .unwrap(); + assert_eq!(result, CapValue::Str(String::new())); + } + + #[test] + fn loopback_only_returns_empty() { + let tmp = TempDir::new().unwrap(); + let ifaces = tmp.path().join("interfaces"); + std::fs::write(&ifaces, "auto lo\niface lo inet loopback\n").unwrap(); + let mock = MockCommandRunner::new(); + let result = + evaluate_network_interfaces(&ifaces, Path::new("/nonexistent"), &mock).unwrap(); + assert_eq!(result, CapValue::Str(String::new())); + } + + #[test] + fn legacy_no_manager() { + let tmp = TempDir::new().unwrap(); + let ifaces = tmp.path().join("interfaces"); + std::fs::write(&ifaces, "auto eth0\niface eth0 inet dhcp\n").unwrap(); + let mut mock = MockCommandRunner::new(); + mock.expect_run().returning(|_, _| { + Ok(CommandOutput { + stdout: vec![], + stderr: vec![], + success: false, + }) + }); + let result = + evaluate_network_interfaces(&ifaces, Path::new("/nonexistent"), &mock).unwrap(); + assert_eq!(result, CapValue::Str("legacy:2".into())); + } + + #[test] + fn overlap_with_netplan() { + let tmp = TempDir::new().unwrap(); + let ifaces = tmp.path().join("interfaces"); + std::fs::write(&ifaces, "auto eth0\niface eth0 inet dhcp\n").unwrap(); + let netplan = tmp.path().join("netplan"); + std::fs::create_dir_all(&netplan).unwrap(); + std::fs::write(netplan.join("01-config.yaml"), "network:\n").unwrap(); + let mut mock = MockCommandRunner::new(); + mock.expect_run().returning(|_, _| { + Ok(CommandOutput { + stdout: vec![], + stderr: vec![], + success: false, + }) + }); + let result = evaluate_network_interfaces(&ifaces, &netplan, &mock).unwrap(); + assert_eq!(result, CapValue::Str("overlap:2:Netplan".into())); + } + + #[test] + fn overlap_with_nm() { + let tmp = TempDir::new().unwrap(); + let ifaces = tmp.path().join("interfaces"); + std::fs::write(&ifaces, "auto eth0\niface eth0 inet dhcp\n").unwrap(); + let mut mock = MockCommandRunner::new(); + mock.expect_run().returning(|_, _| { + Ok(CommandOutput { + stdout: vec![], + stderr: vec![], + success: true, + }) + }); + let result = + evaluate_network_interfaces(&ifaces, Path::new("/nonexistent"), &mock).unwrap(); + assert_eq!(result, CapValue::Str("overlap:2:NetworkManager".into())); + } +} diff --git a/crates/hah-caps/src/sysctl.rs b/crates/hah-caps/src/sysctl.rs new file mode 100644 index 0000000..15d6527 --- /dev/null +++ b/crates/hah-caps/src/sysctl.rs @@ -0,0 +1,115 @@ +//! Sysctl conflict detection capability. + +use std::{fs, path::Path}; + +use anyhow::Result; + +use crate::CapValue; + +const DEFAULT_SYSCTL_DIRS: &[&str] = &["/usr/lib/sysctl.d", "/etc/sysctl.d", "/run/sysctl.d"]; + +/// Return a list of conflict descriptions for sysctl keys that appear with +/// different values across `*.conf` files. +/// +/// Each item has the form `": =, ="`. +/// Scans default sysctl directories when `dirs` is empty. +pub fn sysctl_conflicts(dirs: &[String]) -> Result { + let effective: Vec<&str> = if dirs.is_empty() { + DEFAULT_SYSCTL_DIRS.to_vec() + } else { + dirs.iter().map(String::as_str).collect() + }; + + let mut file_entries: Vec<(String, String)> = Vec::new(); + for dir in effective { + let path = Path::new(dir); + if !path.exists() { + continue; + } + if let Ok(entries) = fs::read_dir(path) { + let mut names: Vec = entries + .flatten() + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("conf")) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + for name in names { + let full = format!("{dir}/{name}"); + if let Ok(content) = fs::read_to_string(&full) { + file_entries.push((full, content)); + } + } + } + } + + let conflicts: Vec = hah_utils::sysctl::find_conflicts(&file_entries) + .into_iter() + .map(|c| { + let detail = c + .assignments + .iter() + .map(|(f, v)| format!("{f}={v}")) + .collect::>() + .join(", "); + format!("{}: {detail}", c.key) + }) + .collect(); + Ok(CapValue::List(conflicts)) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn nonexistent_dir_returns_empty() { + let result = sysctl_conflicts(&["/nonexistent/sysctl.d".to_string()]).unwrap(); + assert_eq!(result, CapValue::List(vec![])); + } + + #[test] + fn detects_conflict() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("10-net.conf"), "net.ipv4.ip_forward = 0\n").unwrap(); + std::fs::write(tmp.path().join("20-net.conf"), "net.ipv4.ip_forward = 1\n").unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let result = sysctl_conflicts(&[dir]).unwrap(); + let CapValue::List(items) = result else { + panic!("expected list"); + }; + assert_eq!(items.len(), 1); + assert!(items[0].contains("net.ipv4.ip_forward")); + } + + #[test] + fn same_value_no_conflict() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("10-a.conf"), "vm.swappiness = 10\n").unwrap(); + std::fs::write(tmp.path().join("20-b.conf"), "vm.swappiness = 10\n").unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let result = sysctl_conflicts(&[dir]).unwrap(); + assert_eq!(result, CapValue::List(vec![])); + } + + #[test] + fn skips_comments_and_empty_lines() { + let tmp = TempDir::new().unwrap(); + std::fs::write( + tmp.path().join("10-a.conf"), + "# comment\n; another comment\n\nnet.ipv4.ip_forward = 1\n", + ) + .unwrap(); + std::fs::write(tmp.path().join("20-b.conf"), "net.ipv4.ip_forward = 1\n").unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let result = sysctl_conflicts(&[dir]).unwrap(); + assert_eq!(result, CapValue::List(vec![])); + } + + #[test] + fn uses_default_dirs_when_empty() { + let result = sysctl_conflicts(&[]); + assert!(result.is_ok()); + } +} diff --git a/crates/hah-checks/src/apt.rs b/crates/hah-checks/src/apt.rs deleted file mode 100644 index 9468390..0000000 --- a/crates/hah-checks/src/apt.rs +++ /dev/null @@ -1,661 +0,0 @@ -use std::{collections::HashSet, fs, path::Path}; - -use hah_core::{ - check::{Check, Context}, - model::{CheckResult, Finding, Remediation, Severity}, -}; - -// ── ResidualConfigCheck ────────────────────────────────────────────────────── - -pub struct ResidualConfigCheck; - -impl Check for ResidualConfigCheck { - fn id(&self) -> &str { - "residual-config" - } - - fn title(&self) -> &str { - "Residual package configuration files" - } - - fn run(&self, ctx: &Context) -> CheckResult { - if !ctx.distro.is_debian_family() { - return CheckResult::default(); - } - - let out = match ctx - .runner - .run("dpkg-query", &["-W", "-f=${Status} ${Package}\n"]) - { - Ok(o) => o, - Err(e) => return CheckResult::default().with_error(e.to_string()), - }; - - let stdout = String::from_utf8_lossy(&out.stdout); - let rc_packages: Vec = stdout - .lines() - .filter(|l| l.starts_with("deinstall ok config-files ")) - .map(|l| { - l.trim_start_matches("deinstall ok config-files ") - .to_string() - }) - .filter(|pkg| !ctx.config.allowlist.packages.contains(pkg)) - .collect(); - - if rc_packages.is_empty() { - return CheckResult::default(); - } - - let list = rc_packages.join(" "); - CheckResult::default().with_finding(Finding { - id: "residual-config".into(), - title: format!( - "{} package(s) with residual configuration", - rc_packages.len() - ), - description: format!( - "These packages were removed but their configuration files remain: {list}." - ), - severity: Severity::Info, - remediation: Some(Remediation { - description: "Purge residual configurations.".into(), - commands: vec![format!("sudo dpkg --purge {list}")], - }), - }) - } -} - -// ── DpkgStateCheck ─────────────────────────────────────────────────────────── - -pub struct DpkgStateCheck; - -impl Check for DpkgStateCheck { - fn id(&self) -> &str { - "dpkg-state" - } - - fn title(&self) -> &str { - "Broken dpkg package states" - } - - fn run(&self, ctx: &Context) -> CheckResult { - if !ctx.distro.is_debian_family() { - return CheckResult::default(); - } - - let out = match ctx.runner.run("dpkg", &["--audit"]) { - Ok(o) => o, - Err(e) => return CheckResult::default().with_error(e.to_string()), - }; - - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - if stdout.trim().is_empty() { - return CheckResult::default(); - } - - CheckResult::default().with_finding(Finding { - id: "dpkg-audit".into(), - title: "dpkg audit reports package state problems".into(), - description: stdout.trim().to_string(), - severity: Severity::Critical, - remediation: Some(Remediation { - description: "Attempt to fix broken packages.".into(), - commands: vec![ - "sudo dpkg --configure -a".into(), - "sudo apt-get install -f".into(), - ], - }), - }) - } -} - -// ── AutoremovableCheck ─────────────────────────────────────────────────────── - -pub struct AutoremovableCheck; - -impl Check for AutoremovableCheck { - fn id(&self) -> &str { - "autoremovable" - } - - fn title(&self) -> &str { - "Auto-removable packages" - } - - fn run(&self, ctx: &Context) -> CheckResult { - if !ctx.distro.is_debian_family() { - return CheckResult::default(); - } - - let out = match ctx.runner.run("apt-get", &["--dry-run", "autoremove"]) { - Ok(o) => o, - Err(e) => return CheckResult::default().with_error(e.to_string()), - }; - - let stdout = String::from_utf8_lossy(&out.stdout); - let count = stdout - .lines() - .filter(|l| l.trim_start().starts_with("Remv ")) - .count(); - - if count == 0 { - return CheckResult::default(); - } - - CheckResult::default().with_finding(Finding { - id: "autoremovable".into(), - title: format!("{count} auto-removable package(s)"), - description: format!("{count} package(s) are no longer needed and can be removed."), - severity: Severity::Info, - remediation: Some(Remediation { - description: "Remove unused auto-installed packages.".into(), - commands: vec!["sudo apt autoremove --purge".into()], - }), - }) - } -} - -// ── AptKeyCheck ────────────────────────────────────────────────────────────── - -pub struct AptKeyCheck; - -/// Return a finding if `path` exists and is non-empty (legacy keyring). -pub(crate) fn apt_key_finding(path: &Path) -> Option { - if path.exists() - && let Ok(meta) = path.metadata() - && meta.len() > 0 - { - Some(Finding { - id: "apt-key-legacy-gpg".into(), - title: "Legacy /etc/apt/trusted.gpg keyring is in use".into(), - description: "The file /etc/apt/trusted.gpg is non-empty. Keys managed here \ - were added with the deprecated `apt-key` command. They should be \ - migrated to named keyring files under /usr/share/keyrings/ and \ - referenced via the signed-by= option in source entries." - .into(), - severity: Severity::Warning, - remediation: Some(Remediation { - description: "Export each key to a dedicated keyring file.".into(), - commands: vec![ - "apt-key list".into(), - "# For each key: sudo gpg --no-default-keyring \ - --keyring /usr/share/keyrings/NAME.gpg \ - --import /tmp/key.asc" - .into(), - ], - }), - }) - } else { - None - } -} - -impl Check for AptKeyCheck { - fn id(&self) -> &str { - "apt-key" - } - - fn title(&self) -> &str { - "Deprecated apt-key signing keys" - } - - fn run(&self, _ctx: &Context) -> CheckResult { - apt_key_finding(Path::new("/etc/apt/trusted.gpg")).map_or_else(CheckResult::default, |f| { - CheckResult::default().with_finding(f) - }) - } -} - -// ── LegacySourcesFormatCheck ───────────────────────────────────────────────── - -pub struct LegacySourcesFormatCheck; - -/// Collect legacy one-line `deb` source files from `sources_list` and every -/// `.list` file inside `sources_d`. Extracted so it can be tested with -/// temporary paths. -pub(crate) fn collect_legacy_source_files(sources_list: &Path, sources_d: &Path) -> Vec { - let mut legacy: Vec = Vec::new(); - - if sources_list.exists() - && let Ok(content) = fs::read_to_string(sources_list) - && content - .lines() - .any(|l| l.starts_with("deb ") || l.starts_with("deb-src ")) - { - legacy.push(sources_list.to_string_lossy().into_owned()); - } - - if let Ok(entries) = fs::read_dir(sources_d) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("list") - && let Ok(content) = fs::read_to_string(&path) - && content - .lines() - .any(|l| l.starts_with("deb ") || l.starts_with("deb-src ")) - { - legacy.push(path.to_string_lossy().into_owned()); - } - } - } - - legacy -} - -fn legacy_sources_finding(legacy_files: Vec) -> Option { - if legacy_files.is_empty() { - return None; - } - let list = legacy_files.join(", "); - Some(Finding { - id: "legacy-sources-format".into(), - title: format!( - "{} file(s) using legacy one-line APT source format", - legacy_files.len() - ), - description: format!( - "The following files use the deprecated one-line `deb` format: {list}. \ - The modern DEB822 `.sources` format is preferred." - ), - severity: Severity::Info, - remediation: Some(Remediation { - description: "Convert to DEB822 format (one .sources file per repository).".into(), - commands: vec!["# See: https://wiki.debian.org/SourcesList#DEB822_format".into()], - }), - }) -} - -impl Check for LegacySourcesFormatCheck { - fn id(&self) -> &str { - "legacy-sources-format" - } - - fn title(&self) -> &str { - "Legacy one-line APT source entries" - } - - fn run(&self, _ctx: &Context) -> CheckResult { - let files = collect_legacy_source_files( - Path::new("/etc/apt/sources.list"), - Path::new("/etc/apt/sources.list.d"), - ); - legacy_sources_finding(files).map_or_else(CheckResult::default, |f| { - CheckResult::default().with_finding(f) - }) - } -} - -// ── UserDefinedPackageCheck ────────────────────────────────────────────────── - -pub struct UserDefinedPackageCheck; - -impl Check for UserDefinedPackageCheck { - fn id(&self) -> &str { - "user-denylist" - } - - fn title(&self) -> &str { - "Packages matching user denylist" - } - - fn run(&self, ctx: &Context) -> CheckResult { - if ctx.config.denylist.packages.is_empty() { - return CheckResult::default(); - } - if !ctx.distro.is_debian_family() { - return CheckResult::default(); - } - - let out = match ctx.runner.run("dpkg-query", &["-W", "-f=${Package}\n"]) { - Ok(o) => o, - Err(e) => return CheckResult::default().with_error(e.to_string()), - }; - - let installed: HashSet = String::from_utf8_lossy(&out.stdout) - .lines() - .map(str::to_string) - .collect(); - - let mut result = CheckResult::default(); - for entry in &ctx.config.denylist.packages { - if installed.contains(&entry.name) { - result = result.with_finding(Finding { - id: format!("user-denylist-{}", entry.name), - title: format!("Package '{}' should not be installed", entry.name), - description: entry.reason.clone(), - severity: Severity::Warning, - remediation: Some(Remediation { - description: format!("Remove {}", entry.name), - commands: vec![format!("sudo apt remove --purge {}", entry.name)], - }), - }); - } - } - result - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - use hah_core::{ - check::Context, - config::{Config, DenylistEntry}, - distro::DistroInfo, - runner::{CommandOutput, CommandRunner}, - }; - use mockall::mock; - use std::sync::Arc; - - mock! { - Runner {} - impl CommandRunner for Runner { - fn run<'a>(&self, program: &'a str, args: &'a [&'a str]) -> std::io::Result; - } - } - - fn ok_output(stdout: &str) -> CommandOutput { - CommandOutput { - stdout: stdout.as_bytes().to_vec(), - stderr: vec![], - success: true, - } - } - - fn make_ctx(runner: Arc, config: Config, distro_id: &str) -> Context { - Context { - verbose: false, - config, - distro: DistroInfo { - id: distro_id.into(), - ..DistroInfo::default() - }, - runner, - } - } - - fn debian_ctx(runner: Arc) -> Context { - make_ctx(runner, Config::default(), "debian") - } - - fn non_debian_ctx() -> Context { - make_ctx(Arc::new(MockRunner::new()), Config::default(), "arch") - } - - // ── ResidualConfigCheck ────────────────────────────────────────────────── - - #[test] - fn residual_config_skips_non_debian() { - assert!( - ResidualConfigCheck - .run(&non_debian_ctx()) - .findings - .is_empty() - ); - } - - #[test] - fn residual_config_clean() { - let mut runner = MockRunner::new(); - runner - .expect_run() - .returning(|_, _| Ok(ok_output("install ok installed bash\n"))); - let result = ResidualConfigCheck.run(&debian_ctx(Arc::new(runner))); - assert!(result.findings.is_empty()); - assert!(result.errors.is_empty()); - } - - #[test] - fn residual_config_finds_rc_packages() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| { - Ok(ok_output( - "deinstall ok config-files pkg-a\ninstall ok installed bash\n", - )) - }); - let result = ResidualConfigCheck.run(&debian_ctx(Arc::new(runner))); - assert_eq!(result.findings.len(), 1); - assert!(result.findings[0].title.contains('1')); - } - - #[test] - fn residual_config_allowlist_filters_package() { - let mut runner = MockRunner::new(); - runner - .expect_run() - .returning(|_, _| Ok(ok_output("deinstall ok config-files known-pkg\n"))); - let mut config = Config::default(); - config.allowlist.packages = vec!["known-pkg".into()]; - let ctx = make_ctx(Arc::new(runner), config, "debian"); - assert!(ResidualConfigCheck.run(&ctx).findings.is_empty()); - } - - #[test] - fn residual_config_runner_error_returns_error() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| { - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "not found", - )) - }); - let result = ResidualConfigCheck.run(&debian_ctx(Arc::new(runner))); - assert!(result.errors.len() == 1); - } - - // ── DpkgStateCheck ─────────────────────────────────────────────────────── - - #[test] - fn dpkg_state_skips_non_debian() { - assert!(DpkgStateCheck.run(&non_debian_ctx()).findings.is_empty()); - } - - #[test] - fn dpkg_state_clean() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| Ok(ok_output(""))); - let result = DpkgStateCheck.run(&debian_ctx(Arc::new(runner))); - assert!(result.findings.is_empty()); - } - - #[test] - fn dpkg_state_broken_packages() { - let mut runner = MockRunner::new(); - runner - .expect_run() - .returning(|_, _| Ok(ok_output("broken package state info\n"))); - let result = DpkgStateCheck.run(&debian_ctx(Arc::new(runner))); - assert_eq!(result.findings.len(), 1); - assert_eq!(result.findings[0].severity, Severity::Critical); - } - - // ── AutoremovableCheck ─────────────────────────────────────────────────── - - #[test] - fn autoremovable_skips_non_debian() { - assert!( - AutoremovableCheck - .run(&non_debian_ctx()) - .findings - .is_empty() - ); - } - - #[test] - fn autoremovable_none() { - let mut runner = MockRunner::new(); - runner - .expect_run() - .returning(|_, _| Ok(ok_output("Reading package lists...\n"))); - let result = AutoremovableCheck.run(&debian_ctx(Arc::new(runner))); - assert!(result.findings.is_empty()); - } - - #[test] - fn autoremovable_finds_packages() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| { - Ok(ok_output( - "Reading package lists...\n Remv old-lib [1.0]\n Remv unused-tool [2.3]\n", - )) - }); - let result = AutoremovableCheck.run(&debian_ctx(Arc::new(runner))); - assert_eq!(result.findings.len(), 1); - assert!(result.findings[0].title.contains('2')); - } - - // ── UserDefinedPackageCheck ────────────────────────────────────────────── - - #[test] - fn user_denylist_skips_empty_denylist() { - assert!( - UserDefinedPackageCheck - .run(&debian_ctx(Arc::new(MockRunner::new()))) - .findings - .is_empty() - ); - } - - #[test] - fn user_denylist_skips_non_debian() { - let mut config = Config::default(); - config.denylist.packages = vec![DenylistEntry { - name: "bad-pkg".into(), - reason: "insecure".into(), - }]; - let ctx = make_ctx(Arc::new(MockRunner::new()), config, "arch"); - assert!(UserDefinedPackageCheck.run(&ctx).findings.is_empty()); - } - - #[test] - fn user_denylist_installed_package_flagged() { - let mut runner = MockRunner::new(); - runner - .expect_run() - .returning(|_, _| Ok(ok_output("bash\nbad-pkg\nvim\n"))); - let mut config = Config::default(); - config.denylist.packages = vec![DenylistEntry { - name: "bad-pkg".into(), - reason: "this is insecure".into(), - }]; - let ctx = make_ctx(Arc::new(runner), config, "debian"); - let result = UserDefinedPackageCheck.run(&ctx); - assert_eq!(result.findings.len(), 1); - assert!(result.findings[0].id.contains("bad-pkg")); - } - - #[test] - fn user_denylist_not_installed_no_finding() { - let mut runner = MockRunner::new(); - runner - .expect_run() - .returning(|_, _| Ok(ok_output("bash\nvim\n"))); - let mut config = Config::default(); - config.denylist.packages = vec![DenylistEntry { - name: "bad-pkg".into(), - reason: "insecure".into(), - }]; - let ctx = make_ctx(Arc::new(runner), config, "debian"); - assert!(UserDefinedPackageCheck.run(&ctx).findings.is_empty()); - } - - // ── apt_key_finding ─────────────────────────────────────────────────────── - - #[test] - fn apt_key_finding_returns_none_when_file_absent() { - assert!(apt_key_finding(std::path::Path::new("/nonexistent/trusted.gpg")).is_none()); - } - - #[test] - fn apt_key_finding_returns_none_when_file_empty() { - let path = std::env::temp_dir().join(format!("hah_gpg_empty_{}.gpg", std::process::id())); - std::fs::write(&path, b"").unwrap(); - let result = apt_key_finding(&path); - let _ = std::fs::remove_file(&path); - assert!(result.is_none()); - } - - #[test] - fn apt_key_finding_returns_warning_when_file_nonempty() { - let path = - std::env::temp_dir().join(format!("hah_gpg_nonempty_{}.gpg", std::process::id())); - std::fs::write(&path, b"fake-gpg-data").unwrap(); - let finding = apt_key_finding(&path); - let _ = std::fs::remove_file(&path); - let f = finding.expect("expected a finding for non-empty gpg file"); - assert_eq!(f.severity, Severity::Warning); - assert_eq!(f.id, "apt-key-legacy-gpg"); - } - - #[test] - fn apt_key_check_id_and_title() { - assert_eq!(AptKeyCheck.id(), "apt-key"); - assert!(!AptKeyCheck.title().is_empty()); - } - - #[test] - fn apt_key_check_runs_without_panic_on_real_system() { - let ctx = make_ctx(Arc::new(MockRunner::new()), Config::default(), "debian"); - let result = AptKeyCheck.run(&ctx); - for f in &result.findings { - assert_eq!(f.severity, Severity::Warning); - } - } - - // ── collect_legacy_source_files ─────────────────────────────────────────── - - #[test] - fn collect_legacy_sources_empty_when_no_deb_lines() { - let tmp = std::env::temp_dir(); - let list = tmp.join(format!("hah_src_{}.list", std::process::id())); - std::fs::write(&list, "# just a comment\n").unwrap(); - let d = tmp.join(format!("hah_srcd_{}", std::process::id())); - std::fs::create_dir_all(&d).unwrap(); - let result = collect_legacy_source_files(&list, &d); - let _ = std::fs::remove_file(&list); - let _ = std::fs::remove_dir_all(&d); - assert!(result.is_empty()); - } - - #[test] - fn collect_legacy_sources_detects_deb_line_in_sources_list() { - let tmp = std::env::temp_dir(); - let list = tmp.join(format!("hah_srclist_{}.list", std::process::id())); - std::fs::write(&list, "deb http://archive.ubuntu.com/ubuntu focal main\n").unwrap(); - let d = tmp.join(format!("hah_srcd2_{}", std::process::id())); - std::fs::create_dir_all(&d).unwrap(); - let result = collect_legacy_source_files(&list, &d); - let _ = std::fs::remove_file(&list); - let _ = std::fs::remove_dir_all(&d); - assert_eq!(result.len(), 1); - } - - #[test] - fn collect_legacy_sources_detects_deb_line_in_sources_d() { - let tmp = std::env::temp_dir(); - let absent_list = tmp.join(format!("hah_absent_{}.list", std::process::id())); - let d = tmp.join(format!("hah_srcd3_{}", std::process::id())); - std::fs::create_dir_all(&d).unwrap(); - std::fs::write( - d.join("extra.list"), - "deb-src http://archive.ubuntu.com/ubuntu focal main\n", - ) - .unwrap(); - let result = collect_legacy_source_files(&absent_list, &d); - let _ = std::fs::remove_dir_all(&d); - assert_eq!(result.len(), 1); - } - - #[test] - fn legacy_sources_format_check_id_and_title() { - assert_eq!(LegacySourcesFormatCheck.id(), "legacy-sources-format"); - assert!(!LegacySourcesFormatCheck.title().is_empty()); - } - - #[test] - fn legacy_sources_format_check_runs_without_panic_on_real_system() { - let ctx = make_ctx(Arc::new(MockRunner::new()), Config::default(), "debian"); - let _ = LegacySourcesFormatCheck.run(&ctx); - } -} diff --git a/crates/hah-checks/src/boot.rs b/crates/hah-checks/src/boot.rs deleted file mode 100644 index dff0303..0000000 --- a/crates/hah-checks/src/boot.rs +++ /dev/null @@ -1,820 +0,0 @@ -use std::path::Path; - -use hah_core::{ - check::{Check, Context}, - model::{CheckResult, Finding, Remediation, Severity}, - runner::CommandRunner, -}; - -// ── helpers ────────────────────────────────────────────────────────────────── - -fn running_kernel(runner: &dyn CommandRunner) -> anyhow::Result { - let out = runner.run("uname", &["-r"])?; - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) -} - -fn installed_kernel_packages(runner: &dyn CommandRunner) -> anyhow::Result> { - let out = runner.run( - "dpkg-query", - &["--show", "--showformat=${Package}\n", "linux-image-*"], - )?; - Ok(String::from_utf8_lossy(&out.stdout) - .lines() - .map(str::to_string) - .collect()) -} - -fn installed_header_packages(runner: &dyn CommandRunner) -> anyhow::Result> { - let out = runner.run( - "dpkg-query", - &["--show", "--showformat=${Package}\n", "linux-headers-*"], - )?; - Ok(String::from_utf8_lossy(&out.stdout) - .lines() - .map(str::to_string) - .collect()) -} - -fn boot_free_bytes(runner: &dyn CommandRunner) -> anyhow::Result { - let out = runner.run("df", &["--block-size=1", "--output=avail", "/boot"])?; - let stdout = String::from_utf8_lossy(&out.stdout); - let avail: u64 = stdout - .lines() - .nth(1) - .ok_or_else(|| anyhow::anyhow!("unexpected df output"))? - .trim() - .parse()?; - Ok(avail) -} - -pub use hah_utils::fs::sanitize_id; - -// ── BootSpaceCheck ─────────────────────────────────────────────────────────── - -pub struct BootSpaceCheck; - -impl Check for BootSpaceCheck { - fn id(&self) -> &str { - "boot-space" - } - - fn title(&self) -> &str { - "Free space on /boot" - } - - fn run(&self, ctx: &Context) -> CheckResult { - let threshold_mb = ctx.config.threshold("boot_space_mb", 100); - let threshold_bytes = threshold_mb * 1024 * 1024; - - let free_bytes = match boot_free_bytes(ctx.runner.as_ref()) { - Ok(n) => n, - Err(e) => return CheckResult::default().with_error(format!("df /boot: {e}")), - }; - - if free_bytes < threshold_bytes { - let free_mb = free_bytes / 1024 / 1024; - CheckResult::default().with_finding(Finding { - id: "boot-space-low".into(), - title: format!("/boot has only {free_mb} MB free"), - description: format!( - "The /boot partition is nearly full ({free_mb} MB free, \ - threshold: {threshold_mb} MB). This can prevent kernel upgrades \ - or initramfs updates from completing." - ), - severity: Severity::Critical, - remediation: Some(Remediation { - description: "Remove unused kernels to free space.".into(), - commands: vec!["sudo apt autoremove --purge".into()], - }), - }) - } else { - CheckResult::default() - } - } -} - -// ── UnusedKernelsCheck ─────────────────────────────────────────────────────── - -pub struct UnusedKernelsCheck; - -impl Check for UnusedKernelsCheck { - fn id(&self) -> &str { - "unused-kernels" - } - - fn title(&self) -> &str { - "Unused installed kernels" - } - - fn run(&self, ctx: &Context) -> CheckResult { - if !ctx.distro.is_debian_family() { - return CheckResult::default(); - } - - let running = match running_kernel(ctx.runner.as_ref()) { - Ok(v) => v, - Err(e) => return CheckResult::default().with_error(e.to_string()), - }; - - let installed = match installed_kernel_packages(ctx.runner.as_ref()) { - Ok(v) => v, - Err(e) => return CheckResult::default().with_error(e.to_string()), - }; - - let unused: Vec = installed - .into_iter() - .filter(|pkg| !pkg.contains(&running)) - .collect(); - - if unused.is_empty() { - return CheckResult::default(); - } - - let list = unused.join(", "); - CheckResult::default().with_finding(Finding { - id: "unused-kernels".into(), - title: format!("{} unused kernel package(s) installed", unused.len()), - description: format!( - "Running kernel: {running}. Unused: {list}. \ - These consume space in /boot and can safely be removed." - ), - severity: Severity::Warning, - remediation: Some(Remediation { - description: "Remove unused kernels with apt.".into(), - commands: vec!["sudo apt autoremove --purge".into()], - }), - }) - } -} - -// ── StaleKernelHeadersCheck ────────────────────────────────────────────────── - -pub struct StaleKernelHeadersCheck; - -impl Check for StaleKernelHeadersCheck { - fn id(&self) -> &str { - "stale-kernel-headers" - } - - fn title(&self) -> &str { - "Stale kernel header packages" - } - - fn run(&self, ctx: &Context) -> CheckResult { - if !ctx.distro.is_debian_family() { - return CheckResult::default(); - } - - let headers = match installed_header_packages(ctx.runner.as_ref()) { - Ok(v) => v, - Err(e) => return CheckResult::default().with_error(e.to_string()), - }; - - let kernels = match installed_kernel_packages(ctx.runner.as_ref()) { - Ok(v) => v, - Err(e) => return CheckResult::default().with_error(e.to_string()), - }; - - let stale: Vec = headers - .into_iter() - .filter(|hdr| { - let version = hdr.trim_start_matches("linux-headers-"); - // Skip meta-packages like linux-headers-generic that have no version - if !version.chars().next().is_some_and(char::is_numeric) { - return false; - } - !kernels.iter().any(|k| k.contains(version)) - }) - .collect(); - - if stale.is_empty() { - return CheckResult::default(); - } - - let list = stale.join(", "); - CheckResult::default().with_finding(Finding { - id: "stale-kernel-headers".into(), - title: format!("{} stale kernel header package(s)", stale.len()), - description: format!("Header packages with no matching kernel: {list}."), - severity: Severity::Info, - remediation: Some(Remediation { - description: "Remove stale header packages.".into(), - commands: stale - .iter() - .map(|p| format!("sudo apt remove --purge {p}")) - .collect(), - }), - }) - } -} - -// ── InitramfsCheck ─────────────────────────────────────────────────────────── - -pub struct InitramfsCheck; - -impl Check for InitramfsCheck { - fn id(&self) -> &str { - "initramfs-size" - } - - fn title(&self) -> &str { - "Oversized initramfs images" - } - - fn run(&self, ctx: &Context) -> CheckResult { - let threshold_mb = ctx.config.threshold("initramfs_size_mb", 100); - let threshold_bytes = threshold_mb * 1024 * 1024; - - let mut result = CheckResult::default(); - let entries = match std::fs::read_dir("/boot") { - Ok(e) => e, - Err(e) => return result.with_error(format!("read_dir /boot: {e}")), - }; - - for entry in entries.flatten() { - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - if !name_str.starts_with("initrd.img-") && !name_str.starts_with("initramfs-") { - continue; - } - if let Ok(meta) = entry.metadata() { - let size = meta.len(); - if size > threshold_bytes { - let size_mb = size / 1024 / 1024; - result = result.with_finding(Finding { - id: format!("initramfs-large-{}", sanitize_id(&name_str)), - title: format!("{name_str} is {size_mb} MB"), - description: format!( - "initramfs image {name_str} exceeds the {threshold_mb} MB threshold. \ - Large images slow boot and consume /boot space." - ), - severity: Severity::Warning, - remediation: Some(Remediation { - description: "Regenerate initramfs images.".into(), - commands: vec!["sudo update-initramfs -u -k all".into()], - }), - }); - } - } - } - result - } -} - -// ── DkmsStatusCheck ────────────────────────────────────────────────────────── - -pub struct DkmsStatusCheck; - -impl Check for DkmsStatusCheck { - fn id(&self) -> &str { - "dkms-status" - } - - fn title(&self) -> &str { - "DKMS module build status" - } - - fn run(&self, ctx: &Context) -> CheckResult { - let out = match ctx.runner.run("dkms", &["status"]) { - Ok(o) => o, - Err(_) => return CheckResult::default(), // dkms not installed - }; - - let stdout = String::from_utf8_lossy(&out.stdout); - let mut result = CheckResult::default(); - - for line in stdout.lines() { - let lower = line.to_lowercase(); - if lower.contains("broken") || lower.contains("not installed") { - result = result.with_finding(Finding { - id: format!("dkms-broken-{}", sanitize_id(line)), - title: format!("DKMS module problem: {line}"), - description: format!( - "DKMS reports a problem with: {line}. \ - This module may not work with the current kernel." - ), - severity: Severity::Warning, - remediation: Some(Remediation { - description: "Attempt DKMS rebuild.".into(), - commands: vec!["sudo dkms autoinstall".into()], - }), - }); - } - } - result - } -} - -// ── InitramfsCompressionCheck ──────────────────────────────────────────────── - -pub struct InitramfsCompressionCheck; - -impl Check for InitramfsCompressionCheck { - fn id(&self) -> &str { - "initramfs-compression" - } - - fn title(&self) -> &str { - "Non-optimal initramfs compression" - } - - fn run(&self, _ctx: &Context) -> CheckResult { - // Read the preferred compression from /etc/initramfs-tools/initramfs.conf - let conf_path = Path::new("/etc/initramfs-tools/initramfs.conf"); - if !conf_path.exists() { - return CheckResult::default(); - } - - let content = match std::fs::read_to_string(conf_path) { - Ok(c) => c, - Err(e) => return CheckResult::default().with_error(e.to_string()), - }; - - classify_compression(&content).map_or_else(CheckResult::default, |f| { - CheckResult::default().with_finding(f) - }) - } -} - -/// Parse compression algorithm from `/etc/initramfs-tools/initramfs.conf` -/// content and return a finding when the algorithm is not `zstd` or `lz4`. -pub(crate) fn classify_compression(content: &str) -> Option { - let compression = content - .lines() - .rfind(|l| l.starts_with("COMPRESS=")) - .and_then(|l| l.split_once('=')) - .map_or_else(|| "gzip".into(), |(_, v)| v.trim().to_lowercase()); - - if compression != "zstd" && compression != "lz4" { - Some(Finding { - id: "initramfs-compression-suboptimal".into(), - title: format!("initramfs uses {compression} compression instead of zstd"), - description: format!( - "The initramfs-tools configuration uses '{compression}' compression. \ - Switching to 'zstd' reduces initramfs size and speeds up boot." - ), - severity: Severity::Info, - remediation: Some(Remediation { - description: "Set COMPRESS=zstd in initramfs.conf and regenerate.".into(), - commands: vec![ - "sudo sed -i 's/^COMPRESS=.*/COMPRESS=zstd/' \ - /etc/initramfs-tools/initramfs.conf" - .into(), - "sudo update-initramfs -u -k all".into(), - ], - }), - }) - } else { - None - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - use hah_core::{ - check::Context, - config::Config, - distro::DistroInfo, - runner::{CommandOutput, CommandRunner}, - }; - use mockall::mock; - use std::sync::Arc; - - mock! { - Runner {} - impl CommandRunner for Runner { - fn run<'a>(&self, program: &'a str, args: &'a [&'a str]) -> std::io::Result; - } - } - - fn ok_output(stdout: &str) -> CommandOutput { - CommandOutput { - stdout: stdout.as_bytes().to_vec(), - stderr: vec![], - success: true, - } - } - - fn make_ctx(runner: Arc, distro_id: &str) -> Context { - Context { - verbose: false, - config: Config::default(), - distro: DistroInfo { - id: distro_id.into(), - ..DistroInfo::default() - }, - runner, - } - } - - fn debian_ctx(runner: Arc) -> Context { - make_ctx(runner, "debian") - } - - // ── sanitize_id ─────────────────────────────────────────────────────────── - - #[test] - fn sanitize_id_simple_string() { - assert_eq!(sanitize_id("linux-image-5-15"), "linux-image-5-15"); - } - - #[test] - fn sanitize_id_replaces_dots_and_slashes() { - assert_eq!(sanitize_id("5.15.0.89"), "5-15-0-89"); - assert_eq!(sanitize_id("/boot/vmlinuz"), "boot-vmlinuz"); - } - - #[test] - fn sanitize_id_trims_leading_trailing_hyphens() { - assert_eq!(sanitize_id("/foo/"), "foo"); - } - - // ── BootSpaceCheck ──────────────────────────────────────────────────────── - - #[test] - fn boot_space_check_id_and_title() { - assert_eq!(BootSpaceCheck.id(), "boot-space"); - assert!(!BootSpaceCheck.title().is_empty()); - } - - #[test] - fn boot_space_ample_free_space() { - let mut runner = MockRunner::new(); - // 500 MB free > 100 MB default threshold - runner - .expect_run() - .returning(|_, _| Ok(ok_output("Avail\n524288000\n"))); - assert!( - BootSpaceCheck - .run(&debian_ctx(Arc::new(runner))) - .findings - .is_empty() - ); - } - - #[test] - fn boot_space_below_threshold() { - let mut runner = MockRunner::new(); - // 5 MB free < 100 MB threshold - runner - .expect_run() - .returning(|_, _| Ok(ok_output("Avail\n5242880\n"))); - let result = BootSpaceCheck.run(&debian_ctx(Arc::new(runner))); - assert_eq!(result.findings.len(), 1); - assert_eq!(result.findings[0].severity, Severity::Critical); - } - - #[test] - fn boot_space_runner_error_returns_error() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| { - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "not found", - )) - }); - assert_eq!( - BootSpaceCheck - .run(&debian_ctx(Arc::new(runner))) - .errors - .len(), - 1 - ); - } - - #[test] - fn boot_space_custom_threshold_not_exceeded() { - let mut runner = MockRunner::new(); - // 50 MB free, threshold 30 MB → OK - runner - .expect_run() - .returning(|_, _| Ok(ok_output("Avail\n52428800\n"))); - let mut config = Config::default(); - config.thresholds.insert("boot_space_mb".into(), 30); - let ctx = Context { - config, - ..debian_ctx(Arc::new(runner)) - }; - assert!(BootSpaceCheck.run(&ctx).findings.is_empty()); - } - - // ── UnusedKernelsCheck ──────────────────────────────────────────────────── - - #[test] - fn unused_kernels_check_id_and_title() { - assert_eq!(UnusedKernelsCheck.id(), "unused-kernels"); - assert!(!UnusedKernelsCheck.title().is_empty()); - } - - #[test] - fn unused_kernels_skips_non_debian() { - assert!( - UnusedKernelsCheck - .run(&make_ctx(Arc::new(MockRunner::new()), "arch")) - .findings - .is_empty() - ); - } - - #[test] - fn unused_kernels_none_when_only_running_kernel_installed() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|program, _| { - if program == "uname" { - Ok(ok_output("5.15.0-89-generic\n")) - } else { - Ok(ok_output("linux-image-5.15.0-89-generic\n")) - } - }); - assert!( - UnusedKernelsCheck - .run(&debian_ctx(Arc::new(runner))) - .findings - .is_empty() - ); - } - - #[test] - fn unused_kernels_finds_old_kernel() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|program, _| { - if program == "uname" { - Ok(ok_output("5.15.0-89-generic\n")) - } else { - Ok(ok_output( - "linux-image-5.15.0-89-generic\nlinux-image-5.15.0-75-generic\n", - )) - } - }); - let result = UnusedKernelsCheck.run(&debian_ctx(Arc::new(runner))); - assert_eq!(result.findings.len(), 1); - } - - #[test] - fn unused_kernels_uname_error_returns_error() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| { - Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "denied", - )) - }); - assert_eq!( - UnusedKernelsCheck - .run(&debian_ctx(Arc::new(runner))) - .errors - .len(), - 1 - ); - } - - #[test] - fn unused_kernels_dpkg_error_returns_error() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|program, _| { - if program == "uname" { - Ok(ok_output("5.15.0-89-generic\n")) - } else { - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "not found", - )) - } - }); - assert_eq!( - UnusedKernelsCheck - .run(&debian_ctx(Arc::new(runner))) - .errors - .len(), - 1 - ); - } - - // ── StaleKernelHeadersCheck ─────────────────────────────────────────────── - - #[test] - fn stale_headers_check_id_and_title() { - assert_eq!(StaleKernelHeadersCheck.id(), "stale-kernel-headers"); - assert!(!StaleKernelHeadersCheck.title().is_empty()); - } - - #[test] - fn stale_headers_skips_non_debian() { - assert!( - StaleKernelHeadersCheck - .run(&make_ctx(Arc::new(MockRunner::new()), "arch")) - .findings - .is_empty() - ); - } - - #[test] - fn stale_headers_none_when_matching_kernel() { - let mut runner = MockRunner::new(); - let call = std::sync::atomic::AtomicUsize::new(0); - runner.expect_run().returning(move |_, _| { - let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if n == 0 { - Ok(ok_output("linux-headers-5.15.0-89-generic\n")) - } else { - Ok(ok_output("linux-image-5.15.0-89-generic\n")) - } - }); - assert!( - StaleKernelHeadersCheck - .run(&debian_ctx(Arc::new(runner))) - .findings - .is_empty() - ); - } - - #[test] - fn stale_headers_finds_orphaned_headers() { - let mut runner = MockRunner::new(); - let call = std::sync::atomic::AtomicUsize::new(0); - runner.expect_run().returning(move |_, _| { - let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if n == 0 { - Ok(ok_output("linux-headers-5.15.0-75-generic\n")) - } else { - Ok(ok_output("linux-image-5.15.0-89-generic\n")) - } - }); - assert_eq!( - StaleKernelHeadersCheck - .run(&debian_ctx(Arc::new(runner))) - .findings - .len(), - 1 - ); - } - - #[test] - fn stale_headers_meta_packages_skipped() { - let mut runner = MockRunner::new(); - let call = std::sync::atomic::AtomicUsize::new(0); - runner.expect_run().returning(move |_, _| { - let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if n == 0 { - Ok(ok_output("linux-headers-generic\n")) - } else { - Ok(ok_output("linux-image-5.15.0-89-generic\n")) - } - }); - assert!( - StaleKernelHeadersCheck - .run(&debian_ctx(Arc::new(runner))) - .findings - .is_empty() - ); - } - - // ── DkmsStatusCheck ─────────────────────────────────────────────────────── - - #[test] - fn dkms_status_check_id_and_title() { - assert_eq!(DkmsStatusCheck.id(), "dkms-status"); - assert!(!DkmsStatusCheck.title().is_empty()); - } - - #[test] - fn dkms_status_all_installed() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| { - Ok(ok_output( - "virtualbox/6.1, 5.15.0-89-generic, x86_64: installed\n", - )) - }); - assert!( - DkmsStatusCheck - .run(&make_ctx(Arc::new(runner), "any")) - .findings - .is_empty() - ); - } - - #[test] - fn dkms_status_broken_module_flagged() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| { - Ok(ok_output( - "virtualbox/6.1, 5.15.0-89-generic, x86_64: broken\n", - )) - }); - let result = DkmsStatusCheck.run(&make_ctx(Arc::new(runner), "any")); - assert_eq!(result.findings.len(), 1); - assert_eq!(result.findings[0].severity, Severity::Warning); - } - - #[test] - fn dkms_status_not_installed_module_flagged() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| { - Ok(ok_output( - "vbox/6.0, 5.15.0-89-generic, x86_64: not installed\n", - )) - }); - assert_eq!( - DkmsStatusCheck - .run(&make_ctx(Arc::new(runner), "any")) - .findings - .len(), - 1 - ); - } - - #[test] - fn dkms_runner_error_returns_empty() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| { - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "dkms missing", - )) - }); - assert!( - DkmsStatusCheck - .run(&make_ctx(Arc::new(runner), "any")) - .findings - .is_empty() - ); - } - - // ── InitramfsCheck ──────────────────────────────────────────────────────── - - #[test] - fn initramfs_check_id_and_title() { - assert_eq!(InitramfsCheck.id(), "initramfs-size"); - assert!(!InitramfsCheck.title().is_empty()); - } - - #[test] - fn initramfs_check_runs_without_panic() { - let ctx = make_ctx(Arc::new(MockRunner::new()), "any"); - let _ = InitramfsCheck.run(&ctx); - } - - #[test] - fn initramfs_check_oversized_file_produces_finding() { - // Write a large temp file to a temp dir and point InitramfsCheck at it - // by temporarily using a very low threshold (1 byte). - let tmp = std::env::temp_dir().join(format!("hah_boot_{}", std::process::id())); - std::fs::create_dir_all(&tmp).unwrap(); - let img = tmp.join("initrd.img-test"); - std::fs::write(&img, b"x").unwrap(); // 1 byte - - // We can't inject the dir path, so test classify_compression indirectly - // via the public helpers. Just assert the on-disk test doesn't panic. - let _ = std::fs::remove_file(&img); - let _ = std::fs::remove_dir_all(&tmp); - } - - // ── InitramfsCompressionCheck ───────────────────────────────────────────── - - #[test] - fn initramfs_compression_check_id_and_title() { - assert_eq!(InitramfsCompressionCheck.id(), "initramfs-compression"); - assert!(!InitramfsCompressionCheck.title().is_empty()); - } - - #[test] - fn initramfs_compression_runs_without_panic() { - let ctx = make_ctx(Arc::new(MockRunner::new()), "any"); - let _ = InitramfsCompressionCheck.run(&ctx); - } - - // ── classify_compression ────────────────────────────────────────────────── - - #[test] - fn classify_compression_gzip_produces_finding() { - let f = classify_compression("COMPRESS=gzip\n").expect("expected finding for gzip"); - assert_eq!(f.severity, Severity::Info); - assert!(f.title.contains("gzip")); - } - - #[test] - fn classify_compression_zstd_returns_none() { - assert!(classify_compression("COMPRESS=zstd\n").is_none()); - } - - #[test] - fn classify_compression_lz4_returns_none() { - assert!(classify_compression("COMPRESS=lz4\n").is_none()); - } - - #[test] - fn classify_compression_default_gzip_when_no_compress_line() { - // No COMPRESS= line → defaults to gzip → finding - let f = classify_compression("# just a comment\n").expect("expected gzip default finding"); - assert!(f.title.contains("gzip")); - } - - #[test] - fn classify_compression_last_compress_line_wins() { - let content = "COMPRESS=gzip\nCOMPRESS=zstd\n"; - // rfind picks the LAST line → zstd → no finding - assert!(classify_compression(content).is_none()); - } -} diff --git a/crates/hah-checks/src/drift.rs b/crates/hah-checks/src/drift.rs deleted file mode 100644 index fd1579b..0000000 --- a/crates/hah-checks/src/drift.rs +++ /dev/null @@ -1,379 +0,0 @@ -use hah_core::{ - check::{Check, Context}, - model::{CheckResult, Finding, Remediation, Severity}, -}; - -use hah_utils::fs::sanitize_id; - -// ── BrokenSymlinksCheck ────────────────────────────────────────────────────── - -pub struct BrokenSymlinksCheck; - -const SCAN_DIRS: &[&str] = &["/etc", "/usr/lib", "/var/lib"]; - -impl Check for BrokenSymlinksCheck { - fn id(&self) -> &str { - "broken-symlinks" - } - - fn title(&self) -> &str { - "Broken symbolic links" - } - - fn run(&self, _ctx: &Context) -> CheckResult { - scan_for_broken_symlinks(SCAN_DIRS) - } -} - -/// Walk `dirs` and collect a finding for every symlink that points to a -/// non-existent target. Extracted so it can be unit-tested with temp dirs. -pub(crate) fn scan_for_broken_symlinks(dirs: &[&str]) -> CheckResult { - let mut result = CheckResult::default(); - for path in hah_utils::fs::broken_symlinks(dirs) { - result = result.with_finding(Finding { - id: format!("broken-symlink-{}", sanitize_id(&path.to_string_lossy())), - title: format!("Broken symlink: {}", path.display()), - description: format!( - "The symlink {} points to a non-existent target.", - path.display() - ), - severity: Severity::Warning, - remediation: Some(Remediation { - description: "Remove the broken symlink.".into(), - commands: vec![format!("sudo rm {}", path.display())], - }), - }); - } - result -} - -// ── OldCrashDumpsCheck ─────────────────────────────────────────────────────── - -pub struct OldCrashDumpsCheck; - -const CRASH_DIRS: &[&str] = &["/var/crash", "/var/lib/systemd/coredump"]; -const MAX_AGE_DAYS: u64 = 30; - -impl Check for OldCrashDumpsCheck { - fn id(&self) -> &str { - "old-crash-dumps" - } - - fn title(&self) -> &str { - "Old crash dumps and core files" - } - - fn run(&self, ctx: &Context) -> CheckResult { - let max_days = ctx.config.threshold("crash_dump_max_days", MAX_AGE_DAYS); - scan_crash_dirs(CRASH_DIRS, max_days) - } -} - -/// Scan `dirs` for files older than `max_days` days and build findings. -/// Extracted for deterministic unit-testing with temp directories. -pub(crate) fn scan_crash_dirs(dirs: &[&str], max_days: u64) -> CheckResult { - let mut result = CheckResult::default(); - for old_file in hah_utils::fs::scan_old_files(dirs, max_days) { - let name = old_file - .path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - let parent = old_file - .path - .parent() - .map(|p| p.display().to_string()) - .unwrap_or_default(); - result = result.with_finding(Finding { - id: format!("crash-dump-{}", sanitize_id(&name)), - title: format!("Old crash dump: {name} ({} KB)", old_file.size_kb), - description: format!( - "{parent}/{name} is more than {max_days} days old and occupies {} KB.", - old_file.size_kb - ), - severity: Severity::Info, - remediation: Some(Remediation { - description: "Remove old crash dump.".into(), - commands: vec![format!("sudo rm {parent}/{name}")], - }), - }); - } - result -} - -// ── JournalSizeCheck ───────────────────────────────────────────────────────── - -pub struct JournalSizeCheck; - -impl Check for JournalSizeCheck { - fn id(&self) -> &str { - "journal-size" - } - - fn title(&self) -> &str { - "systemd journal disk usage" - } - - fn run(&self, ctx: &Context) -> CheckResult { - let threshold_mb = ctx.config.threshold("journal_size_mb", 500); - - let out = match ctx.runner.run("journalctl", &["--disk-usage"]) { - Ok(o) => o, - Err(_) => return CheckResult::default(), - }; - - let stdout = String::from_utf8_lossy(&out.stdout); - let size_bytes = hah_utils::size::parse_journal_disk_usage(&stdout).unwrap_or(0); - let threshold_bytes = threshold_mb * 1_000_000; - - if size_bytes > threshold_bytes { - let size_mb = size_bytes / 1_000_000; - CheckResult::default().with_finding(Finding { - id: "journal-size-large".into(), - title: format!("systemd journal is {size_mb} MB"), - description: format!( - "The systemd journal occupies {size_mb} MB, \ - exceeding the {threshold_mb} MB threshold." - ), - severity: Severity::Warning, - remediation: Some(Remediation { - description: "Vacuum the journal to reclaim space.".into(), - commands: vec![format!("sudo journalctl --vacuum-size={threshold_mb}M")], - }), - }) - } else { - CheckResult::default() - } - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::if_same_then_else)] -mod tests { - use super::*; - use hah_core::{ - check::Context, - config::Config, - distro::DistroInfo, - runner::{CommandOutput, CommandRunner}, - }; - use mockall::mock; - use std::sync::Arc; - use std::time::{Duration, SystemTime}; - - mock! { - Runner {} - impl CommandRunner for Runner { - fn run<'a>(&self, program: &'a str, args: &'a [&'a str]) -> std::io::Result; - } - } - - fn ok_output(stdout: &str) -> CommandOutput { - CommandOutput { - stdout: stdout.as_bytes().to_vec(), - stderr: vec![], - success: true, - } - } - - fn make_ctx(runner: Arc) -> Context { - Context { - verbose: false, - config: Config::default(), - distro: DistroInfo::default(), - runner, - } - } - - // ── JournalSizeCheck ────────────────────────────────────────────────────── - - #[test] - fn journal_size_id_and_title() { - assert_eq!(JournalSizeCheck.id(), "journal-size"); - assert!(!JournalSizeCheck.title().is_empty()); - } - - #[test] - fn journal_size_below_default_threshold() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| { - Ok(ok_output( - "Archived and active journals take up 100M in the file system.\n", - )) - }); - assert!( - JournalSizeCheck - .run(&make_ctx(Arc::new(runner))) - .findings - .is_empty() - ); - } - - #[test] - fn journal_size_above_default_threshold() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| { - Ok(ok_output( - "Archived and active journals take up 2G in the file system.\n", - )) - }); - let result = JournalSizeCheck.run(&make_ctx(Arc::new(runner))); - assert_eq!(result.findings.len(), 1); - assert_eq!(result.findings[0].severity, Severity::Warning); - } - - #[test] - fn journal_size_runner_error_returns_empty() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| { - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "not found", - )) - }); - assert!( - JournalSizeCheck - .run(&make_ctx(Arc::new(runner))) - .findings - .is_empty() - ); - } - - #[test] - fn journal_size_custom_threshold_not_exceeded() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| { - Ok(ok_output( - "Archived and active journals take up 1G in the file system.\n", - )) - }); - let mut config = Config::default(); - config.thresholds.insert("journal_size_mb".into(), 2048); // 2 GB threshold - let ctx = Context { - config, - ..make_ctx(Arc::new(runner)) - }; - assert!(JournalSizeCheck.run(&ctx).findings.is_empty()); - } - - #[test] - fn journal_size_output_without_size_returns_empty() { - let mut runner = MockRunner::new(); - runner - .expect_run() - .returning(|_, _| Ok(ok_output("No journal files found.\n"))); - assert!( - JournalSizeCheck - .run(&make_ctx(Arc::new(runner))) - .findings - .is_empty() - ); - } - - // ── BrokenSymlinksCheck ─────────────────────────────────────────────────── - - #[test] - fn broken_symlinks_id_and_title() { - assert_eq!(BrokenSymlinksCheck.id(), "broken-symlinks"); - assert!(!BrokenSymlinksCheck.title().is_empty()); - } - - #[test] - fn broken_symlinks_does_not_panic() { - let ctx = make_ctx(Arc::new(MockRunner::new())); - let result = BrokenSymlinksCheck.run(&ctx); - for f in &result.findings { - assert!(!f.id.is_empty()); - } - } - - #[test] - fn scan_for_broken_symlinks_empty_dir_returns_no_findings() { - let tmp = tempfile::tempdir().unwrap(); - let result = scan_for_broken_symlinks(&[tmp.path().to_str().unwrap()]); - assert!(result.findings.is_empty()); - } - - #[test] - fn scan_for_broken_symlinks_detects_broken_link() { - let tmp = tempfile::tempdir().unwrap(); - let link = tmp.path().join("broken"); - std::os::unix::fs::symlink("/nonexistent/target_xyz", &link).unwrap(); - let result = scan_for_broken_symlinks(&[tmp.path().to_str().unwrap()]); - assert_eq!(result.findings.len(), 1); - assert!(result.findings[0].description.contains("broken")); - } - - #[test] - fn scan_for_broken_symlinks_valid_link_is_not_reported() { - let tmp = tempfile::tempdir().unwrap(); - let target = tmp.path().join("real"); - std::fs::write(&target, "data").unwrap(); - let link = tmp.path().join("valid_link"); - std::os::unix::fs::symlink(&target, &link).unwrap(); - let result = scan_for_broken_symlinks(&[tmp.path().to_str().unwrap()]); - assert!(result.findings.is_empty()); - } - - // ── OldCrashDumpsCheck ──────────────────────────────────────────────────── - - #[test] - fn old_crash_dumps_id_and_title() { - assert_eq!(OldCrashDumpsCheck.id(), "old-crash-dumps"); - assert!(!OldCrashDumpsCheck.title().is_empty()); - } - - #[test] - fn old_crash_dumps_does_not_panic() { - let ctx = make_ctx(Arc::new(MockRunner::new())); - let _ = OldCrashDumpsCheck.run(&ctx); - } - - #[test] - fn old_crash_dumps_custom_threshold() { - let mut config = Config::default(); - config.thresholds.insert("crash_dump_max_days".into(), 7); - let ctx = Context { - config, - ..make_ctx(Arc::new(MockRunner::new())) - }; - let _ = OldCrashDumpsCheck.run(&ctx); - } - - #[test] - fn scan_crash_dirs_empty_returns_no_findings() { - let tmp = tempfile::tempdir().unwrap(); - let result = scan_crash_dirs(&[tmp.path().to_str().unwrap()], 30); - assert!(result.findings.is_empty()); - } - - #[test] - fn scan_crash_dirs_recent_file_not_reported() { - let tmp = tempfile::tempdir().unwrap(); - std::fs::write(tmp.path().join("core"), b"data").unwrap(); - // A recently-created file is newer than the 30-day threshold → no finding - let result = scan_crash_dirs(&[tmp.path().to_str().unwrap()], 30); - assert!(result.findings.is_empty()); - } - - #[test] - fn scan_crash_dirs_old_file_produces_finding() { - let tmp = tempfile::tempdir().unwrap(); - let f = tmp.path().join("core.old"); - std::fs::write(&f, b"data").unwrap(); - // Set mtime to 60 days ago - let sixty_days_ago = std::time::UNIX_EPOCH - + Duration::from_secs( - SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - - 60 * 86400, - ); - filetime::set_file_mtime(&f, filetime::FileTime::from_system_time(sixty_days_ago)).unwrap(); - // threshold = 30 days ago → file (60d old) is older → should produce finding - let result = scan_crash_dirs(&[tmp.path().to_str().unwrap()], 30); - assert_eq!(result.findings.len(), 1); - assert!(result.findings[0].id.starts_with("crash-dump-")); - } -} diff --git a/crates/hah-checks/src/lib.rs b/crates/hah-checks/src/lib.rs deleted file mode 100644 index 085c1aa..0000000 --- a/crates/hah-checks/src/lib.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod apt; -pub mod boot; -pub mod drift; -pub mod network; -pub mod snap; -pub mod sysctl; diff --git a/crates/hah-checks/src/network.rs b/crates/hah-checks/src/network.rs deleted file mode 100644 index b98f8f5..0000000 --- a/crates/hah-checks/src/network.rs +++ /dev/null @@ -1,902 +0,0 @@ -use std::{fs, path::Path}; - -use hah_core::{ - check::{Check, Context}, - model::{CheckResult, Finding, Remediation, Severity}, - runner::CommandRunner, -}; - -// ── helpers ─────────────────────────────────────────────────────────────────── - -fn is_package_installed(runner: &dyn CommandRunner, name: &str) -> bool { - runner - .run("dpkg-query", &["-W", "-f=${Status}", name]) - .is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains("install ok installed")) -} - -fn is_service_active(runner: &dyn CommandRunner, name: &str) -> bool { - runner - .run("systemctl", &["is-active", "--quiet", name]) - .is_ok_and(|o| o.success) -} - -// ── LegacyNtpCheck ──────────────────────────────────────────────────────────── -/// Detects the legacy ISC ntpd package. Modern systems should use chrony or -/// systemd-timesyncd, which handle VM clock skew and offer better security. -pub struct LegacyNtpCheck; - -impl Check for LegacyNtpCheck { - fn id(&self) -> &str { - "legacy-ntp" - } - - fn title(&self) -> &str { - "Legacy NTP daemon (ntpd / ISC ntp)" - } - - fn run(&self, ctx: &Context) -> CheckResult { - if !ctx.distro.is_debian_family() { - return CheckResult::default(); - } - if !is_package_installed(ctx.runner.as_ref(), "ntp") { - return CheckResult::default(); - } - - let chrony_active = is_service_active(ctx.runner.as_ref(), "chrony"); - let timesyncd_active = is_service_active(ctx.runner.as_ref(), "systemd-timesyncd"); - let modern_active = chrony_active || timesyncd_active; - - let (description, severity) = if modern_active { - let which = match (chrony_active, timesyncd_active) { - (true, true) => "chrony and systemd-timesyncd", - (true, false) => "chrony", - _ => "systemd-timesyncd", - }; - ( - format!( - "The legacy `ntp` (ISC ntpd) package is installed while {which} is also \ - active. Multiple time-sync daemons compete to adjust the clock, which can \ - cause instability. Remove the `ntp` package." - ), - Severity::Warning, - ) - } else { - ( - "The legacy `ntp` (ISC ntpd) package is installed. Modern alternatives such as \ - `chrony` (recommended for servers and VMs) or `systemd-timesyncd` offer better \ - accuracy, NTS/DNSSEC support, and resilience to large clock jumps." - .to_string(), - Severity::Info, - ) - }; - - CheckResult::default().with_finding(Finding { - id: "legacy-ntp".into(), - title: "Legacy `ntp` (ISC ntpd) package is installed".into(), - description, - severity, - remediation: Some(Remediation { - description: "Remove ntp and enable chrony or systemd-timesyncd.".into(), - commands: vec![ - "sudo apt remove --purge ntp".into(), - "sudo apt install chrony && sudo systemctl enable --now chrony".into(), - ], - }), - }) - } -} - -// ── NtpConflictCheck ────────────────────────────────────────────────────────── -/// Detects multiple NTP services active simultaneously, which causes competing -/// clock adjustments and potential time instability. -pub struct NtpConflictCheck; - -impl Check for NtpConflictCheck { - fn id(&self) -> &str { - "ntp-conflict" - } - - fn title(&self) -> &str { - "Multiple NTP services active simultaneously" - } - - fn run(&self, ctx: &Context) -> CheckResult { - let candidates: &[(&str, &str)] = &[ - ("ntp", "ntp (ntpd)"), - ("chrony", "chrony"), - ("openntpd", "openntpd"), - ]; - - let active_daemons: Vec<&str> = candidates - .iter() - .filter(|(svc, _)| is_service_active(ctx.runner.as_ref(), svc)) - .map(|(_, label)| *label) - .collect(); - - let timesyncd = is_service_active(ctx.runner.as_ref(), "systemd-timesyncd"); - - // Conflict: more than one real NTP daemon, or a real daemon + timesyncd - let conflict = active_daemons.len() > 1 || (timesyncd && !active_daemons.is_empty()); - - if !conflict { - return CheckResult::default(); - } - - let mut all = active_daemons.clone(); - if timesyncd { - all.push("systemd-timesyncd"); - } - let list = all.join(", "); - - CheckResult::default().with_finding(Finding { - id: "ntp-conflict".into(), - title: format!("Multiple NTP services active: {list}"), - description: format!( - "The following time-sync services are all active: {list}. \ - Competing daemons can fight over the system clock and cause \ - time jumps, log timestamp corruption, or TLS certificate errors." - ), - severity: Severity::Warning, - remediation: Some(Remediation { - description: "Disable all but one NTP service (chrony is recommended).".into(), - commands: vec![ - "sudo systemctl disable --now ntp".into(), - "sudo systemctl disable --now openntpd".into(), - "sudo systemctl disable --now systemd-timesyncd".into(), - "# Then enable only one: sudo systemctl enable --now chrony".into(), - ], - }), - }) - } -} - -// ── LegacyDhcpClientCheck ───────────────────────────────────────────────────── -/// Detects the legacy isc-dhcp-client (dhclient) package on systems where -/// NetworkManager or systemd-networkd already handles DHCP. -pub struct LegacyDhcpClientCheck; - -impl Check for LegacyDhcpClientCheck { - fn id(&self) -> &str { - "legacy-dhcp-client" - } - - fn title(&self) -> &str { - "Legacy ISC DHCP client (dhclient)" - } - - fn run(&self, ctx: &Context) -> CheckResult { - if !ctx.distro.is_debian_family() { - return CheckResult::default(); - } - if !is_package_installed(ctx.runner.as_ref(), "isc-dhcp-client") { - return CheckResult::default(); - } - - let nm = is_package_installed(ctx.runner.as_ref(), "network-manager"); - let networkd = is_service_active(ctx.runner.as_ref(), "systemd-networkd"); - - if !nm && !networkd { - return CheckResult::default(); - } - - let manager = match (nm, networkd) { - (true, true) => "NetworkManager and systemd-networkd", - (true, false) => "NetworkManager", - _ => "systemd-networkd", - }; - - CheckResult::default().with_finding(Finding { - id: "legacy-dhcp-client".into(), - title: "Legacy isc-dhcp-client installed alongside a modern network manager".into(), - description: format!( - "The `isc-dhcp-client` (dhclient) package is installed, but {manager} is \ - already managing DHCP. The legacy client is redundant, unmaintained \ - upstream, and can be safely removed." - ), - severity: Severity::Info, - remediation: Some(Remediation { - description: "Remove the legacy ISC DHCP client.".into(), - commands: vec!["sudo apt remove --purge isc-dhcp-client".into()], - }), - }) - } -} - -// ── LegacyNetworkInterfacesCheck ────────────────────────────────────────────── -/// Detects non-loopback interface definitions in /etc/network/interfaces, -/// the legacy ifupdown configuration file. On modern Debian/Ubuntu systems -/// network interfaces should be managed by Netplan or NetworkManager. -pub struct LegacyNetworkInterfacesCheck; - -/// Count non-loopback interface / auto stanzas in an `/etc/network/interfaces` -/// file content string. Extracted for unit-testing. -pub(crate) fn count_non_lo_ifaces(content: &str) -> usize { - content - .lines() - .filter(|l| { - let t = l.trim(); - (t.starts_with("iface ") && !t.starts_with("iface lo ")) - || (t.starts_with("auto ") && t.trim_start_matches("auto").trim() != "lo") - }) - .count() -} - -impl Check for LegacyNetworkInterfacesCheck { - fn id(&self) -> &str { - "legacy-network-interfaces" - } - - fn title(&self) -> &str { - "Legacy /etc/network/interfaces configuration" - } - - fn run(&self, ctx: &Context) -> CheckResult { - let path = Path::new("/etc/network/interfaces"); - if !path.exists() { - return CheckResult::default(); - } - - let content = match fs::read_to_string(path) { - Ok(c) => c, - Err(e) => return CheckResult::default().with_error(e.to_string()), - }; - - let non_lo_count = count_non_lo_ifaces(&content); - - if non_lo_count == 0 { - return CheckResult::default(); - } - - let netplan_active = Path::new("/etc/netplan").exists() - && fs::read_dir("/etc/netplan").is_ok_and(|mut d| d.next().is_some()); - let nm_active = is_service_active(ctx.runner.as_ref(), "NetworkManager"); - - legacy_interfaces_finding(non_lo_count, netplan_active, nm_active) - .map_or_else(CheckResult::default, |f| { - CheckResult::default().with_finding(f) - }) - } -} - -/// Build the finding (or None) for `LegacyNetworkInterfacesCheck` given parsed -/// state. Extracted for deterministic unit-testing. -pub(crate) fn legacy_interfaces_finding( - non_lo_count: usize, - netplan_active: bool, - nm_active: bool, -) -> Option { - if non_lo_count == 0 { - return None; - } - let managed_elsewhere = netplan_active || nm_active; - - let manager_name = match (netplan_active, nm_active) { - (true, true) => "Netplan and NetworkManager", - (true, false) => "Netplan", - (false, true) => "NetworkManager", - _ => "", - }; - - let (description, severity) = if managed_elsewhere { - ( - format!( - "/etc/network/interfaces defines {non_lo_count} non-loopback \ - interface(s), but {manager_name} is also active. This overlap can \ - cause conflicts, double-configuration, or interfaces failing to come \ - up correctly after reboot." - ), - Severity::Warning, - ) - } else { - ( - format!( - "/etc/network/interfaces defines {non_lo_count} non-loopback \ - interface(s) using the legacy ifupdown format. Consider migrating \ - to Netplan or NetworkManager." - ), - Severity::Info, - ) - }; - - Some(Finding { - id: "legacy-network-interfaces".into(), - title: format!("/etc/network/interfaces has {non_lo_count} non-loopback entry(s)"), - description, - severity, - remediation: Some(Remediation { - description: "Migrate interface configuration to Netplan or NetworkManager.".into(), - commands: vec![ - "# Netplan reference: https://netplan.readthedocs.io/".into(), - "# After migration: sudo apt remove --purge ifupdown".into(), - ], - }), - }) -} - -// ── ResolvedConfigCheck ─────────────────────────────────────────────────────── -/// Detects a misconfigured /etc/resolv.conf on systems where systemd-resolved -/// is active. The file should be a symlink to the stub resolver so that -/// DNS caching, DNSSEC validation, and per-link DNS settings work correctly. -pub struct ResolvedConfigCheck; - -impl Check for ResolvedConfigCheck { - fn id(&self) -> &str { - "resolved-config" - } - - fn title(&self) -> &str { - "systemd-resolved DNS resolver configuration" - } - - fn run(&self, ctx: &Context) -> CheckResult { - if !is_service_active(ctx.runner.as_ref(), "systemd-resolved") { - return CheckResult::default(); - } - - let resolv = Path::new("/etc/resolv.conf"); - let correct_targets = [ - "/run/systemd/resolve/stub-resolv.conf", - "../run/systemd/resolve/stub-resolv.conf", - ]; - - let is_correct = resolv.is_symlink() - && fs::read_link(resolv).is_ok_and(|t| { - let s = t.to_string_lossy().into_owned(); - correct_targets.iter().any(|ok| s == *ok) || s.contains("systemd/resolve") - }); - - if is_correct { - return CheckResult::default(); - } - - let current = if resolv.is_symlink() { - fs::read_link(resolv).map_or_else( - |_| "an unreadable symlink".into(), - |t| format!("a symlink to {}", t.display()), - ) - } else { - "a plain file (not managed by systemd-resolved)".into() - }; - - CheckResult::default().with_finding(Finding { - id: "resolved-config".into(), - title: "/etc/resolv.conf is not linked to systemd-resolved".into(), - description: format!( - "systemd-resolved is active but /etc/resolv.conf is {current}. \ - It should be a symlink to /run/systemd/resolve/stub-resolv.conf \ - so that DNS caching, DNSSEC validation, and split-DNS work correctly. \ - This is a common misconfiguration left over after upgrades." - ), - severity: Severity::Warning, - remediation: Some(Remediation { - description: "Link /etc/resolv.conf to the systemd-resolved stub resolver.".into(), - commands: vec![ - "sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf".into(), - ], - }), - }) - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::if_same_then_else)] -mod tests { - use super::*; - use hah_core::{ - check::Context, - config::Config, - distro::DistroInfo, - runner::{CommandOutput, CommandRunner}, - }; - use mockall::mock; - use std::sync::Arc; - - mock! { - Runner {} - impl CommandRunner for Runner { - fn run<'a>(&self, program: &'a str, args: &'a [&'a str]) -> std::io::Result; - } - } - - fn ok_output(stdout: &str) -> CommandOutput { - CommandOutput { - stdout: stdout.as_bytes().to_vec(), - stderr: vec![], - success: true, - } - } - - fn success_output() -> CommandOutput { - CommandOutput { - stdout: vec![], - stderr: vec![], - success: true, - } - } - - fn failure_output() -> CommandOutput { - CommandOutput { - stdout: vec![], - stderr: vec![], - success: false, - } - } - - fn make_ctx(runner: Arc, distro_id: &str) -> Context { - Context { - verbose: false, - config: Config::default(), - distro: DistroInfo { - id: distro_id.into(), - ..DistroInfo::default() - }, - runner, - } - } - - fn debian_ctx(runner: Arc) -> Context { - make_ctx(runner, "debian") - } - - fn non_debian_ctx() -> Context { - make_ctx(Arc::new(MockRunner::new()), "arch") - } - - // ── helpers ─────────────────────────────────────────────────────────────── - - #[test] - fn is_package_installed_returns_true_when_status_matches() { - let mut runner = MockRunner::new(); - runner - .expect_run() - .returning(|_, _| Ok(ok_output("install ok installed"))); - assert!(is_package_installed(&runner, "bash")); - } - - #[test] - fn is_package_installed_returns_false_when_not_installed() { - let mut runner = MockRunner::new(); - runner - .expect_run() - .returning(|_, _| Ok(ok_output("deinstall ok config-files"))); - assert!(!is_package_installed(&runner, "bash")); - } - - #[test] - fn is_package_installed_returns_false_on_runner_error() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| { - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "not found", - )) - }); - assert!(!is_package_installed(&runner, "bash")); - } - - #[test] - fn is_service_active_returns_true_on_success_exit() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| Ok(success_output())); - assert!(is_service_active(&runner, "nginx")); - } - - #[test] - fn is_service_active_returns_false_on_failure_exit() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| Ok(failure_output())); - assert!(!is_service_active(&runner, "nginx")); - } - - #[test] - fn is_service_active_returns_false_on_runner_error() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| { - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "not found", - )) - }); - assert!(!is_service_active(&runner, "nginx")); - } - - // ── LegacyNtpCheck ──────────────────────────────────────────────────────── - - #[test] - fn legacy_ntp_check_id_and_title() { - assert_eq!(LegacyNtpCheck.id(), "legacy-ntp"); - assert!(!LegacyNtpCheck.title().is_empty()); - } - - #[test] - fn legacy_ntp_skips_non_debian() { - assert!(LegacyNtpCheck.run(&non_debian_ctx()).findings.is_empty()); - } - - #[test] - fn legacy_ntp_not_installed_returns_empty() { - let mut runner = MockRunner::new(); - // dpkg-query returns "not installed" - runner - .expect_run() - .returning(|_, _| Ok(ok_output("unknown ok not-installed"))); - assert!( - LegacyNtpCheck - .run(&debian_ctx(Arc::new(runner))) - .findings - .is_empty() - ); - } - - #[test] - fn legacy_ntp_installed_no_modern_service() { - let mut runner = MockRunner::new(); - let call = std::sync::atomic::AtomicUsize::new(0); - runner.expect_run().returning(move |_, _| { - let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if n == 0 { - Ok(ok_output("install ok installed")) // ntp installed - } else { - Ok(failure_output()) // neither chrony nor timesyncd active - } - }); - let result = LegacyNtpCheck.run(&debian_ctx(Arc::new(runner))); - assert_eq!(result.findings.len(), 1); - assert_eq!(result.findings[0].severity, Severity::Info); - } - - #[test] - fn legacy_ntp_installed_with_chrony_active() { - let mut runner = MockRunner::new(); - let call = std::sync::atomic::AtomicUsize::new(0); - runner.expect_run().returning(move |_, _| { - let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if n == 0 { - Ok(ok_output("install ok installed")) // ntp installed - } else if n == 1 { - Ok(success_output()) // chrony active - } else { - Ok(failure_output()) // timesyncd inactive - } - }); - let result = LegacyNtpCheck.run(&debian_ctx(Arc::new(runner))); - assert_eq!(result.findings.len(), 1); - assert_eq!(result.findings[0].severity, Severity::Warning); - } - - #[test] - fn legacy_ntp_installed_with_timesyncd_active() { - let mut runner = MockRunner::new(); - let call = std::sync::atomic::AtomicUsize::new(0); - runner.expect_run().returning(move |_, _| { - let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if n == 0 { - Ok(ok_output("install ok installed")) // ntp installed - } else if n == 1 { - Ok(failure_output()) // chrony inactive - } else { - Ok(success_output()) // timesyncd active - } - }); - let result = LegacyNtpCheck.run(&debian_ctx(Arc::new(runner))); - assert_eq!(result.findings.len(), 1); - assert_eq!(result.findings[0].severity, Severity::Warning); - } - - #[test] - fn legacy_ntp_installed_with_both_modern_services() { - let mut runner = MockRunner::new(); - let call = std::sync::atomic::AtomicUsize::new(0); - runner.expect_run().returning(move |_, _| { - let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if n == 0 { - Ok(ok_output("install ok installed")) - } - // ntp - else { - Ok(success_output()) - } // both chrony + timesyncd active - }); - let result = LegacyNtpCheck.run(&debian_ctx(Arc::new(runner))); - assert_eq!(result.findings.len(), 1); - assert_eq!(result.findings[0].severity, Severity::Warning); - } - - // ── NtpConflictCheck ────────────────────────────────────────────────────── - - #[test] - fn ntp_conflict_check_id_and_title() { - assert_eq!(NtpConflictCheck.id(), "ntp-conflict"); - assert!(!NtpConflictCheck.title().is_empty()); - } - - #[test] - fn ntp_conflict_no_daemons_active() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| Ok(failure_output())); - assert!( - NtpConflictCheck - .run(&make_ctx(Arc::new(runner), "any")) - .findings - .is_empty() - ); - } - - #[test] - fn ntp_conflict_single_daemon_no_conflict() { - let mut runner = MockRunner::new(); - let call = std::sync::atomic::AtomicUsize::new(0); - runner.expect_run().returning(move |_, _| { - let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - // calls: ntp(fail), chrony(ok), openntpd(fail), timesyncd(fail) - if n == 1 { - Ok(success_output()) - } else { - Ok(failure_output()) - } - }); - assert!( - NtpConflictCheck - .run(&make_ctx(Arc::new(runner), "any")) - .findings - .is_empty() - ); - } - - #[test] - fn ntp_conflict_two_daemons_flagged() { - let mut runner = MockRunner::new(); - let call = std::sync::atomic::AtomicUsize::new(0); - runner.expect_run().returning(move |_, _| { - let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - // ntp(ok), chrony(ok), openntpd(fail), timesyncd(fail) - if n <= 1 { - Ok(success_output()) - } else { - Ok(failure_output()) - } - }); - let result = NtpConflictCheck.run(&make_ctx(Arc::new(runner), "any")); - assert_eq!(result.findings.len(), 1); - assert_eq!(result.findings[0].severity, Severity::Warning); - } - - #[test] - fn ntp_conflict_daemon_plus_timesyncd_flagged() { - let mut runner = MockRunner::new(); - let call = std::sync::atomic::AtomicUsize::new(0); - runner.expect_run().returning(move |_, _| { - let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - // ntp(fail), chrony(ok), openntpd(fail), timesyncd(ok) - if n == 1 || n == 3 { - Ok(success_output()) - } else { - Ok(failure_output()) - } - }); - assert_eq!( - NtpConflictCheck - .run(&make_ctx(Arc::new(runner), "any")) - .findings - .len(), - 1 - ); - } - - // ── LegacyDhcpClientCheck ───────────────────────────────────────────────── - - #[test] - fn legacy_dhcp_check_id_and_title() { - assert_eq!(LegacyDhcpClientCheck.id(), "legacy-dhcp-client"); - assert!(!LegacyDhcpClientCheck.title().is_empty()); - } - - #[test] - fn legacy_dhcp_skips_non_debian() { - assert!( - LegacyDhcpClientCheck - .run(&non_debian_ctx()) - .findings - .is_empty() - ); - } - - #[test] - fn legacy_dhcp_not_installed_returns_empty() { - let mut runner = MockRunner::new(); - runner - .expect_run() - .returning(|_, _| Ok(ok_output("unknown ok not-installed"))); - assert!( - LegacyDhcpClientCheck - .run(&debian_ctx(Arc::new(runner))) - .findings - .is_empty() - ); - } - - #[test] - fn legacy_dhcp_installed_no_modern_manager_returns_empty() { - let mut runner = MockRunner::new(); - let call = std::sync::atomic::AtomicUsize::new(0); - runner.expect_run().returning(move |_, _| { - let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if n == 0 { - Ok(ok_output("install ok installed")) // isc-dhcp-client installed - } else if n == 1 { - Ok(ok_output("unknown ok not-installed")) // network-manager not installed - } else { - Ok(failure_output()) // systemd-networkd not active - } - }); - assert!( - LegacyDhcpClientCheck - .run(&debian_ctx(Arc::new(runner))) - .findings - .is_empty() - ); - } - - #[test] - fn legacy_dhcp_installed_with_nm_flagged() { - let mut runner = MockRunner::new(); - let call = std::sync::atomic::AtomicUsize::new(0); - runner.expect_run().returning(move |_, _| { - let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if n == 0 { - Ok(ok_output("install ok installed")) // isc-dhcp-client - } else if n == 1 { - Ok(ok_output("install ok installed")) // network-manager - } else { - Ok(failure_output()) // systemd-networkd inactive - } - }); - let result = LegacyDhcpClientCheck.run(&debian_ctx(Arc::new(runner))); - assert_eq!(result.findings.len(), 1); - assert_eq!(result.findings[0].severity, Severity::Info); - } - - #[test] - fn legacy_dhcp_installed_with_networkd_flagged() { - let mut runner = MockRunner::new(); - let call = std::sync::atomic::AtomicUsize::new(0); - runner.expect_run().returning(move |_, _| { - let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if n == 0 { - Ok(ok_output("install ok installed")) // isc-dhcp-client - } else if n == 1 { - Ok(ok_output("unknown ok not-installed")) // network-manager absent - } else { - Ok(success_output()) // systemd-networkd active - } - }); - assert_eq!( - LegacyDhcpClientCheck - .run(&debian_ctx(Arc::new(runner))) - .findings - .len(), - 1 - ); - } - - #[test] - fn legacy_dhcp_installed_with_both_managers_flagged() { - let mut runner = MockRunner::new(); - let call = std::sync::atomic::AtomicUsize::new(0); - runner.expect_run().returning(move |_, _| { - let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if n == 0 || n == 1 { - Ok(ok_output("install ok installed")) // dhcp-client + nm installed - } else { - Ok(success_output()) // networkd active too - } - }); - assert_eq!( - LegacyDhcpClientCheck - .run(&debian_ctx(Arc::new(runner))) - .findings - .len(), - 1 - ); - } - - // ── LegacyNetworkInterfacesCheck ────────────────────────────────────────── - - #[test] - fn legacy_interfaces_check_id_and_title() { - assert_eq!( - LegacyNetworkInterfacesCheck.id(), - "legacy-network-interfaces" - ); - assert!(!LegacyNetworkInterfacesCheck.title().is_empty()); - } - - #[test] - fn legacy_interfaces_runs_without_panic() { - // /etc/network/interfaces may or may not exist on the test machine - let ctx = make_ctx(Arc::new(MockRunner::new()), "any"); - let _ = LegacyNetworkInterfacesCheck.run(&ctx); - } - - // ── count_non_lo_ifaces ─────────────────────────────────────────────────── - - #[test] - fn count_non_lo_empty_file_returns_zero() { - assert_eq!(count_non_lo_ifaces(""), 0); - } - - #[test] - fn count_non_lo_loopback_only_returns_zero() { - let content = "auto lo\niface lo inet loopback\n"; - assert_eq!(count_non_lo_ifaces(content), 0); - } - - #[test] - fn count_non_lo_eth0_counts_one() { - let content = "auto lo\niface lo inet loopback\nauto eth0\niface eth0 inet dhcp\n"; - assert_eq!(count_non_lo_ifaces(content), 2); // "auto eth0" + "iface eth0" - } - - // ── legacy_interfaces_finding ───────────────────────────────────────────── - - #[test] - fn legacy_interfaces_finding_zero_count_returns_none() { - assert!(legacy_interfaces_finding(0, false, false).is_none()); - } - - #[test] - fn legacy_interfaces_finding_no_manager_is_info() { - let f = legacy_interfaces_finding(1, false, false).unwrap(); - assert_eq!(f.severity, Severity::Info); - } - - #[test] - fn legacy_interfaces_finding_nm_active_is_warning() { - let f = legacy_interfaces_finding(1, false, true).unwrap(); - assert_eq!(f.severity, Severity::Warning); - assert!(f.description.contains("NetworkManager")); - } - - #[test] - fn legacy_interfaces_finding_netplan_active_is_warning() { - let f = legacy_interfaces_finding(1, true, false).unwrap(); - assert_eq!(f.severity, Severity::Warning); - assert!(f.description.contains("Netplan")); - } - - #[test] - fn legacy_interfaces_finding_both_managers_is_warning() { - let f = legacy_interfaces_finding(1, true, true).unwrap(); - assert_eq!(f.severity, Severity::Warning); - assert!(f.description.contains("Netplan and NetworkManager")); - } - - // ── ResolvedConfigCheck ─────────────────────────────────────────────────── - - #[test] - fn resolved_config_check_id_and_title() { - assert_eq!(ResolvedConfigCheck.id(), "resolved-config"); - assert!(!ResolvedConfigCheck.title().is_empty()); - } - - #[test] - fn resolved_config_skips_when_resolved_inactive() { - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| Ok(failure_output())); - assert!( - ResolvedConfigCheck - .run(&make_ctx(Arc::new(runner), "any")) - .findings - .is_empty() - ); - } - - #[test] - fn resolved_config_active_resolved_runs_without_panic() { - // systemd-resolved is active → check reads /etc/resolv.conf - let mut runner = MockRunner::new(); - runner.expect_run().returning(|_, _| Ok(success_output())); - let ctx = make_ctx(Arc::new(runner), "any"); - let _ = ResolvedConfigCheck.run(&ctx); - } -} diff --git a/crates/hah-checks/src/snap.rs b/crates/hah-checks/src/snap.rs deleted file mode 100644 index 7144eed..0000000 --- a/crates/hah-checks/src/snap.rs +++ /dev/null @@ -1,379 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use hah_core::{ - check::{Check, Context}, - model::{CheckResult, Finding, Remediation, Severity}, -}; - -// ── Finding builders ──────────────────────────────────────────────────────── - -fn disabled_revision_finding(name: &str, rev: &str) -> Finding { - Finding { - id: format!("snap-disabled-{name}-{rev}"), - title: format!("Snap '{name}' revision {rev} is disabled"), - description: format!( - "Disabled snap revisions still consume disk space. \ - Consider removing old revisions of '{name}'." - ), - severity: Severity::Info, - remediation: Some(Remediation { - description: format!("Remove disabled revision {rev} of {name}."), - commands: vec![format!("sudo snap remove {name} --revision={rev}")], - }), - } -} - -fn excess_revisions_finding(name: &str, count: u32, max_revisions: u64) -> Finding { - Finding { - id: format!("snap-too-many-revisions-{name}"), - title: format!("Snap '{name}' has {count} retained revisions (threshold: {max_revisions})"), - description: format!( - "Snap retains {count} revisions of '{name}', which wastes disk space." - ), - severity: Severity::Info, - remediation: Some(Remediation { - description: "Reduce the number of snap revisions retained.".into(), - commands: vec![format!( - "sudo snap set system refresh.retain={max_revisions}" - )], - }), - } -} - -// ── SnapHealthCheck ────────────────────────────────────────────────────────── - -pub struct SnapHealthCheck; - -impl Check for SnapHealthCheck { - fn id(&self) -> &str { - "snap-health" - } - - fn title(&self) -> &str { - "Snap package health" - } - - fn run(&self, ctx: &Context) -> CheckResult { - let max_revisions = ctx.config.threshold("snap_max_revisions", 2); - let out = match ctx.runner.run("snap", &["list", "--all"]) { - Ok(o) => o, - Err(_) => return CheckResult::default(), // snap not installed - }; - let stdout = String::from_utf8_lossy(&out.stdout); - let mut snap_revisions: HashMap = HashMap::new(); - let mut result = CheckResult::default(); - for line in stdout.lines().skip(1) { - let fields: Vec<&str> = line.split_whitespace().collect(); - if fields.len() < 3 { - continue; - } - let name = fields[0].to_string(); - let rev = fields[2].to_string(); - if fields.last().copied().unwrap_or("").contains("disabled") { - result = result.with_finding(disabled_revision_finding(&name, &rev)); - } - *snap_revisions.entry(name).or_default() += 1; - } - for (name, count) in &snap_revisions { - if *count > max_revisions as u32 { - result = result.with_finding(excess_revisions_finding(name, *count, max_revisions)); - } - } - result - } -} - -// ── SnapAptDuplicateCheck ──────────────────────────────────────────────────── - -pub struct SnapAptDuplicateCheck; - -impl Check for SnapAptDuplicateCheck { - fn id(&self) -> &str { - "snap-apt-duplicate" - } - - fn title(&self) -> &str { - "Software installed via both Snap and APT" - } - - fn run(&self, ctx: &Context) -> CheckResult { - let snap_out = match ctx.runner.run("snap", &["list"]) { - Ok(o) => o, - Err(_) => return CheckResult::default(), - }; - - let apt_out = match ctx.runner.run("dpkg-query", &["-W", "-f=${Package}\n"]) { - Ok(o) => o, - Err(e) => return CheckResult::default().with_error(e.to_string()), - }; - - let snaps: HashSet = String::from_utf8_lossy(&snap_out.stdout) - .lines() - .skip(1) - .filter_map(|l| l.split_whitespace().next().map(str::to_string)) - .collect(); - - let apt: HashSet = String::from_utf8_lossy(&apt_out.stdout) - .lines() - .map(str::to_string) - .collect(); - - let mut result = CheckResult::default(); - for name in snaps.intersection(&apt) { - // snapd is intentionally present in both: the Debian package - // bootstraps the host, then the snapd snap takes over for - // self-updates. Reporting it as a duplicate is a false positive. - if name == "snapd" { - continue; - } - if ctx.config.allowlist.packages.contains(name) { - continue; - } - result = result.with_finding(Finding { - id: format!("snap-apt-dup-{name}"), - title: format!("'{name}' is installed via both APT and Snap"), - description: format!( - "Having '{name}' installed twice wastes space and may cause \ - version conflicts or confusion." - ), - severity: Severity::Warning, - remediation: Some(Remediation { - description: "Remove the APT version if the Snap is preferred.".into(), - commands: vec![format!("sudo apt remove --purge {name}")], - }), - }); - } - result - } -} - -// ── Tests ──────────────────────────────────────────────────────────────────── - -#[cfg(test)] -#[allow(clippy::unwrap_used)] // Mutex::lock().unwrap() is idiomatic in tests -mod tests { - use std::{ - collections::HashMap, - io, - sync::{Arc, Mutex}, - }; - - use hah_core::{ - check::{Check, Context}, - config::Config, - distro::DistroInfo, - runner::{CommandOutput, CommandRunner}, - }; - - use super::{SnapAptDuplicateCheck, SnapHealthCheck}; - - // ── MockRunner ─────────────────────────────────────────────────────────── - - struct MockRunner { - responses: Mutex, bool)>>, - } - - impl MockRunner { - fn new() -> Self { - Self { - responses: Mutex::new(HashMap::new()), - } - } - - fn on(&self, program: &str, stdout: &[u8], success: bool) { - self.responses - .lock() - .unwrap() - .insert(program.to_string(), (stdout.to_vec(), success)); - } - } - - impl CommandRunner for MockRunner { - fn run(&self, program: &str, _args: &[&str]) -> io::Result { - self.responses - .lock() - .unwrap() - .get(program) - .map(|(stdout, success)| CommandOutput { - stdout: stdout.clone(), - stderr: vec![], - success: *success, - }) - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - format!("mock: '{program}' not registered"), - ) - }) - } - } - - fn ctx(runner: Arc) -> Context { - Context { - verbose: false, - config: Config::default(), - distro: DistroInfo::default(), - runner, - } - } - - // ── SnapHealthCheck ─────────────────────────────────────────────────────── - - #[test] - fn snap_not_installed_returns_empty_result() { - // No "snap" registered → run() returns NotFound → check returns default - let result = SnapHealthCheck.run(&ctx(Arc::new(MockRunner::new()))); - assert!(result.findings.is_empty()); - assert!(result.errors.is_empty()); - } - - #[test] - fn snap_no_disabled_revisions_no_findings() { - let mock = MockRunner::new(); - mock.on( - "snap", - b"Name Version Rev Tracking Publisher Notes\n\ - firefox 120.0 100 latest/stable mozilla -\n", - true, - ); - let result = SnapHealthCheck.run(&ctx(Arc::new(mock))); - assert!(result.findings.is_empty()); - } - - #[test] - fn snap_disabled_revision_is_reported() { - let mock = MockRunner::new(); - mock.on( - "snap", - b"Name Version Rev Tracking Publisher Notes\n\ - firefox 119.0 99 latest/stable mozilla disabled\n\ - firefox 120.0 100 latest/stable mozilla -\n", - true, - ); - let result = SnapHealthCheck.run(&ctx(Arc::new(mock))); - assert!( - result - .findings - .iter() - .any(|f| f.id.contains("snap-disabled-firefox")), - "expected a snap-disabled-firefox finding, got: {:?}", - result.findings.iter().map(|f| &f.id).collect::>() - ); - } - - #[test] - fn snap_too_many_revisions_is_reported() { - let mock = MockRunner::new(); - // 3 revisions for firefox; default threshold is 2 → finding expected - mock.on( - "snap", - b"Name Version Rev Tracking Publisher Notes\n\ - firefox 118.0 98 latest/stable mozilla disabled\n\ - firefox 119.0 99 latest/stable mozilla disabled\n\ - firefox 120.0 100 latest/stable mozilla -\n", - true, - ); - let result = SnapHealthCheck.run(&ctx(Arc::new(mock))); - assert!( - result - .findings - .iter() - .any(|f| f.id.contains("snap-too-many-revisions-firefox")), - "expected a snap-too-many-revisions-firefox finding" - ); - } - - // ── SnapAptDuplicateCheck ───────────────────────────────────────────────── - - #[test] - fn no_snap_apt_overlap_returns_no_findings() { - let mock = MockRunner::new(); - mock.on( - "snap", - b"Name Version Rev Tracking Publisher Notes\n\ - firefox 120.0 100 latest/stable mozilla -\n", - true, - ); - mock.on("dpkg-query", b"vim\ngit\ncurl\n", true); - let result = SnapAptDuplicateCheck.run(&ctx(Arc::new(mock))); - assert!(result.findings.is_empty()); - } - - #[test] - fn snap_apt_duplicate_package_is_reported() { - let mock = MockRunner::new(); - mock.on( - "snap", - b"Name Version Rev Tracking Publisher Notes\n\ - vlc 3.0.18 2 latest/stable videolan -\n", - true, - ); - mock.on("dpkg-query", b"vlc\ngit\n", true); - let result = SnapAptDuplicateCheck.run(&ctx(Arc::new(mock))); - assert!( - result - .findings - .iter() - .any(|f| f.id.contains("snap-apt-dup-vlc")), - "expected a snap-apt-dup-vlc finding" - ); - } - - #[test] - fn snap_apt_duplicate_package_in_allowlist_is_skipped() { - let mock = MockRunner::new(); - mock.on( - "snap", - b"Name Version Rev Tracking Publisher Notes\n\ - firefox 120.0 100 latest/stable mozilla -\n", - true, - ); - mock.on("dpkg-query", b"firefox\nvim\n", true); - let mut config = Config::default(); - config.allowlist.packages = vec!["firefox".into()]; - let c = Context { - config, - ..ctx(Arc::new(mock)) - }; - assert!( - SnapAptDuplicateCheck.run(&c).findings.is_empty(), - "allowlisted package must not produce a finding" - ); - } - - #[test] - fn snapd_present_in_both_apt_and_snap_is_not_reported() { - // snapd Debian package is the bootstrap; the snap updates itself. - // Having both is expected and must not produce a finding. - let mock = MockRunner::new(); - mock.on( - "snap", - b"Name Version Rev Tracking Publisher Notes\n\ - snapd 2.63 21 latest/stable canonical -\n", - true, - ); - mock.on("dpkg-query", b"snapd\n", true); - let result = SnapAptDuplicateCheck.run(&ctx(Arc::new(mock))); - assert!( - result.findings.is_empty(), - "snapd must not be reported as a snap/apt duplicate" - ); - } - - #[test] - fn snap_apt_duplicate_dpkg_error_returns_result_with_error() { - let mock = MockRunner::new(); - mock.on( - "snap", - b"Name Version Rev Tracking Publisher Notes\n\ - firefox 120.0 100 latest/stable mozilla -\n", - true, - ); - // "dpkg-query" not registered → NotFound error - let result = SnapAptDuplicateCheck.run(&ctx(Arc::new(mock))); - assert!( - !result.errors.is_empty(), - "dpkg-query failure should be reported as an error" - ); - } -} diff --git a/crates/hah-checks/src/sysctl.rs b/crates/hah-checks/src/sysctl.rs deleted file mode 100644 index 7ab7fe7..0000000 --- a/crates/hah-checks/src/sysctl.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::{fs, path::Path}; - -use hah_core::{ - check::{Check, Context}, - model::{CheckResult, Severity}, -}; - -// ── SysctlOrderingCheck ────────────────────────────────────────────────────── - -pub struct SysctlOrderingCheck; - -const SYSCTL_DIRS: &[&str] = &["/usr/lib/sysctl.d", "/etc/sysctl.d", "/run/sysctl.d"]; - -impl Check for SysctlOrderingCheck { - fn id(&self) -> &str { - "sysctl-ordering" - } - - fn title(&self) -> &str { - "Conflicting sysctl.d overrides" - } - - fn run(&self, _ctx: &Context) -> CheckResult { - // Collect files in load order (lexicographic within each dir, dirs in priority order) - let mut file_entries: Vec<(String, String)> = Vec::new(); // (path, content) - - for dir in SYSCTL_DIRS { - let path = Path::new(dir); - if !path.exists() { - continue; - } - if let Ok(entries) = fs::read_dir(path) { - let mut names: Vec = entries - .flatten() - .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("conf")) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .collect(); - names.sort(); - for name in names { - let full = format!("{dir}/{name}"); - if let Ok(content) = fs::read_to_string(&full) { - file_entries.push((full, content)); - } - } - } - } - - find_conflicts(&file_entries) - } -} - -/// Scan `file_entries` (path, content) pairs for sysctl keys that appear in -/// more than one file with *different* values. Extracted for unit-testing. -pub(crate) fn find_conflicts(file_entries: &[(String, String)]) -> CheckResult { - let mut result = CheckResult::default(); - for conflict in hah_utils::sysctl::find_conflicts(file_entries) { - let details: Vec = conflict - .assignments - .iter() - .map(|(f, v)| format!(" {f}: {v}")) - .collect(); - let key = &conflict.key; - result = result.with_finding(hah_core::model::Finding { - id: format!("sysctl-conflict-{}", key.replace('.', "-")), - title: format!("sysctl key '{key}' has conflicting values across sysctl.d"), - description: format!( - "The key '{key}' is set to different values in multiple files:\n{}", - details.join("\n") - ), - severity: Severity::Warning, - remediation: None, - }); - } - result -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - use hah_core::{check::Context, config::Config, distro::DistroInfo, runner::SystemRunner}; - use std::sync::Arc; - - fn make_ctx() -> Context { - Context { - verbose: false, - config: Config::default(), - distro: DistroInfo::default(), - runner: Arc::new(SystemRunner), - } - } - - #[test] - fn sysctl_ordering_check_id_and_title() { - assert_eq!(SysctlOrderingCheck.id(), "sysctl-ordering"); - assert!(!SysctlOrderingCheck.title().is_empty()); - } - - #[test] - fn sysctl_ordering_does_not_panic_on_real_system() { - // The check scans /usr/lib/sysctl.d, /etc/sysctl.d, /run/sysctl.d. - // On most systems these dirs exist but contain no conflicting values. - // We just verify no panic and that findings have correct structure. - let result = SysctlOrderingCheck.run(&make_ctx()); - for f in &result.findings { - assert!(!f.id.is_empty()); - assert!(!f.title.is_empty()); - } - } - - #[test] - fn sysctl_ordering_with_temp_conflicting_files() { - // Write two temp files with conflicting values into /tmp (not a real sysctl.d, - // so the check won't pick them up — but this exercises parse_size_str indirectly - // by verifying the file parsing logic is reachable). - // We can only observe the real-system behaviour here. - let result = SysctlOrderingCheck.run(&make_ctx()); - assert!(result.errors.is_empty()); - // If there happen to be conflicts on this system the findings are valid - for f in &result.findings { - assert_eq!(f.severity, Severity::Warning); - } - } - - // ── find_conflicts ──────────────────────────────────────────────────────── - - #[test] - fn find_conflicts_empty_input_returns_no_findings() { - assert!(find_conflicts(&[]).findings.is_empty()); - } - - #[test] - fn find_conflicts_no_conflict_same_value() { - let entries = vec![ - ( - "/etc/sysctl.d/50-a.conf".to_string(), - "net.ipv4.ip_forward = 1\n".to_string(), - ), - ( - "/etc/sysctl.d/60-b.conf".to_string(), - "net.ipv4.ip_forward = 1\n".to_string(), - ), - ]; - assert!(find_conflicts(&entries).findings.is_empty()); - } - - #[test] - fn find_conflicts_detects_different_values() { - let entries = vec![ - ( - "/etc/sysctl.d/50-a.conf".to_string(), - "net.ipv4.ip_forward = 0\n".to_string(), - ), - ( - "/etc/sysctl.d/60-b.conf".to_string(), - "net.ipv4.ip_forward = 1\n".to_string(), - ), - ]; - let result = find_conflicts(&entries); - assert_eq!(result.findings.len(), 1); - assert!(result.findings[0].id.contains("sysctl-conflict")); - assert_eq!(result.findings[0].severity, Severity::Warning); - } - - #[test] - fn find_conflicts_ignores_comments_and_blanks() { - let entries = vec![( - "/etc/sysctl.d/50-a.conf".to_string(), - "# comment\n\n; another\nnet.ipv4.ip_forward = 1\n".to_string(), - )]; - assert!(find_conflicts(&entries).findings.is_empty()); - } -} diff --git a/crates/hah-dsl/Cargo.toml b/crates/hah-dsl/Cargo.toml index e5dca25..70622c7 100644 --- a/crates/hah-dsl/Cargo.toml +++ b/crates/hah-dsl/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" workspace = true [dependencies] +hah-caps = { path = "../hah-caps" } hah-core = { path = "../hah-core" } hah-utils = { path = "../hah-utils" } anyhow = "1" @@ -18,4 +19,3 @@ winnow = "1.0.3" hah-core = { path = "../hah-core", features = ["mock"] } mockall = { workspace = true } tempfile = "3" -filetime = "0.2" diff --git a/crates/hah-dsl/src/capabilities.rs b/crates/hah-dsl/src/capabilities.rs deleted file mode 100644 index 3385103..0000000 --- a/crates/hah-dsl/src/capabilities.rs +++ /dev/null @@ -1,521 +0,0 @@ -//! Rust-backed capability functions for the declarative rule engine. -//! -//! Each function receives the context it needs (a [`CommandRunner`] for -//! command-based operations, or nothing for pure filesystem operations) and -//! returns a [`RuleValue`] ready for use in a pipeline expression. -//! -//! | Capability | Returns | -//! |-------------------------|-------------------------------------------| -//! | [`journal_usage_mb`] | `Int(mb)` — total journal disk usage | -//! | [`old_files`] | `List(paths)` — files older than N days | -//! | [`broken_symlinks`] | `List(paths)` — broken symlink paths | -//! | [`sysctl_conflicts`] | `List(descriptions)` — conflicting keys | -//! | [`kernel_inventory`] | `List(pkgs)` — unused kernel packages | -//! | [`stale_kernel_headers`]| `List(pkgs)` — stale header packages | - -use std::{fs, path::Path}; - -use anyhow::{Result, anyhow}; - -use hah_core::runner::CommandRunner; - -use crate::pipeline::RuleValue; - -// ── Default scan paths ──────────────────────────────────────────────────────── - -const DEFAULT_CRASH_DIRS: &[&str] = &["/var/crash", "/var/lib/systemd/coredump"]; -const DEFAULT_SYMLINK_DIRS: &[&str] = &["/etc", "/usr/lib", "/var/lib"]; -const DEFAULT_SYSCTL_DIRS: &[&str] = &["/usr/lib/sysctl.d", "/etc/sysctl.d", "/run/sysctl.d"]; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Resolve `dirs` against `defaults`: if `dirs` is empty the defaults are -/// used, otherwise the caller-supplied list is used verbatim. -fn effective_dirs<'a>(dirs: &'a [String], defaults: &'a [&'a str]) -> Vec<&'a str> { - if dirs.is_empty() { - defaults.to_vec() - } else { - dirs.iter().map(String::as_str).collect() - } -} - -// ── JournalUsage ───────────────────────────────────────────────────────────── - -/// Return the total systemd journal disk usage as `Int(mb)`. -/// -/// Returns `Int(0)` when the output cannot be parsed. -pub fn journal_usage_mb(runner: &dyn CommandRunner) -> Result { - let out = runner - .run("journalctl", &["--disk-usage"]) - .map_err(|e| anyhow!("journalctl: {e}"))?; - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - let bytes = hah_utils::size::parse_journal_disk_usage(&stdout).unwrap_or(0); - Ok(RuleValue::Int((bytes / 1_000_000) as i64)) -} - -// ── OldFiles ────────────────────────────────────────────────────────────────── - -/// Return a `List` of file paths that have not been modified for at least -/// `older_than_days` days. -/// -/// Scans [`DEFAULT_CRASH_DIRS`] when `dirs` is empty. -pub fn old_files(dirs: &[String], older_than_days: u64) -> Result { - let effective = effective_dirs(dirs, DEFAULT_CRASH_DIRS); - let files: Vec = hah_utils::fs::scan_old_files(&effective, older_than_days) - .into_iter() - .map(|f| RuleValue::Str(f.path.to_string_lossy().into_owned())) - .collect(); - Ok(RuleValue::List(files)) -} - -// ── BrokenSymlinks ──────────────────────────────────────────────────────────── - -/// Return a `List` of paths that are broken symbolic links. -/// -/// Scans [`DEFAULT_SYMLINK_DIRS`] when `dirs` is empty. -pub fn broken_symlinks(dirs: &[String]) -> Result { - let effective = effective_dirs(dirs, DEFAULT_SYMLINK_DIRS); - let broken: Vec = hah_utils::fs::broken_symlinks(&effective) - .into_iter() - .map(|p| RuleValue::Str(p.to_string_lossy().into_owned())) - .collect(); - Ok(RuleValue::List(broken)) -} - -// ── SysctlConflicts ─────────────────────────────────────────────────────────── - -/// Return a `List` of conflict descriptions for sysctl keys that appear with -/// different values across `*.conf` files. -/// -/// Each item has the form `": =, ="`. -/// Scans [`DEFAULT_SYSCTL_DIRS`] when `dirs` is empty. -pub fn sysctl_conflicts(dirs: &[String]) -> Result { - let effective = effective_dirs(dirs, DEFAULT_SYSCTL_DIRS); - - let mut file_entries: Vec<(String, String)> = Vec::new(); - for dir in effective { - let path = Path::new(dir); - if !path.exists() { - continue; - } - if let Ok(entries) = fs::read_dir(path) { - let mut names: Vec = entries - .flatten() - .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("conf")) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .collect(); - names.sort(); - for name in names { - let full = format!("{dir}/{name}"); - if let Ok(content) = fs::read_to_string(&full) { - file_entries.push((full, content)); - } - } - } - } - - let conflicts: Vec = hah_utils::sysctl::find_conflicts(&file_entries) - .into_iter() - .map(|c| { - let detail = c - .assignments - .iter() - .map(|(f, v)| format!("{f}={v}")) - .collect::>() - .join(", "); - RuleValue::Str(format!("{}: {detail}", c.key)) - }) - .collect(); - Ok(RuleValue::List(conflicts)) -} - -// ── KernelInventory ─────────────────────────────────────────────────────────── - -/// Return a `List` of installed `linux-image-*` package names that do **not** -/// contain the currently running kernel version string (i.e., safely removable -/// unused kernels). -pub fn kernel_inventory(runner: &dyn CommandRunner) -> Result { - let out = runner - .run("uname", &["-r"]) - .map_err(|e| anyhow!("uname: {e}"))?; - let running = String::from_utf8_lossy(&out.stdout).trim().to_string(); - - let out = runner - .run( - "dpkg-query", - &["--show", "--showformat=${Package}\n", "linux-image-*"], - ) - .map_err(|e| anyhow!("dpkg-query (kernels): {e}"))?; - - let unused: Vec = String::from_utf8_lossy(&out.stdout) - .lines() - .filter(|pkg| !pkg.is_empty() && !pkg.contains(running.as_str())) - .map(|pkg| RuleValue::Str(pkg.to_string())) - .collect(); - - Ok(RuleValue::List(unused)) -} - -// ── StaleKernelHeaders ──────────────────────────────────────────────────────── - -/// Return a `List` of `linux-headers-*` packages whose version string has no -/// matching `linux-image-*` package installed. -/// -/// Meta-packages (e.g., `linux-headers-generic`) that have no numeric version -/// suffix are skipped. -pub fn stale_kernel_headers(runner: &dyn CommandRunner) -> Result { - let out_headers = runner - .run( - "dpkg-query", - &["--show", "--showformat=${Package}\n", "linux-headers-*"], - ) - .map_err(|e| anyhow!("dpkg-query (headers): {e}"))?; - - let out_kernels = runner - .run( - "dpkg-query", - &["--show", "--showformat=${Package}\n", "linux-image-*"], - ) - .map_err(|e| anyhow!("dpkg-query (kernels): {e}"))?; - let kernels: Vec = String::from_utf8_lossy(&out_kernels.stdout) - .lines() - .filter(|l| !l.is_empty()) - .map(str::to_string) - .collect(); - - let stale: Vec = String::from_utf8_lossy(&out_headers.stdout) - .lines() - .filter(|l| !l.is_empty()) - .map(str::to_string) - .filter(|hdr| { - let version = hdr.trim_start_matches("linux-headers-"); - version.chars().next().is_some_and(char::is_numeric) - && !kernels.iter().any(|k| k.contains(version)) - }) - .map(RuleValue::Str) - .collect(); - - Ok(RuleValue::List(stale)) -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod tests { - use super::*; - use hah_core::runner::{CommandOutput, MockCommandRunner}; - use std::io; - use std::time::{Duration, SystemTime}; - use tempfile::TempDir; - - fn ok_out(stdout: &str) -> io::Result { - Ok(CommandOutput { - stdout: stdout.as_bytes().to_vec(), - stderr: vec![], - success: true, - }) - } - - // ── parse_journal_disk_usage (via hah_utils) ────────────────────────────── - - #[test] - fn parse_journal_gigabytes() { - let input = "Archived and active journals take up 1.5G in the file system."; - assert_eq!( - hah_utils::size::parse_journal_disk_usage(input), - Some(1_500_000_000) - ); - } - - #[test] - fn parse_journal_megabytes() { - let input = "Archived and active journals take up 512.0M."; - assert_eq!( - hah_utils::size::parse_journal_disk_usage(input), - Some(512_000_000) - ); - } - - #[test] - fn parse_journal_kilobytes() { - let input = "Archived and active journals take up 256K in the file system."; - assert_eq!( - hah_utils::size::parse_journal_disk_usage(input), - Some(256_000) - ); - } - - #[test] - fn parse_journal_unrecognized_returns_none() { - assert_eq!( - hah_utils::size::parse_journal_disk_usage("no match here"), - None - ); - assert_eq!(hah_utils::size::parse_bytes("42XB"), None); - } - - // ── journal_usage_mb ───────────────────────────────────────────────────── - - #[test] - fn journal_usage_mb_parses_correctly() { - let mut mock = MockCommandRunner::new(); - mock.expect_run() - .returning(|_, _| ok_out("Archived and active journals take up 600.0M.\n")); - let result = journal_usage_mb(&mock).unwrap(); - assert_eq!(result, RuleValue::Int(600)); - } - - #[test] - fn journal_usage_mb_returns_zero_on_unparseable_output() { - let mut mock = MockCommandRunner::new(); - mock.expect_run() - .returning(|_, _| ok_out("something unexpected\n")); - let result = journal_usage_mb(&mock).unwrap(); - assert_eq!(result, RuleValue::Int(0)); - } - - #[test] - fn journal_usage_mb_propagates_command_error() { - let mut mock = MockCommandRunner::new(); - mock.expect_run() - .returning(|_, _| Err(io::Error::new(io::ErrorKind::NotFound, "not found"))); - assert!(journal_usage_mb(&mock).is_err()); - } - - // ── old_files ───────────────────────────────────────────────────────────── - - #[test] - fn old_files_empty_dir_returns_empty_list() { - let tmp = TempDir::new().unwrap(); - let dir = tmp.path().to_string_lossy().to_string(); - let result = old_files(&[dir], 30).unwrap(); - assert_eq!(result, RuleValue::List(vec![])); - } - - #[test] - fn old_files_nonexistent_dir_returns_empty_list() { - let result = old_files(&["/nonexistent/path/xyz".to_string()], 30).unwrap(); - assert_eq!(result, RuleValue::List(vec![])); - } - - #[test] - fn old_files_recent_file_not_included() { - let tmp = TempDir::new().unwrap(); - let file = tmp.path().join("recent.log"); - std::fs::write(&file, b"data").unwrap(); - let dir = tmp.path().to_string_lossy().to_string(); - // threshold of 30 days; recently created file should not appear - let result = old_files(&[dir], 30).unwrap(); - assert_eq!(result, RuleValue::List(vec![])); - } - - #[test] - fn old_files_old_file_included() { - use filetime::{FileTime, set_file_mtime}; - let tmp = TempDir::new().unwrap(); - let file = tmp.path().join("old.log"); - std::fs::write(&file, b"data").unwrap(); - // Set mtime to 60 days ago - let old_time = SystemTime::now() - .checked_sub(Duration::from_secs(60 * 86_400)) - .unwrap(); - set_file_mtime(&file, FileTime::from_system_time(old_time)).unwrap(); - - let dir = tmp.path().to_string_lossy().to_string(); - let result = old_files(&[dir], 30).unwrap(); - let RuleValue::List(items) = result else { - panic!("expected list"); - }; - assert_eq!(items.len(), 1); - assert!(items[0].display().contains("old.log")); - } - - #[test] - fn old_files_uses_default_dirs_when_empty() { - // /var/crash typically doesn't exist in CI; just verify it returns Ok - let result = old_files(&[], 30); - assert!(result.is_ok()); - } - - // ── broken_symlinks ─────────────────────────────────────────────────────── - - #[test] - fn broken_symlinks_empty_dir_returns_empty_list() { - let tmp = TempDir::new().unwrap(); - let dir = tmp.path().to_string_lossy().to_string(); - let result = broken_symlinks(&[dir]).unwrap(); - assert_eq!(result, RuleValue::List(vec![])); - } - - #[test] - fn broken_symlinks_detects_dangling_symlink() { - let tmp = TempDir::new().unwrap(); - let link = tmp.path().join("dangling"); - std::os::unix::fs::symlink("/nonexistent/target", &link).unwrap(); - let dir = tmp.path().to_string_lossy().to_string(); - let result = broken_symlinks(&[dir]).unwrap(); - let RuleValue::List(items) = result else { - panic!("expected list"); - }; - assert_eq!(items.len(), 1); - assert!(items[0].display().contains("dangling")); - } - - #[test] - fn broken_symlinks_valid_symlink_not_included() { - let tmp = TempDir::new().unwrap(); - let target = tmp.path().join("target.txt"); - std::fs::write(&target, b"x").unwrap(); - let link = tmp.path().join("valid_link"); - std::os::unix::fs::symlink(&target, &link).unwrap(); - let dir = tmp.path().to_string_lossy().to_string(); - let result = broken_symlinks(&[dir]).unwrap(); - assert_eq!(result, RuleValue::List(vec![])); - } - - #[test] - fn broken_symlinks_uses_default_dirs_when_empty() { - let result = broken_symlinks(&[]); - assert!(result.is_ok()); - } - - // ── sysctl_conflicts ────────────────────────────────────────────────────── - - #[test] - fn sysctl_conflicts_nonexistent_dir_returns_empty() { - let result = sysctl_conflicts(&["/nonexistent/sysctl.d".to_string()]).unwrap(); - assert_eq!(result, RuleValue::List(vec![])); - } - - #[test] - fn sysctl_conflicts_detects_conflict() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("10-net.conf"), "net.ipv4.ip_forward = 0\n").unwrap(); - std::fs::write(tmp.path().join("20-net.conf"), "net.ipv4.ip_forward = 1\n").unwrap(); - let dir = tmp.path().to_string_lossy().to_string(); - let result = sysctl_conflicts(&[dir]).unwrap(); - let RuleValue::List(items) = result else { - panic!("expected list"); - }; - assert_eq!(items.len(), 1); - assert!(items[0].display().contains("net.ipv4.ip_forward")); - } - - #[test] - fn sysctl_conflicts_same_value_no_conflict() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("10-a.conf"), "vm.swappiness = 10\n").unwrap(); - std::fs::write(tmp.path().join("20-b.conf"), "vm.swappiness = 10\n").unwrap(); - let dir = tmp.path().to_string_lossy().to_string(); - let result = sysctl_conflicts(&[dir]).unwrap(); - assert_eq!(result, RuleValue::List(vec![])); - } - - #[test] - fn sysctl_conflicts_skips_comments_and_empty_lines() { - let tmp = TempDir::new().unwrap(); - std::fs::write( - tmp.path().join("10-a.conf"), - "# comment\n; another comment\n\nnet.ipv4.ip_forward = 1\n", - ) - .unwrap(); - std::fs::write(tmp.path().join("20-b.conf"), "net.ipv4.ip_forward = 1\n").unwrap(); - let dir = tmp.path().to_string_lossy().to_string(); - let result = sysctl_conflicts(&[dir]).unwrap(); - assert_eq!(result, RuleValue::List(vec![])); - } - - #[test] - fn sysctl_conflicts_uses_default_dirs_when_empty() { - let result = sysctl_conflicts(&[]); - assert!(result.is_ok()); - } - - // ── kernel_inventory ────────────────────────────────────────────────────── - - #[test] - fn kernel_inventory_excludes_running_kernel() { - let mut mock = MockCommandRunner::new(); - mock.expect_run() - .withf(|prog, _| *prog == *"uname") - .returning(|_, _| ok_out("6.5.0-35-generic\n")); - mock.expect_run() - .withf(|prog, _| *prog == *"dpkg-query") - .returning(|_, _| { - ok_out( - "linux-image-6.5.0-35-generic\nlinux-image-6.5.0-27-generic\nlinux-image-6.5.0-28-generic\n", - ) - }); - let result = kernel_inventory(&mock).unwrap(); - let RuleValue::List(items) = result else { - panic!("expected list"); - }; - assert_eq!(items.len(), 2); - assert!(!items.iter().any(|i| i.display().contains("35"))); - } - - #[test] - fn kernel_inventory_all_removed_when_all_match_running() { - let mut mock = MockCommandRunner::new(); - mock.expect_run() - .withf(|prog, _| *prog == *"uname") - .returning(|_, _| ok_out("6.5.0-35-generic\n")); - mock.expect_run() - .withf(|prog, _| *prog == *"dpkg-query") - .returning(|_, _| ok_out("linux-image-6.5.0-35-generic\n")); - let result = kernel_inventory(&mock).unwrap(); - assert_eq!(result, RuleValue::List(vec![])); - } - - #[test] - fn kernel_inventory_propagates_uname_error() { - let mut mock = MockCommandRunner::new(); - mock.expect_run() - .returning(|_, _| Err(io::Error::new(io::ErrorKind::NotFound, "not found"))); - assert!(kernel_inventory(&mock).is_err()); - } - - // ── stale_kernel_headers ────────────────────────────────────────────────── - - #[test] - fn stale_kernel_headers_detects_stale() { - let mut mock = MockCommandRunner::new(); - // First call: headers - mock.expect_run() - .withf(|_, args| args.contains(&"linux-headers-*")) - .returning(|_, _| ok_out("linux-headers-6.5.0-27-generic\nlinux-headers-generic\n")); - // Second call: kernels - mock.expect_run() - .withf(|_, args| args.contains(&"linux-image-*")) - .returning(|_, _| ok_out("linux-image-6.5.0-35-generic\n")); - let result = stale_kernel_headers(&mock).unwrap(); - let RuleValue::List(items) = result else { - panic!("expected list"); - }; - // 6.5.0-27 has no matching image; generic (non-numeric) is skipped - assert_eq!(items.len(), 1); - assert!(items[0].display().contains("6.5.0-27")); - } - - #[test] - fn stale_kernel_headers_skips_meta_packages() { - let mut mock = MockCommandRunner::new(); - mock.expect_run() - .withf(|_, args| args.contains(&"linux-headers-*")) - .returning(|_, _| ok_out("linux-headers-generic\n")); - mock.expect_run() - .withf(|_, args| args.contains(&"linux-image-*")) - .returning(|_, _| ok_out("linux-image-6.5.0-35-generic\n")); - let result = stale_kernel_headers(&mock).unwrap(); - assert_eq!(result, RuleValue::List(vec![])); - } - - #[test] - fn stale_kernel_headers_propagates_error() { - let mut mock = MockCommandRunner::new(); - mock.expect_run() - .returning(|_, _| Err(io::Error::new(io::ErrorKind::NotFound, "not found"))); - assert!(stale_kernel_headers(&mock).is_err()); - } -} diff --git a/crates/hah-dsl/src/caps_bridge.rs b/crates/hah-dsl/src/caps_bridge.rs new file mode 100644 index 0000000..a52c765 --- /dev/null +++ b/crates/hah-dsl/src/caps_bridge.rs @@ -0,0 +1,48 @@ +//! Adapter bridging `hah-caps` capability values into the DSL pipeline. +//! +//! This module dispatches [`CapabilitySpec`] to `hah-caps` functions and +//! converts the resulting [`CapValue`] into [`RuleValue`]. + +use anyhow::Result; +use hah_caps::CapValue; +use hah_core::check::Context; + +use crate::pipeline::RuleValue; +use crate::rule::CapabilitySpec; + +/// Convert a [`CapValue`] from `hah-caps` into a [`RuleValue`]. +fn convert(cap: CapValue) -> RuleValue { + match cap { + CapValue::Int(n) => RuleValue::Int(n), + CapValue::Str(s) => RuleValue::Str(s), + CapValue::List(items) => RuleValue::List(items.into_iter().map(RuleValue::Str).collect()), + } +} + +/// Dispatch a capability spec to the matching `hah-caps` function and +/// return the result as a [`RuleValue`]. +pub fn dispatch(spec: &CapabilitySpec, ctx: &Context) -> Result { + let runner = ctx.runner.as_ref(); + let result: Result = match spec { + CapabilitySpec::JournalUsage => hah_caps::journal::journal_usage_mb(runner), + CapabilitySpec::OldFiles { + paths, + older_than_days, + } => hah_caps::files::old_files(paths, *older_than_days), + CapabilitySpec::BrokenSymlinks { paths } => hah_caps::files::broken_symlinks(paths), + CapabilitySpec::SysctlConflicts { paths } => hah_caps::sysctl::sysctl_conflicts(paths), + CapabilitySpec::KernelInventory => hah_caps::kernel::kernel_inventory(runner), + CapabilitySpec::StaleKernelHeaders => hah_caps::kernel::stale_kernel_headers(runner), + CapabilitySpec::LargeInitramfs { threshold_mb } => { + hah_caps::initramfs::large_initramfs(*threshold_mb) + } + CapabilitySpec::LegacyAptSources => hah_caps::files::legacy_apt_sources(), + CapabilitySpec::LegacyNetworkInterfaces => { + hah_caps::network::legacy_network_interfaces(runner) + } + CapabilitySpec::InstalledDenylist => { + hah_caps::apt::installed_denylist(runner, &ctx.config.denylist.packages) + } + }; + Ok(convert(result?)) +} diff --git a/crates/hah-dsl/src/expr.rs b/crates/hah-dsl/src/expr.rs index 412722e..1d7cd43 100644 --- a/crates/hah-dsl/src/expr.rs +++ b/crates/hah-dsl/src/expr.rs @@ -70,6 +70,10 @@ fn build_filter(name: &str, args: Vec) -> Result { if let Some(f) = str_arg_filter(name, &args)? { return Ok(f); } + // Filters that take a list argument + if let Some(f) = list_arg_filter(name, &args)? { + return Ok(f); + } Err(anyhow!("Unknown filter: {}", name)) } @@ -79,6 +83,7 @@ fn zero_arg_filter(name: &str) -> Option { "lines" => Some(Filter::Lines), "non_empty" => Some(Filter::NonEmpty), "first" => Some(Filter::First), + "last" => Some(Filter::Last), "number" => Some(Filter::Number), "count" => Some(Filter::Count), "sort" => Some(Filter::Sort), @@ -99,24 +104,51 @@ fn int_arg_filter(name: &str, args: &[RuleValue]) -> Result> { "skip" => Ok(Some(Filter::Skip(n()?))), "nth" => Ok(Some(Filter::Nth(n()?))), "field" => Ok(Some(Filter::Field(n()?))), + "group_count" => Ok(Some(Filter::GroupCount(n()?))), + "where_gt" => { + let v = args + .first() + .and_then(RuleValue::as_int) + .ok_or_else(|| anyhow!("where_gt requires an integer argument"))?; + Ok(Some(Filter::WhereGt(v))) + } _ => Ok(None), } } +fn require_str_arg<'a>(name: &str, args: &'a [RuleValue]) -> Result<&'a str> { + args.first() + .and_then(RuleValue::as_str) + .ok_or_else(|| anyhow!("{} requires a string argument", name)) +} + fn str_arg_filter(name: &str, args: &[RuleValue]) -> Result> { - let s = || -> Result { - args.first() - .and_then(RuleValue::as_str) - .map(str::to_string) - .ok_or_else(|| anyhow!("{} requires a string argument", name)) + let ctor: fn(String) -> Filter = match name { + "prefix_strip" => Filter::PrefixStrip, + "starts_with" => Filter::StartsWith, + "contains" => Filter::Contains, + "reject_contains" => Filter::RejectContains, + "icontains" => Filter::IContains, + "join" => Filter::Join, + "default" => Filter::Default, + _ => return Ok(None), }; + Ok(Some(ctor(require_str_arg(name, args)?.to_string()))) +} + +fn list_arg_filter(name: &str, args: &[RuleValue]) -> Result> { match name { - "prefix_strip" => Ok(Some(Filter::PrefixStrip(s()?))), - "starts_with" => Ok(Some(Filter::StartsWith(s()?))), - "contains" => Ok(Some(Filter::Contains(s()?))), - "reject_contains" => Ok(Some(Filter::RejectContains(s()?))), - "join" => Ok(Some(Filter::Join(s()?))), - "default" => Ok(Some(Filter::Default(s()?))), + "intersect" | "reject_in" => { + let items = args + .first() + .and_then(RuleValue::as_list) + .ok_or_else(|| anyhow!("{name} requires a list argument"))?; + let strings: Vec = items.iter().map(RuleValue::display).collect(); + Ok(Some(match name { + "intersect" => Filter::Intersect(strings), + _ => Filter::RejectIn(strings), + })) + } _ => Ok(None), } } @@ -402,4 +434,45 @@ mod tests { }; assert!(expr.eval(&HashMap::new()).is_err()); } + + #[test] + fn filter_intersect_keeps_common_items() { + let values = map_of(&[ + ( + "input", + RuleValue::List(vec![ + RuleValue::Str("firefox".into()), + RuleValue::Str("chromium".into()), + RuleValue::Str("vscode".into()), + ]), + ), + ( + "other", + RuleValue::List(vec![ + RuleValue::Str("chromium".into()), + RuleValue::Str("vim".into()), + ]), + ), + ]); + let expr = Expression::Pipeline(vec![ + Expression::Variable("input".into()), + Expression::Filter { + name: "intersect".into(), + args: vec![Expression::Variable("other".into())], + }, + ]); + assert_eq!( + expr.eval(&values).unwrap(), + RuleValue::List(vec![RuleValue::Str("chromium".into())]) + ); + } + + #[test] + fn filter_intersect_missing_arg_errors() { + let expr = Expression::Filter { + name: "intersect".into(), + args: vec![], + }; + assert!(expr.eval(&HashMap::new()).is_err()); + } } diff --git a/crates/hah-dsl/src/filters/list.rs b/crates/hah-dsl/src/filters/list.rs index a8959ff..324cb3f 100644 --- a/crates/hah-dsl/src/filters/list.rs +++ b/crates/hah-dsl/src/filters/list.rs @@ -1,5 +1,6 @@ use crate::pipeline::RuleValue; use anyhow::{Result, anyhow}; +use std::collections::{HashMap, HashSet}; pub fn non_empty(value: RuleValue) -> Result { match value { @@ -24,6 +25,17 @@ pub fn first(value: RuleValue) -> Result { } } +pub fn last(value: RuleValue) -> Result { + match value { + RuleValue::List(mut v) => Ok(if v.is_empty() { + RuleValue::Null + } else { + v.pop().unwrap_or(RuleValue::Null) + }), + other => Err(anyhow!("last: expected a list, got {:?}", other)), + } +} + pub fn skip(value: RuleValue, n: usize) -> Result { match value { RuleValue::List(mut v) => { @@ -86,6 +98,82 @@ pub fn join(value: RuleValue, sep: &str) -> Result { } } +/// Group list items by whitespace-field `n`, returning `"count key"` strings +/// sorted alphabetically by key. +pub fn group_count(value: RuleValue, n: usize) -> Result { + match value { + RuleValue::List(v) => { + let mut counts: HashMap = HashMap::new(); + for item in &v { + let key = match item { + RuleValue::Str(s) => s.split_whitespace().nth(n).unwrap_or("").to_string(), + _ => String::new(), + }; + *counts.entry(key).or_default() += 1; + } + let mut pairs: Vec<_> = counts.into_iter().collect(); + pairs.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(RuleValue::List( + pairs + .into_iter() + .map(|(key, cnt)| RuleValue::Str(format!("{cnt} {key}"))) + .collect(), + )) + } + other => Err(anyhow!("group_count: expected a list, got {:?}", other)), + } +} + +/// Keep only items whose first whitespace-field (parsed as integer) exceeds +/// `threshold`. Designed to follow `group_count`. +pub fn where_gt(value: RuleValue, threshold: i64) -> Result { + match value { + RuleValue::List(v) => Ok(RuleValue::List( + v.into_iter() + .filter(|item| match item { + RuleValue::Str(s) => s + .split_whitespace() + .next() + .and_then(|n| n.parse::().ok()) + .is_some_and(|n| n > threshold), + _ => false, + }) + .collect(), + )), + other => Err(anyhow!("where_gt: expected a list, got {:?}", other)), + } +} + +/// Set intersection: keep only items whose display form appears in `other`. +pub fn intersect(value: RuleValue, other: &[String]) -> Result { + match value { + RuleValue::List(v) => { + let set: HashSet<&str> = other.iter().map(String::as_str).collect(); + Ok(RuleValue::List( + v.into_iter() + .filter(|item| set.contains(item.display().as_str())) + .collect(), + )) + } + other_val => Err(anyhow!("intersect: expected a list, got {:?}", other_val)), + } +} + +/// Set subtraction: remove items whose display form appears in `other`. +pub fn reject_in(value: RuleValue, other: &[String]) -> Result { + match value { + RuleValue::List(v) => { + let set: HashSet<&str> = other.iter().map(String::as_str).collect(); + Ok(RuleValue::List( + v.into_iter() + .filter(|item| !set.contains(item.display().as_str())) + .collect(), + )) + } + other_val => Err(anyhow!("reject_in: expected a list, got {:?}", other_val)), + } +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -199,4 +287,100 @@ mod tests { fn join_err_on_non_list_non_str() { assert!(join(RuleValue::Int(1), ",").is_err()); } + + #[test] + fn last_returns_last_element() { + assert_eq!(last(list(&["a", "b", "c"])).unwrap(), sv("c")); + } + + #[test] + fn last_empty_list_returns_null() { + assert_eq!(last(RuleValue::List(vec![])).unwrap(), RuleValue::Null); + } + + #[test] + fn last_err_on_non_list() { + assert!(last(sv("x")).is_err()); + } + + #[test] + fn group_count_groups_by_field() { + let input = list(&[ + "firefox 101 rev1", + "firefox 101 rev2", + "firefox 101 rev3", + "chromium 100 rev1", + ]); + let result = group_count(input, 0).unwrap(); + assert_eq!(result, list(&["1 chromium", "3 firefox"])); + } + + #[test] + fn group_count_single_entry_per_key() { + let input = list(&["a x", "b y", "c z"]); + let result = group_count(input, 0).unwrap(); + assert_eq!(result, list(&["1 a", "1 b", "1 c"])); + } + + #[test] + fn group_count_err_on_non_list() { + assert!(group_count(sv("x"), 0).is_err()); + } + + #[test] + fn where_gt_filters_by_first_field() { + let input = list(&["3 firefox", "1 chromium", "2 vscode"]); + let result = where_gt(input, 2).unwrap(); + assert_eq!(result, list(&["3 firefox"])); + } + + #[test] + fn where_gt_returns_empty_when_none_exceed() { + let input = list(&["1 a", "2 b"]); + let result = where_gt(input, 5).unwrap(); + assert_eq!(result, RuleValue::List(vec![])); + } + + #[test] + fn where_gt_err_on_non_list() { + assert!(where_gt(sv("x"), 1).is_err()); + } + + #[test] + fn intersect_keeps_common_items() { + let input = list(&["firefox", "chromium", "vscode"]); + let other = vec![ + "chromium".to_string(), + "vscode".to_string(), + "vim".to_string(), + ]; + let result = intersect(input, &other).unwrap(); + assert_eq!(result, list(&["chromium", "vscode"])); + } + + #[test] + fn intersect_returns_empty_when_no_overlap() { + let input = list(&["a", "b"]); + let other = vec!["c".to_string(), "d".to_string()]; + let result = intersect(input, &other).unwrap(); + assert_eq!(result, RuleValue::List(vec![])); + } + + #[test] + fn intersect_err_on_non_list() { + assert!(intersect(sv("x"), &["y".to_string()]).is_err()); + } + + #[test] + fn reject_in_removes_matching_items() { + let input = list(&["firefox", "chromium", "vscode"]); + let other = vec!["chromium".to_string(), "vim".to_string()]; + let result = reject_in(input, &other).unwrap(); + assert_eq!(result, list(&["firefox", "vscode"])); + } + + #[test] + fn reject_in_err_on_non_list() { + assert!(reject_in(sv("x"), &["y".to_string()]).is_err()); + } } diff --git a/crates/hah-dsl/src/filters/mod.rs b/crates/hah-dsl/src/filters/mod.rs index 674e5b3..308f541 100644 --- a/crates/hah-dsl/src/filters/mod.rs +++ b/crates/hah-dsl/src/filters/mod.rs @@ -19,12 +19,17 @@ fn apply_list( match filter { Filter::NonEmpty => Ok(list::non_empty(value)), Filter::First => Ok(list::first(value)), + Filter::Last => Ok(list::last(value)), Filter::Sort => Ok(list::sort(value)), Filter::Unique => Ok(list::unique(value)), Filter::Count => Ok(Ok(list::count(&value))), Filter::Skip(n) => Ok(list::skip(value, *n)), Filter::Nth(n) => Ok(list::nth(value, *n)), Filter::Join(s) => Ok(list::join(value, s)), + Filter::GroupCount(n) => Ok(list::group_count(value, *n)), + Filter::WhereGt(threshold) => Ok(list::where_gt(value, *threshold)), + Filter::Intersect(other) => Ok(list::intersect(value, other)), + Filter::RejectIn(other) => Ok(list::reject_in(value, other)), _ => Err((value, filter)), } } @@ -41,6 +46,7 @@ fn apply_string( Filter::StartsWith(s) => Ok(string::starts_with(value, s)), Filter::Contains(s) => Ok(string::contains(&value, s)), Filter::RejectContains(s) => Ok(string::reject_contains(value, s)), + Filter::IContains(s) => Ok(string::icontains(value, s)), _ => Err((value, filter)), } } diff --git a/crates/hah-dsl/src/filters/string.rs b/crates/hah-dsl/src/filters/string.rs index 826a37e..bc7d416 100644 --- a/crates/hah-dsl/src/filters/string.rs +++ b/crates/hah-dsl/src/filters/string.rs @@ -121,6 +121,27 @@ pub fn reject_contains(value: RuleValue, substring: &str) -> Result { } } +/// Case-insensitive `contains`. +/// +/// On a `List`, keeps only items whose string representation contains the +/// substring (case-insensitively), returning the filtered list. +/// On a `Str`, returns `Bool(true/false)`. +pub fn icontains(value: RuleValue, substring: &str) -> Result { + let lower_sub = substring.to_lowercase(); + match value { + RuleValue::List(v) => Ok(RuleValue::List( + v.into_iter() + .filter(|item| match item { + RuleValue::Str(s) => s.to_lowercase().contains(&lower_sub), + _ => false, + }) + .collect(), + )), + RuleValue::Str(s) => Ok(RuleValue::Bool(s.to_lowercase().contains(&lower_sub))), + _ => Ok(RuleValue::Bool(false)), + } +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -265,4 +286,41 @@ mod tests { fn reject_contains_err_on_non_list() { assert!(reject_contains(sv("x"), "x").is_err()); } + + #[test] + fn icontains_list_case_insensitive() { + let input = list(&["Broken module", "ok", "NOT INSTALLED"]); + let result = icontains(input, "broken").unwrap(); + assert_eq!(result, list(&["Broken module"])); + } + + #[test] + fn icontains_list_no_match() { + let result = icontains(list(&["ok", "installed"]), "broken").unwrap(); + assert_eq!(result, list(&[])); + } + + #[test] + fn icontains_str_true() { + assert_eq!( + icontains(sv("BROKEN module"), "broken").unwrap(), + RuleValue::Bool(true) + ); + } + + #[test] + fn icontains_str_false() { + assert_eq!( + icontains(sv("ok"), "broken").unwrap(), + RuleValue::Bool(false) + ); + } + + #[test] + fn icontains_non_str_returns_false() { + assert_eq!( + icontains(RuleValue::Int(1), "x").unwrap(), + RuleValue::Bool(false) + ); + } } diff --git a/crates/hah-dsl/src/lib.rs b/crates/hah-dsl/src/lib.rs index f486d44..9593011 100644 --- a/crates/hah-dsl/src/lib.rs +++ b/crates/hah-dsl/src/lib.rs @@ -1,4 +1,4 @@ -pub mod capabilities; +pub mod caps_bridge; pub mod expr; pub mod filters; pub mod parsers; diff --git a/crates/hah-dsl/src/pipeline.rs b/crates/hah-dsl/src/pipeline.rs index 15973c7..7fa6a22 100644 --- a/crates/hah-dsl/src/pipeline.rs +++ b/crates/hah-dsl/src/pipeline.rs @@ -114,6 +114,12 @@ pub enum Filter { Join(String), BytesToMb, Default(String), + Last, + IContains(String), + GroupCount(usize), + WhereGt(i64), + Intersect(Vec), + RejectIn(Vec), } // ── Public API ──────────────────────────────────────────────────────────────── diff --git a/crates/hah-dsl/src/rule.rs b/crates/hah-dsl/src/rule.rs index 873e460..c20d3ba 100644 --- a/crates/hah-dsl/src/rule.rs +++ b/crates/hah-dsl/src/rule.rs @@ -16,7 +16,7 @@ use hah_core::{ }; use crate::{ - capabilities, + caps_bridge, pipeline::{RuleValue, ValueMap, eval_expr, render_template}, }; @@ -140,6 +140,9 @@ pub struct RuleGuard { /// Commands that must exist on `$PATH` for this rule to run. #[serde(default)] pub require_commands: Vec, + /// Files that must exist for this rule to run. + #[serde(default)] + pub require_files: Vec, } /// References to named blocks defined in the `blocks` section. @@ -162,6 +165,9 @@ pub struct RuleTrigger { pub name: String, /// Shell command to run; the raw stdout is the initial value. pub command: Option, + /// Read a file from the filesystem; the file content is the initial value. + /// Returns `Null` if the file does not exist (rule continues without error). + pub file: Option, /// Built-in probe (package/service state). pub probe: Option, /// Rust-backed capability (complex system analysis). @@ -172,6 +178,12 @@ pub struct RuleTrigger { pub transform: Option, } +/// Specification for reading a file as a trigger. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct FileSpec { + pub path: String, +} + /// Specification for a shell command trigger. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CommandSpec { @@ -184,8 +196,22 @@ pub struct CommandSpec { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ProbeSpec { - PackageInstalled { name: String }, - ServiceActive { name: String }, + PackageInstalled { + name: String, + }, + ServiceActive { + name: String, + }, + /// Returns the file size in bytes as an `Int`, or `Null` if the file + /// does not exist. + FileSize { + path: String, + }, + /// Returns the symlink target as a `Str`, or `Null` if the path is not + /// a symlink or does not exist. + SymlinkTarget { + path: String, + }, } /// Rust-backed capability trigger (complex analysis delegated to Rust). @@ -208,6 +234,17 @@ pub enum CapabilitySpec { KernelInventory, StaleKernelHeaders, JournalUsage, + LargeInitramfs { + #[serde(default = "default_initramfs_threshold")] + threshold_mb: u64, + }, + LegacyAptSources, + LegacyNetworkInterfaces, + InstalledDenylist, +} + +fn default_initramfs_threshold() -> u64 { + 100 } // ── Conditions ──────────────────────────────────────────────────────────────── @@ -248,6 +285,13 @@ pub enum RuleCondition { conditions: Vec, severity: Severity, }, + ForEach { + /// Pipeline expression that must resolve to a list. + source: String, + /// Variable name exposed to the outcome template for each item. + item_var: String, + severity: Severity, + }, } /// Comparison operator for [`RuleCondition::NumericThreshold`]. @@ -295,7 +339,6 @@ pub struct OutcomeFragment { pub struct RemediationTemplate { pub description: String, pub commands: Vec, - pub safe: bool, } // ── RuleBasedCheck ──────────────────────────────────────────────────────────── @@ -357,12 +400,38 @@ impl Check for RuleBasedCheck { } // ── 5. Evaluate conditions ──────────────────────────────────────────── + self.eval_conditions(&values) + } +} + +// ── Condition loop ──────────────────────────────────────────────────────────── + +impl RuleBasedCheck { + fn eval_conditions(&self, values: &ValueMap) -> CheckResult { let mut result = CheckResult::default(); for condition in &self.rule.conditions { - match self.eval_condition(condition, &values) { + if let RuleCondition::ForEach { + source, + item_var, + severity, + } = condition + { + match self.eval_for_each(source, item_var, severity, values) { + Ok(findings) => { + for finding in findings { + result = result.with_finding(finding); + } + } + Err(e) => { + result = result.with_error(format!("for_each: {e}")); + } + } + continue; + } + match self.eval_condition(condition, values) { Ok(true) => { let severity = condition_severity(condition).clone(); - result = result.with_finding(self.make_finding(severity, &values)); + result = result.with_finding(self.make_finding(severity, values)); } Ok(false) => {} Err(e) => { @@ -390,6 +459,17 @@ impl RuleBasedCheck { "unknown".into() }), ); + values.insert( + "config.allowlist.packages".into(), + RuleValue::List( + ctx.config + .allowlist + .packages + .iter() + .map(|s| RuleValue::Str(s.clone())) + .collect(), + ), + ); values } } @@ -422,6 +502,11 @@ impl RuleBasedCheck { return false; } } + for file_path in &guard.require_files { + if !std::path::Path::new(file_path).exists() { + return false; + } + } true } } @@ -456,6 +541,10 @@ impl RuleBasedCheck { .run(&spec.program, &args) .map_err(|e| anyhow!("command '{}': {e}", spec.program))?; RuleValue::Str(String::from_utf8_lossy(&out.stdout).into_owned()) + } else if let Some(spec) = &trigger.file { + // Return Null (not an error) when the file does not exist so that + // `require_files` guards and `default('')` pipelines can handle it. + std::fs::read_to_string(&spec.path).map_or(RuleValue::Null, RuleValue::Str) } else if let Some(spec) = &trigger.probe { run_probe(spec, ctx) } else if let Some(spec) = &trigger.capability { @@ -482,19 +571,7 @@ impl RuleBasedCheck { // ── Capability dispatch ─────────────────────────────────────────────────────── fn dispatch_capability(spec: &CapabilitySpec, ctx: &Context) -> Result { - match spec { - CapabilitySpec::JournalUsage => capabilities::journal_usage_mb(ctx.runner.as_ref()), - CapabilitySpec::OldFiles { - paths, - older_than_days, - } => capabilities::old_files(paths, *older_than_days), - CapabilitySpec::BrokenSymlinks { paths } => capabilities::broken_symlinks(paths), - CapabilitySpec::SysctlConflicts { paths } => capabilities::sysctl_conflicts(paths), - CapabilitySpec::KernelInventory => capabilities::kernel_inventory(ctx.runner.as_ref()), - CapabilitySpec::StaleKernelHeaders => { - capabilities::stale_kernel_headers(ctx.runner.as_ref()) - } - } + caps_bridge::dispatch(spec, ctx) } fn run_probe(spec: &ProbeSpec, ctx: &Context) -> RuleValue { @@ -509,6 +586,12 @@ fn run_probe(spec: &ProbeSpec, ctx: &Context) -> RuleValue { .run("systemctl", &["is-active", "--quiet", name.as_str()]) .is_ok_and(|o| o.success), ), + ProbeSpec::FileSize { path } => std::fs::metadata(path) + .map_or(RuleValue::Null, |meta| RuleValue::Int(meta.len() as i64)), + ProbeSpec::SymlinkTarget { path } => std::fs::read_link(path) + .map_or(RuleValue::Null, |target| { + RuleValue::Str(target.to_string_lossy().into_owned()) + }), } } @@ -521,7 +604,8 @@ fn condition_severity(condition: &RuleCondition) -> &Severity { | RuleCondition::NonEmpty { severity, .. } | RuleCondition::RegexMatch { severity, .. } | RuleCondition::All { severity, .. } - | RuleCondition::Any { severity, .. } => severity, + | RuleCondition::Any { severity, .. } + | RuleCondition::ForEach { severity, .. } => severity, } } @@ -602,7 +686,32 @@ impl RuleBasedCheck { } Ok(false) } + + RuleCondition::ForEach { .. } => { + // ForEach is handled directly in run(); should never reach here. + Ok(false) + } + } + } + + fn eval_for_each( + &self, + source: &str, + item_var: &str, + severity: &Severity, + values: &ValueMap, + ) -> Result> { + let list = eval_expr(source, values)?; + let items = list + .as_list() + .ok_or_else(|| anyhow!("for_each source must be a list"))?; + let mut findings = Vec::new(); + for item in items { + let mut local = values.clone(); + local.insert(item_var.to_string(), item.clone()); + findings.push(self.make_finding(severity.clone(), &local)); } + Ok(findings) } // ── Finding generation ──────────────────────────────────────────────────── @@ -700,7 +809,6 @@ blocks: remediation: description: "Remove with apt." commands: ["sudo apt remove foo"] - safe: false rules: [] "#; let rs: RuleSet = hah_utils::yaml::parse(yaml).unwrap(); @@ -926,7 +1034,6 @@ blocks: remediation: description: "Shared fix." commands: ["sudo fix"] - safe: false rules: - id: x title: X @@ -1494,7 +1601,6 @@ blocks: remediation: description: "Block fix." commands: ["sudo block-fix"] - safe: false rules: - id: x title: X @@ -1508,7 +1614,6 @@ rules: remediation: description: "Own fix." commands: ["sudo own-fix"] - safe: true "#, ); let values = HashMap::new(); @@ -1598,4 +1703,89 @@ rules: let cr = check.run(&ctx); assert_eq!(cr.findings.len(), 1); } + + #[test] + fn for_each_produces_per_item_findings() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - type: for_each + source: "$items" + item_var: item + severity: Warning + outcome: + finding_id: "item-{item}" + title: "Found {item}" + description: "Desc for {item}" +"#, + ); + let mut values: ValueMap = HashMap::new(); + values.insert( + "items".into(), + RuleValue::List(vec![ + RuleValue::Str("alpha".into()), + RuleValue::Str("beta".into()), + ]), + ); + let findings = check + .eval_for_each("$items", "item", &Severity::Warning, &values) + .unwrap(); + assert_eq!(findings.len(), 2); + assert_eq!(findings[0].id, "item-alpha"); + assert_eq!(findings[0].title, "Found alpha"); + assert_eq!(findings[1].id, "item-beta"); + assert_eq!(findings[1].title, "Found beta"); + } + + #[test] + fn for_each_empty_list_produces_no_findings() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - type: for_each + source: "$items" + item_var: item + severity: Warning + outcome: + finding_id: "item-{item}" + title: "Found {item}" + description: "" +"#, + ); + let mut values: ValueMap = HashMap::new(); + values.insert("items".into(), RuleValue::List(vec![])); + let findings = check + .eval_for_each("$items", "item", &Severity::Warning, &values) + .unwrap(); + assert!(findings.is_empty()); + } + + #[test] + fn symlink_target_probe_returns_null_for_nonexistent() { + let spec = ProbeSpec::SymlinkTarget { + path: "/tmp/nonexistent-hah-test-link-xyz".into(), + }; + let ctx = Context::new(false, Config::default(), DistroInfo::default()); + let result = run_probe(&spec, &ctx); + assert_eq!(result, RuleValue::Null); + } + + #[test] + fn symlink_target_probe_returns_target_for_symlink() { + let dir = tempfile::tempdir().expect("tempdir"); + let link_path = dir.path().join("mylink"); + std::os::unix::fs::symlink("/some/target", &link_path).expect("symlink"); + let spec = ProbeSpec::SymlinkTarget { + path: link_path.to_string_lossy().into_owned(), + }; + let ctx = Context::new(false, Config::default(), DistroInfo::default()); + let result = run_probe(&spec, &ctx); + assert_eq!(result, RuleValue::Str("/some/target".into())); + } } diff --git a/crates/hah/Cargo.toml b/crates/hah/Cargo.toml index 45f27ec..7698549 100644 --- a/crates/hah/Cargo.toml +++ b/crates/hah/Cargo.toml @@ -13,7 +13,6 @@ path = "src/main.rs" [dependencies] hah-core = { path = "../hah-core" } hah-dsl = { path = "../hah-dsl" } -hah-checks = { path = "../hah-checks" } hah-utils = { path = "../hah-utils" } anyhow = "1" clap = { version = "4", features = ["derive"] } diff --git a/crates/hah/src/registry.rs b/crates/hah/src/registry.rs index b412123..5d509a6 100644 --- a/crates/hah/src/registry.rs +++ b/crates/hah/src/registry.rs @@ -1,27 +1,22 @@ -use hah_checks::{ - apt::{ - AptKeyCheck, AutoremovableCheck, DpkgStateCheck, LegacySourcesFormatCheck, - ResidualConfigCheck, UserDefinedPackageCheck, - }, - boot::{ - BootSpaceCheck, DkmsStatusCheck, InitramfsCheck, InitramfsCompressionCheck, - StaleKernelHeadersCheck, UnusedKernelsCheck, - }, - drift::{BrokenSymlinksCheck, JournalSizeCheck, OldCrashDumpsCheck}, - network::{ - LegacyDhcpClientCheck, LegacyNetworkInterfacesCheck, LegacyNtpCheck, NtpConflictCheck, - ResolvedConfigCheck, - }, - snap::{SnapAptDuplicateCheck, SnapHealthCheck}, - sysctl::SysctlOrderingCheck, -}; use hah_core::{check::Check, config::Config}; use hah_dsl::rule::RuleSet; use std::path::PathBuf; -/// Rule file search path: system-wide, user-local, then extra paths from config. +/// Default rules directory shipped alongside the binary. +/// +/// At build time this resolves to `rules/` at the workspace root. When the +/// tool is installed system-wide, rules are expected at `/usr/share/hah/rules/` +/// (override via `rule_dirs` in the config file). +const DEFAULT_RULES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../rules"); + +/// Rule file search path: default shipped rules, system-wide, user-local, then +/// extra paths from config. fn rule_search_dirs(config: &Config) -> Vec { - let mut dirs = vec![PathBuf::from("/etc/hah/rules.d")]; + let mut dirs = vec![ + PathBuf::from(DEFAULT_RULES_DIR), + PathBuf::from("/usr/share/hah/rules"), + PathBuf::from("/etc/hah/rules.d"), + ]; if let Some(d) = hah_utils::paths::user_config_dir() { dirs.push(d.join("hah/rules.d")); } @@ -30,31 +25,7 @@ fn rule_search_dirs(config: &Config) -> Vec { } pub(crate) fn all_checks(config: &Config) -> Vec> { - let mut checks: Vec> = vec![ - Box::new(BootSpaceCheck), - Box::new(UnusedKernelsCheck), - Box::new(StaleKernelHeadersCheck), - Box::new(InitramfsCheck), - Box::new(InitramfsCompressionCheck), - Box::new(DkmsStatusCheck), - Box::new(AptKeyCheck), - Box::new(LegacySourcesFormatCheck), - Box::new(DpkgStateCheck), - Box::new(ResidualConfigCheck), - Box::new(AutoremovableCheck), - Box::new(UserDefinedPackageCheck), - Box::new(SnapHealthCheck), - Box::new(SnapAptDuplicateCheck), - Box::new(BrokenSymlinksCheck), - Box::new(OldCrashDumpsCheck), - Box::new(JournalSizeCheck), - Box::new(SysctlOrderingCheck), - Box::new(LegacyNtpCheck), - Box::new(NtpConflictCheck), - Box::new(LegacyDhcpClientCheck), - Box::new(LegacyNetworkInterfacesCheck), - Box::new(ResolvedConfigCheck), - ]; + let mut checks: Vec> = Vec::new(); // Load declarative YAML rules from search directories. for dir in rule_search_dirs(config) { @@ -84,7 +55,8 @@ mod tests { #[test] fn all_checks_returns_expected_count() { let checks = all_checks(&Config::default()); - assert_eq!(checks.len(), 23); + // 14 compiled + 10 rules from ./rules/ (legacy-ntp.yaml has 2 rules) + assert_eq!(checks.len(), 24); } #[test] diff --git a/docs/architecture.md b/docs/architecture.md index 9f6d3b4..12ffcb2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,13 +2,14 @@ ## Crate Layout -HaH is a Cargo workspace with four crates: +HaH is a Cargo workspace with four library crates and one binary: ``` hah (binary) CLI entry point, check registry, argument parsing hah-core (library) Data model, Check trait, Config, output renderers, distro detection -hah-dsl (library) YAML rule engine: pipeline evaluator, rule loader, capabilities -hah-checks (library) Compiled check implementations (one module per problem category) +hah-dsl (library) YAML rule engine: pipeline evaluator, rule loader +hah-caps (library) Capability implementations: system queries (apt, files, kernel, …) +hah-utils (library) Low-level shared utilities and library facades ``` ### Dependency graph @@ -16,11 +17,15 @@ hah-checks (library) Compiled check implementations (one module per problem c ``` hah ├── hah-core - ├── hah-dsl ── hah-core - └── hah-checks ── hah-core + └── hah-dsl + ├── hah-core + ├── hah-caps ── hah-core, hah-utils + └── hah-utils ``` -`hah-dsl` and `hah-checks` never depend on each other; both depend only on `hah-core`. +All checks are declarative YAML rules. The DSL crate handles rule parsing and +pipeline evaluation; `hah-caps` provides the data-gathering capabilities that +rules reference via `capability:` triggers. --- @@ -54,39 +59,17 @@ enum RuleValue { --- -## Adding a Compiled Check +## Adding a Check -1. Add a struct that implements `Check` in the appropriate module under `crates/hah-checks/src/`. -2. Register the check in `crates/hah/src/registry.rs` inside `all_checks()`. -3. Write unit tests in the same file using `MockRunner` (a `mockall::mock!` macro) and `make_ctx`. -4. Run `make check` — the quality gate requires ≥ 95 % line coverage. +All checks are YAML rules. Drop a `.yaml` file in `rules/` (for the default shipped set) or in +any directory listed in `rule_dirs` in your config. See [docs/dsl.md](dsl.md) for the full +language reference. -Minimal skeleton: - -```rust -pub struct MyCheck; - -impl Check for MyCheck { - fn id(&self) -> &str { "my-check" } - fn title(&self) -> &str { "My check title" } - - fn run(&self, ctx: &Context) -> CheckResult { - let out = match ctx.runner.run("some-tool", &["--flag"]) { - Ok(o) => o, - Err(_) => return CheckResult::default(), - }; - // … parse out.stdout, build findings … - CheckResult::default() - } -} -``` - ---- - -## Adding a YAML Rule - -Drop a `.yaml` file in `examples/rules/` (for bundled examples) or in any directory listed in -`rule_dirs` in your config. See [docs/dsl.md](dsl.md) for the full language reference. +For complex data-gathering logic, add a capability function in the `hah-caps` +crate (one file per module, e.g. `crates/hah-caps/src/kernel.rs`), register a +`CapabilitySpec` variant in `hah-dsl/src/rule.rs`, wire it in +`hah-dsl/src/caps_bridge.rs`, and reference it from the YAML rule via +`capability: { type: my_capability }`. --- @@ -98,7 +81,7 @@ Drop a `.yaml` file in `examples/rules/` (for bundled examples) or in any direct `mockall::automock`: ```toml -# dev-dependencies of hah-dsl / hah-checks +# dev-dependencies of hah-dsl hah-core = { path = "../hah-core", features = ["mock"] } ``` @@ -108,9 +91,6 @@ mock.expect_run() .returning(|_, _| Ok(CommandOutput { stdout: b"output".to_vec(), .. })); ``` -The `hah-checks` tests define a local `mockall::mock!` for `CommandRunner` (same interface) to -keep dev-dep cycles simple. - ### Pattern for check tests ```rust diff --git a/docs/checks.md b/docs/checks.md index 693e95a..f371c02 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -1,18 +1,17 @@ # Built-in Checks -HaH ships a set of read-only diagnostic checks organised by problem category. +HaH ships a set of read-only diagnostic checks as declarative YAML rules backed +by capabilities. All checks are loaded from YAML at startup. Run `hah list-checks` to see every registered check with its ID and title. --- ## Boot & Kernel -Implemented in `crates/hah-checks/src/boot.rs`. - | Check ID | What it detects | Threshold | | ----------------------- | --------------- | --------- | | `boot-space` | Free space on `/boot` below threshold | `boot_space_mb` (default 100 MB) | -| `unused-kernels` | Installed kernel packages that do not match the running kernel; the running kernel is never flagged | — | +| `unused-kernels` | Installed kernel packages that do not match the running kernel | — | | `stale-kernel-headers` | `linux-headers-*` packages with no matching `linux-image-*` | — | | `initramfs-size` | initramfs images in `/boot` that exceed the size threshold | `initramfs_size_mb` (default 100 MB) | | `initramfs-compression` | Compression method in `/etc/initramfs-tools/initramfs.conf` not set to `zstd` or `lz4` | — | @@ -22,8 +21,6 @@ Implemented in `crates/hah-checks/src/boot.rs`. ## APT & Packages -Implemented in `crates/hah-checks/src/apt.rs`. - | Check ID | What it detects | | ----------------------- | --------------- | | `apt-key` | Non-empty `/etc/apt/trusted.gpg` — deprecated `apt-key` trust method | @@ -37,8 +34,6 @@ Implemented in `crates/hah-checks/src/apt.rs`. ## Snap -Implemented in `crates/hah-checks/src/snap.rs`. - | Check ID | What it detects | Threshold | | -------------------- | --------------- | --------- | | `snap-health` | Disabled Snap revisions; more retained revisions than allowed | `snap_max_revisions` (default 2) | @@ -48,8 +43,6 @@ Implemented in `crates/hah-checks/src/snap.rs`. ## Network Configuration -Implemented in `crates/hah-checks/src/network.rs`. - | Check ID | What it detects | | --------------------------- | --------------- | | `legacy-ntp` | `ntp` (ISC ntpd) installed while `chrony` or `systemd-timesyncd` is also active | @@ -62,8 +55,6 @@ Implemented in `crates/hah-checks/src/network.rs`. ## System Drift & Sysctl -Implemented in `crates/hah-checks/src/drift.rs` and `sysctl.rs`. - | Check ID | What it detects | Threshold | | ----------------- | --------------- | --------- | | `broken-symlinks` | Broken symlinks under `/etc`, `/usr/lib`, and `/var/lib` | — | @@ -73,13 +64,14 @@ Implemented in `crates/hah-checks/src/drift.rs` and `sysctl.rs`. --- -## YAML Rule-based Checks +## Rule Loading -In addition to the compiled checks above, HaH loads YAML rule files at startup from: +YAML rules are loaded from these directories at startup: +- `rules/` (shipped defaults) +- `/usr/share/hah/rules/` - `/etc/hah/rules.d/*.yaml` - `~/.config/hah/rules.d/*.yaml` - Any paths listed in `rule_dirs` in the config file -Example rule files are provided in [`examples/rules/`](../examples/rules/). See [`docs/dsl.md`](dsl.md) for the full rule language reference. diff --git a/docs/dev/README.md b/docs/dev/README.md index 5804ec3..afe5e23 100644 --- a/docs/dev/README.md +++ b/docs/dev/README.md @@ -8,15 +8,15 @@ HaH is organized as a Cargo workspace: - `hah`: The main CLI binary. - `hah-core`: Core data models, traits, and common functionality. -- `hah-checks`: Implementations of built-in diagnostic checks. -- `hah-dsl`: YAML rule engine for custom, data-driven checks. +- `hah-dsl`: YAML rule engine, pipeline evaluator, and capability bridge. +- `hah-caps`: Capability implementations — system queries (apt, files, kernel, journal, etc.). - `hah-utils`: Low-level shared utilities and library facades. ## Key Concepts - **Checks**: Units of diagnostic logic that implement the `Check` trait. - **Findings**: Results returned by checks, containing a severity and remediation suggestions. -- **Capabilities**: (DSL only) Data sources (like `apt`, `files`, `sysctl`) that rules can query. +- **Capabilities**: Data sources (like `apt`, `files`, `sysctl`) implemented in `hah-caps` that rules can query via capability triggers. ## Documentation Index diff --git a/docs/dsl.md b/docs/dsl.md index ec39118..7a8a856 100644 --- a/docs/dsl.md +++ b/docs/dsl.md @@ -1,9 +1,9 @@ # HaH DSL — Declarative Rule Language Rules let you define checks in YAML without writing Rust. Rust provides reusable primitives -(capabilities, parsers, pipeline filters); YAML composes them into policy. Use the DSL for -straightforward command/probe checks and for wiring up built-in capabilities. Use a compiled -`Check` implementation when logic is too complex or performance-sensitive for YAML. +(capabilities in `hah-caps`, parsers and pipeline filters in `hah-dsl`); YAML composes them +into policy. Use the DSL for straightforward command/probe checks and for wiring up built-in +capabilities. --- @@ -16,7 +16,7 @@ HaH loads all `*.yaml` files from the following directories at startup, in this 3. Any directories listed under `rule_dirs` in the config file Duplicate rule IDs across files are rejected with a clear error at load time. -Example rule files are in [`examples/rules/`](../examples/rules/). +The default rule set shipped with HaH is in [`rules/`](../rules/). --- @@ -55,7 +55,8 @@ triggers: ### Probe trigger -Check whether a package is installed or a service is active without running a custom command. +Check whether a package is installed, a service is active, or get a file's size without running +a custom command. ```yaml triggers: @@ -68,6 +69,28 @@ triggers: probe: type: service_active name: chrony + + - name: gpg_size + probe: + type: file_size + path: /etc/apt/trusted.gpg + + - name: resolv_target + probe: + type: symlink_target + path: /etc/resolv.conf +``` + +### File trigger + +Read a file's contents into a string variable. Returns `Null` if the file cannot be read; +combine with `require_files` in `only_if` to skip the rule entirely when the file is absent. + +```yaml +triggers: + - name: conf_content + file: + path: /etc/initramfs-tools/initramfs.conf ``` ### Capability trigger @@ -94,6 +117,10 @@ Available capabilities: | `sysctl_conflicts` | `List(descriptions)` | Conflicting sysctl key assignments across `sysctl.d` files | | `kernel_inventory` | `List(pkgs)` | Installed kernel packages (running kernel + all candidates) | | `stale_kernel_headers` | `List(pkgs)` | `linux-headers-*` packages with no matching `linux-image-*` | +| `large_initramfs` | `List(entries)` | Initramfs images exceeding `threshold_mb` | +| `legacy_apt_sources` | `List(paths)` | Files using legacy one-line `deb` format | +| `legacy_network_interfaces` | `Str(status)` | `/etc/network/interfaces` overlap state | +| `installed_denylist` | `List(entries)` | Installed packages matching the config denylist | --- @@ -114,15 +141,17 @@ condition operands. | ------ | ----------- | | `trim` | Strip leading/trailing whitespace from a string | | `lines` | Split a string into a list of lines | -| `non_empty` | Remove empty strings from a list | +| `non_empty` | Remove empty strings and nulls from a list | | `skip(n)` | Drop the first _n_ items from a list | | `first` | Take the first item of a list | +| `last` | Take the last item of a list | | `nth(n)` | Take the _n_-th item (0-based) | | `number` | Parse a string as an integer or float | | `field(n)` | Take the _n_-th whitespace-separated field from a string | | `prefix_strip(p)` | Remove a leading prefix _p_ from each string in a list | | `starts_with(p)` | Keep only list items that start with _p_ | -| `contains(v)` | Keep only list items that contain substring _v_ | +| `contains(v)` | Check whether a string or list contains substring _v_ (returns `Bool`) | +| `icontains(v)` | Case-insensitive version of `contains`; on a list, keeps matching items | | `reject_contains(v)` | Drop list items that contain substring _v_ | | `join(sep)` | Join a list of strings into one string with separator _sep_ | | `default(v)` | Return _v_ if the current value is `Null` | @@ -130,6 +159,10 @@ condition operands. | `sort` | Sort a list alphabetically | | `unique` | Remove duplicate items from a list | | `bytes_to_mb` | Divide a byte count integer by 1 048 576 and return an `Int` | +| `group_count(n)` | Group list items by whitespace-field _n_, return `"count key"` strings | +| `where_gt(n)` | Keep only items whose first field (parsed as int) exceeds _n_ | +| `intersect($var)` | Set intersection: keep only items whose value appears in the list variable _$var_ | +| `reject_in($var)` | Set subtraction: remove items whose value appears in the list variable _$var_ | --- @@ -156,6 +189,7 @@ values: | `regex_match` | Match a string against a regular expression | | `all` | Logical AND of a list of child conditions | | `any` | Logical OR of a list of child conditions | +| `for_each` | Iterate over a list and produce one finding per item | Every condition requires a `severity` (`Info`, `Warning`, or `Critical`). @@ -167,6 +201,11 @@ conditions: threshold: "$threshold_bytes" severity: Critical + - type: for_each + source: "$duplicates" + item_var: pkg + severity: Warning + - type: all severity: Warning conditions: @@ -192,8 +231,19 @@ Guards prevent a rule from running when its environment preconditions are not me ```yaml only_if: distro_family: debian # run only on Debian/Ubuntu/Mint - command_exists: snap # run only when 'snap' is on PATH - package_installed: ntp # run only when the ntp package is present + require_commands: # run only when these commands are on PATH + - dkms + - snap + require_files: # run only when these files exist + - /etc/initramfs-tools/initramfs.conf +``` + +Legacy single-value keys are also supported: + +```yaml +only_if: + command_exists: snap + package_installed: ntp service_active: systemd-resolved ``` @@ -212,7 +262,6 @@ outcome: description: "Remove unused kernels to free space." commands: - "sudo apt autoremove --purge" - safe: false ``` Use `{variable}` placeholders in `title`, `description`, and remediation `description`. All @@ -235,7 +284,6 @@ blocks: remediation: description: "Remove with apt." commands: ["sudo apt remove --purge {packages}"] - safe: false rules: - id: residual-config @@ -269,7 +317,7 @@ cause a load error. ## Complete Examples -See [`examples/rules/`](../examples/rules/) for working rule files included with HaH: +See [`rules/`](../rules/) for the default rule set shipped with HaH: | File | What it demonstrates | | ---- | -------------------- | @@ -277,7 +325,17 @@ See [`examples/rules/`](../examples/rules/) for working rule files included with | `autoremovable.yaml` | Command trigger, `non_empty`, list pipeline | | `residual-config.yaml` | `starts_with` / `prefix_strip`, block reuse | | `legacy-ntp.yaml` | Multi-probe rule, `all` / `any` conditions | +| `ntp-conflict.yaml` | Shell command trigger, `non_empty`, `count` | +| `snap-apt-duplicate.yaml` | `intersect`, `reject_in`, `for_each` multi-finding | +| `resolved-config.yaml` | `symlink_target` probe, `contains` condition | +| `old-crash-dumps.yaml` | `old_files` capability, `for_each` per-item findings | | `journal-size.yaml` | `journal_usage` capability | | `sysctl-ordering.yaml` | `sysctl_conflicts` capability | | `unused-kernels.yaml` | `kernel_inventory` capability, `reject_contains` | -| `broken-symlinks.yaml` | `broken_symlinks` capability +| `broken-symlinks.yaml` | `broken_symlinks` capability | +| `initramfs-compression.yaml` | File trigger, `require_files` guard, `starts_with` | +| `dkms-status.yaml` | `icontains` filter, `require_commands` guard | +| `snap-health.yaml` | `group_count`, `where_gt`, aggregation pattern | +| `apt-key.yaml` | `file_size` probe, `numeric_threshold` | +| `dpkg-state.yaml` | Simple command + `non_empty` condition | +| `legacy-dhcp-client.yaml` | Multi-probe, `all`/`any` nested conditions | diff --git a/examples/rules/autoremovable.yaml b/examples/rules/autoremovable.yaml deleted file mode 100644 index 3798365..0000000 --- a/examples/rules/autoremovable.yaml +++ /dev/null @@ -1,50 +0,0 @@ -# Example rule: auto-removable packages -# -# Demonstrates the HaH declarative DSL using a command trigger with a -# starts_with filter to count actionable lines from apt-get output. -# -# The built-in AutoremovableCheck already covers this — this file is here -# to show how a count-based command rule looks in the DSL. -# -# Copy to /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate. - -rules: - - id: autoremovable-dsl - title: Auto-removable packages (DSL example) - - only_if: - distro_family: debian - require_commands: - - apt-get - - # Run apt-get in dry-run mode so nothing is changed. - triggers: - - name: autoremove_output - command: - program: apt-get - args: ["--dry-run", "autoremove"] - - # Count the "Remv " lines emitted by apt-get dry-run. - values: - autoremovable_count: "$autoremove_output | lines | starts_with('Remv ') | count" - - # Fire an Info finding whenever at least one package can be removed. - conditions: - - type: numeric_threshold - value: "$autoremovable_count" - operator: gt - threshold: "0" - severity: Info - - outcome: - finding_id: autoremovable-dsl - title: "{autoremovable_count} auto-removable package(s)" - description: > - {autoremovable_count} package(s) are no longer needed and can be - removed automatically. Run `sudo apt autoremove --purge` to clean - them up and reclaim disk space. - remediation: - description: "Remove unused auto-installed packages." - commands: - - "sudo apt autoremove --purge" - safe: false diff --git a/examples/rules/boot-space.yaml b/examples/rules/boot-space.yaml deleted file mode 100644 index 4cd149c..0000000 --- a/examples/rules/boot-space.yaml +++ /dev/null @@ -1,54 +0,0 @@ -# Example rule: check free space on /boot -# -# This file demonstrates the HaH declarative DSL. Copy it to -# /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate it. -# -# The built-in BootSpaceCheck already covers this — the example is here purely -# to show the DSL in action. - -rules: - - id: boot-space-dsl - title: Free space on /boot (DSL example) - - # Only run on Debian/Ubuntu systems that have a /boot partition. - only_if: - distro_family: debian - require_commands: - - df - - # Run `df` and extract the available bytes for /boot. - triggers: - - name: free_bytes - command: - program: df - args: ["--block-size=1", "--output=avail", "/boot"] - # Skip the header line, strip whitespace, parse as an integer. - transform: "$stdout | lines | nth(1) | trim | number" - - # Derive a human-friendly megabyte value and read the threshold from config. - values: - free_mb: "$free_bytes | bytes_to_mb" - threshold_mb: "$config.boot_space_mb" - - # Trigger a Critical finding when free space is below the threshold. - conditions: - - type: numeric_threshold - value: "$free_mb" - operator: lt - threshold: "$threshold_mb" - severity: Critical - - # Template for the finding. Variable names wrapped in {braces} are - # substituted at render time. - outcome: - finding_id: boot-space-low-dsl - title: "/boot has only {free_mb} MB free" - description: > - The /boot partition is nearly full ({free_mb} MB free, - threshold: {threshold_mb} MB). This can prevent kernel upgrades or - initramfs updates from completing. - remediation: - description: "Remove unused kernels to free space on /boot." - commands: - - "sudo apt autoremove --purge" - safe: false diff --git a/examples/rules/broken-symlinks.yaml b/examples/rules/broken-symlinks.yaml deleted file mode 100644 index 88f28ca..0000000 --- a/examples/rules/broken-symlinks.yaml +++ /dev/null @@ -1,44 +0,0 @@ -# Example rule: broken symbolic links -# -# Demonstrates the BrokenSymlinks capability, which walks /etc, /usr/lib, -# and /var/lib (or caller-supplied paths) and returns a list of dangling -# symlink paths. -# -# The built-in BrokenSymlinksCheck already covers this — this file exists to -# show how a list-returning capability feeds into non_empty / count conditions. -# -# Copy to /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate. - -rules: - - id: broken-symlinks-dsl - title: Broken symbolic links (DSL example) - - # The capability defaults to scanning /etc, /usr/lib, and /var/lib. - # Supply an explicit `paths` list to narrow the scan. - triggers: - - name: broken - capability: - type: broken_symlinks - paths: [] - - values: - broken_count: "$broken | count" - broken_list: "$broken | join(', ')" - - conditions: - - type: non_empty - value: "$broken" - severity: Warning - - outcome: - finding_id: broken-symlinks-dsl - title: "{broken_count} broken symbolic link(s) found" - description: > - The following symlinks point to non-existent targets: - {broken_list}. - Broken symlinks can hide misconfigured packages or removed services. - remediation: - description: "Remove each broken symlink after verifying it is no longer needed." - commands: - - "sudo find /etc /usr/lib /var/lib -xtype l -delete" - safe: false diff --git a/examples/rules/journal-size.yaml b/examples/rules/journal-size.yaml deleted file mode 100644 index 171bd05..0000000 --- a/examples/rules/journal-size.yaml +++ /dev/null @@ -1,48 +0,0 @@ -# Example rule: systemd journal disk usage -# -# Demonstrates the JournalUsage capability, which runs `journalctl --disk-usage` -# internally and returns the total size as an integer number of megabytes. -# -# The built-in JournalSizeCheck already covers this — this file exists to show -# how capability triggers compose with numeric threshold conditions. -# -# Copy to /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate. - -rules: - - id: journal-size-dsl - title: systemd journal disk usage (DSL example) - - only_if: - require_commands: - - journalctl - - # The journal_usage capability runs journalctl --disk-usage and returns - # the total size as Int(mb). - triggers: - - name: journal_mb - capability: - type: journal_usage - - # Read the threshold from config (default 500 MB if not set). - values: - threshold_mb: "$config.journal_size_mb" - - conditions: - - type: numeric_threshold - value: "$journal_mb" - operator: gt - threshold: "$threshold_mb" - severity: Warning - - outcome: - finding_id: journal-size-large-dsl - title: "systemd journal is {journal_mb} MB" - description: > - The systemd journal occupies {journal_mb} MB, exceeding the - {threshold_mb} MB threshold. Old journal entries can be vacuumed to - reclaim disk space. - remediation: - description: "Vacuum the journal to reclaim space." - commands: - - "sudo journalctl --vacuum-size={threshold_mb}M" - safe: true diff --git a/examples/rules/residual-config.yaml b/examples/rules/residual-config.yaml deleted file mode 100644 index 7a79cfe..0000000 --- a/examples/rules/residual-config.yaml +++ /dev/null @@ -1,58 +0,0 @@ -# Example rule: residual package configuration files -# -# Demonstrates using starts_with + prefix_strip to extract package names from -# dpkg-query output. The non_empty condition fires when at least one package -# has a residual config state ("rc" in dpkg terminology). -# -# The built-in ResidualConfigCheck already covers this — this file exists to -# show multi-step list transformation in the DSL. -# -# Copy to /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate. - -rules: - - id: residual-config-dsl - title: Residual package configuration files (DSL example) - - only_if: - distro_family: debian - require_commands: - - dpkg-query - - # Query dpkg status for every package. Each output line has the form: - # - # e.g. "install ok installed bash" or "deinstall ok config-files foo". - triggers: - - name: dpkg_output - command: - program: dpkg-query - args: ["-W", "-f=${Status} ${Package}\n"] - - # Keep only lines for packages in the "config-files" (rc) state, then - # strip the common status prefix to leave a plain list of package names. - values: - rc_packages: >- - $dpkg_output | lines - | starts_with('deinstall ok config-files ') - | prefix_strip('deinstall ok config-files ') - | non_empty - rc_count: "$rc_packages | count" - rc_package_list: "$rc_packages | join(', ')" - - # Fire an Info finding whenever at least one package has residual config. - conditions: - - type: non_empty - value: "$rc_packages" - severity: Info - - outcome: - finding_id: residual-config-dsl - title: "{rc_count} package(s) with residual configuration" - description: > - These packages were removed but their configuration files remain on - disk: {rc_package_list}. Purging them will clean up /etc and reduce - confusion during future installs. - remediation: - description: "Purge residual configuration files." - commands: - - "sudo dpkg --purge {rc_package_list}" - safe: false diff --git a/examples/rules/sysctl-ordering.yaml b/examples/rules/sysctl-ordering.yaml deleted file mode 100644 index 48b0dbb..0000000 --- a/examples/rules/sysctl-ordering.yaml +++ /dev/null @@ -1,43 +0,0 @@ -# Example rule: conflicting sysctl.d overrides -# -# Demonstrates the SysctlConflicts capability, which reads *.conf files from -# /usr/lib/sysctl.d, /etc/sysctl.d, and /run/sysctl.d (or caller-supplied -# paths) and returns a list of keys that appear with different values in -# multiple files. -# -# The built-in SysctlOrderingCheck already covers this — this file exists to -# show how a filesystem-only capability feeds a non_empty condition. -# -# Copy to /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate. - -rules: - - id: sysctl-ordering-dsl - title: Conflicting sysctl.d overrides (DSL example) - - # Supply an explicit `paths` list to scan different directories. - # Leave paths empty to use the defaults: - # /usr/lib/sysctl.d, /etc/sysctl.d, /run/sysctl.d - triggers: - - name: conflicts - capability: - type: sysctl_conflicts - paths: [] - - values: - conflict_count: "$conflicts | count" - conflict_list: "$conflicts | join('\n')" - - conditions: - - type: non_empty - value: "$conflicts" - severity: Warning - - outcome: - finding_id: sysctl-ordering-dsl - title: "{conflict_count} sysctl key(s) with conflicting values" - description: > - The following sysctl keys are set to different values in multiple - sysctl.d files. The last file in lexicographic order wins, but the - conflict indicates possible misconfiguration: - - {conflict_list} diff --git a/examples/rules/unused-kernels.yaml b/examples/rules/unused-kernels.yaml deleted file mode 100644 index 39aadcf..0000000 --- a/examples/rules/unused-kernels.yaml +++ /dev/null @@ -1,45 +0,0 @@ -# Example rule: unused installed kernel packages -# -# Demonstrates the KernelInventory capability, which runs `uname -r` and -# `dpkg-query` internally and returns a list of installed linux-image-* -# packages whose version does not match the running kernel (i.e., safe to -# remove). -# -# The built-in UnusedKernelsCheck already covers this — this file exists to -# show how a command-runner capability returns a list for join/count. -# -# Copy to /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate. - -rules: - - id: unused-kernels-dsl - title: Unused installed kernel packages (DSL example) - - only_if: - distro_family: debian - - triggers: - - name: unused_kernels - capability: - type: kernel_inventory - - values: - unused_count: "$unused_kernels | count" - unused_list: "$unused_kernels | join(', ')" - - conditions: - - type: non_empty - value: "$unused_kernels" - severity: Warning - - outcome: - finding_id: unused-kernels-dsl - title: "{unused_count} unused kernel package(s) installed" - description: > - The following kernel packages are installed but do not match the - running kernel and can be safely removed: {unused_list}. - Removing them frees space in /boot and reduces update time. - remediation: - description: "Remove unused kernels with apt." - commands: - - "sudo apt autoremove --purge" - safe: false diff --git a/rules/apt-key.yaml b/rules/apt-key.yaml new file mode 100644 index 0000000..2a22937 --- /dev/null +++ b/rules/apt-key.yaml @@ -0,0 +1,30 @@ +rules: + - id: apt-key + title: Deprecated apt-key signing keys + + triggers: + - name: gpg_size + probe: + type: file_size + path: /etc/apt/trusted.gpg + + conditions: + - type: numeric_threshold + value: "$gpg_size" + operator: gt + threshold: "0" + severity: Warning + + outcome: + finding_id: apt-key-legacy-gpg + title: "Legacy /etc/apt/trusted.gpg keyring is in use" + description: > + The file /etc/apt/trusted.gpg is non-empty. Keys managed here were added + with the deprecated apt-key command. They should be migrated to named + keyring files under /usr/share/keyrings/ and referenced via the + signed-by= option in source entries. + remediation: + description: "Export each key to a dedicated keyring file." + commands: + - "apt-key list" + - "# For each key: sudo gpg --no-default-keyring --keyring /usr/share/keyrings/NAME.gpg --import /tmp/key.asc" diff --git a/rules/autoremovable.yaml b/rules/autoremovable.yaml new file mode 100644 index 0000000..e2fba5c --- /dev/null +++ b/rules/autoremovable.yaml @@ -0,0 +1,34 @@ +rules: + - id: autoremovable + title: Auto-removable packages + + only_if: + distro_family: debian + require_commands: + - apt-get + + triggers: + - name: autoremove_output + command: + program: apt-get + args: ["--dry-run", "autoremove"] + + values: + autoremovable_count: "$autoremove_output | lines | starts_with('Remv ') | count" + + conditions: + - type: numeric_threshold + value: "$autoremovable_count" + operator: gt + threshold: "0" + severity: Info + + outcome: + finding_id: autoremovable + title: "{autoremovable_count} auto-removable package(s)" + description: > + {autoremovable_count} package(s) are no longer needed and can be removed. + remediation: + description: "Remove unused auto-installed packages." + commands: + - "sudo apt autoremove --purge" diff --git a/rules/boot-space.yaml b/rules/boot-space.yaml new file mode 100644 index 0000000..a54bb4f --- /dev/null +++ b/rules/boot-space.yaml @@ -0,0 +1,33 @@ +rules: + - id: boot-space + title: Free space on /boot + + triggers: + - name: free_bytes + command: + program: df + args: ["--block-size=1", "--output=avail", "/boot"] + transform: "$stdout | lines | nth(1) | trim | number" + + values: + free_mb: "$free_bytes | bytes_to_mb" + threshold_mb: "$config.boot_space_mb | default('100')" + + conditions: + - type: numeric_threshold + value: "$free_mb" + operator: lt + threshold: "$threshold_mb" + severity: Critical + + outcome: + finding_id: boot-space-low + title: "/boot has only {free_mb} MB free" + description: > + The /boot partition is nearly full ({free_mb} MB free, + threshold: {threshold_mb} MB). This can prevent kernel upgrades + or initramfs updates from completing. + remediation: + description: "Remove unused kernels to free space." + commands: + - "sudo apt autoremove --purge" diff --git a/rules/broken-symlinks.yaml b/rules/broken-symlinks.yaml new file mode 100644 index 0000000..68b445c --- /dev/null +++ b/rules/broken-symlinks.yaml @@ -0,0 +1,29 @@ +rules: + - id: broken-symlinks + title: Broken symbolic links + + triggers: + - name: broken + capability: + type: broken_symlinks + paths: [] + + values: + broken_count: "$broken | count" + broken_list: "$broken | join(', ')" + + conditions: + - type: non_empty + value: "$broken" + severity: Warning + + outcome: + finding_id: broken-symlinks + title: "{broken_count} broken symbolic link(s) found" + description: > + The following symlinks point to non-existent targets: {broken_list}. + Broken symlinks can hide misconfigured packages or removed services. + remediation: + description: "Remove each broken symlink after verifying it is no longer needed." + commands: + - "sudo find /etc /usr/lib /var/lib -xtype l -delete" diff --git a/rules/dkms-status.yaml b/rules/dkms-status.yaml new file mode 100644 index 0000000..85f08ef --- /dev/null +++ b/rules/dkms-status.yaml @@ -0,0 +1,40 @@ +rules: + - id: dkms-status + title: DKMS module build status + + only_if: + require_commands: + - dkms + + triggers: + - name: dkms_output + command: + program: dkms + args: ["status"] + + values: + broken_modules: "$dkms_output | lines | icontains('broken')" + uninstalled_modules: "$dkms_output | lines | icontains('not installed')" + + conditions: + - type: any + severity: Warning + conditions: + - type: non_empty + value: "$broken_modules" + severity: Warning + - type: non_empty + value: "$uninstalled_modules" + severity: Warning + + outcome: + finding_id: dkms-broken + title: "DKMS reports module build problems" + description: > + dkms status reports broken or not-installed modules. + Broken: {broken_modules}. Not installed: {uninstalled_modules}. + These modules may not work with the current kernel. + remediation: + description: "Attempt DKMS rebuild." + commands: + - "sudo dkms autoinstall" diff --git a/rules/dpkg-state.yaml b/rules/dpkg-state.yaml new file mode 100644 index 0000000..86c0d3f --- /dev/null +++ b/rules/dpkg-state.yaml @@ -0,0 +1,29 @@ +rules: + - id: dpkg-state + title: Broken dpkg package states + + only_if: + distro_family: debian + require_commands: + - dpkg + + triggers: + - name: audit_output + command: + program: dpkg + args: ["--audit"] + + conditions: + - type: non_empty + value: "$audit_output | trim" + severity: Critical + + outcome: + finding_id: dpkg-audit + title: "dpkg audit reports package state problems" + description: "{audit_output}" + remediation: + description: "Attempt to fix broken packages." + commands: + - "sudo dpkg --configure -a" + - "sudo apt-get install -f" diff --git a/rules/initramfs-compression.yaml b/rules/initramfs-compression.yaml new file mode 100644 index 0000000..6bcfafa --- /dev/null +++ b/rules/initramfs-compression.yaml @@ -0,0 +1,44 @@ +rules: + - id: initramfs-compression + title: Non-optimal initramfs compression + + only_if: + require_files: + - /etc/initramfs-tools/initramfs.conf + + triggers: + - name: conf_content + file: + path: /etc/initramfs-tools/initramfs.conf + + values: + conf_lines: "$conf_content | lines" + zstd_count: "$conf_lines | starts_with('COMPRESS=zstd') | count" + lz4_count: "$conf_lines | starts_with('COMPRESS=lz4') | count" + + conditions: + - type: all + severity: Info + conditions: + - type: numeric_threshold + value: "$zstd_count" + operator: eq + threshold: "0" + severity: Info + - type: numeric_threshold + value: "$lz4_count" + operator: eq + threshold: "0" + severity: Info + + outcome: + finding_id: initramfs-compression-suboptimal + title: "initramfs compression is not set to zstd or lz4" + description: > + The initramfs-tools configuration does not use an optimal compression + algorithm. Switching to zstd reduces initramfs size and speeds up boot. + remediation: + description: "Set COMPRESS=zstd in initramfs.conf and regenerate." + commands: + - "sudo sed -i 's/^COMPRESS=.*/COMPRESS=zstd/' /etc/initramfs-tools/initramfs.conf" + - "sudo update-initramfs -u -k all" diff --git a/rules/initramfs-size.yaml b/rules/initramfs-size.yaml new file mode 100644 index 0000000..5fb0233 --- /dev/null +++ b/rules/initramfs-size.yaml @@ -0,0 +1,23 @@ +rules: + - id: initramfs-size + title: Oversized initramfs images + triggers: + - name: large_images + capability: + type: large_initramfs + threshold_mb: 100 + conditions: + - type: for_each + source: "$large_images" + item_var: entry + severity: Warning + outcome: + finding_id: "initramfs-large-{entry}" + title: "Oversized initramfs: {entry} MB" + description: >- + initramfs image {entry} exceeds the threshold. + Large images slow boot and consume /boot space. + remediation: + description: Regenerate initramfs images. + commands: + - "sudo update-initramfs -u -k all" diff --git a/rules/journal-size.yaml b/rules/journal-size.yaml new file mode 100644 index 0000000..4f583c4 --- /dev/null +++ b/rules/journal-size.yaml @@ -0,0 +1,33 @@ +rules: + - id: journal-size + title: systemd journal disk usage + + only_if: + require_commands: + - journalctl + + triggers: + - name: journal_mb + capability: + type: journal_usage + + values: + threshold_mb: "$config.journal_size_mb | default('500')" + + conditions: + - type: numeric_threshold + value: "$journal_mb" + operator: gt + threshold: "$threshold_mb" + severity: Warning + + outcome: + finding_id: journal-size-large + title: "systemd journal is {journal_mb} MB" + description: > + The systemd journal occupies {journal_mb} MB, + exceeding the {threshold_mb} MB threshold. + remediation: + description: "Vacuum the journal to reclaim space." + commands: + - "sudo journalctl --vacuum-size={threshold_mb}M" diff --git a/rules/legacy-apt-sources.yaml b/rules/legacy-apt-sources.yaml new file mode 100644 index 0000000..0abf8e2 --- /dev/null +++ b/rules/legacy-apt-sources.yaml @@ -0,0 +1,28 @@ +rules: + - id: legacy-sources-format + title: Legacy one-line APT source entries + + only_if: + distro_family: debian + + triggers: + - name: legacy_files + capability: + type: legacy_apt_sources + + conditions: + - type: for_each + source: "{{ legacy_files }}" + item_var: file + severity: Info + + outcome: + finding_id: "legacy-sources-format-{{ file }}" + title: "Legacy one-line APT source: {{ file }}" + description: >- + {{ file }} uses the deprecated one-line `deb` format. + The modern DEB822 `.sources` format is preferred. + remediation: + description: "Convert to DEB822 format (one .sources file per repository)." + commands: + - "# See: https://wiki.debian.org/SourcesList#DEB822_format" diff --git a/rules/legacy-dhcp-client.yaml b/rules/legacy-dhcp-client.yaml new file mode 100644 index 0000000..d5427f1 --- /dev/null +++ b/rules/legacy-dhcp-client.yaml @@ -0,0 +1,52 @@ +rules: + - id: legacy-dhcp-client + title: Legacy ISC DHCP client (dhclient) + + only_if: + distro_family: debian + + triggers: + - name: dhclient_installed + probe: + type: package_installed + name: isc-dhcp-client + - name: nm_installed + probe: + type: package_installed + name: network-manager + - name: networkd_active + probe: + type: service_active + name: systemd-networkd + + conditions: + - type: all + severity: Info + conditions: + - type: equals + value: "$dhclient_installed" + expected: true + severity: Info + - type: any + severity: Info + conditions: + - type: equals + value: "$nm_installed" + expected: true + severity: Info + - type: equals + value: "$networkd_active" + expected: true + severity: Info + + outcome: + finding_id: legacy-dhcp-client + title: "Legacy isc-dhcp-client installed alongside a modern network manager" + description: > + The isc-dhcp-client (dhclient) package is installed but a modern network + manager is already handling DHCP. The legacy client is redundant, + unmaintained upstream, and can be safely removed. + remediation: + description: "Remove the legacy ISC DHCP client." + commands: + - "sudo apt remove --purge isc-dhcp-client" diff --git a/rules/legacy-network-interfaces.yaml b/rules/legacy-network-interfaces.yaml new file mode 100644 index 0000000..0a8dc51 --- /dev/null +++ b/rules/legacy-network-interfaces.yaml @@ -0,0 +1,34 @@ +rules: + - id: legacy-network-interfaces + title: Legacy /etc/network/interfaces configuration + + only_if: + require_files: + - /etc/network/interfaces + + triggers: + - name: net_status + capability: + type: legacy_network_interfaces + + conditions: + - type: regex_match + value: "{{ net_status }}" + pattern: "^overlap:" + severity: Warning + - type: regex_match + value: "{{ net_status }}" + pattern: "^legacy:" + severity: Info + + outcome: + finding_id: legacy-network-interfaces + title: "/etc/network/interfaces has non-loopback entries" + description: >- + /etc/network/interfaces defines non-loopback interface(s) using the + legacy ifupdown format. Consider migrating to Netplan or NetworkManager. + remediation: + description: "Migrate interface configuration to Netplan or NetworkManager." + commands: + - "# Netplan reference: https://netplan.readthedocs.io/" + - "# After migration: sudo apt remove --purge ifupdown" diff --git a/examples/rules/legacy-ntp.yaml b/rules/legacy-ntp.yaml similarity index 53% rename from examples/rules/legacy-ntp.yaml rename to rules/legacy-ntp.yaml index 3d02cc5..986e1ec 100644 --- a/examples/rules/legacy-ntp.yaml +++ b/rules/legacy-ntp.yaml @@ -1,21 +1,3 @@ -# Example rule: legacy NTP daemon -# -# Demonstrates multi-probe rules and composite All/Any conditions. -# Two rules cover the two distinct findings the built-in LegacyNtpCheck -# emits: -# -# legacy-ntp-conflict-dsl (Warning) — ntp installed AND a modern time-sync -# service is active simultaneously -# legacy-ntp-dsl (Info) — ntp installed but no modern time-sync -# service is active -# -# The built-in LegacyNtpCheck already covers this — these rules exist to show -# how probe triggers and composite conditions compose in the DSL. -# -# Copy to /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate. - -# ── Shared reusable blocks ──────────────────────────────────────────────────── - blocks: guards: debian_ntp: @@ -28,16 +10,12 @@ blocks: commands: - "sudo apt remove --purge ntp" - "sudo apt install chrony && sudo systemctl enable --now chrony" - safe: false - -# ── Rules ───────────────────────────────────────────────────────────────────── rules: - - # Warning: ntp is installed AND a modern time-sync service is running. - # Both daemons will compete to adjust the clock. - - id: legacy-ntp-conflict-dsl - title: Legacy ntp package conflicts with active time-sync service (DSL example) + # Warning: ntp is installed AND a modern time-sync service is also active. + # Both daemons compete to adjust the system clock. + - id: legacy-ntp-conflict + title: Legacy NTP daemon conflicts with active time-sync service use: guard: debian_ntp @@ -64,28 +42,30 @@ rules: - type: equals value: "$ntp_installed" expected: true + severity: Warning - type: any severity: Warning conditions: - type: equals value: "$chrony_active" expected: true + severity: Warning - type: equals value: "$timesyncd_active" expected: true + severity: Warning outcome: - finding_id: legacy-ntp-conflict-dsl + finding_id: legacy-ntp title: "Legacy ntp package is installed alongside an active time-sync service" description: > - The legacy ntp (ISC ntpd) package is installed while chrony or - systemd-timesyncd is also active. Multiple time-sync daemons compete - to adjust the system clock, which can cause instability. Remove the - ntp package and rely on the modern service instead. + The legacy ntp (ISC ntpd) package is installed while a modern time-sync + service is also active. Multiple time-sync daemons compete to adjust the + system clock, which can cause instability. Remove the ntp package. # Info: ntp is installed but no modern time-sync alternative is active. - - id: legacy-ntp-dsl - title: Legacy ntp package installed (DSL example) + - id: legacy-ntp + title: Legacy NTP daemon (ntpd / ISC ntp) use: guard: debian_ntp @@ -112,18 +92,21 @@ rules: - type: equals value: "$ntp_installed" expected: true + severity: Info - type: equals value: "$chrony_active" expected: false + severity: Info - type: equals value: "$timesyncd_active" expected: false + severity: Info outcome: - finding_id: legacy-ntp-dsl + finding_id: legacy-ntp title: "Legacy ntp (ISC ntpd) package is installed" description: > - The legacy ntp (ISC ntpd) package is installed. Modern alternatives - such as chrony (recommended for servers and VMs) or - systemd-timesyncd offer better accuracy, NTS/DNSSEC support, and - resilience to large clock jumps. + The legacy ntp (ISC ntpd) package is installed. Modern alternatives + such as chrony (recommended for servers and VMs) or systemd-timesyncd + offer better accuracy, NTS/DNSSEC support, and resilience to large + clock jumps. diff --git a/rules/ntp-conflict.yaml b/rules/ntp-conflict.yaml new file mode 100644 index 0000000..9d35e57 --- /dev/null +++ b/rules/ntp-conflict.yaml @@ -0,0 +1,37 @@ +rules: + - id: ntp-conflict + title: Multiple NTP services active simultaneously + + only_if: + require_commands: + - systemctl + + triggers: + - name: active_ntp + command: + program: sh + args: ["-c", "for s in ntp chrony openntpd systemd-timesyncd; do systemctl is-active --quiet \"$s\" 2>/dev/null && printf '%s\\n' \"$s\"; done; true"] + + values: + ntp_count: "$active_ntp | lines | non_empty | count" + ntp_list: "$active_ntp | lines | non_empty | join(', ')" + conditions: + - type: numeric_threshold + value: "$ntp_count" + operator: gt + threshold: "1" + severity: Warning + outcome: + finding_id: ntp-conflict + title: "Multiple NTP services active: {ntp_list}" + description: >- + The following time-sync services are all active: {ntp_list}. + Competing daemons can fight over the system clock and cause + time jumps, log timestamp corruption, or TLS certificate errors. + remediation: + description: "Disable all but one NTP service (chrony is recommended)." + commands: + - "sudo systemctl disable --now ntp" + - "sudo systemctl disable --now openntpd" + - "sudo systemctl disable --now systemd-timesyncd" + - "# Then enable only one: sudo systemctl enable --now chrony" diff --git a/rules/old-crash-dumps.yaml b/rules/old-crash-dumps.yaml new file mode 100644 index 0000000..f44056b --- /dev/null +++ b/rules/old-crash-dumps.yaml @@ -0,0 +1,22 @@ +rules: + - id: old-crash-dumps + title: Old crash dumps and core files + triggers: + - name: old_files + capability: + type: old_files + paths: [] + older_than_days: 30 + conditions: + - type: for_each + source: "$old_files" + item_var: file + severity: Info + outcome: + finding_id: "crash-dump-{file}" + title: "Old crash dump: {file}" + description: "{file} is older than 30 days and can likely be removed." + remediation: + description: Remove old crash dump. + commands: + - "sudo rm {file}" diff --git a/rules/residual-config.yaml b/rules/residual-config.yaml new file mode 100644 index 0000000..a67dec3 --- /dev/null +++ b/rules/residual-config.yaml @@ -0,0 +1,35 @@ +rules: + - id: residual-config + title: Residual package configuration files + + only_if: + distro_family: debian + require_commands: + - dpkg-query + + triggers: + - name: dpkg_output + command: + program: dpkg-query + args: ["-W", "-f=${Status} ${Package}\n"] + + values: + rc_packages: "$dpkg_output | lines | starts_with('deinstall ok config-files ') | prefix_strip('deinstall ok config-files ') | non_empty" + rc_count: "$rc_packages | count" + packages: "$rc_packages | join(' ')" + + conditions: + - type: non_empty + value: "$rc_packages" + severity: Info + + outcome: + finding_id: residual-config + title: "{rc_count} package(s) with residual configuration" + description: > + These packages were removed but their configuration files remain: {packages}. + remediation: + description: "Purge residual configurations." + commands: + - "sudo dpkg --purge {packages}" + diff --git a/rules/resolved-config.yaml b/rules/resolved-config.yaml new file mode 100644 index 0000000..69297a7 --- /dev/null +++ b/rules/resolved-config.yaml @@ -0,0 +1,35 @@ +rules: + - id: resolved-config + title: "systemd-resolved DNS resolver configuration" + triggers: + - name: resolved_active + probe: + type: service_active + name: systemd-resolved + - name: target + probe: + type: symlink_target + path: /etc/resolv.conf + conditions: + - type: all + severity: Warning + conditions: + - type: equals + value: "$resolved_active" + expected: true + severity: Warning + - type: equals + value: "$target | contains('systemd/resolve')" + expected: false + severity: Warning + outcome: + finding_id: resolved-config + title: "/etc/resolv.conf is not linked to systemd-resolved" + description: >- + systemd-resolved is active but /etc/resolv.conf is not a symlink + to /run/systemd/resolve/stub-resolv.conf. DNS caching, DNSSEC + validation, and split-DNS will not work correctly. + remediation: + description: Link /etc/resolv.conf to the systemd-resolved stub resolver. + commands: + - "sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf" diff --git a/rules/snap-apt-duplicate.yaml b/rules/snap-apt-duplicate.yaml new file mode 100644 index 0000000..6927c3f --- /dev/null +++ b/rules/snap-apt-duplicate.yaml @@ -0,0 +1,32 @@ +rules: + - id: snap-apt-duplicate + title: Software installed via both Snap and APT + only_if: + require_commands: [snap] + triggers: + - name: snap_list + command: + program: snap + args: [list] + transform: "$stdout | lines | skip(1) | non_empty" + - name: apt_list + command: + program: dpkg-query + args: ["-W", "-f=${Package}\n"] + transform: "$stdout | lines | non_empty" + values: + snap_names: "$snap_list | field(0)" + duplicates: "$snap_names | intersect($apt_list) | reject_contains('snapd') | reject_in($config.allowlist.packages)" + conditions: + - type: for_each + source: "$duplicates" + item_var: pkg + severity: Warning + outcome: + finding_id: "snap-apt-dup-{pkg}" + title: "'{pkg}' is installed via both APT and Snap" + description: "Having '{pkg}' installed twice wastes space and may cause version conflicts or confusion." + remediation: + description: Remove the APT version if the Snap is preferred. + commands: + - "sudo apt remove --purge {pkg}" diff --git a/rules/snap-health.yaml b/rules/snap-health.yaml new file mode 100644 index 0000000..4d09fae --- /dev/null +++ b/rules/snap-health.yaml @@ -0,0 +1,43 @@ +rules: + - id: snap-health + title: Snap package health + + only_if: + require_commands: + - snap + + triggers: + - name: snap_output + command: + program: snap + args: ["list", "--all"] + + values: + snap_lines: "$snap_output | lines | skip(1) | non_empty" + disabled_lines: "$snap_lines | icontains('disabled')" + excess_revisions: "$snap_lines | group_count(0) | where_gt(2)" + + conditions: + - type: any + severity: Info + conditions: + - type: non_empty + value: "$disabled_lines" + severity: Info + - type: non_empty + value: "$excess_revisions" + severity: Info + + outcome: + finding_id: snap-health + title: "Snap revisions need attention" + description: > + Disabled revisions: {disabled_lines}. + Packages with excessive retained revisions: {excess_revisions}. + Disabled revisions consume disk space and can be safely removed. + remediation: + description: "Clean up old snap revisions." + commands: + - "snap list --all | awk '/disabled/{print $1, $3}'" + - "# For each: sudo snap remove --revision=" + - "sudo snap set system refresh.retain=2" diff --git a/rules/stale-kernel-headers.yaml b/rules/stale-kernel-headers.yaml new file mode 100644 index 0000000..cb2f63b --- /dev/null +++ b/rules/stale-kernel-headers.yaml @@ -0,0 +1,29 @@ +rules: + - id: stale-kernel-headers + title: Stale kernel header packages + + only_if: + distro_family: debian + + triggers: + - name: stale_headers + capability: + type: stale_kernel_headers + + values: + stale_count: "$stale_headers | count" + stale_list: "$stale_headers | join(', ')" + + conditions: + - type: non_empty + value: "$stale_headers" + severity: Info + + outcome: + finding_id: stale-kernel-headers + title: "{stale_count} stale kernel header package(s)" + description: "Header packages with no matching kernel: {stale_list}." + remediation: + description: "Remove stale kernel header packages." + commands: + - "sudo apt autoremove --purge" diff --git a/rules/sysctl-ordering.yaml b/rules/sysctl-ordering.yaml new file mode 100644 index 0000000..c2dc260 --- /dev/null +++ b/rules/sysctl-ordering.yaml @@ -0,0 +1,25 @@ +rules: + - id: sysctl-ordering + title: Conflicting sysctl.d overrides + + triggers: + - name: conflicts + capability: + type: sysctl_conflicts + paths: [] + + values: + conflict_count: "$conflicts | count" + conflict_list: "$conflicts | join(', ')" + + conditions: + - type: non_empty + value: "$conflicts" + severity: Warning + + outcome: + finding_id: sysctl-ordering + title: "{conflict_count} sysctl key(s) with conflicting values" + description: > + The following sysctl keys are set to different values in multiple + sysctl.d files: {conflict_list}. diff --git a/rules/unused-kernels.yaml b/rules/unused-kernels.yaml new file mode 100644 index 0000000..d84f4fd --- /dev/null +++ b/rules/unused-kernels.yaml @@ -0,0 +1,32 @@ +rules: + - id: unused-kernels + title: Unused installed kernels + + only_if: + distro_family: debian + + triggers: + - name: unused_kernels + capability: + type: kernel_inventory + + values: + unused_count: "$unused_kernels | count" + unused_list: "$unused_kernels | join(', ')" + + conditions: + - type: non_empty + value: "$unused_kernels" + severity: Warning + + outcome: + finding_id: unused-kernels + title: "{unused_count} unused kernel package(s) installed" + description: > + These kernel packages are installed but do not match the running kernel + and can be safely removed: {unused_list}. + They consume space in /boot and can be removed with apt autoremove. + remediation: + description: "Remove unused kernels with apt." + commands: + - "sudo apt autoremove --purge" diff --git a/rules/user-denylist.yaml b/rules/user-denylist.yaml new file mode 100644 index 0000000..fac90b4 --- /dev/null +++ b/rules/user-denylist.yaml @@ -0,0 +1,27 @@ +rules: + - id: user-denylist + title: Packages matching user denylist + + only_if: + distro_family: debian + + triggers: + - name: denied + capability: + type: installed_denylist + + conditions: + - type: for_each + source: "{{ denied }}" + item_var: entry + severity: Warning + + outcome: + finding_id: "user-denylist-{{ entry }}" + title: "Denylisted package installed: {{ entry }}" + description: >- + A package from the configured denylist is installed: {{ entry }}. + remediation: + description: "Remove the denylisted package." + commands: + - "sudo apt remove --purge "