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
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::model::{Config, Item, Span};
318 use std::fs;
319 use tempfile::TempDir;
320
321 #[test]
322 fn no_duplicates_no_findings() {
323 let config = Config {
324 items: vec![
325 Item::HostBlock {
326 patterns: vec!["a".to_string()],
327 span: Span::new(1),
328 items: vec![],
329 },
330 Item::HostBlock {
331 patterns: vec!["b".to_string()],
332 span: Span::new(3),
333 items: vec![],
334 },
335 ],
336 };
337 let findings = DuplicateHost.check(&config);
338 assert!(findings.is_empty());
339 }
340
341 #[test]
342 fn duplicate_host_warns() {
343 let config = Config {
344 items: vec![
345 Item::HostBlock {
346 patterns: vec!["github.com".to_string()],
347 span: Span::new(1),
348 items: vec![],
349 },
350 Item::HostBlock {
351 patterns: vec!["github.com".to_string()],
352 span: Span::new(5),
353 items: vec![],
354 },
355 ],
356 };
357 let findings = DuplicateHost.check(&config);
358 assert_eq!(findings.len(), 1);
359 assert_eq!(findings[0].rule, "duplicate-host");
360 assert!(findings[0].message.contains("first seen at line 1"));
361 }
362
363 #[test]
364 fn identity_file_exists_no_error() {
365 let tmp = TempDir::new().unwrap();
366 let key_path = tmp.path().join("id_test");
367 fs::write(&key_path, "fake key").unwrap();
368
369 let config = Config {
370 items: vec![Item::HostBlock {
371 patterns: vec!["a".to_string()],
372 span: Span::new(1),
373 items: vec![Item::Directive {
374 key: "IdentityFile".into(),
375 value: key_path.to_string_lossy().into_owned(),
376 span: Span::new(2),
377 }],
378 }],
379 };
380 let findings = IdentityFileExists.check(&config);
381 assert!(findings.is_empty());
382 }
383
384 #[test]
385 fn identity_file_missing_errors() {
386 let config = Config {
387 items: vec![Item::Directive {
388 key: "IdentityFile".into(),
389 value: "/nonexistent/path/id_nope".into(),
390 span: Span::new(1),
391 }],
392 };
393 let findings = IdentityFileExists.check(&config);
394 assert_eq!(findings.len(), 1);
395 assert_eq!(findings[0].rule, "identity-file-exists");
396 }
397
398 #[test]
399 fn identity_file_skips_templates() {
400 let config = Config {
401 items: vec![
402 Item::Directive {
403 key: "IdentityFile".into(),
404 value: "~/.ssh/id_%h".into(),
405 span: Span::new(1),
406 },
407 Item::Directive {
408 key: "IdentityFile".into(),
409 value: "${HOME}/.ssh/id_ed25519".into(),
410 span: Span::new(2),
411 },
412 ],
413 };
414 let findings = IdentityFileExists.check(&config);
415 assert!(findings.is_empty());
416 }
417
418 #[test]
419 fn wildcard_after_specific_no_warning() {
420 let config = Config {
421 items: vec![
422 Item::HostBlock {
423 patterns: vec!["github.com".to_string()],
424 span: Span::new(1),
425 items: vec![],
426 },
427 Item::HostBlock {
428 patterns: vec!["*".to_string()],
429 span: Span::new(5),
430 items: vec![],
431 },
432 ],
433 };
434 let findings = WildcardHostOrder.check(&config);
435 assert!(findings.is_empty());
436 }
437
438 #[test]
439 fn wildcard_before_specific_warns() {
440 let config = Config {
441 items: vec![
442 Item::HostBlock {
443 patterns: vec!["*".to_string()],
444 span: Span::new(1),
445 items: vec![],
446 },
447 Item::HostBlock {
448 patterns: vec!["github.com".to_string()],
449 span: Span::new(5),
450 items: vec![],
451 },
452 ],
453 };
454 let findings = WildcardHostOrder.check(&config);
455 assert_eq!(findings.len(), 1);
456 assert_eq!(findings[0].rule, "wildcard-host-order");
457 assert!(findings[0].message.contains("github.com"));
458 }
459
460 #[test]
463 fn weak_cipher_warns() {
464 let config = Config {
465 items: vec![Item::Directive {
466 key: "Ciphers".into(),
467 value: "aes128-ctr,3des-cbc,aes256-gcm@openssh.com".into(),
468 span: Span::new(1),
469 }],
470 };
471 let findings = DeprecatedWeakAlgorithms.check(&config);
472 assert_eq!(findings.len(), 1);
473 assert_eq!(findings[0].code, "WEAK_ALGO");
474 assert!(findings[0].message.contains("3des-cbc"));
475 assert!(findings[0].message.contains("Ciphers"));
476 }
477
478 #[test]
479 fn weak_mac_warns() {
480 let config = Config {
481 items: vec![Item::Directive {
482 key: "MACs".into(),
483 value: "hmac-sha2-256,hmac-md5".into(),
484 span: Span::new(3),
485 }],
486 };
487 let findings = DeprecatedWeakAlgorithms.check(&config);
488 assert_eq!(findings.len(), 1);
489 assert!(findings[0].message.contains("hmac-md5"));
490 }
491
492 #[test]
493 fn weak_kex_warns() {
494 let config = Config {
495 items: vec![Item::Directive {
496 key: "KexAlgorithms".into(),
497 value: "diffie-hellman-group1-sha1".into(),
498 span: Span::new(1),
499 }],
500 };
501 let findings = DeprecatedWeakAlgorithms.check(&config);
502 assert_eq!(findings.len(), 1);
503 assert!(findings[0].message.contains("diffie-hellman-group1-sha1"));
504 }
505
506 #[test]
507 fn weak_host_key_algorithm_warns() {
508 let config = Config {
509 items: vec![Item::Directive {
510 key: "HostKeyAlgorithms".into(),
511 value: "ssh-ed25519,ssh-dss".into(),
512 span: Span::new(2),
513 }],
514 };
515 let findings = DeprecatedWeakAlgorithms.check(&config);
516 assert_eq!(findings.len(), 1);
517 assert!(findings[0].message.contains("ssh-dss"));
518 }
519
520 #[test]
521 fn weak_pubkey_accepted_warns() {
522 let config = Config {
523 items: vec![Item::Directive {
524 key: "PubkeyAcceptedAlgorithms".into(),
525 value: "ssh-rsa,ssh-ed25519".into(),
526 span: Span::new(1),
527 }],
528 };
529 let findings = DeprecatedWeakAlgorithms.check(&config);
530 assert_eq!(findings.len(), 1);
531 assert!(findings[0].message.contains("ssh-rsa"));
532 }
533
534 #[test]
535 fn strong_algorithms_no_warning() {
536 let config = Config {
537 items: vec![
538 Item::Directive {
539 key: "Ciphers".into(),
540 value: "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com".into(),
541 span: Span::new(1),
542 },
543 Item::Directive {
544 key: "MACs".into(),
545 value: "hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com".into(),
546 span: Span::new(2),
547 },
548 Item::Directive {
549 key: "KexAlgorithms".into(),
550 value: "curve25519-sha256,diffie-hellman-group16-sha512".into(),
551 span: Span::new(3),
552 },
553 ],
554 };
555 let findings = DeprecatedWeakAlgorithms.check(&config);
556 assert!(findings.is_empty());
557 }
558
559 #[test]
560 fn multiple_weak_algorithms_multiple_findings() {
561 let config = Config {
562 items: vec![Item::Directive {
563 key: "Ciphers".into(),
564 value: "3des-cbc,arcfour,blowfish-cbc".into(),
565 span: Span::new(1),
566 }],
567 };
568 let findings = DeprecatedWeakAlgorithms.check(&config);
569 assert_eq!(findings.len(), 3);
570 }
571
572 #[test]
573 fn weak_algo_inside_host_block() {
574 let config = Config {
575 items: vec![Item::HostBlock {
576 patterns: vec!["legacy-server".to_string()],
577 span: Span::new(1),
578 items: vec![Item::Directive {
579 key: "Ciphers".into(),
580 value: "arcfour256".into(),
581 span: Span::new(2),
582 }],
583 }],
584 };
585 let findings = DeprecatedWeakAlgorithms.check(&config);
586 assert_eq!(findings.len(), 1);
587 assert!(findings[0].message.contains("arcfour256"));
588 }
589
590 #[test]
591 fn weak_algo_with_prefix_modifier() {
592 let config = Config {
593 items: vec![Item::Directive {
594 key: "Ciphers".into(),
595 value: "+3des-cbc".into(),
596 span: Span::new(1),
597 }],
598 };
599 let findings = DeprecatedWeakAlgorithms.check(&config);
600 assert_eq!(findings.len(), 1);
601 assert!(findings[0].message.contains("3des-cbc"));
602 }
603
604 #[test]
605 fn non_algorithm_directive_ignored() {
606 let config = Config {
607 items: vec![Item::Directive {
608 key: "HostName".into(),
609 value: "ssh-rsa.example.com".into(),
610 span: Span::new(1),
611 }],
612 };
613 let findings = DeprecatedWeakAlgorithms.check(&config);
614 assert!(findings.is_empty());
615 }
616
617 #[test]
618 fn weak_algo_has_hint() {
619 let config = Config {
620 items: vec![Item::Directive {
621 key: "MACs".into(),
622 value: "hmac-md5".into(),
623 span: Span::new(1),
624 }],
625 };
626 let findings = DeprecatedWeakAlgorithms.check(&config);
627 assert_eq!(findings.len(), 1);
628 let hint = findings[0].hint.as_deref().unwrap();
629 assert!(hint.contains("hmac-md5"));
630 assert!(hint.contains("stronger algorithm"));
631 }
632
633 #[test]
636 fn duplicate_directives_at_root() {
637 let config = Config {
638 items: vec![
639 Item::Directive {
640 key: "User".into(),
641 value: "noah".into(),
642 span: Span::new(1),
643 },
644 Item::Directive {
645 key: "User".into(),
646 value: "noah2".into(),
647 span: Span::new(2),
648 },
649 ],
650 };
651 let findings = DuplicateDirectives.check(&config);
652 assert_eq!(findings.len(), 1);
653 assert_eq!(findings[0].rule, "duplicate-directives");
654 assert_eq!(findings[0].code, "DUP_DIRECTIVE");
655 assert!(findings[0].message.contains("User"));
656 assert!(findings[0].message.contains("first seen at line 1"));
657 }
658
659 #[test]
660 fn duplicate_directives_inside_host_block() {
661 let config = Config {
662 items: vec![Item::HostBlock {
663 patterns: vec!["example.com".to_string()],
664 span: Span::new(1),
665 items: vec![
666 Item::Directive {
667 key: "HostName".into(),
668 value: "1.2.3.4".into(),
669 span: Span::new(2),
670 },
671 Item::Directive {
672 key: "HostName".into(),
673 value: "5.6.7.8".into(),
674 span: Span::new(3),
675 },
676 ],
677 }],
678 };
679 let findings = DuplicateDirectives.check(&config);
680 assert_eq!(findings.len(), 1);
681 assert!(findings[0].message.contains("HostName"));
682 }
683
684 #[test]
685 fn duplicate_directives_case_insensitive() {
686 let config = Config {
687 items: vec![
688 Item::Directive {
689 key: "User".into(),
690 value: "alice".into(),
691 span: Span::new(1),
692 },
693 Item::Directive {
694 key: "user".into(),
695 value: "bob".into(),
696 span: Span::new(2),
697 },
698 ],
699 };
700 let findings = DuplicateDirectives.check(&config);
701 assert_eq!(findings.len(), 1);
702 }
703
704 #[test]
705 fn duplicate_directives_allows_identity_file() {
706 let config = Config {
707 items: vec![Item::HostBlock {
708 patterns: vec!["server".to_string()],
709 span: Span::new(1),
710 items: vec![
711 Item::Directive {
712 key: "IdentityFile".into(),
713 value: "~/.ssh/id_ed25519".into(),
714 span: Span::new(2),
715 },
716 Item::Directive {
717 key: "IdentityFile".into(),
718 value: "~/.ssh/id_rsa".into(),
719 span: Span::new(3),
720 },
721 ],
722 }],
723 };
724 let findings = DuplicateDirectives.check(&config);
725 assert!(findings.is_empty());
726 }
727
728 #[test]
729 fn duplicate_directives_allows_multi_value_directives() {
730 let config = Config {
731 items: vec![
732 Item::Directive {
733 key: "SendEnv".into(),
734 value: "LANG".into(),
735 span: Span::new(1),
736 },
737 Item::Directive {
738 key: "SendEnv".into(),
739 value: "LC_*".into(),
740 span: Span::new(2),
741 },
742 Item::Directive {
743 key: "LocalForward".into(),
744 value: "8080 localhost:80".into(),
745 span: Span::new(3),
746 },
747 Item::Directive {
748 key: "LocalForward".into(),
749 value: "9090 localhost:90".into(),
750 span: Span::new(4),
751 },
752 ],
753 };
754 let findings = DuplicateDirectives.check(&config);
755 assert!(findings.is_empty());
756 }
757
758 #[test]
759 fn no_duplicate_directives_no_findings() {
760 let config = Config {
761 items: vec![Item::HostBlock {
762 patterns: vec!["server".to_string()],
763 span: Span::new(1),
764 items: vec![
765 Item::Directive {
766 key: "User".into(),
767 value: "git".into(),
768 span: Span::new(2),
769 },
770 Item::Directive {
771 key: "HostName".into(),
772 value: "1.2.3.4".into(),
773 span: Span::new(3),
774 },
775 Item::Directive {
776 key: "Port".into(),
777 value: "22".into(),
778 span: Span::new(4),
779 },
780 ],
781 }],
782 };
783 let findings = DuplicateDirectives.check(&config);
784 assert!(findings.is_empty());
785 }
786
787 #[test]
788 fn duplicate_directives_separate_scopes_ok() {
789 let config = Config {
791 items: vec![
792 Item::HostBlock {
793 patterns: vec!["a".to_string()],
794 span: Span::new(1),
795 items: vec![Item::Directive {
796 key: "User".into(),
797 value: "alice".into(),
798 span: Span::new(2),
799 }],
800 },
801 Item::HostBlock {
802 patterns: vec!["b".to_string()],
803 span: Span::new(4),
804 items: vec![Item::Directive {
805 key: "User".into(),
806 value: "bob".into(),
807 span: Span::new(5),
808 }],
809 },
810 ],
811 };
812 let findings = DuplicateDirectives.check(&config);
813 assert!(findings.is_empty());
814 }
815
816 #[test]
817 fn duplicate_directives_has_hint() {
818 let config = Config {
819 items: vec![
820 Item::Directive {
821 key: "Port".into(),
822 value: "22".into(),
823 span: Span::new(1),
824 },
825 Item::Directive {
826 key: "Port".into(),
827 value: "2222".into(),
828 span: Span::new(2),
829 },
830 ],
831 };
832 let findings = DuplicateDirectives.check(&config);
833 assert_eq!(findings.len(), 1);
834 let hint = findings[0].hint.as_deref().unwrap();
835 assert!(hint.contains("first value takes effect"));
836 }
837
838 #[test]
839 fn duplicate_directives_inside_match_block() {
840 let config = Config {
841 items: vec![Item::MatchBlock {
842 criteria: "host example.com".into(),
843 span: Span::new(1),
844 items: vec![
845 Item::Directive {
846 key: "ForwardAgent".into(),
847 value: "yes".into(),
848 span: Span::new(2),
849 },
850 Item::Directive {
851 key: "ForwardAgent".into(),
852 value: "no".into(),
853 span: Span::new(3),
854 },
855 ],
856 }],
857 };
858 let findings = DuplicateDirectives.check(&config);
859 assert_eq!(findings.len(), 1);
860 assert!(findings[0].message.contains("ForwardAgent"));
861 }
862}