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
442pub struct UnsafeControlPath;
446
447impl Rule for UnsafeControlPath {
448 fn name(&self) -> &'static str {
449 "unsafe-control-path"
450 }
451
452 fn check(&self, config: &Config) -> Vec<Finding> {
453 let mut findings = Vec::new();
454 collect_control_path_findings(&config.items, &mut findings);
455 findings
456 }
457}
458
459fn collect_control_path_findings(items: &[Item], findings: &mut Vec<Finding>) {
460 for item in items {
461 match item {
462 Item::Directive { key, value, span } if key.eq_ignore_ascii_case("ControlPath") => {
463 check_control_path(value, span, findings);
464 }
465 Item::HostBlock { items, .. } | Item::MatchBlock { items, .. } => {
466 collect_control_path_findings(items, findings);
467 }
468 _ => {}
469 }
470 }
471}
472
473fn check_control_path(value: &str, span: &Span, findings: &mut Vec<Finding>) {
474 if value.eq_ignore_ascii_case("none") {
476 return;
477 }
478
479 if value.contains("%C") {
481 return;
482 }
483
484 let has_h = value.contains("%h");
485 let has_p = value.contains("%p");
486 let has_r = value.contains("%r");
487
488 if has_h && has_p && has_r {
489 return;
490 }
491
492 let mut missing = Vec::new();
493 if !has_h {
494 missing.push("%h");
495 }
496 if !has_p {
497 missing.push("%p");
498 }
499 if !has_r {
500 missing.push("%r");
501 }
502
503 findings.push(
504 Finding::new(
505 Severity::Warning,
506 "unsafe-control-path",
507 "UNSAFE_CTRL_PATH",
508 format!(
509 "ControlPath is missing {} — connections to different hosts may share a socket",
510 missing.join(", ")
511 ),
512 span.clone(),
513 )
514 .with_hint("include %h, %p, and %r (or %C) in the path"),
515 );
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use crate::model::{Config, Item, Span};
522 use std::fs;
523 use tempfile::TempDir;
524
525 #[test]
526 fn no_duplicates_no_findings() {
527 let config = Config {
528 items: vec![
529 Item::HostBlock {
530 patterns: vec!["a".to_string()],
531 span: Span::new(1),
532 items: vec![],
533 },
534 Item::HostBlock {
535 patterns: vec!["b".to_string()],
536 span: Span::new(3),
537 items: vec![],
538 },
539 ],
540 };
541 let findings = DuplicateHost.check(&config);
542 assert!(findings.is_empty());
543 }
544
545 #[test]
546 fn duplicate_host_warns() {
547 let config = Config {
548 items: vec![
549 Item::HostBlock {
550 patterns: vec!["github.com".to_string()],
551 span: Span::new(1),
552 items: vec![],
553 },
554 Item::HostBlock {
555 patterns: vec!["github.com".to_string()],
556 span: Span::new(5),
557 items: vec![],
558 },
559 ],
560 };
561 let findings = DuplicateHost.check(&config);
562 assert_eq!(findings.len(), 1);
563 assert_eq!(findings[0].rule, "duplicate-host");
564 assert!(findings[0].message.contains("first seen at line 1"));
565 }
566
567 #[test]
568 fn identity_file_exists_no_error() {
569 let tmp = TempDir::new().unwrap();
570 let key_path = tmp.path().join("id_test");
571 fs::write(&key_path, "fake key").unwrap();
572
573 let config = Config {
574 items: vec![Item::HostBlock {
575 patterns: vec!["a".to_string()],
576 span: Span::new(1),
577 items: vec![Item::Directive {
578 key: "IdentityFile".into(),
579 value: key_path.to_string_lossy().into_owned(),
580 span: Span::new(2),
581 }],
582 }],
583 };
584 let findings = IdentityFileExists.check(&config);
585 assert!(findings.is_empty());
586 }
587
588 #[test]
589 fn identity_file_missing_errors() {
590 let config = Config {
591 items: vec![Item::Directive {
592 key: "IdentityFile".into(),
593 value: "/nonexistent/path/id_nope".into(),
594 span: Span::new(1),
595 }],
596 };
597 let findings = IdentityFileExists.check(&config);
598 assert_eq!(findings.len(), 1);
599 assert_eq!(findings[0].rule, "identity-file-exists");
600 }
601
602 #[test]
603 fn identity_file_skips_templates() {
604 let config = Config {
605 items: vec![
606 Item::Directive {
607 key: "IdentityFile".into(),
608 value: "~/.ssh/id_%h".into(),
609 span: Span::new(1),
610 },
611 Item::Directive {
612 key: "IdentityFile".into(),
613 value: "${HOME}/.ssh/id_ed25519".into(),
614 span: Span::new(2),
615 },
616 ],
617 };
618 let findings = IdentityFileExists.check(&config);
619 assert!(findings.is_empty());
620 }
621
622 #[test]
623 fn wildcard_after_specific_no_warning() {
624 let config = Config {
625 items: vec![
626 Item::HostBlock {
627 patterns: vec!["github.com".to_string()],
628 span: Span::new(1),
629 items: vec![],
630 },
631 Item::HostBlock {
632 patterns: vec!["*".to_string()],
633 span: Span::new(5),
634 items: vec![],
635 },
636 ],
637 };
638 let findings = WildcardHostOrder.check(&config);
639 assert!(findings.is_empty());
640 }
641
642 #[test]
643 fn wildcard_before_specific_warns() {
644 let config = Config {
645 items: vec![
646 Item::HostBlock {
647 patterns: vec!["*".to_string()],
648 span: Span::new(1),
649 items: vec![],
650 },
651 Item::HostBlock {
652 patterns: vec!["github.com".to_string()],
653 span: Span::new(5),
654 items: vec![],
655 },
656 ],
657 };
658 let findings = WildcardHostOrder.check(&config);
659 assert_eq!(findings.len(), 1);
660 assert_eq!(findings[0].rule, "wildcard-host-order");
661 assert!(findings[0].message.contains("github.com"));
662 }
663
664 #[test]
667 fn weak_cipher_warns() {
668 let config = Config {
669 items: vec![Item::Directive {
670 key: "Ciphers".into(),
671 value: "aes128-ctr,3des-cbc,aes256-gcm@openssh.com".into(),
672 span: Span::new(1),
673 }],
674 };
675 let findings = DeprecatedWeakAlgorithms.check(&config);
676 assert_eq!(findings.len(), 1);
677 assert_eq!(findings[0].code, "WEAK_ALGO");
678 assert!(findings[0].message.contains("3des-cbc"));
679 assert!(findings[0].message.contains("Ciphers"));
680 }
681
682 #[test]
683 fn weak_mac_warns() {
684 let config = Config {
685 items: vec![Item::Directive {
686 key: "MACs".into(),
687 value: "hmac-sha2-256,hmac-md5".into(),
688 span: Span::new(3),
689 }],
690 };
691 let findings = DeprecatedWeakAlgorithms.check(&config);
692 assert_eq!(findings.len(), 1);
693 assert!(findings[0].message.contains("hmac-md5"));
694 }
695
696 #[test]
697 fn weak_kex_warns() {
698 let config = Config {
699 items: vec![Item::Directive {
700 key: "KexAlgorithms".into(),
701 value: "diffie-hellman-group1-sha1".into(),
702 span: Span::new(1),
703 }],
704 };
705 let findings = DeprecatedWeakAlgorithms.check(&config);
706 assert_eq!(findings.len(), 1);
707 assert!(findings[0].message.contains("diffie-hellman-group1-sha1"));
708 }
709
710 #[test]
711 fn weak_host_key_algorithm_warns() {
712 let config = Config {
713 items: vec![Item::Directive {
714 key: "HostKeyAlgorithms".into(),
715 value: "ssh-ed25519,ssh-dss".into(),
716 span: Span::new(2),
717 }],
718 };
719 let findings = DeprecatedWeakAlgorithms.check(&config);
720 assert_eq!(findings.len(), 1);
721 assert!(findings[0].message.contains("ssh-dss"));
722 }
723
724 #[test]
725 fn weak_pubkey_accepted_warns() {
726 let config = Config {
727 items: vec![Item::Directive {
728 key: "PubkeyAcceptedAlgorithms".into(),
729 value: "ssh-rsa,ssh-ed25519".into(),
730 span: Span::new(1),
731 }],
732 };
733 let findings = DeprecatedWeakAlgorithms.check(&config);
734 assert_eq!(findings.len(), 1);
735 assert!(findings[0].message.contains("ssh-rsa"));
736 }
737
738 #[test]
739 fn strong_algorithms_no_warning() {
740 let config = Config {
741 items: vec![
742 Item::Directive {
743 key: "Ciphers".into(),
744 value: "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com".into(),
745 span: Span::new(1),
746 },
747 Item::Directive {
748 key: "MACs".into(),
749 value: "hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com".into(),
750 span: Span::new(2),
751 },
752 Item::Directive {
753 key: "KexAlgorithms".into(),
754 value: "curve25519-sha256,diffie-hellman-group16-sha512".into(),
755 span: Span::new(3),
756 },
757 ],
758 };
759 let findings = DeprecatedWeakAlgorithms.check(&config);
760 assert!(findings.is_empty());
761 }
762
763 #[test]
764 fn multiple_weak_algorithms_multiple_findings() {
765 let config = Config {
766 items: vec![Item::Directive {
767 key: "Ciphers".into(),
768 value: "3des-cbc,arcfour,blowfish-cbc".into(),
769 span: Span::new(1),
770 }],
771 };
772 let findings = DeprecatedWeakAlgorithms.check(&config);
773 assert_eq!(findings.len(), 3);
774 }
775
776 #[test]
777 fn weak_algo_inside_host_block() {
778 let config = Config {
779 items: vec![Item::HostBlock {
780 patterns: vec!["legacy-server".to_string()],
781 span: Span::new(1),
782 items: vec![Item::Directive {
783 key: "Ciphers".into(),
784 value: "arcfour256".into(),
785 span: Span::new(2),
786 }],
787 }],
788 };
789 let findings = DeprecatedWeakAlgorithms.check(&config);
790 assert_eq!(findings.len(), 1);
791 assert!(findings[0].message.contains("arcfour256"));
792 }
793
794 #[test]
795 fn weak_algo_with_prefix_modifier() {
796 let config = Config {
797 items: vec![Item::Directive {
798 key: "Ciphers".into(),
799 value: "+3des-cbc".into(),
800 span: Span::new(1),
801 }],
802 };
803 let findings = DeprecatedWeakAlgorithms.check(&config);
804 assert_eq!(findings.len(), 1);
805 assert!(findings[0].message.contains("3des-cbc"));
806 }
807
808 #[test]
809 fn non_algorithm_directive_ignored() {
810 let config = Config {
811 items: vec![Item::Directive {
812 key: "HostName".into(),
813 value: "ssh-rsa.example.com".into(),
814 span: Span::new(1),
815 }],
816 };
817 let findings = DeprecatedWeakAlgorithms.check(&config);
818 assert!(findings.is_empty());
819 }
820
821 #[test]
822 fn weak_algo_has_hint() {
823 let config = Config {
824 items: vec![Item::Directive {
825 key: "MACs".into(),
826 value: "hmac-md5".into(),
827 span: Span::new(1),
828 }],
829 };
830 let findings = DeprecatedWeakAlgorithms.check(&config);
831 assert_eq!(findings.len(), 1);
832 let hint = findings[0].hint.as_deref().unwrap();
833 assert!(hint.contains("hmac-md5"));
834 assert!(hint.contains("stronger algorithm"));
835 }
836
837 #[test]
840 fn duplicate_directives_at_root() {
841 let config = Config {
842 items: vec![
843 Item::Directive {
844 key: "User".into(),
845 value: "noah".into(),
846 span: Span::new(1),
847 },
848 Item::Directive {
849 key: "User".into(),
850 value: "noah2".into(),
851 span: Span::new(2),
852 },
853 ],
854 };
855 let findings = DuplicateDirectives.check(&config);
856 assert_eq!(findings.len(), 1);
857 assert_eq!(findings[0].rule, "duplicate-directives");
858 assert_eq!(findings[0].code, "DUP_DIRECTIVE");
859 assert!(findings[0].message.contains("User"));
860 assert!(findings[0].message.contains("first seen at line 1"));
861 }
862
863 #[test]
864 fn duplicate_directives_inside_host_block() {
865 let config = Config {
866 items: vec![Item::HostBlock {
867 patterns: vec!["example.com".to_string()],
868 span: Span::new(1),
869 items: vec![
870 Item::Directive {
871 key: "HostName".into(),
872 value: "1.2.3.4".into(),
873 span: Span::new(2),
874 },
875 Item::Directive {
876 key: "HostName".into(),
877 value: "5.6.7.8".into(),
878 span: Span::new(3),
879 },
880 ],
881 }],
882 };
883 let findings = DuplicateDirectives.check(&config);
884 assert_eq!(findings.len(), 1);
885 assert!(findings[0].message.contains("HostName"));
886 }
887
888 #[test]
889 fn duplicate_directives_case_insensitive() {
890 let config = Config {
891 items: vec![
892 Item::Directive {
893 key: "User".into(),
894 value: "alice".into(),
895 span: Span::new(1),
896 },
897 Item::Directive {
898 key: "user".into(),
899 value: "bob".into(),
900 span: Span::new(2),
901 },
902 ],
903 };
904 let findings = DuplicateDirectives.check(&config);
905 assert_eq!(findings.len(), 1);
906 }
907
908 #[test]
909 fn duplicate_directives_allows_identity_file() {
910 let config = Config {
911 items: vec![Item::HostBlock {
912 patterns: vec!["server".to_string()],
913 span: Span::new(1),
914 items: vec![
915 Item::Directive {
916 key: "IdentityFile".into(),
917 value: "~/.ssh/id_ed25519".into(),
918 span: Span::new(2),
919 },
920 Item::Directive {
921 key: "IdentityFile".into(),
922 value: "~/.ssh/id_rsa".into(),
923 span: Span::new(3),
924 },
925 ],
926 }],
927 };
928 let findings = DuplicateDirectives.check(&config);
929 assert!(findings.is_empty());
930 }
931
932 #[test]
933 fn duplicate_directives_allows_multi_value_directives() {
934 let config = Config {
935 items: vec![
936 Item::Directive {
937 key: "SendEnv".into(),
938 value: "LANG".into(),
939 span: Span::new(1),
940 },
941 Item::Directive {
942 key: "SendEnv".into(),
943 value: "LC_*".into(),
944 span: Span::new(2),
945 },
946 Item::Directive {
947 key: "LocalForward".into(),
948 value: "8080 localhost:80".into(),
949 span: Span::new(3),
950 },
951 Item::Directive {
952 key: "LocalForward".into(),
953 value: "9090 localhost:90".into(),
954 span: Span::new(4),
955 },
956 ],
957 };
958 let findings = DuplicateDirectives.check(&config);
959 assert!(findings.is_empty());
960 }
961
962 #[test]
963 fn no_duplicate_directives_no_findings() {
964 let config = Config {
965 items: vec![Item::HostBlock {
966 patterns: vec!["server".to_string()],
967 span: Span::new(1),
968 items: vec![
969 Item::Directive {
970 key: "User".into(),
971 value: "git".into(),
972 span: Span::new(2),
973 },
974 Item::Directive {
975 key: "HostName".into(),
976 value: "1.2.3.4".into(),
977 span: Span::new(3),
978 },
979 Item::Directive {
980 key: "Port".into(),
981 value: "22".into(),
982 span: Span::new(4),
983 },
984 ],
985 }],
986 };
987 let findings = DuplicateDirectives.check(&config);
988 assert!(findings.is_empty());
989 }
990
991 #[test]
992 fn duplicate_directives_separate_scopes_ok() {
993 let config = Config {
995 items: vec![
996 Item::HostBlock {
997 patterns: vec!["a".to_string()],
998 span: Span::new(1),
999 items: vec![Item::Directive {
1000 key: "User".into(),
1001 value: "alice".into(),
1002 span: Span::new(2),
1003 }],
1004 },
1005 Item::HostBlock {
1006 patterns: vec!["b".to_string()],
1007 span: Span::new(4),
1008 items: vec![Item::Directive {
1009 key: "User".into(),
1010 value: "bob".into(),
1011 span: Span::new(5),
1012 }],
1013 },
1014 ],
1015 };
1016 let findings = DuplicateDirectives.check(&config);
1017 assert!(findings.is_empty());
1018 }
1019
1020 #[test]
1021 fn duplicate_directives_has_hint() {
1022 let config = Config {
1023 items: vec![
1024 Item::Directive {
1025 key: "Port".into(),
1026 value: "22".into(),
1027 span: Span::new(1),
1028 },
1029 Item::Directive {
1030 key: "Port".into(),
1031 value: "2222".into(),
1032 span: Span::new(2),
1033 },
1034 ],
1035 };
1036 let findings = DuplicateDirectives.check(&config);
1037 assert_eq!(findings.len(), 1);
1038 let hint = findings[0].hint.as_deref().unwrap();
1039 assert!(hint.contains("first value takes effect"));
1040 }
1041
1042 #[test]
1043 fn duplicate_directives_inside_match_block() {
1044 let config = Config {
1045 items: vec![Item::MatchBlock {
1046 criteria: "host example.com".into(),
1047 span: Span::new(1),
1048 items: vec![
1049 Item::Directive {
1050 key: "ForwardAgent".into(),
1051 value: "yes".into(),
1052 span: Span::new(2),
1053 },
1054 Item::Directive {
1055 key: "ForwardAgent".into(),
1056 value: "no".into(),
1057 span: Span::new(3),
1058 },
1059 ],
1060 }],
1061 };
1062 let findings = DuplicateDirectives.check(&config);
1063 assert_eq!(findings.len(), 1);
1064 assert!(findings[0].message.contains("ForwardAgent"));
1065 }
1066
1067 #[test]
1070 fn strict_host_key_checking_no_warns() {
1071 let config = Config {
1072 items: vec![Item::Directive {
1073 key: "StrictHostKeyChecking".into(),
1074 value: "no".into(),
1075 span: Span::new(1),
1076 }],
1077 };
1078 let findings = InsecureOption.check(&config);
1079 assert_eq!(findings.len(), 1);
1080 assert_eq!(findings[0].code, "INSECURE_OPT");
1081 assert_eq!(findings[0].severity, Severity::Warning);
1082 assert!(findings[0].message.contains("MITM"));
1083 }
1084
1085 #[test]
1086 fn strict_host_key_checking_off_warns() {
1087 let config = Config {
1088 items: vec![Item::Directive {
1089 key: "StrictHostKeyChecking".into(),
1090 value: "off".into(),
1091 span: Span::new(1),
1092 }],
1093 };
1094 let findings = InsecureOption.check(&config);
1095 assert_eq!(findings.len(), 1);
1096 assert!(findings[0].message.contains("MITM"));
1097 }
1098
1099 #[test]
1100 fn strict_host_key_checking_ask_ok() {
1101 let config = Config {
1102 items: vec![Item::Directive {
1103 key: "StrictHostKeyChecking".into(),
1104 value: "ask".into(),
1105 span: Span::new(1),
1106 }],
1107 };
1108 let findings = InsecureOption.check(&config);
1109 assert!(findings.is_empty());
1110 }
1111
1112 #[test]
1113 fn strict_host_key_checking_accept_new_ok() {
1114 let config = Config {
1115 items: vec![Item::Directive {
1116 key: "StrictHostKeyChecking".into(),
1117 value: "accept-new".into(),
1118 span: Span::new(1),
1119 }],
1120 };
1121 let findings = InsecureOption.check(&config);
1122 assert!(findings.is_empty());
1123 }
1124
1125 #[test]
1126 fn user_known_hosts_dev_null_warns() {
1127 let config = Config {
1128 items: vec![Item::Directive {
1129 key: "UserKnownHostsFile".into(),
1130 value: "/dev/null".into(),
1131 span: Span::new(1),
1132 }],
1133 };
1134 let findings = InsecureOption.check(&config);
1135 assert_eq!(findings.len(), 1);
1136 assert!(findings[0].message.contains("known host keys"));
1137 }
1138
1139 #[test]
1140 fn loglevel_quiet_info() {
1141 let config = Config {
1142 items: vec![Item::Directive {
1143 key: "LogLevel".into(),
1144 value: "QUIET".into(),
1145 span: Span::new(1),
1146 }],
1147 };
1148 let findings = InsecureOption.check(&config);
1149 assert_eq!(findings.len(), 1);
1150 assert_eq!(findings[0].severity, Severity::Info);
1151 }
1152
1153 #[test]
1154 fn forward_agent_yes_on_wildcard_warns() {
1155 let config = Config {
1156 items: vec![Item::HostBlock {
1157 patterns: vec!["*".to_string()],
1158 span: Span::new(1),
1159 items: vec![Item::Directive {
1160 key: "ForwardAgent".into(),
1161 value: "yes".into(),
1162 span: Span::new(2),
1163 }],
1164 }],
1165 };
1166 let findings = InsecureOption.check(&config);
1167 assert_eq!(findings.len(), 1);
1168 assert_eq!(findings[0].severity, Severity::Warning);
1169 assert!(findings[0].message.contains("global"));
1170 }
1171
1172 #[test]
1173 fn forward_agent_yes_on_specific_host_ok() {
1174 let config = Config {
1175 items: vec![Item::HostBlock {
1176 patterns: vec!["bastion.example.com".to_string()],
1177 span: Span::new(1),
1178 items: vec![Item::Directive {
1179 key: "ForwardAgent".into(),
1180 value: "yes".into(),
1181 span: Span::new(2),
1182 }],
1183 }],
1184 };
1185 let findings = InsecureOption.check(&config);
1186 assert!(findings.is_empty());
1187 }
1188
1189 #[test]
1190 fn forward_x11_yes_on_wildcard_warns() {
1191 let config = Config {
1192 items: vec![Item::HostBlock {
1193 patterns: vec!["*".to_string()],
1194 span: Span::new(1),
1195 items: vec![Item::Directive {
1196 key: "ForwardX11".into(),
1197 value: "yes".into(),
1198 span: Span::new(2),
1199 }],
1200 }],
1201 };
1202 let findings = InsecureOption.check(&config);
1203 assert_eq!(findings.len(), 1);
1204 assert!(findings[0].message.contains("X11"));
1205 }
1206
1207 #[test]
1208 fn forward_agent_at_root_level_warns() {
1209 let config = Config {
1211 items: vec![Item::Directive {
1212 key: "ForwardAgent".into(),
1213 value: "yes".into(),
1214 span: Span::new(1),
1215 }],
1216 };
1217 let findings = InsecureOption.check(&config);
1218 assert_eq!(findings.len(), 1);
1219 assert!(findings[0].message.contains("global"));
1220 }
1221
1222 #[test]
1223 fn strict_host_key_inside_host_block_warns() {
1224 let config = Config {
1226 items: vec![Item::HostBlock {
1227 patterns: vec!["dev-server".to_string()],
1228 span: Span::new(1),
1229 items: vec![Item::Directive {
1230 key: "StrictHostKeyChecking".into(),
1231 value: "no".into(),
1232 span: Span::new(2),
1233 }],
1234 }],
1235 };
1236 let findings = InsecureOption.check(&config);
1237 assert_eq!(findings.len(), 1);
1238 assert!(findings[0].message.contains("MITM"));
1239 }
1240
1241 #[test]
1242 fn insecure_option_has_hint() {
1243 let config = Config {
1244 items: vec![Item::Directive {
1245 key: "StrictHostKeyChecking".into(),
1246 value: "no".into(),
1247 span: Span::new(1),
1248 }],
1249 };
1250 let findings = InsecureOption.check(&config);
1251 assert_eq!(findings.len(), 1);
1252 assert!(findings[0].hint.is_some());
1253 assert!(findings[0].hint.as_deref().unwrap().contains("accept-new"));
1254 }
1255
1256 #[test]
1257 fn case_insensitive_directive_and_value() {
1258 let config = Config {
1259 items: vec![Item::Directive {
1260 key: "stricthostkeychecking".into(),
1261 value: "NO".into(),
1262 span: Span::new(1),
1263 }],
1264 };
1265 let findings = InsecureOption.check(&config);
1266 assert_eq!(findings.len(), 1);
1267 }
1268
1269 #[test]
1270 fn multiple_insecure_settings() {
1271 let config = Config {
1272 items: vec![
1273 Item::Directive {
1274 key: "StrictHostKeyChecking".into(),
1275 value: "no".into(),
1276 span: Span::new(1),
1277 },
1278 Item::Directive {
1279 key: "UserKnownHostsFile".into(),
1280 value: "/dev/null".into(),
1281 span: Span::new(2),
1282 },
1283 Item::Directive {
1284 key: "LogLevel".into(),
1285 value: "QUIET".into(),
1286 span: Span::new(3),
1287 },
1288 Item::Directive {
1289 key: "ForwardAgent".into(),
1290 value: "yes".into(),
1291 span: Span::new(4),
1292 },
1293 ],
1294 };
1295 let findings = InsecureOption.check(&config);
1296 assert_eq!(findings.len(), 4);
1298 }
1299
1300 #[test]
1301 fn safe_config_no_findings() {
1302 let config = Config {
1303 items: vec![
1304 Item::Directive {
1305 key: "StrictHostKeyChecking".into(),
1306 value: "yes".into(),
1307 span: Span::new(1),
1308 },
1309 Item::Directive {
1310 key: "LogLevel".into(),
1311 value: "VERBOSE".into(),
1312 span: Span::new(2),
1313 },
1314 Item::HostBlock {
1315 patterns: vec!["myhost".to_string()],
1316 span: Span::new(3),
1317 items: vec![Item::Directive {
1318 key: "ForwardAgent".into(),
1319 value: "yes".into(),
1320 span: Span::new(4),
1321 }],
1322 },
1323 ],
1324 };
1325 let findings = InsecureOption.check(&config);
1326 assert!(findings.is_empty());
1327 }
1328
1329 #[test]
1332 fn control_path_with_all_tokens_ok() {
1333 let config = Config {
1334 items: vec![Item::Directive {
1335 key: "ControlPath".into(),
1336 value: "~/.ssh/sockets/%r@%h-%p".into(),
1337 span: Span::new(1),
1338 }],
1339 };
1340 let findings = UnsafeControlPath.check(&config);
1341 assert!(findings.is_empty());
1342 }
1343
1344 #[test]
1345 fn control_path_with_hash_c_ok() {
1346 let config = Config {
1347 items: vec![Item::Directive {
1348 key: "ControlPath".into(),
1349 value: "~/.ssh/sockets/%C".into(),
1350 span: Span::new(1),
1351 }],
1352 };
1353 let findings = UnsafeControlPath.check(&config);
1354 assert!(findings.is_empty());
1355 }
1356
1357 #[test]
1358 fn control_path_none_ok() {
1359 let config = Config {
1360 items: vec![Item::Directive {
1361 key: "ControlPath".into(),
1362 value: "none".into(),
1363 span: Span::new(1),
1364 }],
1365 };
1366 let findings = UnsafeControlPath.check(&config);
1367 assert!(findings.is_empty());
1368 }
1369
1370 #[test]
1371 fn control_path_none_case_insensitive() {
1372 let config = Config {
1373 items: vec![Item::Directive {
1374 key: "ControlPath".into(),
1375 value: "NONE".into(),
1376 span: Span::new(1),
1377 }],
1378 };
1379 let findings = UnsafeControlPath.check(&config);
1380 assert!(findings.is_empty());
1381 }
1382
1383 #[test]
1384 fn control_path_missing_all_tokens_warns() {
1385 let config = Config {
1386 items: vec![Item::Directive {
1387 key: "ControlPath".into(),
1388 value: "~/.ssh/sockets/master".into(),
1389 span: Span::new(1),
1390 }],
1391 };
1392 let findings = UnsafeControlPath.check(&config);
1393 assert_eq!(findings.len(), 1);
1394 assert_eq!(findings[0].code, "UNSAFE_CTRL_PATH");
1395 assert!(findings[0].message.contains("%h"));
1396 assert!(findings[0].message.contains("%p"));
1397 assert!(findings[0].message.contains("%r"));
1398 }
1399
1400 #[test]
1401 fn control_path_missing_port_warns() {
1402 let config = Config {
1403 items: vec![Item::Directive {
1404 key: "ControlPath".into(),
1405 value: "/tmp/ssh-%r@%h".into(),
1406 span: Span::new(1),
1407 }],
1408 };
1409 let findings = UnsafeControlPath.check(&config);
1410 assert_eq!(findings.len(), 1);
1411 assert!(findings[0].message.contains("%p"));
1412 assert!(!findings[0].message.contains("%h"));
1413 assert!(!findings[0].message.contains("%r"));
1414 }
1415
1416 #[test]
1417 fn control_path_missing_user_warns() {
1418 let config = Config {
1419 items: vec![Item::Directive {
1420 key: "ControlPath".into(),
1421 value: "~/.ssh/sockets/%h-%p".into(),
1422 span: Span::new(1),
1423 }],
1424 };
1425 let findings = UnsafeControlPath.check(&config);
1426 assert_eq!(findings.len(), 1);
1427 assert!(findings[0].message.contains("%r"));
1428 }
1429
1430 #[test]
1431 fn control_path_inside_host_block_warns() {
1432 let config = Config {
1433 items: vec![Item::HostBlock {
1434 patterns: vec!["myhost".to_string()],
1435 span: Span::new(1),
1436 items: vec![Item::Directive {
1437 key: "ControlPath".into(),
1438 value: "/tmp/ssh-socket".into(),
1439 span: Span::new(2),
1440 }],
1441 }],
1442 };
1443 let findings = UnsafeControlPath.check(&config);
1444 assert_eq!(findings.len(), 1);
1445 assert_eq!(findings[0].code, "UNSAFE_CTRL_PATH");
1446 }
1447
1448 #[test]
1449 fn control_path_inside_match_block_warns() {
1450 let config = Config {
1451 items: vec![Item::MatchBlock {
1452 criteria: "host example.com".into(),
1453 span: Span::new(1),
1454 items: vec![Item::Directive {
1455 key: "ControlPath".into(),
1456 value: "~/.ssh/%h".into(),
1457 span: Span::new(2),
1458 }],
1459 }],
1460 };
1461 let findings = UnsafeControlPath.check(&config);
1462 assert_eq!(findings.len(), 1);
1463 }
1464
1465 #[test]
1466 fn control_path_has_hint() {
1467 let config = Config {
1468 items: vec![Item::Directive {
1469 key: "ControlPath".into(),
1470 value: "~/.ssh/sockets/ctrl".into(),
1471 span: Span::new(1),
1472 }],
1473 };
1474 let findings = UnsafeControlPath.check(&config);
1475 assert_eq!(findings.len(), 1);
1476 assert!(findings[0].hint.as_ref().unwrap().contains("%C"));
1477 }
1478}