1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::model::{Config, Finding, Item, Severity, Span};
5use crate::rules::Rule;
6
7pub struct DuplicateHost;
9
10impl Rule for DuplicateHost {
11 fn name(&self) -> &'static str {
12 "duplicate-host"
13 }
14
15 fn check(&self, config: &Config) -> Vec<Finding> {
16 let mut seen: HashMap<String, Span> = HashMap::new();
17 let mut findings = Vec::new();
18
19 for item in &config.items {
20 if let Item::HostBlock { patterns, span, .. } = item {
21 for pattern in patterns {
22 if let Some(first_span) = seen.get(pattern) {
23 findings.push(
24 Finding::new(
25 Severity::Warning,
26 "duplicate-host",
27 "DUP_HOST",
28 format!(
29 "duplicate Host block '{}' (first seen at line {})",
30 pattern, first_span.line
31 ),
32 span.clone(),
33 )
34 .with_hint("remove one of the duplicate Host blocks"),
35 );
36 } else {
37 seen.insert(pattern.clone(), span.clone());
38 }
39 }
40 }
41 }
42
43 findings
44 }
45}
46
47pub struct IdentityFileExists;
50
51impl Rule for IdentityFileExists {
52 fn name(&self) -> &'static str {
53 "identity-file-exists"
54 }
55
56 fn check(&self, config: &Config) -> Vec<Finding> {
57 let mut findings = Vec::new();
58 collect_identity_findings(&config.items, &mut findings);
59 findings
60 }
61}
62
63fn collect_identity_findings(items: &[Item], findings: &mut Vec<Finding>) {
64 for item in items {
65 match item {
66 Item::Directive {
67 key, value, span, ..
68 } if key.eq_ignore_ascii_case("IdentityFile") => {
69 check_identity_file(value, span, findings);
70 }
71 Item::HostBlock { items, .. } | Item::MatchBlock { items, .. } => {
72 collect_identity_findings(items, findings);
73 }
74 _ => {}
75 }
76 }
77}
78
79fn check_identity_file(value: &str, span: &Span, findings: &mut Vec<Finding>) {
80 if value.contains('%') || value.contains("${") {
82 return;
83 }
84
85 let expanded = if let Some(rest) = value.strip_prefix("~/") {
86 if let Some(home) = dirs::home_dir() {
87 home.join(rest)
88 } else {
89 return; }
91 } else {
92 Path::new(value).to_path_buf()
93 };
94
95 if !expanded.exists() {
96 findings.push(
97 Finding::new(
98 Severity::Error,
99 "identity-file-exists",
100 "MISSING_IDENTITY",
101 format!("IdentityFile not found: {}", value),
102 span.clone(),
103 )
104 .with_hint("check the path or remove the directive"),
105 );
106 }
107}
108
109pub struct WildcardHostOrder;
112
113impl Rule for WildcardHostOrder {
114 fn name(&self) -> &'static str {
115 "wildcard-host-order"
116 }
117
118 fn check(&self, config: &Config) -> Vec<Finding> {
119 let mut findings = Vec::new();
120 let mut wildcard_span: Option<Span> = None;
121
122 for item in &config.items {
123 if let Item::HostBlock { patterns, span, .. } = item {
124 for pattern in patterns {
125 if pattern == "*" {
126 if wildcard_span.is_none() {
127 wildcard_span = Some(span.clone());
128 }
129 } else if let Some(ref ws) = wildcard_span {
130 findings.push(Finding::new(
131 Severity::Warning,
132 "wildcard-host-order",
133 "WILDCARD_ORDER",
134 format!(
135 "Host '{}' appears after 'Host *' (line {}); it will never match because Host * already matched",
136 pattern, ws.line
137 ),
138 span.clone(),
139 ).with_hint("move Host * to the end of the file"));
140 }
141 }
142 }
143 }
144
145 findings
146 }
147}
148
149pub struct DeprecatedWeakAlgorithms;
150
151const ALGORITHM_DIRECTIVES: &[&str] = &[
153 "ciphers",
154 "macs",
155 "kexalgorithms",
156 "hostkeyalgorithms",
157 "pubkeyacceptedalgorithms",
158 "pubkeyacceptedkeytypes",
159 "casignaturealgorithms",
160];
161
162const WEAK_ALGORITHMS: &[&str] = &[
164 "3des-cbc",
166 "blowfish-cbc",
167 "cast128-cbc",
168 "arcfour",
169 "arcfour128",
170 "arcfour256",
171 "rijndael-cbc@lysator.liu.se",
172 "hmac-md5",
174 "hmac-md5-96",
175 "hmac-md5-etm@openssh.com",
176 "hmac-md5-96-etm@openssh.com",
177 "hmac-ripemd160",
178 "hmac-ripemd160-etm@openssh.com",
179 "hmac-sha1-96",
180 "hmac-sha1-96-etm@openssh.com",
181 "umac-64@openssh.com",
182 "umac-64-etm@openssh.com",
183 "diffie-hellman-group1-sha1",
185 "diffie-hellman-group14-sha1",
186 "diffie-hellman-group-exchange-sha1",
187 "ssh-dss",
189 "ssh-rsa",
190];
191
192impl Rule for DeprecatedWeakAlgorithms {
193 fn name(&self) -> &'static str {
194 "deprecated-weak-algorithms"
195 }
196
197 fn check(&self, config: &Config) -> Vec<Finding> {
198 let mut findings = Vec::new();
199 collect_weak_algorithm_findings(&config.items, &mut findings);
200 findings
201 }
202}
203
204fn collect_weak_algorithm_findings(items: &[Item], findings: &mut Vec<Finding>) {
205 for item in items {
206 match item {
207 Item::Directive {
208 key, value, span, ..
209 } if ALGORITHM_DIRECTIVES
210 .iter()
211 .any(|d| d.eq_ignore_ascii_case(key)) =>
212 {
213 check_algorithms(key, value, span, findings);
214 }
215 Item::HostBlock { items, .. } | Item::MatchBlock { items, .. } => {
216 collect_weak_algorithm_findings(items, findings);
217 }
218 _ => {}
219 }
220 }
221}
222
223fn check_algorithms(key: &str, value: &str, span: &Span, findings: &mut Vec<Finding>) {
224 for algo in value.split(',') {
225 let algo = algo.trim();
226 if algo.is_empty() {
227 continue;
228 }
229 let bare = algo.trim_start_matches(['+', '-', '^']);
231 if WEAK_ALGORITHMS.iter().any(|w| w.eq_ignore_ascii_case(bare)) {
232 findings.push(
233 Finding::new(
234 Severity::Warning,
235 "deprecated-weak-algorithms",
236 "WEAK_ALGO",
237 format!("weak or deprecated algorithm '{}' in {}", bare, key),
238 span.clone(),
239 )
240 .with_hint(format!("remove '{}' and use a stronger algorithm", bare)),
241 );
242 }
243 }
244}
245
246pub struct DuplicateDirectives;
247
248impl Rule for DuplicateDirectives {
249 fn name(&self) -> &'static str {
250 "duplicate-directives"
251 }
252
253 fn check(&self, config: &Config) -> Vec<Finding> {
254 let mut findings = Vec::new();
255 collect_duplicate_directives(&config.items, &mut findings);
256 findings
257 }
258}
259
260const MULTI_VALUE_DIRECTIVES: &[&str] = &[
262 "identityfile",
263 "certificatefile",
264 "localforward",
265 "remoteforward",
266 "dynamicforward",
267 "sendenv",
268 "setenv",
269 "match",
270 "host",
271];
272
273fn collect_duplicate_directives(items: &[Item], findings: &mut Vec<Finding>) {
274 check_scope_for_duplicates(items, findings);
275 for item in items {
276 match item {
277 Item::HostBlock { items, .. } | Item::MatchBlock { items, .. } => {
278 check_scope_for_duplicates(items, findings);
279 }
280 _ => {}
281 }
282 }
283}
284
285fn check_scope_for_duplicates(items: &[Item], findings: &mut Vec<Finding>) {
286 let mut seen: HashMap<String, Span> = HashMap::new();
287 for item in items {
288 if let Item::Directive { key, span, .. } = item {
289 let lower = key.to_ascii_lowercase();
290 if MULTI_VALUE_DIRECTIVES.contains(&lower.as_str()) {
291 continue;
292 }
293 if let Some(first_span) = seen.get(&lower) {
294 findings.push(
295 Finding::new(
296 Severity::Warning,
297 "duplicate-directives",
298 "DUP_DIRECTIVE",
299 format!(
300 "duplicate directive '{}' (first seen at line {})",
301 key, first_span.line
302 ),
303 span.clone(),
304 )
305 .with_hint("remove the duplicate; only the first value takes effect"),
306 );
307 } else {
308 seen.insert(lower, span.clone());
309 }
310 }
311 }
312}
313
314pub struct InsecureOption;
320
321const INSECURE_SETTINGS: &[(&str, &str, Severity, &str, &str)] = &[
323 (
324 "stricthostkeychecking",
325 "no",
326 Severity::Warning,
327 "disables host key verification, making connections vulnerable to MITM attacks",
328 "remove this or set to 'accept-new' if you want to auto-accept new keys",
329 ),
330 (
331 "stricthostkeychecking",
332 "off",
333 Severity::Warning,
334 "disables host key verification, making connections vulnerable to MITM attacks",
335 "remove this or set to 'accept-new' if you want to auto-accept new keys",
336 ),
337 (
338 "userknownhostsfile",
339 "/dev/null",
340 Severity::Warning,
341 "discards known host keys, disabling host verification entirely",
342 "remove this to use the default ~/.ssh/known_hosts",
343 ),
344 (
345 "loglevel",
346 "quiet",
347 Severity::Info,
348 "suppresses all SSH log output, making issues hard to debug",
349 "use INFO or VERBOSE for better visibility",
350 ),
351];
352
353const RISKY_ON_WILDCARD: &[(&str, &str, &str)] = &[
355 (
356 "forwardagent",
357 "yes",
358 "exposes your SSH agent to every server; an attacker with root on any server can use your keys",
359 ),
360 (
361 "forwardx11",
362 "yes",
363 "forwards your X11 display to every server, allowing remote keystroke capture",
364 ),
365 (
366 "forwardx11trusted",
367 "yes",
368 "gives every server full access to your X11 display",
369 ),
370];
371
372impl Rule for InsecureOption {
373 fn name(&self) -> &'static str {
374 "insecure-option"
375 }
376
377 fn check(&self, config: &Config) -> Vec<Finding> {
378 let mut findings = Vec::new();
379 check_insecure_directives(&config.items, true, &mut findings);
381 for item in &config.items {
382 match item {
383 Item::HostBlock {
384 patterns, items, ..
385 } => {
386 let is_wildcard = patterns.iter().any(|p| p == "*");
387 check_insecure_directives(items, is_wildcard, &mut findings);
388 }
389 Item::MatchBlock { items, .. } => {
390 check_insecure_directives(items, false, &mut findings);
391 }
392 _ => {}
393 }
394 }
395 findings
396 }
397}
398
399fn check_insecure_directives(items: &[Item], is_global: bool, findings: &mut Vec<Finding>) {
400 for item in items {
401 if let Item::Directive { key, value, span } = item {
402 let key_lower = key.to_ascii_lowercase();
403 let val_lower = value.to_ascii_lowercase();
404
405 for &(directive, bad_val, severity, desc, hint) in INSECURE_SETTINGS {
407 if key_lower == directive && val_lower == bad_val {
408 findings.push(
409 Finding::new(
410 severity,
411 "insecure-option",
412 "INSECURE_OPT",
413 format!("{} {} — {}", key, value, desc),
414 span.clone(),
415 )
416 .with_hint(hint),
417 );
418 }
419 }
420
421 if is_global {
423 for &(directive, bad_val, desc) in RISKY_ON_WILDCARD {
424 if key_lower == directive && val_lower == bad_val {
425 findings.push(
426 Finding::new(
427 Severity::Warning,
428 "insecure-option",
429 "INSECURE_OPT",
430 format!("{} {} on a global/wildcard host — {}", key, value, desc),
431 span.clone(),
432 )
433 .with_hint("set this only on specific hosts you trust, not globally"),
434 );
435 }
436 }
437 }
438 }
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use crate::model::{Config, Item, Span};
446 use std::fs;
447 use tempfile::TempDir;
448
449 #[test]
450 fn no_duplicates_no_findings() {
451 let config = Config {
452 items: vec![
453 Item::HostBlock {
454 patterns: vec!["a".to_string()],
455 span: Span::new(1),
456 items: vec![],
457 },
458 Item::HostBlock {
459 patterns: vec!["b".to_string()],
460 span: Span::new(3),
461 items: vec![],
462 },
463 ],
464 };
465 let findings = DuplicateHost.check(&config);
466 assert!(findings.is_empty());
467 }
468
469 #[test]
470 fn duplicate_host_warns() {
471 let config = Config {
472 items: vec![
473 Item::HostBlock {
474 patterns: vec!["github.com".to_string()],
475 span: Span::new(1),
476 items: vec![],
477 },
478 Item::HostBlock {
479 patterns: vec!["github.com".to_string()],
480 span: Span::new(5),
481 items: vec![],
482 },
483 ],
484 };
485 let findings = DuplicateHost.check(&config);
486 assert_eq!(findings.len(), 1);
487 assert_eq!(findings[0].rule, "duplicate-host");
488 assert!(findings[0].message.contains("first seen at line 1"));
489 }
490
491 #[test]
492 fn identity_file_exists_no_error() {
493 let tmp = TempDir::new().unwrap();
494 let key_path = tmp.path().join("id_test");
495 fs::write(&key_path, "fake key").unwrap();
496
497 let config = Config {
498 items: vec![Item::HostBlock {
499 patterns: vec!["a".to_string()],
500 span: Span::new(1),
501 items: vec![Item::Directive {
502 key: "IdentityFile".into(),
503 value: key_path.to_string_lossy().into_owned(),
504 span: Span::new(2),
505 }],
506 }],
507 };
508 let findings = IdentityFileExists.check(&config);
509 assert!(findings.is_empty());
510 }
511
512 #[test]
513 fn identity_file_missing_errors() {
514 let config = Config {
515 items: vec![Item::Directive {
516 key: "IdentityFile".into(),
517 value: "/nonexistent/path/id_nope".into(),
518 span: Span::new(1),
519 }],
520 };
521 let findings = IdentityFileExists.check(&config);
522 assert_eq!(findings.len(), 1);
523 assert_eq!(findings[0].rule, "identity-file-exists");
524 }
525
526 #[test]
527 fn identity_file_skips_templates() {
528 let config = Config {
529 items: vec![
530 Item::Directive {
531 key: "IdentityFile".into(),
532 value: "~/.ssh/id_%h".into(),
533 span: Span::new(1),
534 },
535 Item::Directive {
536 key: "IdentityFile".into(),
537 value: "${HOME}/.ssh/id_ed25519".into(),
538 span: Span::new(2),
539 },
540 ],
541 };
542 let findings = IdentityFileExists.check(&config);
543 assert!(findings.is_empty());
544 }
545
546 #[test]
547 fn wildcard_after_specific_no_warning() {
548 let config = Config {
549 items: vec![
550 Item::HostBlock {
551 patterns: vec!["github.com".to_string()],
552 span: Span::new(1),
553 items: vec![],
554 },
555 Item::HostBlock {
556 patterns: vec!["*".to_string()],
557 span: Span::new(5),
558 items: vec![],
559 },
560 ],
561 };
562 let findings = WildcardHostOrder.check(&config);
563 assert!(findings.is_empty());
564 }
565
566 #[test]
567 fn wildcard_before_specific_warns() {
568 let config = Config {
569 items: vec![
570 Item::HostBlock {
571 patterns: vec!["*".to_string()],
572 span: Span::new(1),
573 items: vec![],
574 },
575 Item::HostBlock {
576 patterns: vec!["github.com".to_string()],
577 span: Span::new(5),
578 items: vec![],
579 },
580 ],
581 };
582 let findings = WildcardHostOrder.check(&config);
583 assert_eq!(findings.len(), 1);
584 assert_eq!(findings[0].rule, "wildcard-host-order");
585 assert!(findings[0].message.contains("github.com"));
586 }
587
588 #[test]
591 fn weak_cipher_warns() {
592 let config = Config {
593 items: vec![Item::Directive {
594 key: "Ciphers".into(),
595 value: "aes128-ctr,3des-cbc,aes256-gcm@openssh.com".into(),
596 span: Span::new(1),
597 }],
598 };
599 let findings = DeprecatedWeakAlgorithms.check(&config);
600 assert_eq!(findings.len(), 1);
601 assert_eq!(findings[0].code, "WEAK_ALGO");
602 assert!(findings[0].message.contains("3des-cbc"));
603 assert!(findings[0].message.contains("Ciphers"));
604 }
605
606 #[test]
607 fn weak_mac_warns() {
608 let config = Config {
609 items: vec![Item::Directive {
610 key: "MACs".into(),
611 value: "hmac-sha2-256,hmac-md5".into(),
612 span: Span::new(3),
613 }],
614 };
615 let findings = DeprecatedWeakAlgorithms.check(&config);
616 assert_eq!(findings.len(), 1);
617 assert!(findings[0].message.contains("hmac-md5"));
618 }
619
620 #[test]
621 fn weak_kex_warns() {
622 let config = Config {
623 items: vec![Item::Directive {
624 key: "KexAlgorithms".into(),
625 value: "diffie-hellman-group1-sha1".into(),
626 span: Span::new(1),
627 }],
628 };
629 let findings = DeprecatedWeakAlgorithms.check(&config);
630 assert_eq!(findings.len(), 1);
631 assert!(findings[0].message.contains("diffie-hellman-group1-sha1"));
632 }
633
634 #[test]
635 fn weak_host_key_algorithm_warns() {
636 let config = Config {
637 items: vec![Item::Directive {
638 key: "HostKeyAlgorithms".into(),
639 value: "ssh-ed25519,ssh-dss".into(),
640 span: Span::new(2),
641 }],
642 };
643 let findings = DeprecatedWeakAlgorithms.check(&config);
644 assert_eq!(findings.len(), 1);
645 assert!(findings[0].message.contains("ssh-dss"));
646 }
647
648 #[test]
649 fn weak_pubkey_accepted_warns() {
650 let config = Config {
651 items: vec![Item::Directive {
652 key: "PubkeyAcceptedAlgorithms".into(),
653 value: "ssh-rsa,ssh-ed25519".into(),
654 span: Span::new(1),
655 }],
656 };
657 let findings = DeprecatedWeakAlgorithms.check(&config);
658 assert_eq!(findings.len(), 1);
659 assert!(findings[0].message.contains("ssh-rsa"));
660 }
661
662 #[test]
663 fn strong_algorithms_no_warning() {
664 let config = Config {
665 items: vec![
666 Item::Directive {
667 key: "Ciphers".into(),
668 value: "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com".into(),
669 span: Span::new(1),
670 },
671 Item::Directive {
672 key: "MACs".into(),
673 value: "hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com".into(),
674 span: Span::new(2),
675 },
676 Item::Directive {
677 key: "KexAlgorithms".into(),
678 value: "curve25519-sha256,diffie-hellman-group16-sha512".into(),
679 span: Span::new(3),
680 },
681 ],
682 };
683 let findings = DeprecatedWeakAlgorithms.check(&config);
684 assert!(findings.is_empty());
685 }
686
687 #[test]
688 fn multiple_weak_algorithms_multiple_findings() {
689 let config = Config {
690 items: vec![Item::Directive {
691 key: "Ciphers".into(),
692 value: "3des-cbc,arcfour,blowfish-cbc".into(),
693 span: Span::new(1),
694 }],
695 };
696 let findings = DeprecatedWeakAlgorithms.check(&config);
697 assert_eq!(findings.len(), 3);
698 }
699
700 #[test]
701 fn weak_algo_inside_host_block() {
702 let config = Config {
703 items: vec![Item::HostBlock {
704 patterns: vec!["legacy-server".to_string()],
705 span: Span::new(1),
706 items: vec![Item::Directive {
707 key: "Ciphers".into(),
708 value: "arcfour256".into(),
709 span: Span::new(2),
710 }],
711 }],
712 };
713 let findings = DeprecatedWeakAlgorithms.check(&config);
714 assert_eq!(findings.len(), 1);
715 assert!(findings[0].message.contains("arcfour256"));
716 }
717
718 #[test]
719 fn weak_algo_with_prefix_modifier() {
720 let config = Config {
721 items: vec![Item::Directive {
722 key: "Ciphers".into(),
723 value: "+3des-cbc".into(),
724 span: Span::new(1),
725 }],
726 };
727 let findings = DeprecatedWeakAlgorithms.check(&config);
728 assert_eq!(findings.len(), 1);
729 assert!(findings[0].message.contains("3des-cbc"));
730 }
731
732 #[test]
733 fn non_algorithm_directive_ignored() {
734 let config = Config {
735 items: vec![Item::Directive {
736 key: "HostName".into(),
737 value: "ssh-rsa.example.com".into(),
738 span: Span::new(1),
739 }],
740 };
741 let findings = DeprecatedWeakAlgorithms.check(&config);
742 assert!(findings.is_empty());
743 }
744
745 #[test]
746 fn weak_algo_has_hint() {
747 let config = Config {
748 items: vec![Item::Directive {
749 key: "MACs".into(),
750 value: "hmac-md5".into(),
751 span: Span::new(1),
752 }],
753 };
754 let findings = DeprecatedWeakAlgorithms.check(&config);
755 assert_eq!(findings.len(), 1);
756 let hint = findings[0].hint.as_deref().unwrap();
757 assert!(hint.contains("hmac-md5"));
758 assert!(hint.contains("stronger algorithm"));
759 }
760
761 #[test]
764 fn duplicate_directives_at_root() {
765 let config = Config {
766 items: vec![
767 Item::Directive {
768 key: "User".into(),
769 value: "noah".into(),
770 span: Span::new(1),
771 },
772 Item::Directive {
773 key: "User".into(),
774 value: "noah2".into(),
775 span: Span::new(2),
776 },
777 ],
778 };
779 let findings = DuplicateDirectives.check(&config);
780 assert_eq!(findings.len(), 1);
781 assert_eq!(findings[0].rule, "duplicate-directives");
782 assert_eq!(findings[0].code, "DUP_DIRECTIVE");
783 assert!(findings[0].message.contains("User"));
784 assert!(findings[0].message.contains("first seen at line 1"));
785 }
786
787 #[test]
788 fn duplicate_directives_inside_host_block() {
789 let config = Config {
790 items: vec![Item::HostBlock {
791 patterns: vec!["example.com".to_string()],
792 span: Span::new(1),
793 items: vec![
794 Item::Directive {
795 key: "HostName".into(),
796 value: "1.2.3.4".into(),
797 span: Span::new(2),
798 },
799 Item::Directive {
800 key: "HostName".into(),
801 value: "5.6.7.8".into(),
802 span: Span::new(3),
803 },
804 ],
805 }],
806 };
807 let findings = DuplicateDirectives.check(&config);
808 assert_eq!(findings.len(), 1);
809 assert!(findings[0].message.contains("HostName"));
810 }
811
812 #[test]
813 fn duplicate_directives_case_insensitive() {
814 let config = Config {
815 items: vec![
816 Item::Directive {
817 key: "User".into(),
818 value: "alice".into(),
819 span: Span::new(1),
820 },
821 Item::Directive {
822 key: "user".into(),
823 value: "bob".into(),
824 span: Span::new(2),
825 },
826 ],
827 };
828 let findings = DuplicateDirectives.check(&config);
829 assert_eq!(findings.len(), 1);
830 }
831
832 #[test]
833 fn duplicate_directives_allows_identity_file() {
834 let config = Config {
835 items: vec![Item::HostBlock {
836 patterns: vec!["server".to_string()],
837 span: Span::new(1),
838 items: vec![
839 Item::Directive {
840 key: "IdentityFile".into(),
841 value: "~/.ssh/id_ed25519".into(),
842 span: Span::new(2),
843 },
844 Item::Directive {
845 key: "IdentityFile".into(),
846 value: "~/.ssh/id_rsa".into(),
847 span: Span::new(3),
848 },
849 ],
850 }],
851 };
852 let findings = DuplicateDirectives.check(&config);
853 assert!(findings.is_empty());
854 }
855
856 #[test]
857 fn duplicate_directives_allows_multi_value_directives() {
858 let config = Config {
859 items: vec![
860 Item::Directive {
861 key: "SendEnv".into(),
862 value: "LANG".into(),
863 span: Span::new(1),
864 },
865 Item::Directive {
866 key: "SendEnv".into(),
867 value: "LC_*".into(),
868 span: Span::new(2),
869 },
870 Item::Directive {
871 key: "LocalForward".into(),
872 value: "8080 localhost:80".into(),
873 span: Span::new(3),
874 },
875 Item::Directive {
876 key: "LocalForward".into(),
877 value: "9090 localhost:90".into(),
878 span: Span::new(4),
879 },
880 ],
881 };
882 let findings = DuplicateDirectives.check(&config);
883 assert!(findings.is_empty());
884 }
885
886 #[test]
887 fn no_duplicate_directives_no_findings() {
888 let config = Config {
889 items: vec![Item::HostBlock {
890 patterns: vec!["server".to_string()],
891 span: Span::new(1),
892 items: vec![
893 Item::Directive {
894 key: "User".into(),
895 value: "git".into(),
896 span: Span::new(2),
897 },
898 Item::Directive {
899 key: "HostName".into(),
900 value: "1.2.3.4".into(),
901 span: Span::new(3),
902 },
903 Item::Directive {
904 key: "Port".into(),
905 value: "22".into(),
906 span: Span::new(4),
907 },
908 ],
909 }],
910 };
911 let findings = DuplicateDirectives.check(&config);
912 assert!(findings.is_empty());
913 }
914
915 #[test]
916 fn duplicate_directives_separate_scopes_ok() {
917 let config = Config {
919 items: vec![
920 Item::HostBlock {
921 patterns: vec!["a".to_string()],
922 span: Span::new(1),
923 items: vec![Item::Directive {
924 key: "User".into(),
925 value: "alice".into(),
926 span: Span::new(2),
927 }],
928 },
929 Item::HostBlock {
930 patterns: vec!["b".to_string()],
931 span: Span::new(4),
932 items: vec![Item::Directive {
933 key: "User".into(),
934 value: "bob".into(),
935 span: Span::new(5),
936 }],
937 },
938 ],
939 };
940 let findings = DuplicateDirectives.check(&config);
941 assert!(findings.is_empty());
942 }
943
944 #[test]
945 fn duplicate_directives_has_hint() {
946 let config = Config {
947 items: vec![
948 Item::Directive {
949 key: "Port".into(),
950 value: "22".into(),
951 span: Span::new(1),
952 },
953 Item::Directive {
954 key: "Port".into(),
955 value: "2222".into(),
956 span: Span::new(2),
957 },
958 ],
959 };
960 let findings = DuplicateDirectives.check(&config);
961 assert_eq!(findings.len(), 1);
962 let hint = findings[0].hint.as_deref().unwrap();
963 assert!(hint.contains("first value takes effect"));
964 }
965
966 #[test]
967 fn duplicate_directives_inside_match_block() {
968 let config = Config {
969 items: vec![Item::MatchBlock {
970 criteria: "host example.com".into(),
971 span: Span::new(1),
972 items: vec![
973 Item::Directive {
974 key: "ForwardAgent".into(),
975 value: "yes".into(),
976 span: Span::new(2),
977 },
978 Item::Directive {
979 key: "ForwardAgent".into(),
980 value: "no".into(),
981 span: Span::new(3),
982 },
983 ],
984 }],
985 };
986 let findings = DuplicateDirectives.check(&config);
987 assert_eq!(findings.len(), 1);
988 assert!(findings[0].message.contains("ForwardAgent"));
989 }
990
991 #[test]
994 fn strict_host_key_checking_no_warns() {
995 let config = Config {
996 items: vec![Item::Directive {
997 key: "StrictHostKeyChecking".into(),
998 value: "no".into(),
999 span: Span::new(1),
1000 }],
1001 };
1002 let findings = InsecureOption.check(&config);
1003 assert_eq!(findings.len(), 1);
1004 assert_eq!(findings[0].code, "INSECURE_OPT");
1005 assert_eq!(findings[0].severity, Severity::Warning);
1006 assert!(findings[0].message.contains("MITM"));
1007 }
1008
1009 #[test]
1010 fn strict_host_key_checking_off_warns() {
1011 let config = Config {
1012 items: vec![Item::Directive {
1013 key: "StrictHostKeyChecking".into(),
1014 value: "off".into(),
1015 span: Span::new(1),
1016 }],
1017 };
1018 let findings = InsecureOption.check(&config);
1019 assert_eq!(findings.len(), 1);
1020 assert!(findings[0].message.contains("MITM"));
1021 }
1022
1023 #[test]
1024 fn strict_host_key_checking_ask_ok() {
1025 let config = Config {
1026 items: vec![Item::Directive {
1027 key: "StrictHostKeyChecking".into(),
1028 value: "ask".into(),
1029 span: Span::new(1),
1030 }],
1031 };
1032 let findings = InsecureOption.check(&config);
1033 assert!(findings.is_empty());
1034 }
1035
1036 #[test]
1037 fn strict_host_key_checking_accept_new_ok() {
1038 let config = Config {
1039 items: vec![Item::Directive {
1040 key: "StrictHostKeyChecking".into(),
1041 value: "accept-new".into(),
1042 span: Span::new(1),
1043 }],
1044 };
1045 let findings = InsecureOption.check(&config);
1046 assert!(findings.is_empty());
1047 }
1048
1049 #[test]
1050 fn user_known_hosts_dev_null_warns() {
1051 let config = Config {
1052 items: vec![Item::Directive {
1053 key: "UserKnownHostsFile".into(),
1054 value: "/dev/null".into(),
1055 span: Span::new(1),
1056 }],
1057 };
1058 let findings = InsecureOption.check(&config);
1059 assert_eq!(findings.len(), 1);
1060 assert!(findings[0].message.contains("known host keys"));
1061 }
1062
1063 #[test]
1064 fn loglevel_quiet_info() {
1065 let config = Config {
1066 items: vec![Item::Directive {
1067 key: "LogLevel".into(),
1068 value: "QUIET".into(),
1069 span: Span::new(1),
1070 }],
1071 };
1072 let findings = InsecureOption.check(&config);
1073 assert_eq!(findings.len(), 1);
1074 assert_eq!(findings[0].severity, Severity::Info);
1075 }
1076
1077 #[test]
1078 fn forward_agent_yes_on_wildcard_warns() {
1079 let config = Config {
1080 items: vec![Item::HostBlock {
1081 patterns: vec!["*".to_string()],
1082 span: Span::new(1),
1083 items: vec![Item::Directive {
1084 key: "ForwardAgent".into(),
1085 value: "yes".into(),
1086 span: Span::new(2),
1087 }],
1088 }],
1089 };
1090 let findings = InsecureOption.check(&config);
1091 assert_eq!(findings.len(), 1);
1092 assert_eq!(findings[0].severity, Severity::Warning);
1093 assert!(findings[0].message.contains("global"));
1094 }
1095
1096 #[test]
1097 fn forward_agent_yes_on_specific_host_ok() {
1098 let config = Config {
1099 items: vec![Item::HostBlock {
1100 patterns: vec!["bastion.example.com".to_string()],
1101 span: Span::new(1),
1102 items: vec![Item::Directive {
1103 key: "ForwardAgent".into(),
1104 value: "yes".into(),
1105 span: Span::new(2),
1106 }],
1107 }],
1108 };
1109 let findings = InsecureOption.check(&config);
1110 assert!(findings.is_empty());
1111 }
1112
1113 #[test]
1114 fn forward_x11_yes_on_wildcard_warns() {
1115 let config = Config {
1116 items: vec![Item::HostBlock {
1117 patterns: vec!["*".to_string()],
1118 span: Span::new(1),
1119 items: vec![Item::Directive {
1120 key: "ForwardX11".into(),
1121 value: "yes".into(),
1122 span: Span::new(2),
1123 }],
1124 }],
1125 };
1126 let findings = InsecureOption.check(&config);
1127 assert_eq!(findings.len(), 1);
1128 assert!(findings[0].message.contains("X11"));
1129 }
1130
1131 #[test]
1132 fn forward_agent_at_root_level_warns() {
1133 let config = Config {
1135 items: vec![Item::Directive {
1136 key: "ForwardAgent".into(),
1137 value: "yes".into(),
1138 span: Span::new(1),
1139 }],
1140 };
1141 let findings = InsecureOption.check(&config);
1142 assert_eq!(findings.len(), 1);
1143 assert!(findings[0].message.contains("global"));
1144 }
1145
1146 #[test]
1147 fn strict_host_key_inside_host_block_warns() {
1148 let config = Config {
1150 items: vec![Item::HostBlock {
1151 patterns: vec!["dev-server".to_string()],
1152 span: Span::new(1),
1153 items: vec![Item::Directive {
1154 key: "StrictHostKeyChecking".into(),
1155 value: "no".into(),
1156 span: Span::new(2),
1157 }],
1158 }],
1159 };
1160 let findings = InsecureOption.check(&config);
1161 assert_eq!(findings.len(), 1);
1162 assert!(findings[0].message.contains("MITM"));
1163 }
1164
1165 #[test]
1166 fn insecure_option_has_hint() {
1167 let config = Config {
1168 items: vec![Item::Directive {
1169 key: "StrictHostKeyChecking".into(),
1170 value: "no".into(),
1171 span: Span::new(1),
1172 }],
1173 };
1174 let findings = InsecureOption.check(&config);
1175 assert_eq!(findings.len(), 1);
1176 assert!(findings[0].hint.is_some());
1177 assert!(findings[0].hint.as_deref().unwrap().contains("accept-new"));
1178 }
1179
1180 #[test]
1181 fn case_insensitive_directive_and_value() {
1182 let config = Config {
1183 items: vec![Item::Directive {
1184 key: "stricthostkeychecking".into(),
1185 value: "NO".into(),
1186 span: Span::new(1),
1187 }],
1188 };
1189 let findings = InsecureOption.check(&config);
1190 assert_eq!(findings.len(), 1);
1191 }
1192
1193 #[test]
1194 fn multiple_insecure_settings() {
1195 let config = Config {
1196 items: vec![
1197 Item::Directive {
1198 key: "StrictHostKeyChecking".into(),
1199 value: "no".into(),
1200 span: Span::new(1),
1201 },
1202 Item::Directive {
1203 key: "UserKnownHostsFile".into(),
1204 value: "/dev/null".into(),
1205 span: Span::new(2),
1206 },
1207 Item::Directive {
1208 key: "LogLevel".into(),
1209 value: "QUIET".into(),
1210 span: Span::new(3),
1211 },
1212 Item::Directive {
1213 key: "ForwardAgent".into(),
1214 value: "yes".into(),
1215 span: Span::new(4),
1216 },
1217 ],
1218 };
1219 let findings = InsecureOption.check(&config);
1220 assert_eq!(findings.len(), 4);
1222 }
1223
1224 #[test]
1225 fn safe_config_no_findings() {
1226 let config = Config {
1227 items: vec![
1228 Item::Directive {
1229 key: "StrictHostKeyChecking".into(),
1230 value: "yes".into(),
1231 span: Span::new(1),
1232 },
1233 Item::Directive {
1234 key: "LogLevel".into(),
1235 value: "VERBOSE".into(),
1236 span: Span::new(2),
1237 },
1238 Item::HostBlock {
1239 patterns: vec!["myhost".to_string()],
1240 span: Span::new(3),
1241 items: vec![Item::Directive {
1242 key: "ForwardAgent".into(),
1243 value: "yes".into(),
1244 span: Span::new(4),
1245 }],
1246 },
1247 ],
1248 };
1249 let findings = InsecureOption.check(&config);
1250 assert!(findings.is_empty());
1251 }
1252}