mod prefilter;
mod sql;
use std::fmt;
pub use hayai::engine::{
ChainedNormalizer, IdentityNormalizer, MatchEngine, Normalizer, NullPrefilter, PathNormalizer,
Prefilter, contains_ascii_ci,
};
use hayai::engine::RegexMatcher;
pub use self::prefilter::PrefixPrefilter;
pub use self::sql::SqlCommentStripper;
use crate::model::{Decision, Rule, Severity};
pub trait RuleEngine {
#[must_use]
fn check(&self, command: &str) -> Decision;
#[must_use]
fn rules(&self) -> &[Rule];
#[must_use]
fn rule_count(&self) -> usize {
self.rules().len()
}
}
pub type ProductionNormalizer = ChainedNormalizer<PathNormalizer, SqlCommentStripper>;
pub type NixStoreNormalizer = PathNormalizer;
pub struct RegexEngine<N: Normalizer = ProductionNormalizer, P: Prefilter = PrefixPrefilter> {
matcher: RegexMatcher<N, P>,
rules: Vec<Rule>,
}
impl<N: Normalizer + fmt::Debug, P: Prefilter + fmt::Debug> fmt::Debug for RegexEngine<N, P> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RegexEngine")
.field("rule_count", &self.rules.len())
.field("matcher", &self.matcher)
.finish()
}
}
impl RegexEngine {
pub fn new(rules: Vec<Rule>) -> anyhow::Result<Self> {
Self::with_plugins(
rules,
ChainedNormalizer {
first: PathNormalizer,
second: SqlCommentStripper,
},
PrefixPrefilter,
)
}
}
impl<N: Normalizer, P: Prefilter> RegexEngine<N, P> {
pub fn with_plugins(rules: Vec<Rule>, normalizer: N, prefilter: P) -> anyhow::Result<Self> {
let patterns: Vec<String> = rules.iter().map(|r| r.pattern.clone()).collect();
let matcher = RegexMatcher::with_plugins(patterns, normalizer, prefilter)?;
Ok(Self { matcher, rules })
}
}
impl<N: Normalizer, P: Prefilter> RuleEngine for RegexEngine<N, P> {
fn check(&self, command: &str) -> Decision {
let matches = self.matcher.check(command);
if matches.is_empty() {
return Decision::Allow;
}
let mut best_warn: Option<&Rule> = None;
for idx in matches {
let rule = &self.rules[idx];
match rule.severity {
Severity::Block => return Decision::from_rule(rule),
Severity::Warn if best_warn.is_none() => best_warn = Some(rule),
Severity::Warn => {}
}
}
best_warn.map_or(Decision::Allow, Decision::from_rule)
}
fn rules(&self) -> &[Rule] {
&self.rules
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config;
use crate::model::Category;
fn engine() -> RegexEngine {
RegexEngine::new(config::default_rules()).unwrap()
}
fn assert_blocks(cmd: &str) {
let e = engine();
match e.check(cmd) {
Decision::Block { .. } => {}
other => panic!("expected Block for '{cmd}', got {other}"),
}
}
fn assert_warns(cmd: &str) {
let e = engine();
match e.check(cmd) {
Decision::Warn { .. } => {}
other => panic!("expected Warn for '{cmd}', got {other}"),
}
}
fn assert_allows(cmd: &str) {
let e = engine();
match e.check(cmd) {
Decision::Allow => {}
other => panic!("expected Allow for '{cmd}', got {other}"),
}
}
#[test]
fn path_normalizer_strips_nix_store_path() {
let n = PathNormalizer;
let result = n.normalize("/nix/store/abc123-pkg-1.0/bin/guardrail check");
assert_eq!(&*result, "guardrail check");
assert!(matches!(result, Cow::Owned(_)));
}
#[test]
fn path_normalizer_borrows_when_no_path() {
let n = PathNormalizer;
let result = n.normalize("cargo test");
assert_eq!(&*result, "cargo test");
assert!(matches!(result, Cow::Borrowed(_)));
}
#[test]
fn path_normalizer_strips_multiple_nix_paths() {
let n = PathNormalizer;
let result =
n.normalize("/nix/store/abc-foo-1.0/bin/cmd1 && /nix/store/def-bar-2.0/bin/cmd2");
assert_eq!(&*result, "cmd1 && cmd2");
}
#[test]
fn path_normalizer_strips_usr_bin() {
let n = PathNormalizer;
let result = n.normalize("/usr/bin/rm -rf /");
assert_eq!(&*result, "rm -rf /");
assert!(matches!(result, Cow::Owned(_)));
}
#[test]
fn path_normalizer_strips_usr_local_bin() {
let n = PathNormalizer;
let result = n.normalize("/usr/local/bin/terraform destroy");
assert_eq!(&*result, "terraform destroy");
}
#[test]
fn path_normalizer_strips_bin() {
let n = PathNormalizer;
let result = n.normalize("/bin/rm -rf /");
assert_eq!(&*result, "rm -rf /");
}
#[test]
fn path_normalizer_strips_sbin() {
let n = PathNormalizer;
let result = n.normalize("/sbin/mkfs.ext4 /dev/sda1");
assert_eq!(&*result, "mkfs.ext4 /dev/sda1");
}
#[test]
fn identity_normalizer_is_noop() {
let n = IdentityNormalizer;
let result = n.normalize("anything");
assert!(matches!(result, Cow::Borrowed("anything")));
}
#[test]
fn sql_comment_stripper_block_comment() {
let n = SqlCommentStripper;
let result = n.normalize("DELETE/**/FROM users");
assert_eq!(result.trim(), "DELETE FROM users");
}
#[test]
fn sql_comment_stripper_sneaky_block_comment() {
let n = SqlCommentStripper;
let result = n.normalize("DROP/* sneaky */TABLE users");
assert_eq!(result.trim(), "DROP TABLE users");
}
#[test]
fn sql_comment_stripper_line_comment() {
let n = SqlCommentStripper;
let result = n.normalize("DROP TABLE -- this is a comment\nusers");
assert!(result.contains("DROP TABLE"));
}
#[test]
fn sql_comment_stripper_preserves_cli_flags() {
let n = SqlCommentStripper;
let result = n.normalize("cargo build -- --release");
assert_eq!(&*result, "cargo build -- --release");
assert!(matches!(result, Cow::Borrowed(_)));
}
#[test]
fn sql_comment_stripper_no_comments() {
let n = SqlCommentStripper;
let result = n.normalize("SELECT * FROM users");
assert!(matches!(result, Cow::Borrowed(_)));
}
#[test]
fn chained_normalizer_chains_path_and_sql() {
let n = ChainedNormalizer { first: PathNormalizer, second: SqlCommentStripper };
let result = n.normalize("/usr/bin/psql -c 'DROP/**/TABLE users'");
assert!(result.contains("DROP"));
assert!(result.contains("TABLE"));
assert!(!result.contains("/usr/bin/"));
}
#[test]
fn chained_normalizer_borrows_when_clean() {
let n: ProductionNormalizer = ChainedNormalizer { first: PathNormalizer, second: SqlCommentStripper };
let result = n.normalize("cargo test");
assert!(matches!(result, Cow::Borrowed(_)));
}
#[test]
fn chained_normalizer_only_first_transforms() {
let n = ChainedNormalizer { first: PathNormalizer, second: SqlCommentStripper };
let result = n.normalize("/usr/bin/ls -la");
assert_eq!(&*result, "ls -la");
}
#[test]
fn chained_normalizer_only_second_transforms() {
let n = ChainedNormalizer { first: PathNormalizer, second: SqlCommentStripper };
let result = n.normalize("DROP/**/TABLE users");
assert!(result.contains("DROP"));
assert!(result.contains("TABLE"));
}
#[test]
fn chained_normalizer_identity_is_noop() {
let n = ChainedNormalizer { first: IdentityNormalizer, second: IdentityNormalizer };
let result = n.normalize("anything");
assert!(matches!(result, Cow::Borrowed("anything")));
}
#[test]
fn prefix_prefilter_safe_commands() {
let p = PrefixPrefilter;
assert!(p.is_safe("ls -la"));
assert!(p.is_safe("cat file.txt"));
assert!(p.is_safe("rg pattern ."));
assert!(p.is_safe("wc -l file"));
assert!(p.is_safe("head -5 file"));
}
#[test]
fn prefix_prefilter_dangerous_commands() {
let p = PrefixPrefilter;
assert!(!p.is_safe("rm -rf /"));
assert!(!p.is_safe("git push --force"));
assert!(!p.is_safe("kubectl delete namespace prod"));
assert!(!p.is_safe("terraform destroy"));
}
#[test]
fn prefix_prefilter_sql_keywords() {
let p = PrefixPrefilter;
assert!(!p.is_safe("echo 'DROP TABLE users' | psql"));
}
#[test]
fn null_prefilter_never_safe() {
let p = NullPrefilter;
assert!(!p.is_safe("ls -la"));
assert!(!p.is_safe("cargo test"));
}
#[test]
fn engine_with_null_prefilter_checks_everything() {
let rules = config::default_rules();
let normalizer = ChainedNormalizer { first: PathNormalizer, second: SqlCommentStripper };
let engine =
RegexEngine::with_plugins(rules, normalizer, NullPrefilter).unwrap();
assert!(matches!(engine.check("ls -la"), Decision::Allow));
assert!(matches!(engine.check("rm -rf /"), Decision::Block { .. }));
}
#[test]
fn engine_with_identity_normalizer_no_nix_strip() {
let rules = config::default_rules();
let engine =
RegexEngine::with_plugins(rules, IdentityNormalizer, PrefixPrefilter).unwrap();
assert!(matches!(
engine.check("/nix/store/abc123-coreutils-9.0/bin/rm -rf /"),
Decision::Allow
));
}
#[test]
fn engine_debug_impl() {
let engine = engine();
let debug = format!("{engine:?}");
assert!(debug.contains("RegexEngine"));
assert!(debug.contains("rule_count"));
}
#[test]
fn nix_shell_wrapped_mkfs_not_blocked() {
let cmd = "/nix/store/abc123-e2fsprogs-1.47/bin/crate2nix generate";
assert_allows(cmd);
}
#[test]
fn actual_mkfs_still_blocked() {
assert_blocks("mkfs.ext4 /dev/sda1");
assert_blocks("sudo mkfs.ext4 /dev/sda1");
}
#[test]
fn nix_store_path_with_real_danger() {
assert_blocks("/nix/store/abc123-coreutils-9.0/bin/rm -rf /");
}
#[test]
fn usr_bin_rm_blocked() {
assert_blocks("/usr/bin/rm -rf /");
}
#[test]
fn sbin_mkfs_blocked() {
assert_blocks("/sbin/mkfs.ext4 /dev/sda1");
}
#[test]
fn usr_local_bin_terraform_blocked() {
assert_blocks("/usr/local/bin/terraform destroy");
}
#[test]
fn bin_rm_blocked() {
assert_blocks("/bin/rm -rf /");
}
#[test] fn rm_rf_root_blocked() { assert_blocks("rm -rf /"); }
#[test] fn rm_rf_root_var_blocked() { assert_blocks("rm -rf /"); }
#[test] fn rm_rf_home_blocked() { assert_blocks("rm -rf ~"); }
#[test] fn rm_rf_home_var_blocked() { assert_blocks("rm -rf $HOME"); }
#[test] fn rm_rf_cwd_blocked() { assert_blocks("rm -rf ."); }
#[test] fn rm_rf_target_allowed() { assert_allows("rm -rf ./target"); }
#[test] fn rm_rf_subdir_allowed() { assert_allows("rm -rf ~/code/old-project"); }
#[test] fn rm_single_file_allowed() { assert_allows("rm file.txt"); }
#[test] fn dd_disk_blocked() { assert_blocks("dd if=/dev/zero of=/dev/sda bs=1M"); }
#[test] fn dd_file_allowed() { assert_allows("dd if=input.img of=output.img"); }
#[test] fn mkfs_blocked() { assert_blocks("mkfs.ext4 /dev/sda1"); }
#[test] fn force_push_main_blocked() { assert_blocks("git push --force origin main"); }
#[test] fn force_push_master_blocked() { assert_blocks("git push --force origin master"); }
#[test] fn force_push_bare_blocked() { assert_blocks("git push --force"); }
#[test] fn force_push_feature_allowed() { assert_allows("git push --force origin feature-xyz"); }
#[test] fn normal_push_allowed() { assert_allows("git push origin main"); }
#[test] fn reset_hard_warned() { assert_warns("git reset --hard HEAD~1"); }
#[test] fn reset_soft_allowed() { assert_allows("git reset --soft HEAD~1"); }
#[test] fn clean_force_warned() { assert_warns("git clean -fd"); }
#[test] fn branch_force_delete_warned() { assert_warns("git branch -D old-branch"); }
#[test] fn branch_delete_allowed() { assert_allows("git branch -d merged-branch"); }
#[test] fn drop_table_blocked() { assert_blocks("psql -c 'DROP TABLE users'"); }
#[test] fn drop_table_lower_blocked() { assert_blocks("psql -c 'drop table users'"); }
#[test] fn drop_database_blocked() { assert_blocks("psql -c 'DROP DATABASE mydb'"); }
#[test] fn drop_schema_blocked() { assert_blocks("mysql -e 'DROP SCHEMA test'"); }
#[test] fn truncate_blocked() { assert_blocks("psql -c 'TRUNCATE TABLE logs'"); }
#[test] fn delete_no_where_blocked() { assert_blocks("psql -c 'DELETE FROM users'"); }
#[test] fn delete_with_where_allowed() { assert_allows("psql -c 'DELETE FROM users WHERE id = 5'"); }
#[test] fn select_allowed() { assert_allows("psql -c 'SELECT * FROM users'"); }
#[test] fn create_table_allowed() { assert_allows("psql -c 'CREATE TABLE new_table (id int)'"); }
#[test] fn insert_allowed() { assert_allows("psql -c 'INSERT INTO users VALUES (1)'"); }
#[test] fn drop_table_single_quotes() { assert_blocks("psql -c 'DROP TABLE users'"); }
#[test] fn drop_table_double_quotes() { assert_blocks(r#"psql -c "DROP TABLE users""#); }
#[test] fn drop_table_heredoc() { assert_blocks("psql <<EOF\nDROP TABLE users;\nEOF"); }
#[test] fn drop_table_pipe() { assert_blocks("echo 'DROP TABLE users' | psql"); }
#[test] fn drop_table_e_flag() { assert_blocks("mysql -e 'DROP TABLE users'"); }
#[test] fn drop_table_multiline() { assert_blocks("psql -c '\nDROP TABLE\nusers\n'"); }
#[test] fn truncate_semicolon() { assert_blocks("psql -c 'TRUNCATE TABLE logs;'"); }
#[test] fn delete_from_semicolon() { assert_blocks("psql -c 'DELETE FROM users;'"); }
#[test] fn drop_table_block_comment() { assert_blocks("psql -c 'DROP/**/TABLE users'"); }
#[test] fn drop_sneaky_comment() { assert_blocks("psql -c 'DROP/* sneaky */TABLE users'"); }
#[test] fn delete_block_comment() { assert_blocks("psql -c 'DELETE/**/FROM users'"); }
#[test] fn select_star_not_blocked() { assert_allows("psql -c 'SELECT * FROM users'"); }
#[test] fn create_not_blocked() { assert_allows("psql -c 'CREATE TABLE t (id int)'"); }
#[test] fn alter_add_col_allowed() { assert_allows("psql -c 'ALTER TABLE t ADD COLUMN name text'"); }
#[test] fn kubectl_delete_ns_blocked() { assert_blocks("kubectl delete namespace production"); }
#[test] fn kubectl_delete_ns_short() { assert_blocks("kubectl delete ns staging"); }
#[test] fn kubectl_delete_all_blocked() { assert_blocks("kubectl delete pods --all"); }
#[test] fn kubectl_delete_pod_allowed() { assert_allows("kubectl delete pod stuck-pod -n staging"); }
#[test] fn kubectl_get_allowed() { assert_allows("kubectl get pods -n production"); }
#[test] fn helm_uninstall_prod_blocked() { assert_blocks("helm uninstall myapp -n production"); }
#[test] fn helm_uninstall_staging_allowed() { assert_allows("helm uninstall myapp -n staging"); }
#[test] fn nix_gc_delete_warned() { assert_warns("nix-collect-garbage -d"); }
#[test] fn nix_store_gc_warned() { assert_warns("nix store gc"); }
#[test] fn nix_build_allowed() { assert_allows("nix build .#default"); }
#[test] fn docker_system_prune_warned() { assert_warns("docker system prune -af"); }
#[test] fn docker_volume_prune_warned() { assert_warns("docker volume prune -f"); }
#[test] fn docker_build_allowed() { assert_allows("docker build -t myimage ."); }
#[test] fn sops_decrypt_pipe_warned() { assert_warns("sops -d secrets.yaml | cat"); }
#[test] fn sops_decrypt_file_allowed() { assert_allows("sops -d secrets.yaml > decrypted.yaml"); }
#[test] fn echo_token_warned() { assert_warns("echo $GITHUB_TOKEN"); }
#[test] fn echo_normal_allowed() { assert_allows("echo hello world"); }
#[test] fn terraform_destroy_blocked() { assert_blocks("terraform destroy"); }
#[test] fn terraform_apply_auto_warned() { assert_warns("terraform apply -auto-approve"); }
#[test] fn terraform_plan_allowed() { assert_allows("terraform plan"); }
#[test] fn terraform_apply_allowed() { assert_allows("terraform apply"); }
#[test] fn terraform_force_unlock_blocked() { assert_blocks("terraform force-unlock abc123"); }
#[test] fn terraform_state_rm_blocked() { assert_blocks("terraform state rm aws_instance.web"); }
#[test] fn terraform_state_list_allowed() { assert_allows("terraform state list"); }
#[test] fn pulumi_destroy_blocked() { assert_blocks("pulumi destroy"); }
#[test] fn pulumi_up_allowed() { assert_allows("pulumi up"); }
#[test] fn flux_uninstall_blocked() { assert_blocks("flux uninstall"); }
#[test] fn flux_delete_source_warned() { assert_warns("flux delete source git my-repo"); }
#[test] fn flux_delete_ks_warned() { assert_warns("flux delete kustomization my-app"); }
#[test] fn flux_reconcile_allowed() { assert_allows("flux reconcile kustomization my-app"); }
#[test] fn flux_get_allowed() { assert_allows("flux get kustomizations"); }
#[test]
fn engine_compiles_all_defaults() {
let e = engine();
assert!(e.rule_count() >= 30);
}
#[test]
fn rules_returns_slice() {
let e = engine();
let rules: &[Rule] = e.rules();
assert!(rules.len() >= 60, "expected 60+ default rules, got {}", rules.len());
}
#[test]
fn invalid_regex_rejected() {
let rules = vec![Rule::builder("bad", "[invalid").build()];
assert!(RegexEngine::new(rules).is_err());
}
#[test]
fn block_takes_priority_over_warn() {
let rules = vec![
Rule::builder("warn-rule", r"rm\s+-rf")
.severity(Severity::Warn)
.build(),
Rule::builder("block-rule", r"rm\s+-rf")
.severity(Severity::Block)
.build(),
];
let engine = RegexEngine::new(rules).unwrap();
match engine.check("rm -rf /tmp/test") {
Decision::Block { rule, .. } => assert_eq!(rule, "block-rule"),
other => panic!("expected Block, got {other}"),
}
}
#[test] fn var_as_command_warned() { assert_warns("$cmd --force"); }
#[test] fn indirect_eval_var_warned() { assert_warns(r#"eval "$user_input""#); }
#[test] fn bash_c_var_warned() { assert_warns(r#"bash -c "$cmd""#); }
#[test] fn backtick_rm_warned() { assert_warns("echo `rm -rf /tmp`"); }
#[test] fn backtick_date_allowed() { assert_allows("echo `date`"); }
#[test] fn echo_dollar_home_allowed() { assert_allows("echo $HOME"); }
#[test]
fn prefilter_dollar_not_safe() {
let p = PrefixPrefilter;
assert!(!p.is_safe("$cmd --force"));
}
#[test]
fn prefilter_backtick_not_safe() {
let p = PrefixPrefilter;
assert!(!p.is_safe("echo `rm -rf /`"));
}
#[test]
fn prefilter_sql_block_comment_not_safe() {
let p = PrefixPrefilter;
assert!(!p.is_safe("SELECT /*evil*/ 1"));
}
#[test]
fn prefilter_sql_line_comment_not_safe() {
let p = PrefixPrefilter;
assert!(!p.is_safe("SELECT 1 -- comment"));
}
#[test]
fn prefilter_cli_double_dash_is_safe() {
let p = PrefixPrefilter;
assert!(p.is_safe("rg --release pattern ."));
}
#[test]
fn empty_command_allowed() {
assert_allows("");
}
#[test]
fn whitespace_only_command_allowed() {
assert_allows(" ");
}
#[test]
fn unicode_safe_command_allowed() {
assert_allows("echo 'cafe resume'");
}
#[test]
fn very_long_safe_command_allowed() {
let long = format!("cargo {}", "build ".repeat(500));
assert_allows(&long);
}
#[test]
fn contains_ascii_ci_matches() {
assert!(contains_ascii_ci(b"hello DROP TABLE world", b"DROP "));
assert!(contains_ascii_ci(b"hello drop table world", b"DROP "));
assert!(contains_ascii_ci(b"hello Drop Table world", b"DROP "));
}
#[test]
fn contains_ascii_ci_no_match() {
assert!(!contains_ascii_ci(b"hello world", b"DROP "));
assert!(!contains_ascii_ci(b"DROPX", b"DROP "));
}
#[test]
fn contains_ascii_ci_empty() {
assert!(contains_ascii_ci(b"anything", b""));
assert!(!contains_ascii_ci(b"", b"DROP "));
}
#[test]
fn empty_rules_engine() {
let engine = RegexEngine::new(vec![]).unwrap();
assert!(matches!(engine.check("rm -rf /"), Decision::Allow));
assert_eq!(engine.rule_count(), 0);
assert!(engine.rules().is_empty());
}
#[test]
fn warn_only_engine() {
let rules = vec![
Rule::builder("w1", r"rm\s+-rf").severity(Severity::Warn).build(),
Rule::builder("w2", r"delete").severity(Severity::Warn).build(),
];
let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
match engine.check("rm -rf /tmp") {
Decision::Warn { rule, .. } => assert_eq!(rule, "w1"),
other => panic!("expected Warn, got {other}"),
}
}
#[test]
fn multiple_warn_returns_first() {
let rules = vec![
Rule::builder("first-warn", r"rm").severity(Severity::Warn).build(),
Rule::builder("second-warn", r"rm\s+-rf").severity(Severity::Warn).build(),
];
let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
match engine.check("rm -rf /") {
Decision::Warn { rule, .. } => assert_eq!(rule, "first-warn"),
other => panic!("expected first Warn, got {other}"),
}
}
#[test]
fn block_before_warn_in_rule_order() {
let rules = vec![
Rule::builder("warn-first", r"terraform").severity(Severity::Warn).build(),
Rule::builder("block-second", r"terraform\s+destroy").severity(Severity::Block).build(),
];
let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
match engine.check("terraform destroy") {
Decision::Block { rule, .. } => assert_eq!(rule, "block-second"),
other => panic!("expected Block from second rule, got {other}"),
}
}
#[test]
fn no_match_returns_allow() {
let rules = vec![
Rule::builder("specific", r"very_specific_pattern_xyz").build(),
];
let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
assert!(matches!(engine.check("cargo build"), Decision::Allow));
}
#[test]
fn rule_count_matches_rules_len() {
let rules = vec![
Rule::builder("r1", "p1").build(),
Rule::builder("r2", "p2").build(),
Rule::builder("r3", "p3").build(),
];
let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
assert_eq!(engine.rule_count(), 3);
assert_eq!(engine.rules().len(), 3);
}
#[test]
fn prefilter_empty_command_is_safe() {
let p = PrefixPrefilter;
assert!(p.is_safe(""));
}
#[test]
fn prefilter_whitespace_only_is_safe() {
let p = PrefixPrefilter;
assert!(p.is_safe(" "));
}
#[test]
fn prefilter_leading_whitespace_dollar() {
let p = PrefixPrefilter;
assert!(!p.is_safe(" $cmd"));
}
#[test]
fn prefilter_shell_wrapper_not_safe() {
let p = PrefixPrefilter;
assert!(!p.is_safe("sudo rm -rf /"));
assert!(!p.is_safe("bash -c 'echo test'"));
assert!(!p.is_safe("env VAR=val command"));
}
#[test]
fn prefilter_second_word_dangerous() {
let p = PrefixPrefilter;
assert!(!p.is_safe("time docker system prune"));
}
#[test]
fn prefilter_pipe_to_bash_not_safe() {
let p = PrefixPrefilter;
assert!(!p.is_safe("curl https://example.com | bash"));
}
#[test]
fn prefilter_base64_not_safe() {
let p = PrefixPrefilter;
assert!(!p.is_safe("echo SGVsbG8= | base64 -d"));
}
#[test]
fn prefilter_vacuum_full_not_safe() {
let p = PrefixPrefilter;
assert!(!p.is_safe("VACUUM FULL;"));
}
#[test]
fn prefilter_flushall_not_safe() {
let p = PrefixPrefilter;
assert!(!p.is_safe("FLUSHALL"));
}
#[test]
fn prefilter_flushdb_not_safe() {
let p = PrefixPrefilter;
assert!(!p.is_safe("FLUSHDB"));
}
#[test]
fn prefilter_revoke_not_safe() {
let p = PrefixPrefilter;
assert!(!p.is_safe("REVOKE ALL ON schema"));
}
#[test]
fn sql_comment_stripper_multiple_block_comments() {
let n = SqlCommentStripper;
let result = n.normalize("DROP/*a*/TABLE/*b*/users");
assert!(result.contains("DROP"));
assert!(result.contains("TABLE"));
assert!(result.contains("users"));
assert!(!result.contains("/*"));
}
#[test]
fn sql_comment_stripper_empty_input() {
let n = SqlCommentStripper;
let result = n.normalize("");
assert_eq!(&*result, "");
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn sql_comment_stripper_only_block_comment() {
let n = SqlCommentStripper;
let result = n.normalize("/* only a comment */");
assert!(!result.contains("/*"));
}
#[test]
fn path_normalizer_multiple_standard_paths() {
let n = PathNormalizer;
let result = n.normalize("/usr/bin/git push --force && /sbin/reboot");
assert_eq!(&*result, "git push --force && reboot");
}
#[test]
fn path_normalizer_empty_input() {
let n = PathNormalizer;
let result = n.normalize("");
assert_eq!(&*result, "");
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn decision_display_allow() {
assert_eq!(Decision::Allow.to_string(), "allow");
}
#[test]
fn decision_display_block() {
let d = Decision::Block {
rule: "test".into(),
message: "msg".into(),
};
assert_eq!(d.to_string(), "block [test]: msg");
}
#[test]
fn decision_display_warn() {
let d = Decision::Warn {
rule: "test".into(),
message: "msg".into(),
};
assert_eq!(d.to_string(), "warn [test]: msg");
}
#[test]
fn severity_display() {
assert_eq!(Severity::Block.to_string(), "block");
assert_eq!(Severity::Warn.to_string(), "warn");
}
#[test]
fn category_display() {
assert_eq!(Category::Filesystem.to_string(), "filesystem");
assert_eq!(Category::Git.to_string(), "git");
assert_eq!(Category::Cloud.to_string(), "cloud");
assert_eq!(Category::Nosql.to_string(), "nosql");
}
#[test]
fn prefix_set_is_non_empty() {
let set = PrefixPrefilter::prefix_set();
assert!(!set.is_empty());
}
#[test]
fn prefix_set_contains_known_prefixes() {
let set = PrefixPrefilter::prefix_set();
for expected in ["rm", "git", "kubectl", "terraform", "docker", "aws"] {
assert!(set.contains(expected), "prefix_set missing '{expected}'");
}
}
#[test]
fn prefix_set_does_not_contain_safe_commands() {
let set = PrefixPrefilter::prefix_set();
for safe in ["ls", "cat", "rg", "wc", "head", "tail", "grep"] {
assert!(!set.contains(safe), "prefix_set should not contain '{safe}'");
}
}
#[test]
fn rule_count_default_impl_equals_rules_len() {
let rules = vec![
Rule::builder("a", "a").build(),
Rule::builder("b", "b").build(),
];
let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
assert_eq!(engine.rule_count(), engine.rules().len());
}
#[test]
fn prefilter_third_word_dangerous() {
let p = PrefixPrefilter;
assert!(!p.is_safe("some other rm -rf /"));
}
#[test]
fn prefilter_fourth_word_not_checked() {
let p = PrefixPrefilter;
assert!(p.is_safe("one two three rm -rf /"));
}
#[test]
fn prefilter_sql_line_comment_tab_not_safe() {
let p = PrefixPrefilter;
assert!(!p.is_safe("SELECT 1 --\thidden"));
}
use std::borrow::Cow;
}