1mod prefilter;
2mod sql;
3
4use std::fmt;
5
6pub use hayai::engine::{
7 ChainedNormalizer, IdentityNormalizer, MatchEngine, Normalizer, NullPrefilter, PathNormalizer,
8 Prefilter, contains_ascii_ci,
9};
10use hayai::engine::RegexMatcher;
11
12pub use self::prefilter::PrefixPrefilter;
13pub use self::sql::SqlCommentStripper;
14
15use crate::model::{Decision, Rule, Severity};
16
17pub trait RuleEngine {
23 #[must_use]
24 fn check(&self, command: &str) -> Decision;
25 #[must_use]
26 fn rules(&self) -> &[Rule];
27 #[must_use]
28 fn rule_count(&self) -> usize {
29 self.rules().len()
30 }
31}
32
33pub type ProductionNormalizer = ChainedNormalizer<PathNormalizer, SqlCommentStripper>;
35
36pub type NixStoreNormalizer = PathNormalizer;
38
39pub struct RegexEngine<N: Normalizer = ProductionNormalizer, P: Prefilter = PrefixPrefilter> {
63 matcher: RegexMatcher<N, P>,
64 rules: Vec<Rule>,
65}
66
67impl<N: Normalizer + fmt::Debug, P: Prefilter + fmt::Debug> fmt::Debug for RegexEngine<N, P> {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 f.debug_struct("RegexEngine")
70 .field("rule_count", &self.rules.len())
71 .field("matcher", &self.matcher)
72 .finish()
73 }
74}
75
76impl RegexEngine {
78 pub fn new(rules: Vec<Rule>) -> anyhow::Result<Self> {
85 Self::with_plugins(
86 rules,
87 ChainedNormalizer {
88 first: PathNormalizer,
89 second: SqlCommentStripper,
90 },
91 PrefixPrefilter,
92 )
93 }
94}
95
96impl<N: Normalizer, P: Prefilter> RegexEngine<N, P> {
97 pub fn with_plugins(rules: Vec<Rule>, normalizer: N, prefilter: P) -> anyhow::Result<Self> {
103 let patterns: Vec<String> = rules.iter().map(|r| r.pattern.clone()).collect();
104 let matcher = RegexMatcher::with_plugins(patterns, normalizer, prefilter)?;
105 Ok(Self { matcher, rules })
106 }
107}
108
109impl<N: Normalizer, P: Prefilter> RuleEngine for RegexEngine<N, P> {
110 fn check(&self, command: &str) -> Decision {
111 let matches = self.matcher.check(command);
112 if matches.is_empty() {
113 return Decision::Allow;
114 }
115
116 let mut best_warn: Option<&Rule> = None;
117
118 for idx in matches {
119 let rule = &self.rules[idx];
120 match rule.severity {
121 Severity::Block => return Decision::from_rule(rule),
122 Severity::Warn if best_warn.is_none() => best_warn = Some(rule),
123 Severity::Warn => {}
124 }
125 }
126
127 best_warn.map_or(Decision::Allow, Decision::from_rule)
128 }
129
130 fn rules(&self) -> &[Rule] {
131 &self.rules
132 }
133}
134
135#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::config;
143 use crate::model::Category;
144
145 fn engine() -> RegexEngine {
146 RegexEngine::new(config::default_rules()).unwrap()
147 }
148
149 fn assert_blocks(cmd: &str) {
150 let e = engine();
151 match e.check(cmd) {
152 Decision::Block { .. } => {}
153 other => panic!("expected Block for '{cmd}', got {other}"),
154 }
155 }
156
157 fn assert_warns(cmd: &str) {
158 let e = engine();
159 match e.check(cmd) {
160 Decision::Warn { .. } => {}
161 other => panic!("expected Warn for '{cmd}', got {other}"),
162 }
163 }
164
165 fn assert_allows(cmd: &str) {
166 let e = engine();
167 match e.check(cmd) {
168 Decision::Allow => {}
169 other => panic!("expected Allow for '{cmd}', got {other}"),
170 }
171 }
172
173 #[test]
176 fn path_normalizer_strips_nix_store_path() {
177 let n = PathNormalizer;
178 let result = n.normalize("/nix/store/abc123-pkg-1.0/bin/guardrail check");
179 assert_eq!(&*result, "guardrail check");
180 assert!(matches!(result, Cow::Owned(_)));
181 }
182
183 #[test]
184 fn path_normalizer_borrows_when_no_path() {
185 let n = PathNormalizer;
186 let result = n.normalize("cargo test");
187 assert_eq!(&*result, "cargo test");
188 assert!(matches!(result, Cow::Borrowed(_)));
189 }
190
191 #[test]
192 fn path_normalizer_strips_multiple_nix_paths() {
193 let n = PathNormalizer;
194 let result =
195 n.normalize("/nix/store/abc-foo-1.0/bin/cmd1 && /nix/store/def-bar-2.0/bin/cmd2");
196 assert_eq!(&*result, "cmd1 && cmd2");
197 }
198
199 #[test]
200 fn path_normalizer_strips_usr_bin() {
201 let n = PathNormalizer;
202 let result = n.normalize("/usr/bin/rm -rf /");
203 assert_eq!(&*result, "rm -rf /");
204 assert!(matches!(result, Cow::Owned(_)));
205 }
206
207 #[test]
208 fn path_normalizer_strips_usr_local_bin() {
209 let n = PathNormalizer;
210 let result = n.normalize("/usr/local/bin/terraform destroy");
211 assert_eq!(&*result, "terraform destroy");
212 }
213
214 #[test]
215 fn path_normalizer_strips_bin() {
216 let n = PathNormalizer;
217 let result = n.normalize("/bin/rm -rf /");
218 assert_eq!(&*result, "rm -rf /");
219 }
220
221 #[test]
222 fn path_normalizer_strips_sbin() {
223 let n = PathNormalizer;
224 let result = n.normalize("/sbin/mkfs.ext4 /dev/sda1");
225 assert_eq!(&*result, "mkfs.ext4 /dev/sda1");
226 }
227
228 #[test]
229 fn identity_normalizer_is_noop() {
230 let n = IdentityNormalizer;
231 let result = n.normalize("anything");
232 assert!(matches!(result, Cow::Borrowed("anything")));
233 }
234
235 #[test]
238 fn sql_comment_stripper_block_comment() {
239 let n = SqlCommentStripper;
240 let result = n.normalize("DELETE/**/FROM users");
241 assert_eq!(result.trim(), "DELETE FROM users");
242 }
243
244 #[test]
245 fn sql_comment_stripper_sneaky_block_comment() {
246 let n = SqlCommentStripper;
247 let result = n.normalize("DROP/* sneaky */TABLE users");
248 assert_eq!(result.trim(), "DROP TABLE users");
249 }
250
251 #[test]
252 fn sql_comment_stripper_line_comment() {
253 let n = SqlCommentStripper;
254 let result = n.normalize("DROP TABLE -- this is a comment\nusers");
255 assert!(result.contains("DROP TABLE"));
256 }
257
258 #[test]
259 fn sql_comment_stripper_preserves_cli_flags() {
260 let n = SqlCommentStripper;
261 let result = n.normalize("cargo build -- --release");
262 assert_eq!(&*result, "cargo build -- --release");
263 assert!(matches!(result, Cow::Borrowed(_)));
264 }
265
266 #[test]
267 fn sql_comment_stripper_no_comments() {
268 let n = SqlCommentStripper;
269 let result = n.normalize("SELECT * FROM users");
270 assert!(matches!(result, Cow::Borrowed(_)));
271 }
272
273 #[test]
276 fn chained_normalizer_chains_path_and_sql() {
277 let n = ChainedNormalizer { first: PathNormalizer, second: SqlCommentStripper };
278 let result = n.normalize("/usr/bin/psql -c 'DROP/**/TABLE users'");
279 assert!(result.contains("DROP"));
280 assert!(result.contains("TABLE"));
281 assert!(!result.contains("/usr/bin/"));
282 }
283
284 #[test]
285 fn chained_normalizer_borrows_when_clean() {
286 let n: ProductionNormalizer = ChainedNormalizer { first: PathNormalizer, second: SqlCommentStripper };
287 let result = n.normalize("cargo test");
288 assert!(matches!(result, Cow::Borrowed(_)));
289 }
290
291 #[test]
292 fn chained_normalizer_only_first_transforms() {
293 let n = ChainedNormalizer { first: PathNormalizer, second: SqlCommentStripper };
294 let result = n.normalize("/usr/bin/ls -la");
295 assert_eq!(&*result, "ls -la");
296 }
297
298 #[test]
299 fn chained_normalizer_only_second_transforms() {
300 let n = ChainedNormalizer { first: PathNormalizer, second: SqlCommentStripper };
301 let result = n.normalize("DROP/**/TABLE users");
302 assert!(result.contains("DROP"));
303 assert!(result.contains("TABLE"));
304 }
305
306 #[test]
307 fn chained_normalizer_identity_is_noop() {
308 let n = ChainedNormalizer { first: IdentityNormalizer, second: IdentityNormalizer };
309 let result = n.normalize("anything");
310 assert!(matches!(result, Cow::Borrowed("anything")));
311 }
312
313 #[test]
316 fn prefix_prefilter_safe_commands() {
317 let p = PrefixPrefilter;
318 assert!(p.is_safe("ls -la"));
319 assert!(p.is_safe("cat file.txt"));
320 assert!(p.is_safe("rg pattern ."));
321 assert!(p.is_safe("wc -l file"));
322 assert!(p.is_safe("head -5 file"));
323 }
324
325 #[test]
326 fn prefix_prefilter_dangerous_commands() {
327 let p = PrefixPrefilter;
328 assert!(!p.is_safe("rm -rf /"));
329 assert!(!p.is_safe("git push --force"));
330 assert!(!p.is_safe("kubectl delete namespace prod"));
331 assert!(!p.is_safe("terraform destroy"));
332 }
333
334 #[test]
335 fn prefix_prefilter_sql_keywords() {
336 let p = PrefixPrefilter;
337 assert!(!p.is_safe("echo 'DROP TABLE users' | psql"));
338 }
339
340 #[test]
341 fn null_prefilter_never_safe() {
342 let p = NullPrefilter;
343 assert!(!p.is_safe("ls -la"));
344 assert!(!p.is_safe("cargo test"));
345 }
346
347 #[test]
350 fn engine_with_null_prefilter_checks_everything() {
351 let rules = config::default_rules();
352 let normalizer = ChainedNormalizer { first: PathNormalizer, second: SqlCommentStripper };
353 let engine =
354 RegexEngine::with_plugins(rules, normalizer, NullPrefilter).unwrap();
355 assert!(matches!(engine.check("ls -la"), Decision::Allow));
356 assert!(matches!(engine.check("rm -rf /"), Decision::Block { .. }));
357 }
358
359 #[test]
360 fn engine_with_identity_normalizer_no_nix_strip() {
361 let rules = config::default_rules();
362 let engine =
363 RegexEngine::with_plugins(rules, IdentityNormalizer, PrefixPrefilter).unwrap();
364 assert!(matches!(
365 engine.check("/nix/store/abc123-coreutils-9.0/bin/rm -rf /"),
366 Decision::Allow
367 ));
368 }
369
370 #[test]
371 fn engine_debug_impl() {
372 let engine = engine();
373 let debug = format!("{engine:?}");
374 assert!(debug.contains("RegexEngine"));
375 assert!(debug.contains("rule_count"));
376 }
377
378 #[test]
381 fn nix_shell_wrapped_mkfs_not_blocked() {
382 let cmd = "/nix/store/abc123-e2fsprogs-1.47/bin/crate2nix generate";
383 assert_allows(cmd);
384 }
385
386 #[test]
387 fn actual_mkfs_still_blocked() {
388 assert_blocks("mkfs.ext4 /dev/sda1");
389 assert_blocks("sudo mkfs.ext4 /dev/sda1");
390 }
391
392 #[test]
393 fn nix_store_path_with_real_danger() {
394 assert_blocks("/nix/store/abc123-coreutils-9.0/bin/rm -rf /");
395 }
396
397 #[test]
398 fn usr_bin_rm_blocked() {
399 assert_blocks("/usr/bin/rm -rf /");
400 }
401
402 #[test]
403 fn sbin_mkfs_blocked() {
404 assert_blocks("/sbin/mkfs.ext4 /dev/sda1");
405 }
406
407 #[test]
408 fn usr_local_bin_terraform_blocked() {
409 assert_blocks("/usr/local/bin/terraform destroy");
410 }
411
412 #[test]
413 fn bin_rm_blocked() {
414 assert_blocks("/bin/rm -rf /");
415 }
416
417 #[test] fn rm_rf_root_blocked() { assert_blocks("rm -rf /"); }
420 #[test] fn rm_rf_root_var_blocked() { assert_blocks("rm -rf /"); }
421 #[test] fn rm_rf_home_blocked() { assert_blocks("rm -rf ~"); }
422 #[test] fn rm_rf_home_var_blocked() { assert_blocks("rm -rf $HOME"); }
423 #[test] fn rm_rf_cwd_blocked() { assert_blocks("rm -rf ."); }
424 #[test] fn rm_rf_target_allowed() { assert_allows("rm -rf ./target"); }
425 #[test] fn rm_rf_subdir_allowed() { assert_allows("rm -rf ~/code/old-project"); }
426 #[test] fn rm_single_file_allowed() { assert_allows("rm file.txt"); }
427 #[test] fn dd_disk_blocked() { assert_blocks("dd if=/dev/zero of=/dev/sda bs=1M"); }
428 #[test] fn dd_file_allowed() { assert_allows("dd if=input.img of=output.img"); }
429 #[test] fn mkfs_blocked() { assert_blocks("mkfs.ext4 /dev/sda1"); }
430
431 #[test] fn force_push_main_blocked() { assert_blocks("git push --force origin main"); }
434 #[test] fn force_push_master_blocked() { assert_blocks("git push --force origin master"); }
435 #[test] fn force_push_bare_blocked() { assert_blocks("git push --force"); }
436 #[test] fn force_push_feature_allowed() { assert_allows("git push --force origin feature-xyz"); }
437 #[test] fn normal_push_allowed() { assert_allows("git push origin main"); }
438 #[test] fn reset_hard_warned() { assert_warns("git reset --hard HEAD~1"); }
439 #[test] fn reset_soft_allowed() { assert_allows("git reset --soft HEAD~1"); }
440 #[test] fn clean_force_warned() { assert_warns("git clean -fd"); }
441 #[test] fn branch_force_delete_warned() { assert_warns("git branch -D old-branch"); }
442 #[test] fn branch_delete_allowed() { assert_allows("git branch -d merged-branch"); }
443
444 #[test] fn drop_table_blocked() { assert_blocks("psql -c 'DROP TABLE users'"); }
447 #[test] fn drop_table_lower_blocked() { assert_blocks("psql -c 'drop table users'"); }
448 #[test] fn drop_database_blocked() { assert_blocks("psql -c 'DROP DATABASE mydb'"); }
449 #[test] fn drop_schema_blocked() { assert_blocks("mysql -e 'DROP SCHEMA test'"); }
450 #[test] fn truncate_blocked() { assert_blocks("psql -c 'TRUNCATE TABLE logs'"); }
451 #[test] fn delete_no_where_blocked() { assert_blocks("psql -c 'DELETE FROM users'"); }
452 #[test] fn delete_with_where_allowed() { assert_allows("psql -c 'DELETE FROM users WHERE id = 5'"); }
453 #[test] fn select_allowed() { assert_allows("psql -c 'SELECT * FROM users'"); }
454 #[test] fn create_table_allowed() { assert_allows("psql -c 'CREATE TABLE new_table (id int)'"); }
455 #[test] fn insert_allowed() { assert_allows("psql -c 'INSERT INTO users VALUES (1)'"); }
456
457 #[test] fn drop_table_single_quotes() { assert_blocks("psql -c 'DROP TABLE users'"); }
460 #[test] fn drop_table_double_quotes() { assert_blocks(r#"psql -c "DROP TABLE users""#); }
461 #[test] fn drop_table_heredoc() { assert_blocks("psql <<EOF\nDROP TABLE users;\nEOF"); }
462 #[test] fn drop_table_pipe() { assert_blocks("echo 'DROP TABLE users' | psql"); }
463 #[test] fn drop_table_e_flag() { assert_blocks("mysql -e 'DROP TABLE users'"); }
464 #[test] fn drop_table_multiline() { assert_blocks("psql -c '\nDROP TABLE\nusers\n'"); }
465 #[test] fn truncate_semicolon() { assert_blocks("psql -c 'TRUNCATE TABLE logs;'"); }
466 #[test] fn delete_from_semicolon() { assert_blocks("psql -c 'DELETE FROM users;'"); }
467
468 #[test] fn drop_table_block_comment() { assert_blocks("psql -c 'DROP/**/TABLE users'"); }
471 #[test] fn drop_sneaky_comment() { assert_blocks("psql -c 'DROP/* sneaky */TABLE users'"); }
472 #[test] fn delete_block_comment() { assert_blocks("psql -c 'DELETE/**/FROM users'"); }
473 #[test] fn select_star_not_blocked() { assert_allows("psql -c 'SELECT * FROM users'"); }
474 #[test] fn create_not_blocked() { assert_allows("psql -c 'CREATE TABLE t (id int)'"); }
475 #[test] fn alter_add_col_allowed() { assert_allows("psql -c 'ALTER TABLE t ADD COLUMN name text'"); }
476
477 #[test] fn kubectl_delete_ns_blocked() { assert_blocks("kubectl delete namespace production"); }
480 #[test] fn kubectl_delete_ns_short() { assert_blocks("kubectl delete ns staging"); }
481 #[test] fn kubectl_delete_all_blocked() { assert_blocks("kubectl delete pods --all"); }
482 #[test] fn kubectl_delete_pod_allowed() { assert_allows("kubectl delete pod stuck-pod -n staging"); }
483 #[test] fn kubectl_get_allowed() { assert_allows("kubectl get pods -n production"); }
484 #[test] fn helm_uninstall_prod_blocked() { assert_blocks("helm uninstall myapp -n production"); }
485 #[test] fn helm_uninstall_staging_allowed() { assert_allows("helm uninstall myapp -n staging"); }
486
487 #[test] fn nix_gc_delete_warned() { assert_warns("nix-collect-garbage -d"); }
490 #[test] fn nix_store_gc_warned() { assert_warns("nix store gc"); }
491 #[test] fn nix_build_allowed() { assert_allows("nix build .#default"); }
492
493 #[test] fn docker_system_prune_warned() { assert_warns("docker system prune -af"); }
496 #[test] fn docker_volume_prune_warned() { assert_warns("docker volume prune -f"); }
497 #[test] fn docker_build_allowed() { assert_allows("docker build -t myimage ."); }
498
499 #[test] fn sops_decrypt_pipe_warned() { assert_warns("sops -d secrets.yaml | cat"); }
502 #[test] fn sops_decrypt_file_allowed() { assert_allows("sops -d secrets.yaml > decrypted.yaml"); }
503 #[test] fn echo_token_warned() { assert_warns("echo $GITHUB_TOKEN"); }
504 #[test] fn echo_normal_allowed() { assert_allows("echo hello world"); }
505
506 #[test] fn terraform_destroy_blocked() { assert_blocks("terraform destroy"); }
509 #[test] fn terraform_apply_auto_warned() { assert_warns("terraform apply -auto-approve"); }
510 #[test] fn terraform_plan_allowed() { assert_allows("terraform plan"); }
511 #[test] fn terraform_apply_allowed() { assert_allows("terraform apply"); }
512 #[test] fn terraform_force_unlock_blocked() { assert_blocks("terraform force-unlock abc123"); }
513 #[test] fn terraform_state_rm_blocked() { assert_blocks("terraform state rm aws_instance.web"); }
514 #[test] fn terraform_state_list_allowed() { assert_allows("terraform state list"); }
515 #[test] fn pulumi_destroy_blocked() { assert_blocks("pulumi destroy"); }
516 #[test] fn pulumi_up_allowed() { assert_allows("pulumi up"); }
517
518 #[test] fn flux_uninstall_blocked() { assert_blocks("flux uninstall"); }
521 #[test] fn flux_delete_source_warned() { assert_warns("flux delete source git my-repo"); }
522 #[test] fn flux_delete_ks_warned() { assert_warns("flux delete kustomization my-app"); }
523 #[test] fn flux_reconcile_allowed() { assert_allows("flux reconcile kustomization my-app"); }
524 #[test] fn flux_get_allowed() { assert_allows("flux get kustomizations"); }
525
526 #[test]
529 fn engine_compiles_all_defaults() {
530 let e = engine();
531 assert!(e.rule_count() >= 30);
532 }
533
534 #[test]
535 fn rules_returns_slice() {
536 let e = engine();
537 let rules: &[Rule] = e.rules();
538 assert!(rules.len() >= 60, "expected 60+ default rules, got {}", rules.len());
539 }
540
541 #[test]
542 fn invalid_regex_rejected() {
543 let rules = vec![Rule::builder("bad", "[invalid").build()];
544 assert!(RegexEngine::new(rules).is_err());
545 }
546
547 #[test]
548 fn block_takes_priority_over_warn() {
549 let rules = vec![
550 Rule::builder("warn-rule", r"rm\s+-rf")
551 .severity(Severity::Warn)
552 .build(),
553 Rule::builder("block-rule", r"rm\s+-rf")
554 .severity(Severity::Block)
555 .build(),
556 ];
557 let engine = RegexEngine::new(rules).unwrap();
558 match engine.check("rm -rf /tmp/test") {
559 Decision::Block { rule, .. } => assert_eq!(rule, "block-rule"),
560 other => panic!("expected Block, got {other}"),
561 }
562 }
563
564 #[test] fn var_as_command_warned() { assert_warns("$cmd --force"); }
567 #[test] fn indirect_eval_var_warned() { assert_warns(r#"eval "$user_input""#); }
568 #[test] fn bash_c_var_warned() { assert_warns(r#"bash -c "$cmd""#); }
569 #[test] fn backtick_rm_warned() { assert_warns("echo `rm -rf /tmp`"); }
570 #[test] fn backtick_date_allowed() { assert_allows("echo `date`"); }
571 #[test] fn echo_dollar_home_allowed() { assert_allows("echo $HOME"); }
572
573 #[test]
576 fn prefilter_dollar_not_safe() {
577 let p = PrefixPrefilter;
578 assert!(!p.is_safe("$cmd --force"));
579 }
580
581 #[test]
582 fn prefilter_backtick_not_safe() {
583 let p = PrefixPrefilter;
584 assert!(!p.is_safe("echo `rm -rf /`"));
585 }
586
587 #[test]
588 fn prefilter_sql_block_comment_not_safe() {
589 let p = PrefixPrefilter;
590 assert!(!p.is_safe("SELECT /*evil*/ 1"));
591 }
592
593 #[test]
594 fn prefilter_sql_line_comment_not_safe() {
595 let p = PrefixPrefilter;
596 assert!(!p.is_safe("SELECT 1 -- comment"));
597 }
598
599 #[test]
600 fn prefilter_cli_double_dash_is_safe() {
601 let p = PrefixPrefilter;
602 assert!(p.is_safe("rg --release pattern ."));
603 }
604
605 #[test]
608 fn empty_command_allowed() {
609 assert_allows("");
610 }
611
612 #[test]
613 fn whitespace_only_command_allowed() {
614 assert_allows(" ");
615 }
616
617 #[test]
618 fn unicode_safe_command_allowed() {
619 assert_allows("echo 'cafe resume'");
620 }
621
622 #[test]
623 fn very_long_safe_command_allowed() {
624 let long = format!("cargo {}", "build ".repeat(500));
625 assert_allows(&long);
626 }
627
628 #[test]
629 fn contains_ascii_ci_matches() {
630 assert!(contains_ascii_ci(b"hello DROP TABLE world", b"DROP "));
631 assert!(contains_ascii_ci(b"hello drop table world", b"DROP "));
632 assert!(contains_ascii_ci(b"hello Drop Table world", b"DROP "));
633 }
634
635 #[test]
636 fn contains_ascii_ci_no_match() {
637 assert!(!contains_ascii_ci(b"hello world", b"DROP "));
638 assert!(!contains_ascii_ci(b"DROPX", b"DROP "));
639 }
640
641 #[test]
642 fn contains_ascii_ci_empty() {
643 assert!(contains_ascii_ci(b"anything", b""));
644 assert!(!contains_ascii_ci(b"", b"DROP "));
645 }
646
647 #[test]
650 fn empty_rules_engine() {
651 let engine = RegexEngine::new(vec![]).unwrap();
652 assert!(matches!(engine.check("rm -rf /"), Decision::Allow));
653 assert_eq!(engine.rule_count(), 0);
654 assert!(engine.rules().is_empty());
655 }
656
657 #[test]
658 fn warn_only_engine() {
659 let rules = vec![
660 Rule::builder("w1", r"rm\s+-rf").severity(Severity::Warn).build(),
661 Rule::builder("w2", r"delete").severity(Severity::Warn).build(),
662 ];
663 let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
664 match engine.check("rm -rf /tmp") {
665 Decision::Warn { rule, .. } => assert_eq!(rule, "w1"),
666 other => panic!("expected Warn, got {other}"),
667 }
668 }
669
670 #[test]
671 fn multiple_warn_returns_first() {
672 let rules = vec![
673 Rule::builder("first-warn", r"rm").severity(Severity::Warn).build(),
674 Rule::builder("second-warn", r"rm\s+-rf").severity(Severity::Warn).build(),
675 ];
676 let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
677 match engine.check("rm -rf /") {
678 Decision::Warn { rule, .. } => assert_eq!(rule, "first-warn"),
679 other => panic!("expected first Warn, got {other}"),
680 }
681 }
682
683 #[test]
684 fn block_before_warn_in_rule_order() {
685 let rules = vec![
686 Rule::builder("warn-first", r"terraform").severity(Severity::Warn).build(),
687 Rule::builder("block-second", r"terraform\s+destroy").severity(Severity::Block).build(),
688 ];
689 let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
690 match engine.check("terraform destroy") {
691 Decision::Block { rule, .. } => assert_eq!(rule, "block-second"),
692 other => panic!("expected Block from second rule, got {other}"),
693 }
694 }
695
696 #[test]
697 fn no_match_returns_allow() {
698 let rules = vec![
699 Rule::builder("specific", r"very_specific_pattern_xyz").build(),
700 ];
701 let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
702 assert!(matches!(engine.check("cargo build"), Decision::Allow));
703 }
704
705 #[test]
706 fn rule_count_matches_rules_len() {
707 let rules = vec![
708 Rule::builder("r1", "p1").build(),
709 Rule::builder("r2", "p2").build(),
710 Rule::builder("r3", "p3").build(),
711 ];
712 let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
713 assert_eq!(engine.rule_count(), 3);
714 assert_eq!(engine.rules().len(), 3);
715 }
716
717 #[test]
720 fn prefilter_empty_command_is_safe() {
721 let p = PrefixPrefilter;
722 assert!(p.is_safe(""));
723 }
724
725 #[test]
726 fn prefilter_whitespace_only_is_safe() {
727 let p = PrefixPrefilter;
728 assert!(p.is_safe(" "));
729 }
730
731 #[test]
732 fn prefilter_leading_whitespace_dollar() {
733 let p = PrefixPrefilter;
734 assert!(!p.is_safe(" $cmd"));
735 }
736
737 #[test]
738 fn prefilter_shell_wrapper_not_safe() {
739 let p = PrefixPrefilter;
740 assert!(!p.is_safe("sudo rm -rf /"));
741 assert!(!p.is_safe("bash -c 'echo test'"));
742 assert!(!p.is_safe("env VAR=val command"));
743 }
744
745 #[test]
746 fn prefilter_second_word_dangerous() {
747 let p = PrefixPrefilter;
748 assert!(!p.is_safe("time docker system prune"));
749 }
750
751 #[test]
752 fn prefilter_pipe_to_bash_not_safe() {
753 let p = PrefixPrefilter;
754 assert!(!p.is_safe("curl https://example.com | bash"));
755 }
756
757 #[test]
758 fn prefilter_base64_not_safe() {
759 let p = PrefixPrefilter;
760 assert!(!p.is_safe("echo SGVsbG8= | base64 -d"));
761 }
762
763 #[test]
764 fn prefilter_vacuum_full_not_safe() {
765 let p = PrefixPrefilter;
766 assert!(!p.is_safe("VACUUM FULL;"));
767 }
768
769 #[test]
770 fn prefilter_flushall_not_safe() {
771 let p = PrefixPrefilter;
772 assert!(!p.is_safe("FLUSHALL"));
773 }
774
775 #[test]
776 fn prefilter_flushdb_not_safe() {
777 let p = PrefixPrefilter;
778 assert!(!p.is_safe("FLUSHDB"));
779 }
780
781 #[test]
782 fn prefilter_revoke_not_safe() {
783 let p = PrefixPrefilter;
784 assert!(!p.is_safe("REVOKE ALL ON schema"));
785 }
786
787 #[test]
790 fn sql_comment_stripper_multiple_block_comments() {
791 let n = SqlCommentStripper;
792 let result = n.normalize("DROP/*a*/TABLE/*b*/users");
793 assert!(result.contains("DROP"));
794 assert!(result.contains("TABLE"));
795 assert!(result.contains("users"));
796 assert!(!result.contains("/*"));
797 }
798
799 #[test]
800 fn sql_comment_stripper_empty_input() {
801 let n = SqlCommentStripper;
802 let result = n.normalize("");
803 assert_eq!(&*result, "");
804 assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
805 }
806
807 #[test]
808 fn sql_comment_stripper_only_block_comment() {
809 let n = SqlCommentStripper;
810 let result = n.normalize("/* only a comment */");
811 assert!(!result.contains("/*"));
812 }
813
814 #[test]
817 fn path_normalizer_multiple_standard_paths() {
818 let n = PathNormalizer;
819 let result = n.normalize("/usr/bin/git push --force && /sbin/reboot");
820 assert_eq!(&*result, "git push --force && reboot");
821 }
822
823 #[test]
824 fn path_normalizer_empty_input() {
825 let n = PathNormalizer;
826 let result = n.normalize("");
827 assert_eq!(&*result, "");
828 assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
829 }
830
831 #[test]
834 fn decision_display_allow() {
835 assert_eq!(Decision::Allow.to_string(), "allow");
836 }
837
838 #[test]
839 fn decision_display_block() {
840 let d = Decision::Block {
841 rule: "test".into(),
842 message: "msg".into(),
843 };
844 assert_eq!(d.to_string(), "block [test]: msg");
845 }
846
847 #[test]
848 fn decision_display_warn() {
849 let d = Decision::Warn {
850 rule: "test".into(),
851 message: "msg".into(),
852 };
853 assert_eq!(d.to_string(), "warn [test]: msg");
854 }
855
856 #[test]
857 fn severity_display() {
858 assert_eq!(Severity::Block.to_string(), "block");
859 assert_eq!(Severity::Warn.to_string(), "warn");
860 }
861
862 #[test]
863 fn category_display() {
864 assert_eq!(Category::Filesystem.to_string(), "filesystem");
865 assert_eq!(Category::Git.to_string(), "git");
866 assert_eq!(Category::Cloud.to_string(), "cloud");
867 assert_eq!(Category::Nosql.to_string(), "nosql");
868 }
869
870 #[test]
873 fn prefix_set_is_non_empty() {
874 let set = PrefixPrefilter::prefix_set();
875 assert!(!set.is_empty());
876 }
877
878 #[test]
879 fn prefix_set_contains_known_prefixes() {
880 let set = PrefixPrefilter::prefix_set();
881 for expected in ["rm", "git", "kubectl", "terraform", "docker", "aws"] {
882 assert!(set.contains(expected), "prefix_set missing '{expected}'");
883 }
884 }
885
886 #[test]
887 fn prefix_set_does_not_contain_safe_commands() {
888 let set = PrefixPrefilter::prefix_set();
889 for safe in ["ls", "cat", "rg", "wc", "head", "tail", "grep"] {
890 assert!(!set.contains(safe), "prefix_set should not contain '{safe}'");
891 }
892 }
893
894 #[test]
897 fn rule_count_default_impl_equals_rules_len() {
898 let rules = vec![
899 Rule::builder("a", "a").build(),
900 Rule::builder("b", "b").build(),
901 ];
902 let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
903 assert_eq!(engine.rule_count(), engine.rules().len());
904 }
905
906 #[test]
909 fn prefilter_third_word_dangerous() {
910 let p = PrefixPrefilter;
911 assert!(!p.is_safe("some other rm -rf /"));
912 }
913
914 #[test]
915 fn prefilter_fourth_word_not_checked() {
916 let p = PrefixPrefilter;
917 assert!(p.is_safe("one two three rm -rf /"));
918 }
919
920 #[test]
923 fn prefilter_sql_line_comment_tab_not_safe() {
924 let p = PrefixPrefilter;
925 assert!(!p.is_safe("SELECT 1 --\thidden"));
926 }
927
928 use std::borrow::Cow;
930}