Skip to main content

sshconfig_lint/rules/
basic.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::model::{Config, Finding, Item, Severity, Span};
5use crate::rules::Rule;
6
7/// Warns when multiple Host blocks have the same pattern.
8pub 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
47/// Errors when an IdentityFile points to a file that doesn't exist.
48/// Skips paths containing `%` or `${` (template variables).
49pub 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    // Skip template variables
81    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; // Can't resolve ~ without home dir
90        }
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
109/// Warns when `Host *` appears before more specific Host blocks.
110/// In OpenSSH, first match wins, so `Host *` should usually come last.
111pub 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
151/// Directives whose values are comma-separated algorithm lists.
152const ALGORITHM_DIRECTIVES: &[&str] = &[
153    "ciphers",
154    "macs",
155    "kexalgorithms",
156    "hostkeyalgorithms",
157    "pubkeyacceptedalgorithms",
158    "pubkeyacceptedkeytypes",
159    "casignaturealgorithms",
160];
161
162/// Known deprecated or weak algorithms.
163const WEAK_ALGORITHMS: &[&str] = &[
164    // Ciphers
165    "3des-cbc",
166    "blowfish-cbc",
167    "cast128-cbc",
168    "arcfour",
169    "arcfour128",
170    "arcfour256",
171    "rijndael-cbc@lysator.liu.se",
172    // MACs
173    "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    // Key exchange
184    "diffie-hellman-group1-sha1",
185    "diffie-hellman-group14-sha1",
186    "diffie-hellman-group-exchange-sha1",
187    // Host key / signature
188    "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        // Handle +/- prefix modifiers (e.g. +ssh-rsa)
230        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
260/// Directives that are allowed (or expected) to appear multiple times.
261const 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/// Warns about directives that weaken SSH security.
315///
316/// Catches dangerous settings like StrictHostKeyChecking no (disables MITM
317/// protection) and ForwardAgent yes on wildcard hosts (exposes your agent to
318/// every server you connect to).
319pub struct InsecureOption;
320
321/// (directive_lowercase, bad_value, severity, code, hint)
322const 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
353/// Directives that are risky when set on a wildcard Host *.
354const 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 root-level directives (implicitly global)
380        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            // Always-bad settings
406            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            // Risky-on-wildcard settings
422            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/// Warns when `ControlPath` doesn't include the tokens needed to uniquely
443/// identify connections. The OpenSSH man page recommends that ControlPath
444/// include at least `%h`, `%p`, and `%r` (or alternatively `%C`).
445pub 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    // "none" disables connection sharing — nothing to check
475    if value.eq_ignore_ascii_case("none") {
476        return;
477    }
478
479    // %C is a hash of %l%h%p%r — a single token that covers all four
480    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    // ── DeprecatedWeakAlgorithms tests ──
665
666    #[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    // ── DuplicateDirectives tests ──
838
839    #[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        // Same directive in different Host blocks should NOT warn
994        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    // ── InsecureOption tests ──
1068
1069    #[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        // Root-level directives are implicitly global
1210        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        // Always-bad settings should warn even inside a specific host block
1225        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        // StrictHostKeyChecking + UserKnownHostsFile + LogLevel + ForwardAgent (root=global)
1297        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    // ---- UnsafeControlPath tests ----
1330
1331    #[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}