diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 0de20f4..693d8f4 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -41,3 +41,6 @@ jobs: - name: Code metrics run: make metrics + + - name: Validate rules + run: make validate diff --git a/Makefile b/Makefile index a58c8c9..70fc21f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all setup fmt fmt-check lint test audit coverage coverage-ci metrics check doc-dependencies +.PHONY: all setup fmt fmt-check lint test audit coverage coverage-ci metrics check doc-dependencies validate # Configuration for quality gates COVERAGE_MIN_THRESHOLD ?= 95 @@ -47,5 +47,8 @@ doc-dependencies: check-dependencies: cargo run --manifest-path tools/hah-deps/Cargo.toml --release --quiet -- --check -check: fmt-check lint test audit coverage-ci metrics check-dependencies +validate: + cargo run -p hah --quiet -- validate rules/ + +check: fmt-check lint test audit coverage-ci metrics validate check-dependencies diff --git a/README.md b/README.md index 5488f2b..a63417d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ hah | ----------------- | ------------------------------------------------- | | `hah scan` | Run all enabled checks and report findings | | `hah list-checks` | List every registered check with its ID and title | +| `hah validate` | Validate rule file syntax without running checks | ### `hah scan` options @@ -35,7 +36,7 @@ hah - [User Guide](docs/user/README.md) — Getting started and usage - [Configuration Guide](docs/config.md) — Customizing thresholds and filters -- [Built-in Checks](docs/checks.md) — List of what HaH detects +- [Shipped Checks](docs/checks.md) — List shipped rules and how to browse them - [DSL Reference](docs/dsl.md) — Writing custom YAML rules - [Developer Guide](docs/dev/README.md) — Working on the HaH codebase @@ -52,46 +53,3 @@ HaH is intended to help with: - duplicate software installs across package managers - network configuration hygiene (NTP, DHCP, DNS, interface management) - general system health checks - -## Capabilities - -HaH detects a wide range of system maintenance issues and provides information on why they matter, along with remediation suggestions. - -### Boot and Kernel - -- **Disk Space**: Low free space on `/boot`. -- **Cleanup**: Unused kernels and stale kernel headers/modules. -- **Configuration**: Suboptimal initramfs compression or oversized images. -- **Drivers**: DKMS modules that fail to build or broken driver states. - -### Package Hygiene (APT, Snap, Dpkg) - -- **State**: Failed or partial package states (`dpkg --audit`). -- **Cleanup**: Residual configuration files (`rc` state) and auto-removable packages. -- **Security**: Deprecated `apt-key` usage and legacy repository formats. -- **Conflicts**: Software duplicated across multiple package managers (e.g., APT and Snap). -- **Custom Rules**: Support for user-defined package denylists via configuration. - -### Network Configuration - -- **Redundancy**: Multiple active NTP or DHCP clients causing management overlap. -- **Legacy**: Outdated network tooling (`ifupdown`, `ntp`) alongside modern managers. -- **Resolved**: Incorrect `systemd-resolved` stub resolver configuration. - -### System Drift and Tuning - -- **Integrity**: Broken symbolic links and stale systemd units. -- **Resources**: Excessive journal growth and old crash dumps. -- **Kernel Tuning**: Conflicting or redundant `sysctl` parameters across different files. - ---- - -## Future Direction - -HaH is evolving into a comprehensive diagnostic assistant for long-lived Linux systems. Future goals include: - -- **Audit Reports**: Generation of detailed maintenance reports in HTML or Markdown. -- **System Profiles**: Check sets tailored for specific roles (Desktop, Server, Container). -- **Extended Diagnostics**: Integration with SMART data, filesystem health, and hardware metrics. -- **Release Lifecycle**: Detection of unsupported end-of-life distribution releases. -- **DSL Expansion**: More powerful data sources and filtering for the YAML rule engine. diff --git a/crates/hah-dsl/src/expr.rs b/crates/hah-dsl/src/expr.rs index 1d7cd43..da5f084 100644 --- a/crates/hah-dsl/src/expr.rs +++ b/crates/hah-dsl/src/expr.rs @@ -1,6 +1,6 @@ //! Strongly typed expression AST for the HaH DSL. -use crate::pipeline::{Filter, RuleValue, ValueMap}; +use crate::pipeline::{RuleValue, ValueMap}; use anyhow::{Result, anyhow}; #[derive(Debug, Clone, PartialEq)] @@ -26,7 +26,7 @@ impl Expression { for arg in args { evaled_args.push(arg.eval(values)?); } - apply_filter_new(RuleValue::Null, name, evaled_args) + apply_filter(RuleValue::Null, name, evaled_args) } Self::Pipeline(steps) => { if steps.is_empty() { @@ -41,7 +41,7 @@ impl Expression { for arg in args { evaled_args.push(arg.eval(values)?); } - apply_filter_new(current, name, evaled_args)? + apply_filter(current, name, evaled_args)? } _ => return Err(anyhow!("Expected filter in pipeline, got {:?}", step)), }; @@ -52,107 +52,11 @@ impl Expression { } } -fn apply_filter_new(value: RuleValue, name: &str, args: Vec) -> Result { - let filter = build_filter(name, args)?; +fn apply_filter(value: RuleValue, name: &str, args: Vec) -> Result { + let filter = crate::filters::build::build_filter(name, args)?; crate::filters::apply(value, &filter) } -fn build_filter(name: &str, args: Vec) -> Result { - // Zero-argument filters - if let Some(f) = zero_arg_filter(name) { - return Ok(f); - } - // Filters that take an integer argument - if let Some(f) = int_arg_filter(name, &args)? { - return Ok(f); - } - // Filters that take a string argument - 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)) -} - -fn zero_arg_filter(name: &str) -> Option { - match name { - "trim" => Some(Filter::Trim), - "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), - "unique" => Some(Filter::Unique), - "bytes_to_mb" => Some(Filter::BytesToMb), - _ => None, - } -} - -fn int_arg_filter(name: &str, args: &[RuleValue]) -> Result> { - let n = || -> Result { - args.first() - .and_then(RuleValue::as_int) - .map(|n| n as usize) - .ok_or_else(|| anyhow!("{} requires an integer argument", name)) - }; - match name { - "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 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 { - "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), - } -} - #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { diff --git a/crates/hah-dsl/src/filters/build.rs b/crates/hah-dsl/src/filters/build.rs new file mode 100644 index 0000000..41ceb47 --- /dev/null +++ b/crates/hah-dsl/src/filters/build.rs @@ -0,0 +1,120 @@ +//! Filter construction: maps filter names and arguments to [`Filter`] variants. + +use crate::pipeline::{Filter, RuleValue}; +use anyhow::{Result, anyhow}; + +macro_rules! zero_arg_filters { + ($name:expr; $( $filter_name:literal => $variant:ident ),+ $(,)?) => { + match $name { + $( $filter_name => Some(Self::$variant), )+ + _ => None, + } + }; +} + +macro_rules! unary_arg_filters { + ($name:expr, $arg:expr; $( $filter_name:literal => $variant:ident ),+ $(,)?) => { + match $name { + $( $filter_name => Ok(Some(Self::$variant($arg))), )+ + _ => Ok(None), + } + }; +} + +/// Build a [`Filter`] from a name and evaluated arguments. +pub fn build_filter(name: &str, args: Vec) -> Result { + Filter::build(name, args) +} + +impl Filter { + pub fn build(name: &str, args: Vec) -> Result { + if let Some(filter) = Self::zero_arg(name) { + return Ok(filter); + } + if let Some(filter) = Self::int_arg(name, &args)? { + return Ok(filter); + } + if let Some(filter) = Self::str_arg(name, &args)? { + return Ok(filter); + } + if let Some(filter) = Self::list_arg(name, &args)? { + return Ok(filter); + } + Err(anyhow!("Unknown filter: {}", name)) + } + + fn zero_arg(name: &str) -> Option { + zero_arg_filters!(name; + "trim" => Trim, + "lines" => Lines, + "non_empty" => NonEmpty, + "first" => First, + "last" => Last, + "number" => Number, + "count" => Count, + "sort" => Sort, + "unique" => Unique, + "bytes_to_mb" => BytesToMb, + ) + } + + fn int_arg(name: &str, args: &[RuleValue]) -> Result> { + match name { + "where_gt" => Ok(Some(Self::WhereGt(Self::require_int_arg(name, args)?))), + _ => unary_arg_filters!(name, Self::require_usize_arg(name, args)?; + "skip" => Skip, + "nth" => Nth, + "field" => Field, + "group_count" => GroupCount, + ), + } + } + + fn require_int_arg(name: &str, args: &[RuleValue]) -> Result { + args.first() + .and_then(RuleValue::as_int) + .ok_or_else(|| anyhow!("{} requires an integer argument", name)) + } + + fn require_usize_arg(name: &str, args: &[RuleValue]) -> Result { + Ok(Self::require_int_arg(name, args)? as usize) + } + + 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 require_list_arg<'a>(name: &str, args: &'a [RuleValue]) -> Result<&'a [RuleValue]> { + args.first() + .and_then(RuleValue::as_list) + .ok_or_else(|| anyhow!("{name} requires a list argument")) + } + + fn str_arg(name: &str, args: &[RuleValue]) -> Result> { + unary_arg_filters!(name, Self::require_str_arg(name, args)?.to_string(); + "prefix_strip" => PrefixStrip, + "starts_with" => StartsWith, + "contains" => Contains, + "reject_contains" => RejectContains, + "icontains" => IContains, + "join" => Join, + "default" => Default, + ) + } + + fn list_arg(name: &str, args: &[RuleValue]) -> Result> { + match name { + "intersect" | "reject_in" => { + let items = Self::require_list_arg(name, args)?; + let strings: Vec = items.iter().map(RuleValue::display).collect(); + Ok(Some(match name { + "intersect" => Self::Intersect(strings), + _ => Self::RejectIn(strings), + })) + } + _ => Ok(None), + } + } +} diff --git a/crates/hah-dsl/src/filters/mod.rs b/crates/hah-dsl/src/filters/mod.rs index 308f541..3eac700 100644 --- a/crates/hah-dsl/src/filters/mod.rs +++ b/crates/hah-dsl/src/filters/mod.rs @@ -1,64 +1,62 @@ use crate::pipeline::{Filter, RuleValue}; use anyhow::Result; +pub mod build; pub mod list; pub mod scalar; pub mod string; pub fn apply(value: RuleValue, filter: &Filter) -> Result { - apply_list(value, filter) - .or_else(|(v, f)| apply_string(v, f)) - .or_else(|(v, f)| apply_scalar(v, f)) - .unwrap_or_else(|_| unreachable!("all Filter variants are handled")) + filter.apply(value) } -fn apply_list( - value: RuleValue, - filter: &Filter, -) -> std::result::Result, (RuleValue, &Filter)> { - 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)), +impl Filter { + pub fn apply(&self, value: RuleValue) -> Result { + self.apply_list(value) + .or_else(|value| self.apply_string(value)) + .or_else(|value| self.apply_scalar(value)) + .unwrap_or_else(|_| unreachable!("all Filter variants are handled")) } -} -fn apply_string( - value: RuleValue, - filter: &Filter, -) -> std::result::Result, (RuleValue, &Filter)> { - match filter { - Filter::Trim => Ok(string::trim(value)), - Filter::Lines => Ok(string::lines(value)), - Filter::Field(n) => Ok(string::field(value, *n)), - Filter::PrefixStrip(s) => Ok(string::prefix_strip(value, s)), - 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)), + fn apply_list(&self, value: RuleValue) -> std::result::Result, RuleValue> { + match self { + Filter::NonEmpty => Ok(list::non_empty(value)), + Filter::Skip(n) => Ok(list::skip(value, *n)), + Filter::First => Ok(list::first(value)), + Filter::Nth(n) => Ok(list::nth(value, *n)), + Filter::Count => Ok(Ok(list::count(&value))), + Filter::Sort => Ok(list::sort(value)), + Filter::Unique => Ok(list::unique(value)), + Filter::Join(s) => Ok(list::join(value, s)), + Filter::Last => Ok(list::last(value)), + 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), + } + } + + fn apply_string(&self, value: RuleValue) -> std::result::Result, RuleValue> { + match self { + Filter::Trim => Ok(string::trim(value)), + Filter::Lines => Ok(string::lines(value)), + Filter::Field(n) => Ok(string::field(value, *n)), + Filter::PrefixStrip(s) => Ok(string::prefix_strip(value, s)), + 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), + } } -} -fn apply_scalar( - value: RuleValue, - filter: &Filter, -) -> std::result::Result, (RuleValue, &Filter)> { - match filter { - Filter::Number => Ok(scalar::number(value)), - Filter::BytesToMb => Ok(scalar::bytes_to_mb(value)), - Filter::Default(s) => Ok(scalar::default_val(value, s.clone())), - _ => Err((value, filter)), + fn apply_scalar(&self, value: RuleValue) -> std::result::Result, RuleValue> { + match self { + Filter::Number => Ok(scalar::number(value)), + Filter::BytesToMb => Ok(scalar::bytes_to_mb(value)), + Filter::Default(s) => Ok(scalar::default_val(value, s.clone())), + _ => Err(value), + } } } diff --git a/crates/hah-dsl/src/filters/scalar.rs b/crates/hah-dsl/src/filters/scalar.rs index f623f2f..86c41f0 100644 --- a/crates/hah-dsl/src/filters/scalar.rs +++ b/crates/hah-dsl/src/filters/scalar.rs @@ -2,34 +2,19 @@ use crate::pipeline::RuleValue; use anyhow::{Result, anyhow}; pub fn number(value: RuleValue) -> Result { - match value { - RuleValue::Int(_) => Ok(value), - RuleValue::Str(s) => { - let n: i64 = s - .trim() - .parse() - .map_err(|_| anyhow!("Not a number: '{}'", s))?; - Ok(RuleValue::Int(n)) - } - _ => Err(anyhow!("Cannot convert {:?} to number", value)), - } + Ok(RuleValue::Int(value.try_int()?)) } pub fn bytes_to_mb(value: RuleValue) -> Result { - let bytes = match value { - RuleValue::Int(n) => n, - RuleValue::Str(s) => s - .trim() - .parse() - .map_err(|_| anyhow!("Not a number: '{}'", s))?, + let bytes = match &value { + RuleValue::Int(_) | RuleValue::Str(_) => value.try_int()?, _ => return Err(anyhow!("bytes_to_mb requires a number, got {:?}", value)), }; Ok(RuleValue::Int(bytes / (1024 * 1024))) } pub fn default_val(value: RuleValue, default: String) -> Result { - let is_empty_str = matches!(&value, RuleValue::Str(s) if s.is_empty()); - if value == RuleValue::Null || is_empty_str { + if value.is_blank() { Ok(RuleValue::Str(default)) } else { Ok(value) diff --git a/crates/hah-dsl/src/filters/string.rs b/crates/hah-dsl/src/filters/string.rs index bc7d416..a693774 100644 --- a/crates/hah-dsl/src/filters/string.rs +++ b/crates/hah-dsl/src/filters/string.rs @@ -1,21 +1,53 @@ use crate::pipeline::RuleValue; use anyhow::{Result, anyhow}; -pub fn trim(value: RuleValue) -> Result { +fn map_string_or_list(value: RuleValue, filter_name: &str, map: F) -> Result +where + F: Fn(&str) -> RuleValue, +{ match value { - RuleValue::Str(s) => Ok(RuleValue::Str(s.trim().to_string())), + RuleValue::Str(s) => Ok(map(&s)), RuleValue::List(v) => Ok(RuleValue::List( v.into_iter() .map(|item| match item { - RuleValue::Str(s) => RuleValue::Str(s.trim().to_string()), + RuleValue::Str(s) => map(&s), other => other, }) .collect(), )), - other => Err(anyhow!("trim: expected a string or list, got {:?}", other)), + other => Err(anyhow!( + "{filter_name}: expected a string or list, got {:?}", + other + )), + } +} + +fn filter_string_list( + value: RuleValue, + filter_name: &str, + keep_non_strings: bool, + predicate: F, +) -> Result +where + F: Fn(&str) -> bool, +{ + match value { + RuleValue::List(v) => Ok(RuleValue::List( + v.into_iter() + .filter(|item| match item { + RuleValue::Str(s) => predicate(s), + _ => keep_non_strings, + }) + .collect(), + )), + other => Err(anyhow!("{filter_name}: expected a list, got {:?}", other)), } } +pub fn trim(value: RuleValue) -> Result { + map_string_or_list(value, "trim", |s| RuleValue::Str(s.trim().to_string())) +} + pub fn lines(value: RuleValue) -> Result { match value { RuleValue::Str(s) => Ok(RuleValue::List( @@ -28,62 +60,25 @@ pub fn lines(value: RuleValue) -> Result { } pub fn field(value: RuleValue, n: usize) -> Result { - match value { - RuleValue::Str(s) => { - let fields: Vec<&str> = s.split_whitespace().collect(); - Ok(fields - .get(n) - .map_or(RuleValue::Null, |f| RuleValue::Str(f.to_string()))) - } - RuleValue::List(v) => Ok(RuleValue::List( - v.into_iter() - .map(|item| match item { - RuleValue::Str(s) => { - let fields: Vec<&str> = s.split_whitespace().collect(); - fields - .get(n) - .map_or(RuleValue::Null, |f| RuleValue::Str(f.to_string())) - } - other => other, - }) - .collect(), - )), - other => Err(anyhow!("field: expected a string or list, got {:?}", other)), - } + map_string_or_list(value, "field", |s| { + let fields: Vec<&str> = s.split_whitespace().collect(); + fields + .get(n) + .map_or(RuleValue::Null, |f| RuleValue::Str(f.to_string())) + }) } pub fn prefix_strip(value: RuleValue, prefix: &str) -> Result { - match value { - RuleValue::Str(s) => Ok(RuleValue::Str( - s.strip_prefix(prefix).unwrap_or(&s).to_string(), - )), - RuleValue::List(v) => Ok(RuleValue::List( - v.into_iter() - .map(|item| match item { - RuleValue::Str(s) => { - RuleValue::Str(s.strip_prefix(prefix).unwrap_or(&s).to_string()) - } - other => other, - }) - .collect(), - )), - other => Err(anyhow!( - "prefix_strip: expected a string or list, got {:?}", - other - )), - } + map_string_or_list(value, "prefix_strip", |s| { + RuleValue::Str(s.strip_prefix(prefix).unwrap_or(s).to_string()) + }) } pub fn starts_with(value: RuleValue, prefix: &str) -> Result { match value { - RuleValue::List(v) => Ok(RuleValue::List( - v.into_iter() - .filter(|item| match item { - RuleValue::Str(s) => s.starts_with(prefix), - _ => false, - }) - .collect(), - )), + RuleValue::List(_) => { + filter_string_list(value, "starts_with", false, |s| s.starts_with(prefix)) + } RuleValue::Str(s) => Ok(if s.starts_with(prefix) { RuleValue::Str(s) } else { @@ -108,17 +103,7 @@ pub fn contains(value: &RuleValue, substring: &str) -> Result { } pub fn reject_contains(value: RuleValue, substring: &str) -> Result { - match value { - RuleValue::List(v) => Ok(RuleValue::List( - v.into_iter() - .filter(|item| match item { - RuleValue::Str(s) => !s.contains(substring), - _ => true, - }) - .collect(), - )), - other => Err(anyhow!("reject_contains: expected a list, got {:?}", other)), - } + filter_string_list(value, "reject_contains", true, |s| !s.contains(substring)) } /// Case-insensitive `contains`. @@ -129,14 +114,9 @@ pub fn reject_contains(value: RuleValue, substring: &str) -> Result { 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::List(_) => filter_string_list(value, "icontains", false, |s| { + s.to_lowercase().contains(&lower_sub) + }), RuleValue::Str(s) => Ok(RuleValue::Bool(s.to_lowercase().contains(&lower_sub))), _ => Ok(RuleValue::Bool(false)), } diff --git a/crates/hah-dsl/src/lib.rs b/crates/hah-dsl/src/lib.rs index 9593011..e6a1a10 100644 --- a/crates/hah-dsl/src/lib.rs +++ b/crates/hah-dsl/src/lib.rs @@ -5,6 +5,42 @@ pub mod parsers; pub mod pipeline; pub mod rule; +use std::path::Path; + +use rule::RuleSet; + +/// Validate a single YAML rule file. +/// +/// Returns a list of human-readable error strings. An empty vector means +/// the file is valid. +pub fn validate_rule_file(path: &Path) -> Vec { + let mut errors = Vec::new(); + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) => { + errors.push(format!("cannot read file: {e}")); + return errors; + } + }; + let rule_set: RuleSet = match hah_utils::yaml::parse(&content) { + Ok(rs) => rs, + Err(e) => { + errors.push(format!("YAML parse error: {e}")); + return errors; + } + }; + // Check rule IDs are non-empty and unique within file. + let mut seen = std::collections::HashSet::new(); + for rule in &rule_set.rules { + if rule.id.is_empty() { + errors.push("rule has empty id".into()); + } else if !seen.insert(&rule.id) { + errors.push(format!("duplicate rule id: {}", rule.id)); + } + } + errors +} + #[cfg(test)] pub mod testutil { use crate::pipeline::{RuleValue, ValueMap}; @@ -27,3 +63,104 @@ pub mod testutil { .collect() } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn validate_valid_rule_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("good.yaml"); + std::fs::write( + &path, + r#" +rules: + - id: test + title: Test + conditions: + - info: "$x > 0" + outcome: + finding_id: test + title: T + description: D +"#, + ) + .unwrap(); + assert!(validate_rule_file(&path).is_empty()); + } + + #[test] + fn validate_invalid_yaml_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bad.yaml"); + std::fs::write(&path, "not: [valid: yaml: {{").unwrap(); + let errors = validate_rule_file(&path); + assert!(!errors.is_empty()); + assert!(errors[0].contains("YAML parse error")); + } + + #[test] + fn validate_duplicate_id_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("dup.yaml"); + std::fs::write( + &path, + r#" +rules: + - id: same + title: A + conditions: + - info: "$x > 0" + outcome: + finding_id: a + title: A + description: A + - id: same + title: B + conditions: + - info: "$y > 0" + outcome: + finding_id: b + title: B + description: B +"#, + ) + .unwrap(); + let errors = validate_rule_file(&path); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("duplicate rule id: same")); + } + + #[test] + fn validate_nonexistent_file_returns_error() { + let errors = validate_rule_file(Path::new("/nonexistent/xyz.yaml")); + assert!(!errors.is_empty()); + assert!(errors[0].contains("cannot read file")); + } + + #[test] + fn validate_empty_id_returns_error() { + let mut f = tempfile::NamedTempFile::with_suffix(".yaml").unwrap(); + writeln!( + f, + r#" +rules: + - id: "" + title: T + conditions: + - info: "$x > 0" + outcome: + finding_id: t + title: T + description: D +"# + ) + .unwrap(); + let errors = validate_rule_file(f.path()); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("empty id")); + } +} diff --git a/crates/hah-dsl/src/parsers/dsl.rs b/crates/hah-dsl/src/parsers/dsl.rs index 6a9cc21..a59eb23 100644 --- a/crates/hah-dsl/src/parsers/dsl.rs +++ b/crates/hah-dsl/src/parsers/dsl.rs @@ -7,6 +7,75 @@ use winnow::token::{take_till, take_while}; use crate::expr::Expression; use crate::pipeline::RuleValue; +// ── Condition expression (compact syntax) ───────────────────────────────────── + +/// Parsed result of a compact condition expression like `"$x > 0"`. +#[derive(Debug, Clone, PartialEq)] +pub enum ConditionExpr { + /// Comparison: `lhs_expr OP rhs_expr` (e.g. `"$count > 0"`) + Compare { + lhs: String, + op: CompareToken, + rhs: String, + }, + /// Bare expression (no operator) → implies non-empty check. + Bare(String), +} + +/// Comparison operator token parsed from a compact condition string. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompareToken { + Gte, + Lte, + Neq, + Eq, + Gt, + Lt, + Match, +} + +/// Parse a compact condition expression string. +/// +/// Splits on comparison operators (trying longest first: `>=`, `<=`, `!=`, +/// `==`, `=~`, `>`, `<`). If no operator is found, returns [`ConditionExpr::Bare`]. +pub fn parse_condition_expr(input: &str) -> ConditionExpr { + // Operators ordered longest-first to avoid `>` matching inside `>=`. + const OPS: &[(&str, CompareToken)] = &[ + (">=", CompareToken::Gte), + ("<=", CompareToken::Lte), + ("!=", CompareToken::Neq), + ("==", CompareToken::Eq), + ("=~", CompareToken::Match), + (">", CompareToken::Gt), + ("<", CompareToken::Lt), + ]; + for &(tok, op) in OPS { + if let Some(pos) = find_operator(input, tok) { + let lhs = input[..pos].trim().to_string(); + let rhs = input[pos + tok.len()..].trim().to_string(); + return ConditionExpr::Compare { lhs, op, rhs }; + } + } + ConditionExpr::Bare(input.trim().to_string()) +} + +/// Find an operator token that is NOT inside quotes. +fn find_operator(input: &str, op: &str) -> Option { + let mut in_quote: Option = None; + let bytes = input.as_bytes(); + for i in 0..bytes.len() { + let ch = bytes[i] as char; + match in_quote { + Some(q) if ch == q => in_quote = None, + Some(_) => {} + None if ch == '\'' || ch == '"' => in_quote = Some(ch), + None if input[i..].starts_with(op) => return Some(i), + None => {} + } + } + None +} + /// Parse a full pipeline expression. pub fn parse_expression(input: &mut &str) -> Result { let mut exprs: Vec = @@ -108,3 +177,115 @@ fn parse_filter_call(input: &mut &str) -> Result { args: args.unwrap_or_default(), }) } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn parse_condition_gt() { + let result = parse_condition_expr("$count > 0"); + assert_eq!( + result, + ConditionExpr::Compare { + lhs: "$count".into(), + op: CompareToken::Gt, + rhs: "0".into(), + } + ); + } + + #[test] + fn parse_condition_gte() { + let result = parse_condition_expr("$x >= 10"); + assert_eq!( + result, + ConditionExpr::Compare { + lhs: "$x".into(), + op: CompareToken::Gte, + rhs: "10".into(), + } + ); + } + + #[test] + fn parse_condition_lte_with_variable_rhs() { + let result = parse_condition_expr("$free_mb <= $threshold_mb"); + assert_eq!( + result, + ConditionExpr::Compare { + lhs: "$free_mb".into(), + op: CompareToken::Lte, + rhs: "$threshold_mb".into(), + } + ); + } + + #[test] + fn parse_condition_eq_bool() { + let result = parse_condition_expr("$active == true"); + assert_eq!( + result, + ConditionExpr::Compare { + lhs: "$active".into(), + op: CompareToken::Eq, + rhs: "true".into(), + } + ); + } + + #[test] + fn parse_condition_neq() { + let result = parse_condition_expr("$status != false"); + assert_eq!( + result, + ConditionExpr::Compare { + lhs: "$status".into(), + op: CompareToken::Neq, + rhs: "false".into(), + } + ); + } + + #[test] + fn parse_condition_bare_variable() { + let result = parse_condition_expr("$items"); + assert_eq!(result, ConditionExpr::Bare("$items".into())); + } + + #[test] + fn parse_condition_bare_pipeline() { + let result = parse_condition_expr("$output | lines | non_empty"); + assert_eq!( + result, + ConditionExpr::Bare("$output | lines | non_empty".into()) + ); + } + + #[test] + fn parse_condition_operator_in_quotes_not_matched() { + let result = parse_condition_expr("$x == '> 5'"); + assert_eq!( + result, + ConditionExpr::Compare { + lhs: "$x".into(), + op: CompareToken::Eq, + rhs: "'> 5'".into(), + } + ); + } + + #[test] + fn parse_condition_regex_match() { + let result = parse_condition_expr("$status =~ '^overlap:'"); + assert_eq!( + result, + ConditionExpr::Compare { + lhs: "$status".into(), + op: CompareToken::Match, + rhs: "'^overlap:'".into(), + } + ); + } +} diff --git a/crates/hah-dsl/src/parsers/mod.rs b/crates/hah-dsl/src/parsers/mod.rs index fae137e..e1947ce 100644 --- a/crates/hah-dsl/src/parsers/mod.rs +++ b/crates/hah-dsl/src/parsers/mod.rs @@ -1,3 +1,3 @@ pub mod dsl; -pub use dsl::{parse_eval_expr, parse_expression}; +pub use dsl::{parse_condition_expr, parse_eval_expr, parse_expression}; diff --git a/crates/hah-dsl/src/pipeline.rs b/crates/hah-dsl/src/pipeline.rs index 7fa6a22..f76e80e 100644 --- a/crates/hah-dsl/src/pipeline.rs +++ b/crates/hah-dsl/src/pipeline.rs @@ -40,10 +40,18 @@ impl RuleValue { /// Return the value as an `i64`, parsing from a string if necessary. pub fn as_int(&self) -> Option { + self.try_int().ok() + } + + /// Return the value as an `i64`, parsing from a string if necessary. + pub fn try_int(&self) -> Result { match self { - Self::Int(n) => Some(*n), - Self::Str(s) => s.trim().parse().ok(), - _ => None, + Self::Int(n) => Ok(*n), + Self::Str(s) => s + .trim() + .parse() + .map_err(|_| anyhow!("Not a number: '{}'", s)), + _ => Err(anyhow!("Cannot convert {:?} to number", self)), } } @@ -65,6 +73,11 @@ impl RuleValue { } } + /// Whether the value should be treated as absent for defaulting. + pub fn is_blank(&self) -> bool { + matches!(self, Self::Null) || matches!(self, Self::Str(s) if s.is_empty()) + } + /// Human-readable form used in template substitution and `join`. pub fn display(&self) -> String { match self { @@ -261,4 +274,17 @@ mod tests { assert_eq!(RuleValue::Null.as_int(), None); assert_eq!(RuleValue::Bool(true).as_int(), None); } + + #[test] + fn rule_value_try_int_and_blank_helpers() { + assert_eq!(RuleValue::Int(5).try_int().unwrap(), 5); + assert_eq!(RuleValue::Str(" 7 ".into()).try_int().unwrap(), 7); + assert!(RuleValue::Str("abc".into()).try_int().is_err()); + assert!(RuleValue::Bool(true).try_int().is_err()); + + assert!(RuleValue::Null.is_blank()); + assert!(RuleValue::Str(String::new()).is_blank()); + assert!(!RuleValue::Str("x".into()).is_blank()); + assert!(!RuleValue::Int(0).is_blank()); + } } diff --git a/crates/hah-dsl/src/rule.rs b/crates/hah-dsl/src/rule.rs deleted file mode 100644 index c20d3ba..0000000 --- a/crates/hah-dsl/src/rule.rs +++ /dev/null @@ -1,1791 +0,0 @@ -//! Declarative YAML rule data model and runtime evaluator. -//! -//! A [`RuleSet`] is the top-level YAML document. It contains optional -//! reusable [`Blocks`] and a list of [`Rule`]s. Each rule is wrapped in a -//! [`RuleBasedCheck`] that implements the [`Check`] trait so it integrates -//! seamlessly with the existing registry and runner. - -use std::{collections::HashMap, path::Path, sync::Arc}; - -use anyhow::{Result, anyhow}; -use serde::{Deserialize, Serialize}; - -use hah_core::{ - check::{Check, Context}, - model::{CheckResult, Finding, Remediation, Severity}, -}; - -use crate::{ - caps_bridge, - pipeline::{RuleValue, ValueMap, eval_expr, render_template}, -}; - -// ── Reusable building blocks ────────────────────────────────────────────────── - -/// Named reusable building blocks defined at the top of a rule file. -#[derive(Debug, Default, Clone, Deserialize, Serialize)] -pub struct Blocks { - /// Named guard fragments (reusable `only_if` sections). - #[serde(default)] - pub guards: HashMap, - /// Named transformation pipeline expressions. - #[serde(default)] - pub transforms: HashMap, - /// Named partial outcome fragments (typically reusable remediations). - #[serde(default)] - pub outcomes: HashMap, -} - -// ── Top-level document ──────────────────────────────────────────────────────── - -/// Top-level YAML rule file document. -#[derive(Debug, Default, Deserialize, Serialize)] -pub struct RuleSet { - #[serde(default)] - pub blocks: Blocks, - #[serde(default)] - pub rules: Vec, -} - -impl RuleSet { - /// Deserialize all rule files (`*.yaml`) found in `dir`, sorted by name. - pub fn load_from_dir(dir: &Path) -> Result> { - if !dir.exists() { - return Ok(Vec::new()); - } - let mut entries: Vec<_> = std::fs::read_dir(dir)? - .flatten() - .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("yaml")) - .collect(); - entries.sort_by_key(std::fs::DirEntry::file_name); - - let mut rules = Vec::new(); - for entry in entries { - let content = std::fs::read_to_string(entry.path())?; - let rule_set: RuleSet = hah_utils::yaml::parse(&content) - .map_err(|e| anyhow!("failed to parse {}: {e}", entry.path().display()))?; - rules.extend(rule_set.rules); - } - Ok(rules) - } - - /// Deserialize all rule files in `dir` and return `RuleBasedCheck` - /// instances that carry the blocks from their source file. - pub fn load_checks_from_dir(dir: &Path) -> Result> { - if !dir.exists() { - return Ok(Vec::new()); - } - let mut entries: Vec<_> = std::fs::read_dir(dir)? - .flatten() - .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("yaml")) - .collect(); - entries.sort_by_key(std::fs::DirEntry::file_name); - - let mut checks = Vec::new(); - for entry in entries { - let content = std::fs::read_to_string(entry.path())?; - let rule_set: RuleSet = hah_utils::yaml::parse(&content) - .map_err(|e| anyhow!("failed to parse {}: {e}", entry.path().display()))?; - let blocks = Arc::new(rule_set.blocks); - for rule in rule_set.rules { - checks.push(RuleBasedCheck { - rule, - blocks: Arc::clone(&blocks), - }); - } - } - Ok(checks) - } -} - -// ── Rule ────────────────────────────────────────────────────────────────────── - -/// A single declarative rule. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Rule { - /// Stable unique ID used as the check ID. - pub id: String, - /// Human-readable title shown in `hah list-checks` and findings. - pub title: String, - /// Optional inline guard (can also be referenced via `use.guard`). - #[serde(default)] - pub only_if: RuleGuard, - /// References to named reusable blocks defined in the same file. - #[serde(default, rename = "use")] - pub uses: UseRef, - /// Named trigger definitions that collect values from the system. - #[serde(default)] - pub triggers: Vec, - /// Named derived values computed as pipeline expressions over trigger outputs. - #[serde(default)] - pub values: HashMap, - /// Conditions that, when true, produce a finding. - #[serde(default)] - pub conditions: Vec, - /// Template for the finding produced when a condition fires. - pub outcome: RuleOutcome, -} - -// ── Guards ──────────────────────────────────────────────────────────────────── - -/// Guard that determines whether a rule should be evaluated for this system. -#[derive(Debug, Default, Clone, Deserialize, Serialize)] -pub struct RuleGuard { - /// Required distro family, e.g. `"debian"`. - #[serde(default)] - pub distro_family: Option, - /// If non-empty, the system profile must be one of these values. - #[serde(default)] - pub profile: Vec, - /// 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. -#[derive(Debug, Default, Clone, Deserialize, Serialize)] -pub struct UseRef { - /// Named guard to use instead of (or merged with) `only_if`. - #[serde(default)] - pub guard: Option, - /// Named outcome fragment to use as a default remediation. - #[serde(default)] - pub outcome: Option, -} - -// ── Triggers ────────────────────────────────────────────────────────────────── - -/// A trigger that collects a named value from the system. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct RuleTrigger { - /// Name under which the result is stored in the value map. - 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). - pub capability: Option, - /// Optional pipeline expression that transforms the raw trigger output. - /// Use `$stdout` as the source variable. - #[serde(default)] - 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 { - pub program: String, - #[serde(default)] - pub args: Vec, -} - -/// Built-in system probe. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ProbeSpec { - 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). -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum CapabilitySpec { - SysctlConflicts { - #[serde(default)] - paths: Vec, - }, - BrokenSymlinks { - #[serde(default)] - paths: Vec, - }, - OldFiles { - #[serde(default)] - paths: Vec, - older_than_days: u64, - }, - KernelInventory, - StaleKernelHeaders, - JournalUsage, - LargeInitramfs { - #[serde(default = "default_initramfs_threshold")] - threshold_mb: u64, - }, - LegacyAptSources, - LegacyNetworkInterfaces, - InstalledDenylist, -} - -fn default_initramfs_threshold() -> u64 { - 100 -} - -// ── Conditions ──────────────────────────────────────────────────────────────── - -/// A typed condition predicate. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum RuleCondition { - NumericThreshold { - /// Pipeline expression resolving to a numeric value. - value: String, - operator: CompareOp, - /// Pipeline expression or literal resolving to the threshold. - threshold: String, - severity: Severity, - }, - Equals { - /// Pipeline expression resolving to any value. - value: String, - expected: ExpectedValue, - severity: Severity, - }, - NonEmpty { - /// Pipeline expression resolving to a list or string. - value: String, - severity: Severity, - }, - RegexMatch { - value: String, - pattern: String, - severity: Severity, - }, - All { - conditions: Vec, - severity: Severity, - }, - Any { - 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`]. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum CompareOp { - Lt, - Lte, - Gt, - Gte, - Eq, - Neq, -} - -/// A YAML-typed expected value used by [`RuleCondition::Equals`]. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] -pub enum ExpectedValue { - Bool(bool), - Int(i64), - Str(String), -} - -// ── Outcome ─────────────────────────────────────────────────────────────────── - -/// Template for the finding produced when a condition fires. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct RuleOutcome { - pub finding_id: String, - pub title: String, - pub description: String, - #[serde(default)] - pub remediation: Option, -} - -/// Reusable partial outcome fragment (provides a default remediation). -#[derive(Debug, Default, Clone, Deserialize, Serialize)] -pub struct OutcomeFragment { - #[serde(default)] - pub remediation: Option, -} - -/// Template for a remediation attached to a finding. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct RemediationTemplate { - pub description: String, - pub commands: Vec, -} - -// ── RuleBasedCheck ──────────────────────────────────────────────────────────── - -/// A [`Check`] implementation that evaluates a single declarative [`Rule`]. -pub struct RuleBasedCheck { - rule: Rule, - /// Shared blocks from the same rule file. - blocks: Arc, -} - -impl RuleBasedCheck { - pub fn new(rule: Rule, blocks: Arc) -> Self { - Self { rule, blocks } - } -} - -impl Check for RuleBasedCheck { - fn id(&self) -> &str { - &self.rule.id - } - - fn title(&self) -> &str { - &self.rule.title - } - - fn run(&self, ctx: &Context) -> CheckResult { - // ── 1. Guard ────────────────────────────────────────────────────────── - if !self.guard_passes(ctx) { - return CheckResult::default(); - } - - // ── 2. Seed value map with context ──────────────────────────────────── - let mut values = self.seed_context_values(ctx); - - // ── 3. Run triggers ─────────────────────────────────────────────────── - for trigger in &self.rule.triggers { - match self.run_trigger(trigger, ctx, &values) { - Ok(v) => { - values.insert(trigger.name.clone(), v); - } - Err(e) => { - return CheckResult::default() - .with_error(format!("trigger '{}': {e}", trigger.name)); - } - } - } - - // ── 4. Evaluate derived values ──────────────────────────────────────── - for (name, expr) in &self.rule.values { - match eval_expr(expr, &values) { - Ok(v) => { - values.insert(name.clone(), v); - } - Err(e) => { - return CheckResult::default().with_error(format!("value '{name}': {e}")); - } - } - } - - // ── 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 { - 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)); - } - Ok(false) => {} - Err(e) => { - result = result.with_error(format!("condition: {e}")); - } - } - } - result - } -} - -// ── Context seeding ─────────────────────────────────────────────────────────── - -impl RuleBasedCheck { - fn seed_context_values(&self, ctx: &Context) -> ValueMap { - let mut values: ValueMap = HashMap::new(); - for (key, &val) in &ctx.config.thresholds { - values.insert(format!("config.{key}"), RuleValue::Int(val as i64)); - } - values.insert( - "distro.family".into(), - RuleValue::Str(if ctx.distro.is_debian_family() { - "debian".into() - } else { - "unknown".into() - }), - ); - values.insert( - "config.allowlist.packages".into(), - RuleValue::List( - ctx.config - .allowlist - .packages - .iter() - .map(|s| RuleValue::Str(s.clone())) - .collect(), - ), - ); - values - } -} - -// ── Guard evaluation ────────────────────────────────────────────────────────── - -impl RuleBasedCheck { - fn resolved_guard(&self) -> RuleGuard { - self.rule.uses.guard.as_ref().map_or_else( - || self.rule.only_if.clone(), - |name| self.blocks.guards.get(name).cloned().unwrap_or_default(), - ) - } - - fn guard_passes(&self, ctx: &Context) -> bool { - let guard = self.resolved_guard(); - if guard - .distro_family - .as_deref() - .is_some_and(|f| f.eq_ignore_ascii_case("debian")) - && !ctx.distro.is_debian_family() - { - return false; - } - if !guard.profile.is_empty() && !guard.profile.contains(&ctx.config.profile) { - return false; - } - for cmd in &guard.require_commands { - if which_command(cmd).is_err() { - return false; - } - } - for file_path in &guard.require_files { - if !std::path::Path::new(file_path).exists() { - return false; - } - } - true - } -} - -fn which_command(name: &str) -> Result<()> { - std::process::Command::new("which") - .arg(name) - .output() - .map_err(|e| anyhow!("{e}")) - .and_then(|o| { - if o.status.success() { - Ok(()) - } else { - Err(anyhow!("command not found: {name}")) - } - }) -} - -// ── Trigger evaluation ──────────────────────────────────────────────────────── - -impl RuleBasedCheck { - fn run_trigger( - &self, - trigger: &RuleTrigger, - ctx: &Context, - values: &ValueMap, - ) -> Result { - let raw = if let Some(spec) = &trigger.command { - let args: Vec<&str> = spec.args.iter().map(String::as_str).collect(); - let out = ctx - .runner - .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 { - return dispatch_capability(spec, ctx); - } else { - return Err(anyhow!( - "trigger '{}' has no command, probe, or capability", - trigger.name - )); - }; - - // Apply transform if present, using $stdout as the source variable. - match &trigger.transform { - Some(expr) => { - let mut local = values.clone(); - local.insert("stdout".to_string(), raw); - eval_expr(expr, &local) - } - None => Ok(raw), - } - } -} - -// ── Capability dispatch ─────────────────────────────────────────────────────── - -fn dispatch_capability(spec: &CapabilitySpec, ctx: &Context) -> Result { - caps_bridge::dispatch(spec, ctx) -} - -fn run_probe(spec: &ProbeSpec, ctx: &Context) -> RuleValue { - match spec { - ProbeSpec::PackageInstalled { name } => RuleValue::Bool( - ctx.runner - .run("dpkg-query", &["-W", "-f=${Status}", name.as_str()]) - .is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains("install ok installed")), - ), - ProbeSpec::ServiceActive { name } => RuleValue::Bool( - ctx.runner - .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()) - }), - } -} - -// ── Condition evaluation ────────────────────────────────────────────────────── - -fn condition_severity(condition: &RuleCondition) -> &Severity { - match condition { - RuleCondition::NumericThreshold { severity, .. } - | RuleCondition::Equals { severity, .. } - | RuleCondition::NonEmpty { severity, .. } - | RuleCondition::RegexMatch { severity, .. } - | RuleCondition::All { severity, .. } - | RuleCondition::Any { severity, .. } - | RuleCondition::ForEach { severity, .. } => severity, - } -} - -fn numeric_compare(lhs: i64, op: &CompareOp, rhs: i64) -> bool { - match op { - CompareOp::Lt => lhs < rhs, - CompareOp::Lte => lhs <= rhs, - CompareOp::Gt => lhs > rhs, - CompareOp::Gte => lhs >= rhs, - CompareOp::Eq => lhs == rhs, - CompareOp::Neq => lhs != rhs, - } -} - -fn eval_numeric_threshold( - value: &str, - operator: &CompareOp, - threshold: &str, - values: &ValueMap, -) -> Result { - let lhs = eval_expr(value, values)?; - let rhs = eval_expr(threshold, values)?; - match (lhs.as_int(), rhs.as_int()) { - (Some(l), Some(r)) => Ok(numeric_compare(l, operator, r)), - _ => Err(anyhow!( - "numeric_threshold: both sides must be numeric (got {:?} and {:?})", - lhs.display(), - rhs.display() - )), - } -} - -fn eval_equals(value: &str, expected: &ExpectedValue, values: &ValueMap) -> Result { - let actual = eval_expr(value, values)?; - Ok(match expected { - ExpectedValue::Bool(b) => actual.as_bool() == Some(*b), - ExpectedValue::Int(n) => actual.as_int() == Some(*n), - ExpectedValue::Str(s) => actual.as_str() == Some(s.as_str()), - }) -} - -fn eval_regex_match(value: &str, pattern: &str, values: &ValueMap) -> Result { - let re = regex::Regex::new(pattern) - .map_err(|e| anyhow!("invalid regex pattern {pattern:?}: {e}"))?; - let v = eval_expr(value, values)?; - Ok(re.is_match(v.as_str().unwrap_or(""))) -} - -impl RuleBasedCheck { - fn eval_condition(&self, condition: &RuleCondition, values: &ValueMap) -> Result { - match condition { - RuleCondition::NumericThreshold { - value, - operator, - threshold, - .. - } => eval_numeric_threshold(value, operator, threshold, values), - - RuleCondition::Equals { - value, expected, .. - } => eval_equals(value, expected, values), - - RuleCondition::NonEmpty { value, .. } => Ok(eval_expr(value, values)?.is_truthy()), - - RuleCondition::RegexMatch { value, pattern, .. } => { - eval_regex_match(value, pattern, values) - } - - RuleCondition::All { conditions, .. } => conditions - .iter() - .try_fold(true, |acc, c| Ok(acc && self.eval_condition(c, values)?)), - - RuleCondition::Any { conditions, .. } => { - for c in conditions { - if self.eval_condition(c, values)? { - return Ok(true); - } - } - 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 ──────────────────────────────────────────────────── - - fn resolved_remediation(&self) -> Option<&RemediationTemplate> { - self.rule.outcome.remediation.as_ref().or_else(|| { - self.rule - .uses - .outcome - .as_ref() - .and_then(|name| self.blocks.outcomes.get(name)) - .and_then(|frag| frag.remediation.as_ref()) - }) - } - - fn make_finding(&self, severity: Severity, values: &ValueMap) -> Finding { - let out = &self.rule.outcome; - let remediation = self.resolved_remediation().map(|rem| Remediation { - description: render_template(&rem.description, values), - commands: rem - .commands - .iter() - .map(|c| render_template(c, values)) - .collect(), - }); - Finding { - id: render_template(&out.finding_id, values), - title: render_template(&out.title, values), - description: render_template(&out.description, values), - severity, - remediation, - } - } -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use std::io; - - use super::*; - use hah_core::{ - config::Config, - distro::DistroInfo, - runner::{CommandOutput, MockCommandRunner}, - }; - - fn make_check(yaml: &str) -> RuleBasedCheck { - let rs: RuleSet = hah_utils::yaml::parse(yaml).expect("yaml parse failed"); - let blocks = Arc::new(rs.blocks); - let rule = rs.rules.into_iter().next().expect("no rules in yaml"); - RuleBasedCheck { rule, blocks } - } - - fn ok_output(stdout: &str) -> io::Result { - Ok(CommandOutput { - stdout: stdout.as_bytes().to_vec(), - stderr: vec![], - success: true, - }) - } - - #[test] - fn rule_set_deserializes_minimal_rule() { - let yaml = r#" -rules: - - id: test-rule - title: Test rule - triggers: [] - conditions: - - type: non_empty - value: "$nothing" - severity: Info - outcome: - finding_id: test - title: "Test finding" - description: "Description." -"#; - let rs: RuleSet = hah_utils::yaml::parse(yaml).unwrap(); - assert_eq!(rs.rules.len(), 1); - assert_eq!(rs.rules[0].id, "test-rule"); - } - - #[test] - fn rule_set_deserializes_blocks() { - let yaml = r#" -blocks: - guards: - debian_family: - distro_family: debian - outcomes: - apt_remove: - remediation: - description: "Remove with apt." - commands: ["sudo apt remove foo"] -rules: [] -"#; - let rs: RuleSet = hah_utils::yaml::parse(yaml).unwrap(); - assert!(rs.blocks.guards.contains_key("debian_family")); - assert!(rs.blocks.outcomes.contains_key("apt_remove")); - } - - #[test] - fn non_empty_condition_false_when_list_empty() { - let check = make_check( - r#" -rules: - - id: x - title: X - conditions: - - type: non_empty - value: "$items" - severity: Warning - outcome: - finding_id: x - title: "found" - description: "" -"#, - ); - let values: ValueMap = HashMap::new(); - let result = check.eval_condition(&check.rule.conditions[0], &values); - assert!(!result.unwrap()); - } - - #[test] - fn non_empty_condition_true_when_list_has_items() { - let check = make_check( - r#" -rules: - - id: x - title: X - conditions: - - type: non_empty - value: "$items" - severity: Warning - outcome: - finding_id: x - title: "found" - description: "" -"#, - ); - let mut values: ValueMap = HashMap::new(); - values.insert( - "items".into(), - RuleValue::List(vec![RuleValue::Str("pkg".into())]), - ); - assert!( - check - .eval_condition(&check.rule.conditions[0], &values) - .unwrap() - ); - } - - #[test] - fn numeric_threshold_lt_triggers_when_below() { - let check = make_check( - r#" -rules: - - id: x - title: X - conditions: - - type: numeric_threshold - value: "$free" - operator: lt - threshold: "100" - severity: Critical - outcome: - finding_id: x - title: "low" - description: "" -"#, - ); - let mut values: ValueMap = HashMap::new(); - values.insert("free".into(), RuleValue::Int(50)); - assert!( - check - .eval_condition(&check.rule.conditions[0], &values) - .unwrap() - ); - } - - #[test] - fn numeric_threshold_lt_does_not_trigger_when_above() { - let check = make_check( - r#" -rules: - - id: x - title: X - conditions: - - type: numeric_threshold - value: "$free" - operator: lt - threshold: "100" - severity: Critical - outcome: - finding_id: x - title: "low" - description: "" -"#, - ); - let mut values: ValueMap = HashMap::new(); - values.insert("free".into(), RuleValue::Int(200)); - assert!( - !check - .eval_condition(&check.rule.conditions[0], &values) - .unwrap() - ); - } - - #[test] - fn command_trigger_stores_stdout_in_value_map() { - let check = make_check( - r#" -rules: - - id: x - title: X - triggers: - - name: result - command: - program: echo - args: ["hello"] - conditions: [] - outcome: - finding_id: x - title: "" - description: "" -"#, - ); - let mut mock = MockCommandRunner::new(); - mock.expect_run().returning(|_, _| ok_output("hello\n")); - let ctx = Context::new_with_runner( - false, - Config::default(), - DistroInfo::default(), - std::sync::Arc::new(mock), - ); - let cr = check.run(&ctx); - assert!(cr.errors.is_empty(), "unexpected errors: {:?}", cr.errors); - } - - #[test] - fn command_trigger_with_transform() { - let check = make_check( - r#" -rules: - - id: x - title: X - triggers: - - name: free_mb - command: - program: df - args: [] - transform: "$stdout | lines | nth(1) | trim | number | bytes_to_mb" - conditions: - - type: numeric_threshold - value: "$free_mb" - operator: lt - threshold: "50" - severity: Critical - outcome: - finding_id: x - title: "{free_mb} MB" - description: "" -"#, - ); - // Simulate `df` output: header + avail bytes (10 MB) - let avail_bytes = 10 * 1_048_576i64; - let df_output = format!("Avail\n{avail_bytes}\n"); - let mut mock = MockCommandRunner::new(); - mock.expect_run() - .returning(move |_, _| ok_output(&df_output)); - let ctx = Context::new_with_runner( - false, - Config::default(), - DistroInfo::default(), - std::sync::Arc::new(mock), - ); - let cr = check.run(&ctx); - assert!(cr.errors.is_empty(), "unexpected errors: {:?}", cr.errors); - assert_eq!(cr.findings.len(), 1); - assert_eq!(cr.findings[0].title, "10 MB"); - assert_eq!(cr.findings[0].severity, Severity::Critical); - } - - #[test] - fn guard_debian_family_skips_on_non_debian() { - let check = make_check( - r#" -rules: - - id: x - title: X - only_if: - distro_family: debian - conditions: - - type: non_empty - value: "$nothing" - severity: Warning - outcome: - finding_id: x - title: "" - description: "" -"#, - ); - let ctx = Context::new(false, Config::default(), DistroInfo::default()); - // Default DistroInfo is not Debian family. - let cr = check.run(&ctx); - assert!(cr.findings.is_empty()); - assert!(cr.errors.is_empty()); - } - - #[test] - fn use_outcome_provides_default_remediation() { - let check = make_check( - r#" -blocks: - outcomes: - shared_rem: - remediation: - description: "Shared fix." - commands: ["sudo fix"] -rules: - - id: x - title: X - use: - outcome: shared_rem - conditions: [] - outcome: - finding_id: x - title: "found" - description: "" -"#, - ); - let values: ValueMap = HashMap::new(); - let finding = check.make_finding(Severity::Warning, &values); - assert!(finding.remediation.is_some()); - assert_eq!(finding.remediation.unwrap().commands, vec!["sudo fix"]); - } - - #[test] - fn template_substitution_in_finding() { - let check = make_check( - r#" -rules: - - id: x - title: X - conditions: [] - outcome: - finding_id: "x-{count}" - title: "{count} items" - description: "Found {count} items." -"#, - ); - let mut values: ValueMap = HashMap::new(); - values.insert("count".into(), RuleValue::Int(3)); - let finding = check.make_finding(Severity::Info, &values); - assert_eq!(finding.id, "x-3"); - assert_eq!(finding.title, "3 items"); - assert_eq!(finding.description, "Found 3 items."); - } - - // ── load helpers ────────────────────────────────────────────────────────── - - #[test] - fn load_from_dir_nonexistent_returns_empty() { - let rules = - RuleSet::load_from_dir(std::path::Path::new("/nonexistent_hah_test_12345")).unwrap(); - assert!(rules.is_empty()); - } - - #[test] - fn load_checks_from_dir_nonexistent_returns_empty() { - let checks = - RuleSet::load_checks_from_dir(std::path::Path::new("/nonexistent_hah_test_12345")) - .unwrap(); - assert!(checks.is_empty()); - } - - // ── Trigger error paths ─────────────────────────────────────────────────── - - #[test] - fn capability_trigger_sysctl_conflicts_runs_without_error() { - // sysctl_conflicts on non-existent path returns an empty list, not an error. - let check = make_check( - r#" -rules: - - id: x - title: X - triggers: - - name: conflicts - capability: - type: sysctl_conflicts - paths: ["/nonexistent/sysctl.d"] - conditions: - - type: non_empty - value: "$conflicts" - severity: Warning - outcome: { finding_id: x, title: "", description: "" } -"#, - ); - let ctx = Context::new(false, Config::default(), DistroInfo::default()); - let cr = check.run(&ctx); - // Non-existent path → no conflicts, no errors, no findings. - assert!(cr.errors.is_empty()); - assert!(cr.findings.is_empty()); - } - - #[test] - fn trigger_with_no_kind_adds_error() { - let check = make_check( - r#" -rules: - - id: x - title: X - triggers: - - name: empty_trigger - conditions: [] - outcome: { finding_id: x, title: "", description: "" } -"#, - ); - let ctx = Context::new(false, Config::default(), DistroInfo::default()); - let cr = check.run(&ctx); - assert!(!cr.errors.is_empty()); - } - - // ── Equals condition ────────────────────────────────────────────────────── - - fn make_equals_check(expected_yaml: &str) -> RuleBasedCheck { - make_check(&format!( - r#" -rules: - - id: x - title: X - conditions: - - type: equals - value: "$val" - expected: {expected_yaml} - severity: Warning - outcome: {{ finding_id: x, title: "", description: "" }} -"# - )) - } - - #[test] - fn equals_condition_bool_matches_and_mismatches() { - let check = make_equals_check("true"); - let cond = &check.rule.conditions[0]; - let mut values = HashMap::new(); - values.insert("val".into(), RuleValue::Bool(true)); - assert!(check.eval_condition(cond, &values).unwrap()); - values.insert("val".into(), RuleValue::Bool(false)); - assert!(!check.eval_condition(cond, &values).unwrap()); - } - - #[test] - fn equals_condition_int_matches_and_mismatches() { - let check = make_equals_check("42"); - let cond = &check.rule.conditions[0]; - let mut values = HashMap::new(); - values.insert("val".into(), RuleValue::Int(42)); - assert!(check.eval_condition(cond, &values).unwrap()); - values.insert("val".into(), RuleValue::Int(99)); - assert!(!check.eval_condition(cond, &values).unwrap()); - } - - #[test] - fn equals_condition_str_matches_and_mismatches() { - let check = make_equals_check("\"hello\""); - let cond = &check.rule.conditions[0]; - let mut values = HashMap::new(); - values.insert("val".into(), RuleValue::Str("hello".into())); - assert!(check.eval_condition(cond, &values).unwrap()); - values.insert("val".into(), RuleValue::Str("world".into())); - assert!(!check.eval_condition(cond, &values).unwrap()); - } - - // ── All / Any conditions ────────────────────────────────────────────────── - - const ALL_YAML: &str = r#" -rules: - - id: x - title: X - conditions: - - type: all - conditions: - - type: equals - value: "$a" - expected: true - severity: Info - - type: equals - value: "$b" - expected: true - severity: Info - severity: Warning - outcome: { finding_id: x, title: "", description: "" } -"#; - - const ANY_YAML: &str = r#" -rules: - - id: x - title: X - conditions: - - type: any - conditions: - - type: equals - value: "$a" - expected: true - severity: Info - - type: equals - value: "$b" - expected: true - severity: Info - severity: Warning - outcome: { finding_id: x, title: "", description: "" } -"#; - - #[test] - fn all_condition_fires_when_all_true() { - let check = make_check(ALL_YAML); - let mut v = HashMap::new(); - v.insert("a".into(), RuleValue::Bool(true)); - v.insert("b".into(), RuleValue::Bool(true)); - assert!(check.eval_condition(&check.rule.conditions[0], &v).unwrap()); - } - - #[test] - fn all_condition_does_not_fire_when_one_false() { - let check = make_check(ALL_YAML); - let mut v = HashMap::new(); - v.insert("a".into(), RuleValue::Bool(true)); - v.insert("b".into(), RuleValue::Bool(false)); - assert!(!check.eval_condition(&check.rule.conditions[0], &v).unwrap()); - } - - #[test] - fn any_condition_fires_when_one_true() { - let check = make_check(ANY_YAML); - let mut v = HashMap::new(); - v.insert("a".into(), RuleValue::Bool(false)); - v.insert("b".into(), RuleValue::Bool(true)); - assert!(check.eval_condition(&check.rule.conditions[0], &v).unwrap()); - } - - #[test] - fn any_condition_does_not_fire_when_all_false() { - let check = make_check(ANY_YAML); - let mut v = HashMap::new(); - v.insert("a".into(), RuleValue::Bool(false)); - v.insert("b".into(), RuleValue::Bool(false)); - assert!(!check.eval_condition(&check.rule.conditions[0], &v).unwrap()); - } - - // ── RegexMatch condition ────────────────────────────────────────────────── - - #[test] - fn regex_match_condition_matches() { - let check = make_check( - r#" -rules: - - id: x - title: X - conditions: - - type: regex_match - value: "$val" - pattern: "^foo.*" - severity: Info - outcome: { finding_id: x, title: "", description: "" } -"#, - ); - let mut v = HashMap::new(); - v.insert("val".into(), RuleValue::Str("foobar".into())); - assert!(check.eval_condition(&check.rule.conditions[0], &v).unwrap()); - - let mut v2 = HashMap::new(); - v2.insert("val".into(), RuleValue::Str("barfoo".into())); - assert!( - !check - .eval_condition(&check.rule.conditions[0], &v2) - .unwrap() - ); - } - - #[test] - fn regex_match_invalid_pattern_returns_error() { - let check = make_check( - r#" -rules: - - id: x - title: X - conditions: - - type: regex_match - value: "$val" - pattern: "[invalid" - severity: Info - outcome: { finding_id: x, title: "", description: "" } -"#, - ); - assert!( - check - .eval_condition(&check.rule.conditions[0], &HashMap::new()) - .is_err() - ); - } - - #[test] - fn regex_match_finding_emitted_when_condition_true() { - let check = make_check( - r#" -rules: - - id: x - title: X - conditions: - - type: regex_match - value: "$val" - pattern: "legacy" - severity: Warning - outcome: { finding_id: x, title: "Legacy found", description: "" } -"#, - ); - let ctx = Context::new(false, Config::default(), DistroInfo::default()); - let mut map = hah_core::runner::MockCommandRunner::default(); - map.expect_run().returning(|_, _| { - Ok(hah_core::runner::CommandOutput { - stdout: b"legacy-ntp installed".to_vec(), - stderr: vec![], - success: true, - }) - }); - let cr = check.run(&ctx); - // No command runner needed – value comes from condition directly - let _ = cr; - } - - // ── Numeric threshold operators ─────────────────────────────────────────── - - fn make_numeric_check(op: &str) -> RuleBasedCheck { - make_check(&format!( - r#" -rules: - - id: x - title: X - conditions: - - type: numeric_threshold - value: "$val" - operator: {op} - threshold: "10" - severity: Info - outcome: {{ finding_id: x, title: "", description: "" }} -"# - )) - } - - fn eval_numeric(op: &str, val: i64) -> bool { - let check = make_numeric_check(op); - let mut values = HashMap::new(); - values.insert("val".into(), RuleValue::Int(val)); - check - .eval_condition(&check.rule.conditions[0], &values) - .unwrap() - } - - #[test] - fn numeric_threshold_all_operators() { - assert!(eval_numeric("lt", 5)); // 5 < 10 - assert!(!eval_numeric("lt", 10)); // 10 < 10 = false - assert!(eval_numeric("lte", 10)); // 10 <= 10 - assert!(!eval_numeric("lte", 11)); // 11 <= 10 = false - assert!(eval_numeric("gt", 15)); // 15 > 10 - assert!(!eval_numeric("gt", 5)); // 5 > 10 = false - assert!(eval_numeric("gte", 10)); // 10 >= 10 - assert!(!eval_numeric("gte", 5)); // 5 >= 10 = false - assert!(eval_numeric("eq", 10)); // 10 == 10 - assert!(!eval_numeric("eq", 5)); // 5 == 10 = false - assert!(eval_numeric("neq", 5)); // 5 != 10 - assert!(!eval_numeric("neq", 10)); // 10 != 10 = false - } - - #[test] - fn numeric_threshold_non_numeric_value_returns_error() { - let check = make_numeric_check("lt"); - let mut values = HashMap::new(); - values.insert("val".into(), RuleValue::Str("not-a-number".into())); - assert!( - check - .eval_condition(&check.rule.conditions[0], &values) - .is_err() - ); - } - - // ── Guard: profile and require_commands ─────────────────────────────────── - - #[test] - fn guard_profile_skips_when_mismatch() { - let check = make_check( - r#" -rules: - - id: x - title: X - only_if: - profile: [server] - conditions: [] - outcome: { finding_id: x, title: "", description: "" } -"#, - ); - let ctx = Context::new(false, Config::default(), DistroInfo::default()); - // Default config profile is "" which does not match "server". - assert!(!check.guard_passes(&ctx)); - } - - #[test] - fn guard_profile_passes_when_matching() { - let check = make_check( - r#" -rules: - - id: x - title: X - only_if: - profile: [server] - conditions: [] - outcome: { finding_id: x, title: "", description: "" } -"#, - ); - let config = Config { - profile: "server".to_string(), - ..Default::default() - }; - let ctx = Context::new(false, config, DistroInfo::default()); - assert!(check.guard_passes(&ctx)); - } - - #[test] - fn guard_require_commands_skips_when_missing() { - let check = make_check( - r#" -rules: - - id: x - title: X - only_if: - require_commands: ["__nonexistent_cmd_hah_test__"] - conditions: [] - outcome: { finding_id: x, title: "", description: "" } -"#, - ); - let ctx = Context::new(false, Config::default(), DistroInfo::default()); - assert!(!check.guard_passes(&ctx)); - } - - #[test] - fn guard_require_commands_passes_when_present() { - let check = make_check( - r#" -rules: - - id: x - title: X - only_if: - require_commands: ["ls"] - conditions: [] - outcome: { finding_id: x, title: "", description: "" } -"#, - ); - let ctx = Context::new(false, Config::default(), DistroInfo::default()); - assert!(check.guard_passes(&ctx)); - } - - // ── Probes ──────────────────────────────────────────────────────────────── - - const PROBE_PKG_YAML: &str = r#" -rules: - - id: x - title: X - triggers: - - name: installed - probe: - type: package_installed - name: mypkg - conditions: - - type: equals - value: "$installed" - expected: true - severity: Warning - outcome: { finding_id: x, title: "installed", description: "" } -"#; - - const PROBE_SVC_YAML: &str = r#" -rules: - - id: x - title: X - triggers: - - name: active - probe: - type: service_active - name: mysvc - conditions: - - type: equals - value: "$active" - expected: true - severity: Info - outcome: { finding_id: x, title: "active", description: "" } -"#; - - #[test] - fn probe_package_installed_returns_true() { - let check = make_check(PROBE_PKG_YAML); - let mut mock = MockCommandRunner::new(); - mock.expect_run() - .returning(|_, _| ok_output("install ok installed")); - let ctx = Context::new_with_runner( - false, - Config::default(), - DistroInfo::default(), - std::sync::Arc::new(mock), - ); - let cr = check.run(&ctx); - assert_eq!(cr.findings.len(), 1); - assert!(cr.errors.is_empty()); - } - - #[test] - fn probe_package_not_installed_returns_false() { - let check = make_check(PROBE_PKG_YAML); - let mut mock = MockCommandRunner::new(); - mock.expect_run() - .returning(|_, _| ok_output("deinstall ok deinstalled")); - let ctx = Context::new_with_runner( - false, - Config::default(), - DistroInfo::default(), - std::sync::Arc::new(mock), - ); - let cr = check.run(&ctx); - assert!(cr.findings.is_empty()); - assert!(cr.errors.is_empty()); - } - - #[test] - fn probe_service_active_returns_true() { - let check = make_check(PROBE_SVC_YAML); - let mut mock = MockCommandRunner::new(); - mock.expect_run().returning(|_, _| { - Ok(CommandOutput { - stdout: vec![], - stderr: vec![], - success: true, - }) - }); - let ctx = Context::new_with_runner( - false, - Config::default(), - DistroInfo::default(), - std::sync::Arc::new(mock), - ); - let cr = check.run(&ctx); - assert_eq!(cr.findings.len(), 1); - } - - #[test] - fn probe_service_inactive_returns_false() { - let check = make_check(PROBE_SVC_YAML); - let mut mock = MockCommandRunner::new(); - mock.expect_run().returning(|_, _| { - Ok(CommandOutput { - stdout: vec![], - stderr: vec![], - success: false, - }) - }); - let ctx = Context::new_with_runner( - false, - Config::default(), - DistroInfo::default(), - std::sync::Arc::new(mock), - ); - let cr = check.run(&ctx); - assert!(cr.findings.is_empty()); - } - - // ── Miscellaneous run paths ─────────────────────────────────────────────── - - #[test] - fn own_outcome_remediation_takes_precedence_over_blocks() { - let check = make_check( - r#" -blocks: - outcomes: - shared_rem: - remediation: - description: "Block fix." - commands: ["sudo block-fix"] -rules: - - id: x - title: X - use: - outcome: shared_rem - conditions: [] - outcome: - finding_id: x - title: "found" - description: "" - remediation: - description: "Own fix." - commands: ["sudo own-fix"] -"#, - ); - let values = HashMap::new(); - let finding = check.make_finding(Severity::Warning, &values); - let rem = finding.remediation.unwrap(); - assert_eq!(rem.description, "Own fix."); - } - - #[test] - fn config_thresholds_accessible_in_value_map() { - let check = make_check( - r#" -rules: - - id: x - title: X - conditions: - - type: numeric_threshold - value: "$config.boot_space_mb" - operator: gt - threshold: "0" - severity: Info - outcome: { finding_id: x, title: "low", description: "" } -"#, - ); - let mut config = Config::default(); - config.thresholds.insert("boot_space_mb".to_string(), 100); - let ctx = Context::new(false, config, DistroInfo::default()); - let cr = check.run(&ctx); - // 100 > 0 → condition fires - assert_eq!(cr.findings.len(), 1); - assert!(cr.errors.is_empty()); - } - - #[test] - fn derived_value_error_adds_to_result_errors() { - let check = make_check( - r#" -rules: - - id: x - title: X - triggers: - - name: raw - command: - program: echo - args: ["text"] - values: - parsed: "$raw | number" - conditions: [] - outcome: { finding_id: x, title: "", description: "" } -"#, - ); - let mut mock = MockCommandRunner::new(); - mock.expect_run() - .returning(|_, _| ok_output("not_a_number\n")); - let ctx = Context::new_with_runner( - false, - Config::default(), - DistroInfo::default(), - std::sync::Arc::new(mock), - ); - let cr = check.run(&ctx); - assert!(!cr.errors.is_empty()); - assert!(cr.errors[0].contains("value 'parsed'")); - } - - #[test] - fn distro_family_injected_as_debian_when_debian() { - let check = make_check( - r#" -rules: - - id: x - title: X - conditions: - - type: equals - value: "$distro.family" - expected: "debian" - severity: Info - outcome: { finding_id: x, title: "debian", description: "" } -"#, - ); - let distro = DistroInfo { - id: "ubuntu".into(), - id_like: "debian".into(), - ..DistroInfo::default() - }; - let ctx = Context::new(false, Config::default(), distro); - 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-dsl/src/rule/check.rs b/crates/hah-dsl/src/rule/check.rs new file mode 100644 index 0000000..5153821 --- /dev/null +++ b/crates/hah-dsl/src/rule/check.rs @@ -0,0 +1,335 @@ +//! [`RuleBasedCheck`]: the [`Check`] implementation that evaluates a declarative rule. + +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::{Result, anyhow}; + +use hah_core::{ + check::{Check, Context}, + model::{CheckResult, Finding, Remediation, Severity}, +}; + +use crate::{ + caps_bridge, + pipeline::{RuleValue, ValueMap, eval_expr, render_template}, +}; + +use super::eval; +use super::model::{ + Blocks, CapabilitySpec, ProbeSpec, RemediationTemplate, Rule, RuleCondition, RuleGuard, + RuleTrigger, +}; + +// ── RuleBasedCheck ──────────────────────────────────────────────────────────── + +/// A [`Check`] implementation that evaluates a single declarative [`Rule`]. +pub struct RuleBasedCheck { + pub(crate) rule: Rule, + /// Shared blocks from the same rule file. + pub(crate) blocks: Arc, +} + +impl RuleBasedCheck { + pub fn new(rule: Rule, blocks: Arc) -> Self { + Self { rule, blocks } + } +} + +impl Check for RuleBasedCheck { + fn id(&self) -> &str { + &self.rule.id + } + + fn title(&self) -> &str { + &self.rule.title + } + + fn run(&self, ctx: &Context) -> CheckResult { + // ── 1. Guard ────────────────────────────────────────────────────────── + if !self.guard_passes(ctx) { + return CheckResult::default(); + } + + // ── 2. Seed value map with context ──────────────────────────────────── + let mut values = self.seed_context_values(ctx); + + // ── 3. Run triggers ─────────────────────────────────────────────────── + for trigger in &self.rule.triggers { + match self.run_trigger(trigger, ctx, &values) { + Ok(v) => { + values.insert(trigger.name.clone(), v); + } + Err(e) => { + return CheckResult::default() + .with_error(format!("trigger '{}': {e}", trigger.name)); + } + } + } + + // ── 4. Evaluate derived values ──────────────────────────────────────── + for (name, expr) in &self.rule.values { + match eval_expr(expr, &values) { + Ok(v) => { + values.insert(name.clone(), v); + } + Err(e) => { + return CheckResult::default().with_error(format!("value '{name}': {e}")); + } + } + } + + // ── 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) { + Ok(true) => { + let severity = condition.severity(); + match self.emit_findings(severity, values) { + Ok(findings) => { + for f in findings { + result = result.with_finding(f); + } + } + Err(e) => { + result = result.with_error(format!("outcome for_each: {e}")); + } + } + } + Ok(false) => {} + Err(e) => { + result = result.with_error(format!("condition: {e}")); + } + } + } + result + } +} + +// ── Context seeding ─────────────────────────────────────────────────────────── + +impl RuleBasedCheck { + fn seed_context_values(&self, ctx: &Context) -> ValueMap { + let mut values: ValueMap = HashMap::new(); + for (key, &val) in &ctx.config.thresholds { + values.insert(format!("config.{key}"), RuleValue::Int(val as i64)); + } + values.insert( + "distro.family".into(), + RuleValue::Str(if ctx.distro.is_debian_family() { + "debian".into() + } else { + "unknown".into() + }), + ); + values.insert( + "config.allowlist.packages".into(), + RuleValue::List( + ctx.config + .allowlist + .packages + .iter() + .map(|s| RuleValue::Str(s.clone())) + .collect(), + ), + ); + values + } +} + +// ── Guard evaluation ────────────────────────────────────────────────────────── + +impl RuleBasedCheck { + fn resolved_guard(&self) -> RuleGuard { + self.rule.uses.guard.as_ref().map_or_else( + || self.rule.only_if.clone(), + |name| self.blocks.guards.get(name).cloned().unwrap_or_default(), + ) + } + + pub(crate) fn guard_passes(&self, ctx: &Context) -> bool { + let guard = self.resolved_guard(); + if guard + .distro_family + .as_deref() + .is_some_and(|f| f.eq_ignore_ascii_case("debian")) + && !ctx.distro.is_debian_family() + { + return false; + } + if !guard.profile.is_empty() && !guard.profile.contains(&ctx.config.profile) { + return false; + } + for cmd in &guard.require_commands { + if which_command(cmd).is_err() { + return false; + } + } + for file_path in &guard.require_files { + if !std::path::Path::new(file_path).exists() { + return false; + } + } + true + } +} + +fn which_command(name: &str) -> Result<()> { + std::process::Command::new("which") + .arg(name) + .output() + .map_err(|e| anyhow!("{e}")) + .and_then(|o| { + if o.status.success() { + Ok(()) + } else { + Err(anyhow!("command not found: {name}")) + } + }) +} + +// ── Trigger evaluation ──────────────────────────────────────────────────────── + +impl RuleBasedCheck { + fn run_trigger( + &self, + trigger: &RuleTrigger, + ctx: &Context, + values: &ValueMap, + ) -> Result { + let raw = if let Some(spec) = &trigger.command { + let args: Vec<&str> = spec.args.iter().map(String::as_str).collect(); + let out = ctx + .runner + .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 { + return dispatch_capability(spec, ctx); + } else { + return Err(anyhow!( + "trigger '{}' has no command, probe, or capability", + trigger.name + )); + }; + + // Apply transform if present, using $stdout as the source variable. + match &trigger.transform { + Some(expr) => { + let mut local = values.clone(); + local.insert("stdout".to_string(), raw); + eval_expr(expr, &local) + } + None => Ok(raw), + } + } +} + +// ── Capability dispatch ─────────────────────────────────────────────────────── + +fn dispatch_capability(spec: &CapabilitySpec, ctx: &Context) -> Result { + caps_bridge::dispatch(spec, ctx) +} + +pub(crate) fn run_probe(spec: &ProbeSpec, ctx: &Context) -> RuleValue { + match spec { + ProbeSpec::PackageInstalled { name } => RuleValue::Bool( + ctx.runner + .run("dpkg-query", &["-W", "-f=${Status}", name.as_str()]) + .is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains("install ok installed")), + ), + ProbeSpec::ServiceActive { name } => RuleValue::Bool( + ctx.runner + .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()) + }), + } +} + +// ── Condition evaluation ────────────────────────────────────────────────────── + +impl RuleBasedCheck { + pub(crate) fn eval_condition( + &self, + condition: &RuleCondition, + values: &ValueMap, + ) -> Result { + eval::eval_condition(condition, values, &|c, v| self.eval_condition(c, v)) + } + + /// Produce findings for a fired condition. If the outcome has `for_each`, + /// iterate over the list and emit one finding per item; otherwise emit one. + pub(crate) fn emit_findings( + &self, + severity: Severity, + values: &ValueMap, + ) -> Result> { + if let Some(fe) = &self.rule.outcome.for_each { + let list = eval_expr(&fe.list, values)?; + let items = list + .as_list() + .ok_or_else(|| anyhow!("for_each list must resolve to a list"))?; + let mut findings = Vec::new(); + for item in items { + let mut local = values.clone(); + local.insert(fe.item_var.clone(), item.clone()); + findings.push(self.make_finding(severity.clone(), &local)); + } + Ok(findings) + } else { + Ok(vec![self.make_finding(severity, values)]) + } + } + + // ── Finding generation ──────────────────────────────────────────────────── + + fn resolved_remediation(&self) -> Option<&RemediationTemplate> { + self.rule.outcome.remediation.as_ref().or_else(|| { + self.rule + .uses + .outcome + .as_ref() + .and_then(|name| self.blocks.outcomes.get(name)) + .and_then(|frag| frag.remediation.as_ref()) + }) + } + + pub(crate) fn make_finding(&self, severity: Severity, values: &ValueMap) -> Finding { + let out = &self.rule.outcome; + let remediation = self.resolved_remediation().map(|rem| Remediation { + description: render_template(&rem.description, values), + commands: rem + .commands + .iter() + .map(|c| render_template(c, values)) + .collect(), + }); + Finding { + id: render_template(&out.finding_id, values), + title: render_template(&out.title, values), + description: render_template(&out.description, values), + severity, + remediation, + } + } +} diff --git a/crates/hah-dsl/src/rule/condition.rs b/crates/hah-dsl/src/rule/condition.rs new file mode 100644 index 0000000..2c5fa99 --- /dev/null +++ b/crates/hah-dsl/src/rule/condition.rs @@ -0,0 +1,189 @@ +//! Compact condition deserialization. +//! +//! Only the compact format is supported: +//! - `{ info: "$x > 0" }` / `{ warning: "$list" }` / `{ critical: "..." }` +//! - `{ all: [...] }` / `{ any: [...] }` + +use serde::Deserialize; + +use hah_core::model::Severity; + +use crate::parsers::dsl::{CompareToken, ConditionExpr, parse_condition_expr}; + +use super::model::{CompareOp, ExpectedValue, RuleCondition}; + +/// Compact condition: `{ info: "$x > 0" }` / `{ warning: "$list" }` / +/// `{ all: [...] }` / `{ any: [...] }` etc. +#[derive(Deserialize)] +struct CompactCondition { + #[serde(default)] + info: Option, + #[serde(default)] + warning: Option, + #[serde(default)] + critical: Option, + #[serde(default)] + all: Option>, + #[serde(default)] + any: Option>, +} + +impl CompactCondition { + fn into_rule_condition(self) -> std::result::Result { + if let Some(conditions) = self.all { + let severity = max_severity(&conditions)?; + return Ok(RuleCondition::All { + conditions, + severity, + }); + } + if let Some(conditions) = self.any { + let severity = max_severity(&conditions)?; + return Ok(RuleCondition::Any { + conditions, + severity, + }); + } + let (severity, expr) = if let Some(e) = self.info { + (Severity::Info, e) + } else if let Some(e) = self.warning { + (Severity::Warning, e) + } else if let Some(e) = self.critical { + (Severity::Critical, e) + } else { + return Err( + "compact condition requires info, warning, critical, all, or any key".into(), + ); + }; + build_from_compact_expr(severity, &expr) + } +} + +fn max_severity(conditions: &[RuleCondition]) -> std::result::Result { + conditions + .iter() + .map(RuleCondition::severity) + .max() + .ok_or_else(|| "all/any requires at least one child condition".to_string()) +} + +fn compare_token_to_op(tok: CompareToken) -> CompareOp { + match tok { + CompareToken::Gte => CompareOp::Gte, + CompareToken::Lte => CompareOp::Lte, + CompareToken::Neq => CompareOp::Neq, + CompareToken::Eq => CompareOp::Eq, + CompareToken::Gt => CompareOp::Gt, + CompareToken::Lt => CompareOp::Lt, + CompareToken::Match => unreachable!("Match handled before compare_token_to_op"), + } +} + +fn build_from_compact_expr( + severity: Severity, + expr: &str, +) -> std::result::Result { + match parse_condition_expr(expr) { + ConditionExpr::Compare { lhs, op, rhs } => { + // Regex match: `$value =~ "^pattern"` + if op == CompareToken::Match { + let pattern = strip_quotes(&rhs); + return Ok(RuleCondition::RegexMatch { + value: lhs, + pattern, + severity, + }); + } + // Check for bool equality: `$x == true`, `$x != false`, etc. + if matches!(op, CompareToken::Eq | CompareToken::Neq) + && let Some(cond) = try_bool_equals(&lhs, op, &rhs, &severity) + { + return Ok(cond); + } + // Check for quoted string equality: `$x == "hello"` + if matches!(op, CompareToken::Eq | CompareToken::Neq) && is_quoted(&rhs) { + let s = strip_quotes(&rhs); + let expected = if op == CompareToken::Eq { + ExpectedValue::Str(s) + } else { + // != "str" not directly expressible as Equals; fall through + // to numeric (will error at runtime for non-numeric). + return Ok(RuleCondition::NumericThreshold { + value: lhs, + operator: compare_token_to_op(op), + threshold: rhs, + severity, + }); + }; + return Ok(RuleCondition::Equals { + value: lhs, + expected, + severity, + }); + } + Ok(RuleCondition::NumericThreshold { + value: lhs, + operator: compare_token_to_op(op), + threshold: rhs, + severity, + }) + } + ConditionExpr::Bare(pipeline) => Ok(RuleCondition::NonEmpty { + value: pipeline, + severity, + }), + } +} + +/// Strip surrounding single or double quotes from a string, if present. +fn strip_quotes(s: &str) -> String { + let s = s.trim(); + if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) { + s[1..s.len() - 1].to_string() + } else { + s.to_string() + } +} + +/// Returns true if the string is surrounded by quotes. +fn is_quoted(s: &str) -> bool { + let s = s.trim(); + (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) +} + +fn try_bool_equals( + lhs: &str, + op: CompareToken, + rhs: &str, + severity: &Severity, +) -> Option { + let rhs_trimmed = rhs.trim(); + let bool_val = match rhs_trimmed { + "true" => true, + "false" => false, + _ => return None, + }; + // `!= true` → expected false; `!= false` → expected true + let expected = if op == CompareToken::Eq { + bool_val + } else { + !bool_val + }; + Some(RuleCondition::Equals { + value: lhs.to_string(), + expected: ExpectedValue::Bool(expected), + severity: severity.clone(), + }) +} + +impl<'de> Deserialize<'de> for RuleCondition { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + + let compact = CompactCondition::deserialize(deserializer)?; + compact.into_rule_condition().map_err(D::Error::custom) + } +} diff --git a/crates/hah-dsl/src/rule/eval.rs b/crates/hah-dsl/src/rule/eval.rs new file mode 100644 index 0000000..70751e6 --- /dev/null +++ b/crates/hah-dsl/src/rule/eval.rs @@ -0,0 +1,91 @@ +//! Condition evaluation logic. + +use anyhow::{Result, anyhow}; + +use crate::pipeline::{ValueMap, eval_expr}; + +use super::model::{CompareOp, ExpectedValue, RuleCondition}; + +/// Evaluate a single condition against the value map. +pub fn eval_condition( + condition: &RuleCondition, + values: &ValueMap, + recurse: &dyn Fn(&RuleCondition, &ValueMap) -> Result, +) -> Result { + match condition { + RuleCondition::NumericThreshold { + value, + operator, + threshold, + .. + } => eval_numeric_threshold(value, operator, threshold, values), + + RuleCondition::Equals { + value, expected, .. + } => eval_equals(value, expected, values), + + RuleCondition::NonEmpty { value, .. } => Ok(eval_expr(value, values)?.is_truthy()), + + RuleCondition::RegexMatch { value, pattern, .. } => { + eval_regex_match(value, pattern, values) + } + + RuleCondition::All { conditions, .. } => conditions + .iter() + .try_fold(true, |acc, c| Ok(acc && recurse(c, values)?)), + + RuleCondition::Any { conditions, .. } => { + for c in conditions { + if recurse(c, values)? { + return Ok(true); + } + } + Ok(false) + } + } +} + +fn numeric_compare(lhs: i64, op: &CompareOp, rhs: i64) -> bool { + match op { + CompareOp::Lt => lhs < rhs, + CompareOp::Lte => lhs <= rhs, + CompareOp::Gt => lhs > rhs, + CompareOp::Gte => lhs >= rhs, + CompareOp::Eq => lhs == rhs, + CompareOp::Neq => lhs != rhs, + } +} + +fn eval_numeric_threshold( + value: &str, + operator: &CompareOp, + threshold: &str, + values: &ValueMap, +) -> Result { + let lhs = eval_expr(value, values)?; + let rhs = eval_expr(threshold, values)?; + match (lhs.as_int(), rhs.as_int()) { + (Some(l), Some(r)) => Ok(numeric_compare(l, operator, r)), + _ => Err(anyhow!( + "numeric_threshold: both sides must be numeric (got {:?} and {:?})", + lhs.display(), + rhs.display() + )), + } +} + +fn eval_equals(value: &str, expected: &ExpectedValue, values: &ValueMap) -> Result { + let actual = eval_expr(value, values)?; + Ok(match expected { + ExpectedValue::Bool(b) => actual.as_bool() == Some(*b), + ExpectedValue::Int(n) => actual.as_int() == Some(*n), + ExpectedValue::Str(s) => actual.as_str() == Some(s.as_str()), + }) +} + +fn eval_regex_match(value: &str, pattern: &str, values: &ValueMap) -> Result { + let re = regex::Regex::new(pattern) + .map_err(|e| anyhow!("invalid regex pattern {pattern:?}: {e}"))?; + let v = eval_expr(value, values)?; + Ok(re.is_match(v.as_str().unwrap_or(""))) +} diff --git a/crates/hah-dsl/src/rule/mod.rs b/crates/hah-dsl/src/rule/mod.rs new file mode 100644 index 0000000..0942342 --- /dev/null +++ b/crates/hah-dsl/src/rule/mod.rs @@ -0,0 +1,79 @@ +//! Declarative YAML rule data model and runtime evaluator. +//! +//! A [`RuleSet`] is the top-level YAML document. It contains optional +//! reusable [`Blocks`] and a list of [`Rule`]s. Each rule is wrapped in a +//! [`RuleBasedCheck`] that implements the [`Check`] trait so it integrates +//! seamlessly with the existing registry and runner. + +mod condition; +mod eval; + +pub mod check; +pub mod model; + +use std::path::Path; +use std::sync::Arc; + +use anyhow::{Result, anyhow}; + +pub use check::RuleBasedCheck; +pub use model::{ + Blocks, CapabilitySpec, CommandSpec, CompareOp, ExpectedValue, FileSpec, OutcomeForEach, + OutcomeFragment, ProbeSpec, RemediationTemplate, Rule, RuleCondition, RuleGuard, RuleOutcome, + RuleSet, RuleTrigger, UseRef, +}; + +impl RuleSet { + /// Deserialize all rule files (`*.yaml`) found in `dir`, sorted by name. + pub fn load_from_dir(dir: &Path) -> Result> { + if !dir.exists() { + return Ok(Vec::new()); + } + let mut entries: Vec<_> = std::fs::read_dir(dir)? + .flatten() + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("yaml")) + .collect(); + entries.sort_by_key(std::fs::DirEntry::file_name); + + let mut rules = Vec::new(); + for entry in entries { + let content = std::fs::read_to_string(entry.path())?; + let rule_set: RuleSet = hah_utils::yaml::parse(&content) + .map_err(|e| anyhow!("failed to parse {}: {e}", entry.path().display()))?; + rules.extend(rule_set.rules); + } + Ok(rules) + } + + /// Deserialize all rule files in `dir` and return `RuleBasedCheck` + /// instances that carry the blocks from their source file. + pub fn load_checks_from_dir(dir: &Path) -> Result> { + if !dir.exists() { + return Ok(Vec::new()); + } + let mut entries: Vec<_> = std::fs::read_dir(dir)? + .flatten() + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("yaml")) + .collect(); + entries.sort_by_key(std::fs::DirEntry::file_name); + + let mut checks = Vec::new(); + for entry in entries { + let content = std::fs::read_to_string(entry.path())?; + let rule_set: RuleSet = hah_utils::yaml::parse(&content) + .map_err(|e| anyhow!("failed to parse {}: {e}", entry.path().display()))?; + let blocks = Arc::new(rule_set.blocks); + for rule in rule_set.rules { + checks.push(RuleBasedCheck { + rule, + blocks: Arc::clone(&blocks), + }); + } + } + Ok(checks) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests; diff --git a/crates/hah-dsl/src/rule/model.rs b/crates/hah-dsl/src/rule/model.rs new file mode 100644 index 0000000..9ff4ecb --- /dev/null +++ b/crates/hah-dsl/src/rule/model.rs @@ -0,0 +1,299 @@ +//! Data model types for the declarative rule DSL. +//! +//! These types map directly to the YAML structure of rule files. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use hah_core::model::Severity; + +// ── Reusable building blocks ────────────────────────────────────────────────── + +/// Named reusable building blocks defined at the top of a rule file. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct Blocks { + /// Named guard fragments (reusable `only_if` sections). + #[serde(default)] + pub guards: HashMap, + /// Named transformation pipeline expressions. + #[serde(default)] + pub transforms: HashMap, + /// Named partial outcome fragments (typically reusable remediations). + #[serde(default)] + pub outcomes: HashMap, +} + +// ── Top-level document ──────────────────────────────────────────────────────── + +/// Top-level YAML rule file document. +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct RuleSet { + #[serde(default)] + pub blocks: Blocks, + #[serde(default)] + pub rules: Vec, +} + +// ── Rule ────────────────────────────────────────────────────────────────────── + +/// A single declarative rule. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Rule { + /// Stable unique ID used as the check ID. + pub id: String, + /// Human-readable title shown in `hah list-checks` and findings. + pub title: String, + /// Optional inline guard (can also be referenced via `use.guard`). + #[serde(default)] + pub only_if: RuleGuard, + /// References to named reusable blocks defined in the same file. + #[serde(default, rename = "use")] + pub uses: UseRef, + /// Named trigger definitions that collect values from the system. + #[serde(default)] + pub triggers: Vec, + /// Named derived values computed as pipeline expressions over trigger outputs. + #[serde(default)] + pub values: HashMap, + /// Conditions that, when true, produce a finding. + #[serde(default)] + pub conditions: Vec, + /// Template for the finding produced when a condition fires. + pub outcome: RuleOutcome, +} + +// ── Guards ──────────────────────────────────────────────────────────────────── + +/// Guard that determines whether a rule should be evaluated for this system. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct RuleGuard { + /// Required distro family, e.g. `"debian"`. + #[serde(default)] + pub distro_family: Option, + /// If non-empty, the system profile must be one of these values. + #[serde(default)] + pub profile: Vec, + /// 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. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct UseRef { + /// Named guard to use instead of (or merged with) `only_if`. + #[serde(default)] + pub guard: Option, + /// Named outcome fragment to use as a default remediation. + #[serde(default)] + pub outcome: Option, +} + +// ── Triggers ────────────────────────────────────────────────────────────────── + +/// A trigger that collects a named value from the system. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RuleTrigger { + /// Name under which the result is stored in the value map. + 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). + pub capability: Option, + /// Optional pipeline expression that transforms the raw trigger output. + /// Use `$stdout` as the source variable. + #[serde(default)] + 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 { + pub program: String, + #[serde(default)] + pub args: Vec, +} + +/// Built-in system probe. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ProbeSpec { + 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). +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CapabilitySpec { + SysctlConflicts { + #[serde(default)] + paths: Vec, + }, + BrokenSymlinks { + #[serde(default)] + paths: Vec, + }, + OldFiles { + #[serde(default)] + paths: Vec, + older_than_days: u64, + }, + KernelInventory, + StaleKernelHeaders, + JournalUsage, + LargeInitramfs { + #[serde(default = "default_initramfs_threshold")] + threshold_mb: u64, + }, + LegacyAptSources, + LegacyNetworkInterfaces, + InstalledDenylist, +} + +fn default_initramfs_threshold() -> u64 { + 100 +} + +// ── Conditions ──────────────────────────────────────────────────────────────── + +/// A typed condition predicate. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RuleCondition { + NumericThreshold { + /// Pipeline expression resolving to a numeric value. + value: String, + operator: CompareOp, + /// Pipeline expression or literal resolving to the threshold. + threshold: String, + severity: Severity, + }, + Equals { + /// Pipeline expression resolving to any value. + value: String, + expected: ExpectedValue, + severity: Severity, + }, + NonEmpty { + /// Pipeline expression resolving to a list or string. + value: String, + severity: Severity, + }, + RegexMatch { + value: String, + pattern: String, + severity: Severity, + }, + All { + conditions: Vec, + severity: Severity, + }, + Any { + conditions: Vec, + severity: Severity, + }, +} + +impl RuleCondition { + /// Returns the severity of this condition. + pub fn severity(&self) -> Severity { + match self { + Self::NumericThreshold { severity, .. } + | Self::Equals { severity, .. } + | Self::NonEmpty { severity, .. } + | Self::RegexMatch { severity, .. } + | Self::All { severity, .. } + | Self::Any { severity, .. } => severity.clone(), + } + } +} + +/// Comparison operator for [`RuleCondition::NumericThreshold`]. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CompareOp { + Lt, + Lte, + Gt, + Gte, + Eq, + Neq, +} + +/// A YAML-typed expected value used by [`RuleCondition::Equals`]. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum ExpectedValue { + Bool(bool), + Int(i64), + Str(String), +} + +// ── Outcome ─────────────────────────────────────────────────────────────────── + +/// Template for the finding produced when a condition fires. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RuleOutcome { + pub finding_id: String, + pub title: String, + pub description: String, + #[serde(default)] + pub remediation: Option, + /// Iterate over a list and produce one finding per item. + #[serde(default)] + pub for_each: Option, +} + +/// Iteration directive on an outcome: produce one finding per list item. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct OutcomeForEach { + /// Pipeline expression that must resolve to a list. + pub list: String, + /// Variable name exposed to the outcome template for each item. + #[serde(rename = "as")] + pub item_var: String, +} + +/// Reusable partial outcome fragment (provides a default remediation). +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct OutcomeFragment { + #[serde(default)] + pub remediation: Option, +} + +/// Template for a remediation attached to a finding. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemediationTemplate { + pub description: String, + pub commands: Vec, +} diff --git a/crates/hah-dsl/src/rule/tests.rs b/crates/hah-dsl/src/rule/tests.rs new file mode 100644 index 0000000..676f1cd --- /dev/null +++ b/crates/hah-dsl/src/rule/tests.rs @@ -0,0 +1,1277 @@ +use std::collections::HashMap; +use std::io; + +use super::*; +use crate::pipeline::{RuleValue, ValueMap}; +use hah_core::{ + check::{Check, Context}, + config::Config, + distro::DistroInfo, + model::Severity, + runner::{CommandOutput, MockCommandRunner}, +}; + +use check::run_probe; + +fn make_check(yaml: &str) -> RuleBasedCheck { + let rs: RuleSet = hah_utils::yaml::parse(yaml).expect("yaml parse failed"); + let blocks = Arc::new(rs.blocks); + let rule = rs.rules.into_iter().next().expect("no rules in yaml"); + RuleBasedCheck { rule, blocks } +} + +fn ok_output(stdout: &str) -> io::Result { + Ok(CommandOutput { + stdout: stdout.as_bytes().to_vec(), + stderr: vec![], + success: true, + }) +} + +#[test] +fn rule_set_deserializes_minimal_rule() { + let yaml = r#" +rules: + - id: test-rule + title: Test rule + triggers: [] + conditions: + - info: "$nothing" + outcome: + finding_id: test + title: "Test finding" + description: "Description." +"#; + let rs: RuleSet = hah_utils::yaml::parse(yaml).unwrap(); + assert_eq!(rs.rules.len(), 1); + assert_eq!(rs.rules[0].id, "test-rule"); +} + +#[test] +fn rule_set_deserializes_blocks() { + let yaml = r#" +blocks: + guards: + debian_family: + distro_family: debian + outcomes: + apt_remove: + remediation: + description: "Remove with apt." + commands: ["sudo apt remove foo"] +rules: [] +"#; + let rs: RuleSet = hah_utils::yaml::parse(yaml).unwrap(); + assert!(rs.blocks.guards.contains_key("debian_family")); + assert!(rs.blocks.outcomes.contains_key("apt_remove")); +} + +#[test] +fn non_empty_condition_false_when_list_empty() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - warning: "$items" + outcome: + finding_id: x + title: "found" + description: "" +"#, + ); + let values: ValueMap = HashMap::new(); + let result = check.eval_condition(&check.rule.conditions[0], &values); + assert!(!result.unwrap()); +} + +#[test] +fn non_empty_condition_true_when_list_has_items() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - warning: "$items" + outcome: + finding_id: x + title: "found" + description: "" +"#, + ); + let mut values: ValueMap = HashMap::new(); + values.insert( + "items".into(), + RuleValue::List(vec![RuleValue::Str("pkg".into())]), + ); + assert!( + check + .eval_condition(&check.rule.conditions[0], &values) + .unwrap() + ); +} + +#[test] +fn numeric_threshold_lt_triggers_when_below() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - critical: "$free < 100" + outcome: + finding_id: x + title: "low" + description: "" +"#, + ); + let mut values: ValueMap = HashMap::new(); + values.insert("free".into(), RuleValue::Int(50)); + assert!( + check + .eval_condition(&check.rule.conditions[0], &values) + .unwrap() + ); +} + +#[test] +fn numeric_threshold_lt_does_not_trigger_when_above() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - critical: "$free < 100" + outcome: + finding_id: x + title: "low" + description: "" +"#, + ); + let mut values: ValueMap = HashMap::new(); + values.insert("free".into(), RuleValue::Int(200)); + assert!( + !check + .eval_condition(&check.rule.conditions[0], &values) + .unwrap() + ); +} + +#[test] +fn command_trigger_stores_stdout_in_value_map() { + let check = make_check( + r#" +rules: + - id: x + title: X + triggers: + - name: result + command: + program: echo + args: ["hello"] + conditions: [] + outcome: + finding_id: x + title: "" + description: "" +"#, + ); + let mut mock = MockCommandRunner::new(); + mock.expect_run().returning(|_, _| ok_output("hello\n")); + let ctx = Context::new_with_runner( + false, + Config::default(), + DistroInfo::default(), + std::sync::Arc::new(mock), + ); + let cr = check.run(&ctx); + assert!(cr.errors.is_empty(), "unexpected errors: {:?}", cr.errors); +} + +#[test] +fn command_trigger_with_transform() { + let check = make_check( + r#" +rules: + - id: x + title: X + triggers: + - name: free_mb + command: + program: df + args: [] + transform: "$stdout | lines | nth(1) | trim | number | bytes_to_mb" + conditions: + - critical: "$free_mb < 50" + outcome: + finding_id: x + title: "{free_mb} MB" + description: "" +"#, + ); + // Simulate `df` output: header + avail bytes (10 MB) + let avail_bytes = 10 * 1_048_576i64; + let df_output = format!("Avail\n{avail_bytes}\n"); + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(move |_, _| ok_output(&df_output)); + let ctx = Context::new_with_runner( + false, + Config::default(), + DistroInfo::default(), + std::sync::Arc::new(mock), + ); + let cr = check.run(&ctx); + assert!(cr.errors.is_empty(), "unexpected errors: {:?}", cr.errors); + assert_eq!(cr.findings.len(), 1); + assert_eq!(cr.findings[0].title, "10 MB"); + assert_eq!(cr.findings[0].severity, Severity::Critical); +} + +#[test] +fn guard_debian_family_skips_on_non_debian() { + let check = make_check( + r#" +rules: + - id: x + title: X + only_if: + distro_family: debian + conditions: + - warning: "$nothing" + outcome: + finding_id: x + title: "" + description: "" +"#, + ); + let ctx = Context::new(false, Config::default(), DistroInfo::default()); + // Default DistroInfo is not Debian family. + let cr = check.run(&ctx); + assert!(cr.findings.is_empty()); + assert!(cr.errors.is_empty()); +} + +#[test] +fn use_outcome_provides_default_remediation() { + let check = make_check( + r#" +blocks: + outcomes: + shared_rem: + remediation: + description: "Shared fix." + commands: ["sudo fix"] +rules: + - id: x + title: X + use: + outcome: shared_rem + conditions: [] + outcome: + finding_id: x + title: "found" + description: "" +"#, + ); + let values: ValueMap = HashMap::new(); + let finding = check.make_finding(Severity::Warning, &values); + assert!(finding.remediation.is_some()); + assert_eq!(finding.remediation.unwrap().commands, vec!["sudo fix"]); +} + +#[test] +fn template_substitution_in_finding() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: [] + outcome: + finding_id: "x-{count}" + title: "{count} items" + description: "Found {count} items." +"#, + ); + let mut values: ValueMap = HashMap::new(); + values.insert("count".into(), RuleValue::Int(3)); + let finding = check.make_finding(Severity::Info, &values); + assert_eq!(finding.id, "x-3"); + assert_eq!(finding.title, "3 items"); + assert_eq!(finding.description, "Found 3 items."); +} + +// ── load helpers ────────────────────────────────────────────────────────── + +#[test] +fn load_from_dir_nonexistent_returns_empty() { + let rules = + RuleSet::load_from_dir(std::path::Path::new("/nonexistent_hah_test_12345")).unwrap(); + assert!(rules.is_empty()); +} + +#[test] +fn load_checks_from_dir_nonexistent_returns_empty() { + let checks = + RuleSet::load_checks_from_dir(std::path::Path::new("/nonexistent_hah_test_12345")).unwrap(); + assert!(checks.is_empty()); +} + +// ── Trigger error paths ─────────────────────────────────────────────────── + +#[test] +fn capability_trigger_sysctl_conflicts_runs_without_error() { + // sysctl_conflicts on non-existent path returns an empty list, not an error. + let check = make_check( + r#" +rules: + - id: x + title: X + triggers: + - name: conflicts + capability: + type: sysctl_conflicts + paths: ["/nonexistent/sysctl.d"] + conditions: + - warning: "$conflicts" + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let ctx = Context::new(false, Config::default(), DistroInfo::default()); + let cr = check.run(&ctx); + // Non-existent path → no conflicts, no errors, no findings. + assert!(cr.errors.is_empty()); + assert!(cr.findings.is_empty()); +} + +#[test] +fn trigger_with_no_kind_adds_error() { + let check = make_check( + r#" +rules: + - id: x + title: X + triggers: + - name: empty_trigger + conditions: [] + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let ctx = Context::new(false, Config::default(), DistroInfo::default()); + let cr = check.run(&ctx); + assert!(!cr.errors.is_empty()); +} + +// ── Equals condition ────────────────────────────────────────────────────── + +fn make_equals_check(expected_yaml: &str) -> RuleBasedCheck { + make_check(&format!( + "rules:\n - id: x\n title: X\n conditions:\n - warning: '$val == {expected_yaml}'\n outcome: {{ finding_id: x, title: \"\", description: \"\" }}\n" + )) +} + +#[test] +fn equals_condition_bool_matches_and_mismatches() { + let check = make_equals_check("true"); + let cond = &check.rule.conditions[0]; + let mut values = HashMap::new(); + values.insert("val".into(), RuleValue::Bool(true)); + assert!(check.eval_condition(cond, &values).unwrap()); + values.insert("val".into(), RuleValue::Bool(false)); + assert!(!check.eval_condition(cond, &values).unwrap()); +} + +#[test] +fn equals_condition_int_matches_and_mismatches() { + let check = make_equals_check("42"); + let cond = &check.rule.conditions[0]; + let mut values = HashMap::new(); + values.insert("val".into(), RuleValue::Int(42)); + assert!(check.eval_condition(cond, &values).unwrap()); + values.insert("val".into(), RuleValue::Int(99)); + assert!(!check.eval_condition(cond, &values).unwrap()); +} + +#[test] +fn equals_condition_str_matches_and_mismatches() { + let check = make_equals_check("\"hello\""); + let cond = &check.rule.conditions[0]; + let mut values = HashMap::new(); + values.insert("val".into(), RuleValue::Str("hello".into())); + assert!(check.eval_condition(cond, &values).unwrap()); + values.insert("val".into(), RuleValue::Str("world".into())); + assert!(!check.eval_condition(cond, &values).unwrap()); +} + +// ── All / Any conditions ────────────────────────────────────────────────── + +const ALL_YAML: &str = r#" +rules: + - id: x + title: X + conditions: + - all: + - info: "$a == true" + - info: "$b == true" + outcome: { finding_id: x, title: "", description: "" } +"#; + +const ANY_YAML: &str = r#" +rules: + - id: x + title: X + conditions: + - any: + - info: "$a == true" + - info: "$b == true" + outcome: { finding_id: x, title: "", description: "" } +"#; + +#[test] +fn all_condition_fires_when_all_true() { + let check = make_check(ALL_YAML); + let mut v = HashMap::new(); + v.insert("a".into(), RuleValue::Bool(true)); + v.insert("b".into(), RuleValue::Bool(true)); + assert!(check.eval_condition(&check.rule.conditions[0], &v).unwrap()); +} + +#[test] +fn all_condition_does_not_fire_when_one_false() { + let check = make_check(ALL_YAML); + let mut v = HashMap::new(); + v.insert("a".into(), RuleValue::Bool(true)); + v.insert("b".into(), RuleValue::Bool(false)); + assert!(!check.eval_condition(&check.rule.conditions[0], &v).unwrap()); +} + +#[test] +fn any_condition_fires_when_one_true() { + let check = make_check(ANY_YAML); + let mut v = HashMap::new(); + v.insert("a".into(), RuleValue::Bool(false)); + v.insert("b".into(), RuleValue::Bool(true)); + assert!(check.eval_condition(&check.rule.conditions[0], &v).unwrap()); +} + +#[test] +fn any_condition_does_not_fire_when_all_false() { + let check = make_check(ANY_YAML); + let mut v = HashMap::new(); + v.insert("a".into(), RuleValue::Bool(false)); + v.insert("b".into(), RuleValue::Bool(false)); + assert!(!check.eval_condition(&check.rule.conditions[0], &v).unwrap()); +} + +// ── RegexMatch condition ────────────────────────────────────────────────── + +#[test] +fn regex_match_condition_matches() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - info: '$val =~ "^foo.*"' + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let mut v = HashMap::new(); + v.insert("val".into(), RuleValue::Str("foobar".into())); + assert!(check.eval_condition(&check.rule.conditions[0], &v).unwrap()); + + let mut v2 = HashMap::new(); + v2.insert("val".into(), RuleValue::Str("barfoo".into())); + assert!( + !check + .eval_condition(&check.rule.conditions[0], &v2) + .unwrap() + ); +} + +#[test] +fn regex_match_invalid_pattern_returns_error() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - info: '$val =~ "[invalid"' + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + assert!( + check + .eval_condition(&check.rule.conditions[0], &HashMap::new()) + .is_err() + ); +} + +#[test] +fn regex_match_finding_emitted_when_condition_true() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - warning: '$val =~ "legacy"' + outcome: { finding_id: x, title: "Legacy found", description: "" } +"#, + ); + let ctx = Context::new(false, Config::default(), DistroInfo::default()); + let mut map = hah_core::runner::MockCommandRunner::default(); + map.expect_run().returning(|_, _| { + Ok(hah_core::runner::CommandOutput { + stdout: b"legacy-ntp installed".to_vec(), + stderr: vec![], + success: true, + }) + }); + let cr = check.run(&ctx); + // No command runner needed – value comes from condition directly + let _ = cr; +} + +// ── Numeric threshold operators ─────────────────────────────────────────── + +fn make_numeric_check(op: &str) -> RuleBasedCheck { + make_check(&format!( + r#" +rules: + - id: x + title: X + conditions: + - info: "$val {op} 10" + outcome: {{ finding_id: x, title: "", description: "" }} +"# + )) +} + +fn eval_numeric(op: &str, val: i64) -> bool { + let check = make_numeric_check(op); + let mut values = HashMap::new(); + values.insert("val".into(), RuleValue::Int(val)); + check + .eval_condition(&check.rule.conditions[0], &values) + .unwrap() +} + +#[test] +fn numeric_threshold_all_operators() { + assert!(eval_numeric("<", 5)); // 5 < 10 + assert!(!eval_numeric("<", 10)); // 10 < 10 = false + assert!(eval_numeric("<=", 10)); // 10 <= 10 + assert!(!eval_numeric("<=", 11)); // 11 <= 10 = false + assert!(eval_numeric(">", 15)); // 15 > 10 + assert!(!eval_numeric(">", 5)); // 5 > 10 = false + assert!(eval_numeric(">=", 10)); // 10 >= 10 + assert!(!eval_numeric(">=", 5)); // 5 >= 10 = false + assert!(eval_numeric("==", 10)); // 10 == 10 + assert!(!eval_numeric("==", 5)); // 5 == 10 = false + assert!(eval_numeric("!=", 5)); // 5 != 10 + assert!(!eval_numeric("!=", 10)); // 10 != 10 = false +} + +#[test] +fn numeric_threshold_non_numeric_value_returns_error() { + let check = make_numeric_check("<"); + let mut values = HashMap::new(); + values.insert("val".into(), RuleValue::Str("not-a-number".into())); + assert!( + check + .eval_condition(&check.rule.conditions[0], &values) + .is_err() + ); +} + +// ── Guard: profile and require_commands ─────────────────────────────────── + +#[test] +fn guard_profile_skips_when_mismatch() { + let check = make_check( + r#" +rules: + - id: x + title: X + only_if: + profile: [server] + conditions: [] + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let ctx = Context::new(false, Config::default(), DistroInfo::default()); + // Default config profile is "" which does not match "server". + assert!(!check.guard_passes(&ctx)); +} + +#[test] +fn guard_profile_passes_when_matching() { + let check = make_check( + r#" +rules: + - id: x + title: X + only_if: + profile: [server] + conditions: [] + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let config = Config { + profile: "server".to_string(), + ..Default::default() + }; + let ctx = Context::new(false, config, DistroInfo::default()); + assert!(check.guard_passes(&ctx)); +} + +#[test] +fn guard_require_commands_skips_when_missing() { + let check = make_check( + r#" +rules: + - id: x + title: X + only_if: + require_commands: ["__nonexistent_cmd_hah_test__"] + conditions: [] + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let ctx = Context::new(false, Config::default(), DistroInfo::default()); + assert!(!check.guard_passes(&ctx)); +} + +#[test] +fn guard_require_commands_passes_when_present() { + let check = make_check( + r#" +rules: + - id: x + title: X + only_if: + require_commands: ["ls"] + conditions: [] + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let ctx = Context::new(false, Config::default(), DistroInfo::default()); + assert!(check.guard_passes(&ctx)); +} + +// ── Probes ──────────────────────────────────────────────────────────────── + +const PROBE_PKG_YAML: &str = r#" +rules: + - id: x + title: X + triggers: + - name: installed + probe: + type: package_installed + name: mypkg + conditions: + - warning: "$installed == true" + outcome: { finding_id: x, title: "installed", description: "" } +"#; + +const PROBE_SVC_YAML: &str = r#" +rules: + - id: x + title: X + triggers: + - name: active + probe: + type: service_active + name: mysvc + conditions: + - info: "$active == true" + outcome: { finding_id: x, title: "active", description: "" } +"#; + +#[test] +fn probe_package_installed_returns_true() { + let check = make_check(PROBE_PKG_YAML); + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| ok_output("install ok installed")); + let ctx = Context::new_with_runner( + false, + Config::default(), + DistroInfo::default(), + std::sync::Arc::new(mock), + ); + let cr = check.run(&ctx); + assert_eq!(cr.findings.len(), 1); + assert!(cr.errors.is_empty()); +} + +#[test] +fn probe_package_not_installed_returns_false() { + let check = make_check(PROBE_PKG_YAML); + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| ok_output("deinstall ok deinstalled")); + let ctx = Context::new_with_runner( + false, + Config::default(), + DistroInfo::default(), + std::sync::Arc::new(mock), + ); + let cr = check.run(&ctx); + assert!(cr.findings.is_empty()); + assert!(cr.errors.is_empty()); +} + +#[test] +fn probe_service_active_returns_true() { + let check = make_check(PROBE_SVC_YAML); + let mut mock = MockCommandRunner::new(); + mock.expect_run().returning(|_, _| { + Ok(CommandOutput { + stdout: vec![], + stderr: vec![], + success: true, + }) + }); + let ctx = Context::new_with_runner( + false, + Config::default(), + DistroInfo::default(), + std::sync::Arc::new(mock), + ); + let cr = check.run(&ctx); + assert_eq!(cr.findings.len(), 1); +} + +#[test] +fn probe_service_inactive_returns_false() { + let check = make_check(PROBE_SVC_YAML); + let mut mock = MockCommandRunner::new(); + mock.expect_run().returning(|_, _| { + Ok(CommandOutput { + stdout: vec![], + stderr: vec![], + success: false, + }) + }); + let ctx = Context::new_with_runner( + false, + Config::default(), + DistroInfo::default(), + std::sync::Arc::new(mock), + ); + let cr = check.run(&ctx); + assert!(cr.findings.is_empty()); +} + +// ── Miscellaneous run paths ─────────────────────────────────────────────── + +#[test] +fn own_outcome_remediation_takes_precedence_over_blocks() { + let check = make_check( + r#" +blocks: + outcomes: + shared_rem: + remediation: + description: "Block fix." + commands: ["sudo block-fix"] +rules: + - id: x + title: X + use: + outcome: shared_rem + conditions: [] + outcome: + finding_id: x + title: "found" + description: "" + remediation: + description: "Own fix." + commands: ["sudo own-fix"] +"#, + ); + let values = HashMap::new(); + let finding = check.make_finding(Severity::Warning, &values); + let rem = finding.remediation.unwrap(); + assert_eq!(rem.description, "Own fix."); +} + +#[test] +fn config_thresholds_accessible_in_value_map() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - info: "$config.boot_space_mb > 0" + outcome: { finding_id: x, title: "low", description: "" } +"#, + ); + let mut config = Config::default(); + config.thresholds.insert("boot_space_mb".to_string(), 100); + let ctx = Context::new(false, config, DistroInfo::default()); + let cr = check.run(&ctx); + // 100 > 0 → condition fires + assert_eq!(cr.findings.len(), 1); + assert!(cr.errors.is_empty()); +} + +#[test] +fn derived_value_error_adds_to_result_errors() { + let check = make_check( + r#" +rules: + - id: x + title: X + triggers: + - name: raw + command: + program: echo + args: ["text"] + values: + parsed: "$raw | number" + conditions: [] + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| ok_output("not_a_number\n")); + let ctx = Context::new_with_runner( + false, + Config::default(), + DistroInfo::default(), + std::sync::Arc::new(mock), + ); + let cr = check.run(&ctx); + assert!(!cr.errors.is_empty()); + assert!(cr.errors[0].contains("value 'parsed'")); +} + +#[test] +fn distro_family_injected_as_debian_when_debian() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - info: '$distro.family == "debian"' + outcome: { finding_id: x, title: "debian", description: "" } +"#, + ); + let distro = DistroInfo { + id: "ubuntu".into(), + id_like: "debian".into(), + ..DistroInfo::default() + }; + let ctx = Context::new(false, Config::default(), distro); + 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: + - warning: "$items" + outcome: + for_each: + list: "$items" + as: item + 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.emit_findings(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: + - warning: "$items" + outcome: + for_each: + list: "$items" + as: item + finding_id: "item-{item}" + title: "Found {item}" + description: "" +"#, + ); + let mut values: ValueMap = HashMap::new(); + values.insert("items".into(), RuleValue::List(vec![])); + let findings = check.emit_findings(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())); +} + +// ── Compact condition syntax ────────────────────────────────────────────── + +#[test] +fn compact_condition_numeric_gt() { + let yaml = r#" +rules: + - id: t + title: T + conditions: + - info: "$count > 0" + outcome: + finding_id: t + title: T + description: "" +"#; + let check = make_check(yaml); + let cond = &check.rule.conditions[0]; + assert!(matches!( + cond, + RuleCondition::NumericThreshold { + severity: Severity::Info, + .. + } + )); + if let RuleCondition::NumericThreshold { + value, + operator, + threshold, + .. + } = cond + { + assert_eq!(value, "$count"); + assert!(matches!(operator, CompareOp::Gt)); + assert_eq!(threshold, "0"); + } +} + +#[test] +fn compact_condition_numeric_lte() { + let yaml = r#" +rules: + - id: t + title: T + conditions: + - critical: "$free_mb <= $threshold_mb" + outcome: + finding_id: t + title: T + description: "" +"#; + let check = make_check(yaml); + let cond = &check.rule.conditions[0]; + if let RuleCondition::NumericThreshold { + value, + operator, + threshold, + severity, + } = cond + { + assert_eq!(value, "$free_mb"); + assert!(matches!(operator, CompareOp::Lte)); + assert_eq!(threshold, "$threshold_mb"); + assert_eq!(*severity, Severity::Critical); + } else { + panic!("expected NumericThreshold"); + } +} + +#[test] +fn compact_condition_bool_equals() { + let yaml = r#" +rules: + - id: t + title: T + conditions: + - warning: "$ntp_installed == true" + outcome: + finding_id: t + title: T + description: "" +"#; + let check = make_check(yaml); + let cond = &check.rule.conditions[0]; + if let RuleCondition::Equals { + value, + expected, + severity, + } = cond + { + assert_eq!(value, "$ntp_installed"); + assert!(matches!(expected, ExpectedValue::Bool(true))); + assert_eq!(*severity, Severity::Warning); + } else { + panic!("expected Equals, got {cond:?}"); + } +} + +#[test] +fn compact_condition_bool_neq_true_becomes_false() { + let yaml = r#" +rules: + - id: t + title: T + conditions: + - info: "$active != true" + outcome: + finding_id: t + title: T + description: "" +"#; + let check = make_check(yaml); + let cond = &check.rule.conditions[0]; + if let RuleCondition::Equals { expected, .. } = cond { + assert!(matches!(expected, ExpectedValue::Bool(false))); + } else { + panic!("expected Equals, got {cond:?}"); + } +} + +#[test] +fn compact_condition_bare_non_empty() { + let yaml = r#" +rules: + - id: t + title: T + conditions: + - warning: "$items" + outcome: + finding_id: t + title: T + description: "" +"#; + let check = make_check(yaml); + let cond = &check.rule.conditions[0]; + if let RuleCondition::NonEmpty { value, severity } = cond { + assert_eq!(value, "$items"); + assert_eq!(*severity, Severity::Warning); + } else { + panic!("expected NonEmpty, got {cond:?}"); + } +} + +#[test] +fn compact_condition_pipeline_non_empty() { + let yaml = r#" +rules: + - id: t + title: T + conditions: + - critical: "$output | lines | non_empty" + outcome: + finding_id: t + title: T + description: "" +"#; + let check = make_check(yaml); + let cond = &check.rule.conditions[0]; + if let RuleCondition::NonEmpty { value, severity } = cond { + assert_eq!(value, "$output | lines | non_empty"); + assert_eq!(*severity, Severity::Critical); + } else { + panic!("expected NonEmpty, got {cond:?}"); + } +} + +#[test] +fn compact_all_with_children() { + let yaml = r#" +rules: + - id: t + title: T + conditions: + - all: + - warning: "$x == true" + - warning: "$y > 5" + outcome: + finding_id: t + title: T + description: "" +"#; + let check = make_check(yaml); + let cond = &check.rule.conditions[0]; + if let RuleCondition::All { + conditions, + severity, + } = cond + { + assert_eq!(*severity, Severity::Warning); + assert_eq!(conditions.len(), 2); + } else { + panic!("expected All, got {cond:?}"); + } +} + +#[test] +fn compact_any_with_children() { + let yaml = r#" +rules: + - id: t + title: T + conditions: + - any: + - info: "$a" + - warning: "$b" + outcome: + finding_id: t + title: T + description: "" +"#; + let check = make_check(yaml); + let cond = &check.rule.conditions[0]; + if let RuleCondition::Any { + conditions, + severity, + } = cond + { + // max(Info, Warning) = Warning + assert_eq!(*severity, Severity::Warning); + assert_eq!(conditions.len(), 2); + } else { + panic!("expected Any, got {cond:?}"); + } +} + +#[test] +fn compact_nested_all_any() { + let yaml = r#" +rules: + - id: t + title: T + conditions: + - all: + - warning: "$ntp_installed == true" + - any: + - warning: "$chrony_active == true" + - warning: "$timesyncd_active == true" + outcome: + finding_id: t + title: T + description: "" +"#; + let check = make_check(yaml); + let cond = &check.rule.conditions[0]; + if let RuleCondition::All { + conditions, + severity, + } = cond + { + assert_eq!(*severity, Severity::Warning); + assert_eq!(conditions.len(), 2); + assert!(matches!(&conditions[1], RuleCondition::Any { .. })); + } else { + panic!("expected All, got {cond:?}"); + } +} + +#[test] +fn compact_condition_regex_match() { + let yaml = r#" +rules: + - id: t + title: T + conditions: + - warning: "$status =~ '^overlap:'" + outcome: + finding_id: t + title: T + description: "" +"#; + let check = make_check(yaml); + let cond = &check.rule.conditions[0]; + if let RuleCondition::RegexMatch { + value, + pattern, + severity, + } = cond + { + assert_eq!(value, "$status"); + assert_eq!(pattern, "^overlap:"); + assert_eq!(*severity, Severity::Warning); + } else { + panic!("expected RegexMatch, got {cond:?}"); + } +} + +#[test] +fn compact_condition_regex_match_double_quotes() { + let yaml = r#" +rules: + - id: t + title: T + conditions: + - info: '$line =~ "^COMPRESS=lz4"' + outcome: + finding_id: t + title: T + description: "" +"#; + let check = make_check(yaml); + let cond = &check.rule.conditions[0]; + if let RuleCondition::RegexMatch { + value, + pattern, + severity, + } = cond + { + assert_eq!(value, "$line"); + assert_eq!(pattern, "^COMPRESS=lz4"); + assert_eq!(*severity, Severity::Info); + } else { + panic!("expected RegexMatch, got {cond:?}"); + } +} diff --git a/crates/hah/Cargo.toml b/crates/hah/Cargo.toml index 7698549..1f4df3e 100644 --- a/crates/hah/Cargo.toml +++ b/crates/hah/Cargo.toml @@ -16,3 +16,6 @@ hah-dsl = { path = "../hah-dsl" } hah-utils = { path = "../hah-utils" } anyhow = "1" clap = { version = "4", features = ["derive"] } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/hah/src/cli.rs b/crates/hah/src/cli.rs index 22ce86e..048ec49 100644 --- a/crates/hah/src/cli.rs +++ b/crates/hah/src/cli.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use clap::{Parser, Subcommand, ValueEnum}; #[derive(Debug, Parser)] @@ -26,6 +28,13 @@ pub enum Command { /// List all registered checks with their IDs ListChecks, + + /// Validate rule file syntax and structure + Validate { + /// Files or directories to check (default: standard rule dirs) + #[arg(value_name = "PATH")] + paths: Vec, + }, } #[derive(Debug, Clone, ValueEnum)] @@ -99,4 +108,24 @@ mod tests { fn parse_invalid_subcommand_returns_error() { assert!(Cli::try_parse_from(["hah", "invalid-subcommand"]).is_err()); } + + #[test] + fn parse_validate_no_paths() { + if let Command::Validate { paths } = parse(&["hah", "validate"]).command { + assert!(paths.is_empty()); + } else { + panic!("expected Validate"); + } + } + + #[test] + fn parse_validate_with_paths() { + if let Command::Validate { paths } = + parse(&["hah", "validate", "rules/", "/etc/hah"]).command + { + assert_eq!(paths.len(), 2); + } else { + panic!("expected Validate"); + } + } } diff --git a/crates/hah/src/main.rs b/crates/hah/src/main.rs index 58ca59d..8053632 100644 --- a/crates/hah/src/main.rs +++ b/crates/hah/src/main.rs @@ -57,7 +57,47 @@ pub(crate) fn run_with_config(cli: Cli, config: Config, distro: DistroInfo) -> b } false } + + Command::Validate { paths } => run_lint(&paths, &config), + } +} + +fn run_lint(paths: &[std::path::PathBuf], config: &Config) -> bool { + let dirs: Vec = if paths.is_empty() { + registry::rule_search_dirs(config) + } else { + paths.to_vec() + }; + + let mut has_errors = false; + for path in &dirs { + let files = collect_yaml_files(path); + for file in files { + let errors = hah_dsl::validate_rule_file(&file); + for err in errors { + eprintln!("{}: {err}", file.display()); + has_errors = true; + } + } } + if !has_errors { + println!("All rule files are valid."); + } + has_errors +} + +fn collect_yaml_files(path: &std::path::Path) -> Vec { + if path.is_file() { + return vec![path.to_path_buf()]; + } + let Ok(entries) = std::fs::read_dir(path) else { + return vec![]; + }; + entries + .flatten() + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("yaml")) + .map(|e| e.path()) + .collect() } /// Load config + distro from the real system, then delegate to [`run_with_config`]. @@ -181,4 +221,35 @@ mod tests { // Exercises the Config::load() / DistroInfo::detect() code paths. run(parse(&["hah", "scan", "--check", "__no_such_check__"])); } + + #[test] + fn validate_shipped_rules_returns_false() { + assert!(!run_with_config( + parse(&["hah", "validate"]), + Config::default(), + DistroInfo::default(), + )); + } + + #[test] + fn validate_explicit_rules_dir_returns_false() { + assert!(!run_with_config( + parse(&["hah", "validate", "rules/"]), + Config::default(), + DistroInfo::default(), + )); + } + + #[test] + fn validate_bad_file_returns_true() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("bad.yaml"); + std::fs::write(&path, "not: [valid: {{").expect("write"); + let result = run_with_config( + parse(&["hah", "validate", path.to_str().expect("utf8")]), + Config::default(), + DistroInfo::default(), + ); + assert!(result); + } } diff --git a/crates/hah/src/registry.rs b/crates/hah/src/registry.rs index 5d509a6..3af4034 100644 --- a/crates/hah/src/registry.rs +++ b/crates/hah/src/registry.rs @@ -11,7 +11,7 @@ const DEFAULT_RULES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../rule /// Rule file search path: default shipped rules, system-wide, user-local, then /// extra paths from config. -fn rule_search_dirs(config: &Config) -> Vec { +pub(crate) fn rule_search_dirs(config: &Config) -> Vec { let mut dirs = vec![ PathBuf::from(DEFAULT_RULES_DIR), PathBuf::from("/usr/share/hah/rules"), diff --git a/docs/checks.md b/docs/checks.md index f371c02..39fa81c 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -1,77 +1,20 @@ -# Built-in Checks +# Shipped Checks -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. +HaH includes a set of pre-configured diagnostic rules covering boot hygiene, +package management, network configuration, and system drift. ---- +## Listing Checks -## Boot & Kernel +To browse all available checks with their IDs and descriptions, run: -| 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 | — | -| `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` | — | -| `dkms-status` | DKMS modules in broken or not-installed state | — | +```bash +hah list-checks +``` ---- +This lists every rule loaded from the built-in defaults and any custom +directories configured via `rule_dirs`. -## APT & Packages +## Customising -| Check ID | What it detects | -| ----------------------- | --------------- | -| `apt-key` | Non-empty `/etc/apt/trusted.gpg` — deprecated `apt-key` trust method | -| `legacy-sources-format` | One-line `deb` entries in `sources.list` or `sources.list.d/*.list` instead of the modern `.sources` format | -| `dpkg-state` | Failed or partial package states reported by `dpkg --audit` | -| `residual-config` | Packages in the `rc` state (removed but configuration files remain) | -| `autoremovable` | Automatically-removable packages that were never cleaned up | -| `user-denylist` | Packages matching the denylist entries in the config file | - ---- - -## Snap - -| Check ID | What it detects | Threshold | -| -------------------- | --------------- | --------- | -| `snap-health` | Disabled Snap revisions; more retained revisions than allowed | `snap_max_revisions` (default 2) | -| `snap-apt-duplicate` | Software installed via both APT and Snap simultaneously | — | - ---- - -## Network Configuration - -| Check ID | What it detects | -| --------------------------- | --------------- | -| `legacy-ntp` | `ntp` (ISC ntpd) installed while `chrony` or `systemd-timesyncd` is also active | -| `ntp-conflict` | Multiple NTP services active at the same time (ntpd, chrony, openntpd, timesyncd) | -| `legacy-dhcp-client` | `isc-dhcp-client` (dhclient) installed alongside NetworkManager or systemd-networkd | -| `legacy-network-interfaces` | Non-loopback entries in `/etc/network/interfaces` (legacy ifupdown) alongside Netplan or NetworkManager | -| `resolved-config` | `/etc/resolv.conf` not symlinked to systemd-resolved's stub resolver when systemd-resolved is active | - ---- - -## System Drift & Sysctl - -| Check ID | What it detects | Threshold | -| ----------------- | --------------- | --------- | -| `broken-symlinks` | Broken symlinks under `/etc`, `/usr/lib`, and `/var/lib` | — | -| `old-crash-dumps` | Files in `/var/crash` and `/var/lib/systemd/coredump` older than the threshold | `crash_dump_max_days` (default 30 days) | -| `journal-size` | systemd journal disk usage above the threshold | `journal_size_mb` (default 500 MB) | -| `sysctl-ordering` | The same sysctl key assigned conflicting values across multiple `sysctl.d` files | — | - ---- - -## Rule Loading - -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 - -See [`docs/dsl.md`](dsl.md) for the full rule language reference. +To skip or restrict checks, see the [Configuration Reference](config.md). +To write your own rules, see the [DSL Reference](dsl.md). diff --git a/docs/dev/README.md b/docs/dev/README.md index afe5e23..ed74854 100644 --- a/docs/dev/README.md +++ b/docs/dev/README.md @@ -24,7 +24,6 @@ HaH is organized as a Cargo workspace: - [DSL Language Reference](../dsl.md) - [Utilities Library (hah-utils)](utils.md) - [Project Plan](plan.md) -- [Roadmap](roadmap.md) ## Development Workflow diff --git a/docs/dsl.md b/docs/dsl.md index 7a8a856..2139e6c 100644 --- a/docs/dsl.md +++ b/docs/dsl.md @@ -189,37 +189,39 @@ 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`). +### Compact syntax + +The most concise way to write conditions. Use a severity key (`info`, `warning`, or `critical`) +with an expression string: + ```yaml conditions: - - type: numeric_threshold - value: "$free_bytes" - operator: lt - threshold: "$threshold_bytes" - severity: Critical - - - type: for_each - source: "$duplicates" - item_var: pkg - severity: Warning - - - type: all - severity: Warning - conditions: - - type: equals - value: "$ntp_installed" - expected: true - - type: any - conditions: - - type: equals - value: "$chrony_active" - expected: true - - type: equals - value: "$timesyncd_active" - expected: true + - info: "$residual_packages" # non-empty check + - warning: "$count > 5" # numeric threshold (gt) + - critical: "$free_mb < 50" # numeric threshold (lt) + - info: "$enabled == true" # boolean equality + - warning: "$status != true" # boolean inequality (becomes equals false) + - warning: '$status =~ "^overlap:"' # regex match + - info: '$family == "debian"' # string equality +``` + +Supported operators: `>`, `>=`, `<`, `<=`, `==`, `!=`, `=~`. When no operator is present, the +expression is treated as a `non_empty` check on the pipeline result. Quoted RHS with `==` +produces a string equality check; unquoted RHS produces a numeric comparison. + +Use `all:` and `any:` to combine conditions. Severity is auto-derived as the maximum severity +of the children: + +```yaml +conditions: + - all: + - warning: "$ntp_installed == true" + - any: + - warning: "$chrony_active == true" + - warning: "$timesyncd_active == true" ``` --- @@ -267,6 +269,30 @@ outcome: Use `{variable}` placeholders in `title`, `description`, and remediation `description`. All `values:` and trigger names are available for substitution. +### Per-item iteration (`for_each`) + +When a condition fires on a list, produce one finding per item instead of a single finding: + +```yaml +conditions: + - warning: "$duplicates" + +outcome: + for_each: + list: "$duplicates" + as: pkg + finding_id: "snap-apt-dup-{pkg}" + title: "'{pkg}' is installed via both APT and Snap" + description: "Having '{pkg}' installed twice wastes space." + remediation: + description: Remove the APT version if the Snap is preferred. + commands: + - "sudo apt remove --purge {pkg}" +``` + +The `{item_var}` placeholder (here `{pkg}`) is available in all outcome template fields. +Without `for_each`, a single finding is emitted when the condition fires. + --- ## Reusable Blocks @@ -339,3 +365,17 @@ See [`rules/`](../rules/) for the default rule set shipped with HaH: | `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 | + +--- + +## Validating Rule Files + +Use `hah validate` to check rule file syntax without running any checks: + +```bash +hah validate # validates all rule search dirs +hah validate rules/boot-space.yaml # validate a specific file +hah validate my-rules/ # validate all YAML files in a directory +``` + +This catches YAML parse errors, unknown condition types, and duplicate rule IDs early. diff --git a/docs/plan.md b/docs/plan.md index 71a81b5..3ff0e62 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -2,9 +2,8 @@ | Document | Audience | Description | | -------- | -------- | ----------- | -| [checks.md](checks.md) | End users | Every built-in check: ID, what it detects, configurable thresholds | +| [checks.md](checks.md) | End users | Shipped checks: how to list them and where to find details | | [config.md](config.md) | End users | Configuration file reference with all keys, defaults, and examples | | [dsl.md](dsl.md) | Rule authors | YAML rule language: triggers, pipeline filters, conditions, capabilities | | [architecture.md](architecture.md) | Developers | Crate layout, key types, how to add checks, testing infrastructure | -| [roadmap.md](roadmap.md) | Developers | Planned features and DSL extensions | | [../DEPENDENCIES.md](../DEPENDENCIES.md) | Developers | Direct dependencies (auto-generated by `make doc-dependencies`) | diff --git a/docs/roadmap.md b/docs/roadmap.md deleted file mode 100644 index b7b212a..0000000 --- a/docs/roadmap.md +++ /dev/null @@ -1,45 +0,0 @@ -# Roadmap - -Planned features that are not yet implemented. Items are roughly ordered by priority but the order is not a commitment. - ---- - -## CLI & UX - -| Feature | Notes | -| ------- | ----- | -| `hah report` | Audit report in HTML, Markdown, or JSON | -| Profiles (`desktop`, `server`, `vm`, `container`) | Activate or skip check sets based on the declared system role | - ---- - -## New Checks - -| Feature | Notes | -| ------- | ----- | -| Flatpak duplicate check | Flag software installed via both APT/Snap and Flatpak; only when `flatpak` is present | -| SMART / fsck integration | Surface `smartctl` and filesystem check results | -| EOL release check | Compare distro version against a bundled end-of-life date database | -| Orphaned package check | Packages installed from repositories that are no longer in any active source | -| Stale systemd units | Units referencing binaries that no longer exist | -| Legacy config drift | Config files that differ significantly from current package defaults | -| Snap preferred check | Flag packages where the Snap version is preferred over APT | - ---- - -## DSL Extensions - -| Feature | Notes | -| ------- | ----- | -| `where(field, op, value)` list filter | Filter structured records returned by capabilities | -| JSON Schema for rule files (`schemars`) | Enable editor autocompletion and validation of `.yaml` rule files | -| Typed config structs | Replace `HashMap` thresholds with typed config structs once DSL keys stabilise | - ---- - -## Testing & Quality - -| Feature | Notes | -| ------- | ----- | -| CLI integration tests (`assert_cmd` / `predicates`) | End-to-end tests of `hah scan` exit codes and output format | -| Snapshot tests (`insta`) | Detect regressions in rendered terminal/JSON/YAML output | diff --git a/rules/apt-key.yaml b/rules/apt-key.yaml index 2a22937..31d18da 100644 --- a/rules/apt-key.yaml +++ b/rules/apt-key.yaml @@ -9,11 +9,7 @@ rules: path: /etc/apt/trusted.gpg conditions: - - type: numeric_threshold - value: "$gpg_size" - operator: gt - threshold: "0" - severity: Warning + - warning: "$gpg_size > 0" outcome: finding_id: apt-key-legacy-gpg diff --git a/rules/autoremovable.yaml b/rules/autoremovable.yaml index e2fba5c..67d5cd7 100644 --- a/rules/autoremovable.yaml +++ b/rules/autoremovable.yaml @@ -17,11 +17,7 @@ rules: autoremovable_count: "$autoremove_output | lines | starts_with('Remv ') | count" conditions: - - type: numeric_threshold - value: "$autoremovable_count" - operator: gt - threshold: "0" - severity: Info + - info: "$autoremovable_count > 0" outcome: finding_id: autoremovable diff --git a/rules/boot-space.yaml b/rules/boot-space.yaml index a54bb4f..5a5cc6b 100644 --- a/rules/boot-space.yaml +++ b/rules/boot-space.yaml @@ -14,11 +14,7 @@ rules: threshold_mb: "$config.boot_space_mb | default('100')" conditions: - - type: numeric_threshold - value: "$free_mb" - operator: lt - threshold: "$threshold_mb" - severity: Critical + - critical: "$free_mb < $threshold_mb" outcome: finding_id: boot-space-low diff --git a/rules/broken-symlinks.yaml b/rules/broken-symlinks.yaml index 68b445c..e2c73b8 100644 --- a/rules/broken-symlinks.yaml +++ b/rules/broken-symlinks.yaml @@ -13,9 +13,7 @@ rules: broken_list: "$broken | join(', ')" conditions: - - type: non_empty - value: "$broken" - severity: Warning + - warning: "$broken" outcome: finding_id: broken-symlinks diff --git a/rules/dkms-status.yaml b/rules/dkms-status.yaml index 85f08ef..c5fab07 100644 --- a/rules/dkms-status.yaml +++ b/rules/dkms-status.yaml @@ -17,15 +17,9 @@ rules: 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 + - any: + - warning: "$broken_modules" + - warning: "$uninstalled_modules" outcome: finding_id: dkms-broken diff --git a/rules/dpkg-state.yaml b/rules/dpkg-state.yaml index 86c0d3f..5ea9371 100644 --- a/rules/dpkg-state.yaml +++ b/rules/dpkg-state.yaml @@ -14,9 +14,7 @@ rules: args: ["--audit"] conditions: - - type: non_empty - value: "$audit_output | trim" - severity: Critical + - critical: "$audit_output | trim" outcome: finding_id: dpkg-audit diff --git a/rules/initramfs-compression.yaml b/rules/initramfs-compression.yaml index 6bcfafa..9c4c963 100644 --- a/rules/initramfs-compression.yaml +++ b/rules/initramfs-compression.yaml @@ -17,19 +17,9 @@ rules: 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 + - all: + - info: "$zstd_count == 0" + - info: "$lz4_count == 0" outcome: finding_id: initramfs-compression-suboptimal diff --git a/rules/initramfs-size.yaml b/rules/initramfs-size.yaml index 5fb0233..0c74d87 100644 --- a/rules/initramfs-size.yaml +++ b/rules/initramfs-size.yaml @@ -7,11 +7,11 @@ rules: type: large_initramfs threshold_mb: 100 conditions: - - type: for_each - source: "$large_images" - item_var: entry - severity: Warning + - warning: "$large_images" outcome: + for_each: + list: "$large_images" + as: entry finding_id: "initramfs-large-{entry}" title: "Oversized initramfs: {entry} MB" description: >- diff --git a/rules/journal-size.yaml b/rules/journal-size.yaml index 4f583c4..0ae5d61 100644 --- a/rules/journal-size.yaml +++ b/rules/journal-size.yaml @@ -15,11 +15,7 @@ rules: threshold_mb: "$config.journal_size_mb | default('500')" conditions: - - type: numeric_threshold - value: "$journal_mb" - operator: gt - threshold: "$threshold_mb" - severity: Warning + - warning: "$journal_mb > $threshold_mb" outcome: finding_id: journal-size-large diff --git a/rules/legacy-apt-sources.yaml b/rules/legacy-apt-sources.yaml index 0abf8e2..9b51665 100644 --- a/rules/legacy-apt-sources.yaml +++ b/rules/legacy-apt-sources.yaml @@ -11,12 +11,12 @@ rules: type: legacy_apt_sources conditions: - - type: for_each - source: "{{ legacy_files }}" - item_var: file - severity: Info + - info: "$legacy_files" outcome: + for_each: + list: "$legacy_files" + as: file finding_id: "legacy-sources-format-{{ file }}" title: "Legacy one-line APT source: {{ file }}" description: >- diff --git a/rules/legacy-dhcp-client.yaml b/rules/legacy-dhcp-client.yaml index d5427f1..391e6b8 100644 --- a/rules/legacy-dhcp-client.yaml +++ b/rules/legacy-dhcp-client.yaml @@ -20,24 +20,11 @@ rules: 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 + - all: + - info: "$dhclient_installed == true" + - any: + - info: "$nm_installed == true" + - info: "$networkd_active == true" outcome: finding_id: legacy-dhcp-client diff --git a/rules/legacy-network-interfaces.yaml b/rules/legacy-network-interfaces.yaml index 0a8dc51..651b870 100644 --- a/rules/legacy-network-interfaces.yaml +++ b/rules/legacy-network-interfaces.yaml @@ -12,14 +12,8 @@ rules: 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 + - warning: "$net_status =~ '^overlap:'" + - info: "$net_status =~ '^legacy:'" outcome: finding_id: legacy-network-interfaces diff --git a/rules/legacy-ntp.yaml b/rules/legacy-ntp.yaml index 986e1ec..2b4668d 100644 --- a/rules/legacy-ntp.yaml +++ b/rules/legacy-ntp.yaml @@ -36,24 +36,11 @@ rules: name: systemd-timesyncd conditions: - - type: all - severity: Warning - conditions: - - 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 + - all: + - warning: "$ntp_installed == true" + - any: + - warning: "$chrony_active == true" + - warning: "$timesyncd_active == true" outcome: finding_id: legacy-ntp @@ -86,21 +73,10 @@ rules: name: systemd-timesyncd conditions: - - type: all - severity: Info - conditions: - - 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 + - all: + - info: "$ntp_installed == true" + - info: "$chrony_active != true" + - info: "$timesyncd_active != true" outcome: finding_id: legacy-ntp diff --git a/rules/ntp-conflict.yaml b/rules/ntp-conflict.yaml index 9d35e57..9ef3099 100644 --- a/rules/ntp-conflict.yaml +++ b/rules/ntp-conflict.yaml @@ -16,11 +16,7 @@ rules: 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 + - warning: "$ntp_count > 1" outcome: finding_id: ntp-conflict title: "Multiple NTP services active: {ntp_list}" diff --git a/rules/old-crash-dumps.yaml b/rules/old-crash-dumps.yaml index f44056b..9a9f24f 100644 --- a/rules/old-crash-dumps.yaml +++ b/rules/old-crash-dumps.yaml @@ -8,11 +8,11 @@ rules: paths: [] older_than_days: 30 conditions: - - type: for_each - source: "$old_files" - item_var: file - severity: Info + - info: "$old_files" outcome: + for_each: + list: "$old_files" + as: file finding_id: "crash-dump-{file}" title: "Old crash dump: {file}" description: "{file} is older than 30 days and can likely be removed." diff --git a/rules/residual-config.yaml b/rules/residual-config.yaml index a67dec3..6e5db1b 100644 --- a/rules/residual-config.yaml +++ b/rules/residual-config.yaml @@ -19,9 +19,7 @@ rules: packages: "$rc_packages | join(' ')" conditions: - - type: non_empty - value: "$rc_packages" - severity: Info + - info: "$rc_packages" outcome: finding_id: residual-config diff --git a/rules/resolved-config.yaml b/rules/resolved-config.yaml index 69297a7..bd0942f 100644 --- a/rules/resolved-config.yaml +++ b/rules/resolved-config.yaml @@ -11,17 +11,9 @@ rules: 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 + - all: + - warning: "$resolved_active == true" + - warning: "$target | contains('systemd/resolve') != true" outcome: finding_id: resolved-config title: "/etc/resolv.conf is not linked to systemd-resolved" diff --git a/rules/snap-apt-duplicate.yaml b/rules/snap-apt-duplicate.yaml index 6927c3f..b266c83 100644 --- a/rules/snap-apt-duplicate.yaml +++ b/rules/snap-apt-duplicate.yaml @@ -18,11 +18,11 @@ rules: 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 + - warning: "$duplicates" outcome: + for_each: + list: "$duplicates" + as: pkg 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." diff --git a/rules/snap-health.yaml b/rules/snap-health.yaml index 4d09fae..b2a99ae 100644 --- a/rules/snap-health.yaml +++ b/rules/snap-health.yaml @@ -18,15 +18,9 @@ rules: 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 + - any: + - info: "$disabled_lines" + - info: "$excess_revisions" outcome: finding_id: snap-health diff --git a/rules/stale-kernel-headers.yaml b/rules/stale-kernel-headers.yaml index cb2f63b..b15ebd3 100644 --- a/rules/stale-kernel-headers.yaml +++ b/rules/stale-kernel-headers.yaml @@ -15,9 +15,7 @@ rules: stale_list: "$stale_headers | join(', ')" conditions: - - type: non_empty - value: "$stale_headers" - severity: Info + - info: "$stale_headers" outcome: finding_id: stale-kernel-headers diff --git a/rules/sysctl-ordering.yaml b/rules/sysctl-ordering.yaml index c2dc260..173cab5 100644 --- a/rules/sysctl-ordering.yaml +++ b/rules/sysctl-ordering.yaml @@ -13,9 +13,7 @@ rules: conflict_list: "$conflicts | join(', ')" conditions: - - type: non_empty - value: "$conflicts" - severity: Warning + - warning: "$conflicts" outcome: finding_id: sysctl-ordering diff --git a/rules/unused-kernels.yaml b/rules/unused-kernels.yaml index d84f4fd..86f7678 100644 --- a/rules/unused-kernels.yaml +++ b/rules/unused-kernels.yaml @@ -15,9 +15,7 @@ rules: unused_list: "$unused_kernels | join(', ')" conditions: - - type: non_empty - value: "$unused_kernels" - severity: Warning + - warning: "$unused_kernels" outcome: finding_id: unused-kernels diff --git a/rules/user-denylist.yaml b/rules/user-denylist.yaml index fac90b4..c582754 100644 --- a/rules/user-denylist.yaml +++ b/rules/user-denylist.yaml @@ -11,12 +11,12 @@ rules: type: installed_denylist conditions: - - type: for_each - source: "{{ denied }}" - item_var: entry - severity: Warning + - warning: "$denied" outcome: + for_each: + list: "$denied" + as: entry finding_id: "user-denylist-{{ entry }}" title: "Denylisted package installed: {{ entry }}" description: >-