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#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::model::{Config, Item, Span};
446    use std::fs;
447    use tempfile::TempDir;
448
449    #[test]
450    fn no_duplicates_no_findings() {
451        let config = Config {
452            items: vec![
453                Item::HostBlock {
454                    patterns: vec!["a".to_string()],
455                    span: Span::new(1),
456                    items: vec![],
457                },
458                Item::HostBlock {
459                    patterns: vec!["b".to_string()],
460                    span: Span::new(3),
461                    items: vec![],
462                },
463            ],
464        };
465        let findings = DuplicateHost.check(&config);
466        assert!(findings.is_empty());
467    }
468
469    #[test]
470    fn duplicate_host_warns() {
471        let config = Config {
472            items: vec![
473                Item::HostBlock {
474                    patterns: vec!["github.com".to_string()],
475                    span: Span::new(1),
476                    items: vec![],
477                },
478                Item::HostBlock {
479                    patterns: vec!["github.com".to_string()],
480                    span: Span::new(5),
481                    items: vec![],
482                },
483            ],
484        };
485        let findings = DuplicateHost.check(&config);
486        assert_eq!(findings.len(), 1);
487        assert_eq!(findings[0].rule, "duplicate-host");
488        assert!(findings[0].message.contains("first seen at line 1"));
489    }
490
491    #[test]
492    fn identity_file_exists_no_error() {
493        let tmp = TempDir::new().unwrap();
494        let key_path = tmp.path().join("id_test");
495        fs::write(&key_path, "fake key").unwrap();
496
497        let config = Config {
498            items: vec![Item::HostBlock {
499                patterns: vec!["a".to_string()],
500                span: Span::new(1),
501                items: vec![Item::Directive {
502                    key: "IdentityFile".into(),
503                    value: key_path.to_string_lossy().into_owned(),
504                    span: Span::new(2),
505                }],
506            }],
507        };
508        let findings = IdentityFileExists.check(&config);
509        assert!(findings.is_empty());
510    }
511
512    #[test]
513    fn identity_file_missing_errors() {
514        let config = Config {
515            items: vec![Item::Directive {
516                key: "IdentityFile".into(),
517                value: "/nonexistent/path/id_nope".into(),
518                span: Span::new(1),
519            }],
520        };
521        let findings = IdentityFileExists.check(&config);
522        assert_eq!(findings.len(), 1);
523        assert_eq!(findings[0].rule, "identity-file-exists");
524    }
525
526    #[test]
527    fn identity_file_skips_templates() {
528        let config = Config {
529            items: vec![
530                Item::Directive {
531                    key: "IdentityFile".into(),
532                    value: "~/.ssh/id_%h".into(),
533                    span: Span::new(1),
534                },
535                Item::Directive {
536                    key: "IdentityFile".into(),
537                    value: "${HOME}/.ssh/id_ed25519".into(),
538                    span: Span::new(2),
539                },
540            ],
541        };
542        let findings = IdentityFileExists.check(&config);
543        assert!(findings.is_empty());
544    }
545
546    #[test]
547    fn wildcard_after_specific_no_warning() {
548        let config = Config {
549            items: vec![
550                Item::HostBlock {
551                    patterns: vec!["github.com".to_string()],
552                    span: Span::new(1),
553                    items: vec![],
554                },
555                Item::HostBlock {
556                    patterns: vec!["*".to_string()],
557                    span: Span::new(5),
558                    items: vec![],
559                },
560            ],
561        };
562        let findings = WildcardHostOrder.check(&config);
563        assert!(findings.is_empty());
564    }
565
566    #[test]
567    fn wildcard_before_specific_warns() {
568        let config = Config {
569            items: vec![
570                Item::HostBlock {
571                    patterns: vec!["*".to_string()],
572                    span: Span::new(1),
573                    items: vec![],
574                },
575                Item::HostBlock {
576                    patterns: vec!["github.com".to_string()],
577                    span: Span::new(5),
578                    items: vec![],
579                },
580            ],
581        };
582        let findings = WildcardHostOrder.check(&config);
583        assert_eq!(findings.len(), 1);
584        assert_eq!(findings[0].rule, "wildcard-host-order");
585        assert!(findings[0].message.contains("github.com"));
586    }
587
588    // ── DeprecatedWeakAlgorithms tests ──
589
590    #[test]
591    fn weak_cipher_warns() {
592        let config = Config {
593            items: vec![Item::Directive {
594                key: "Ciphers".into(),
595                value: "aes128-ctr,3des-cbc,aes256-gcm@openssh.com".into(),
596                span: Span::new(1),
597            }],
598        };
599        let findings = DeprecatedWeakAlgorithms.check(&config);
600        assert_eq!(findings.len(), 1);
601        assert_eq!(findings[0].code, "WEAK_ALGO");
602        assert!(findings[0].message.contains("3des-cbc"));
603        assert!(findings[0].message.contains("Ciphers"));
604    }
605
606    #[test]
607    fn weak_mac_warns() {
608        let config = Config {
609            items: vec![Item::Directive {
610                key: "MACs".into(),
611                value: "hmac-sha2-256,hmac-md5".into(),
612                span: Span::new(3),
613            }],
614        };
615        let findings = DeprecatedWeakAlgorithms.check(&config);
616        assert_eq!(findings.len(), 1);
617        assert!(findings[0].message.contains("hmac-md5"));
618    }
619
620    #[test]
621    fn weak_kex_warns() {
622        let config = Config {
623            items: vec![Item::Directive {
624                key: "KexAlgorithms".into(),
625                value: "diffie-hellman-group1-sha1".into(),
626                span: Span::new(1),
627            }],
628        };
629        let findings = DeprecatedWeakAlgorithms.check(&config);
630        assert_eq!(findings.len(), 1);
631        assert!(findings[0].message.contains("diffie-hellman-group1-sha1"));
632    }
633
634    #[test]
635    fn weak_host_key_algorithm_warns() {
636        let config = Config {
637            items: vec![Item::Directive {
638                key: "HostKeyAlgorithms".into(),
639                value: "ssh-ed25519,ssh-dss".into(),
640                span: Span::new(2),
641            }],
642        };
643        let findings = DeprecatedWeakAlgorithms.check(&config);
644        assert_eq!(findings.len(), 1);
645        assert!(findings[0].message.contains("ssh-dss"));
646    }
647
648    #[test]
649    fn weak_pubkey_accepted_warns() {
650        let config = Config {
651            items: vec![Item::Directive {
652                key: "PubkeyAcceptedAlgorithms".into(),
653                value: "ssh-rsa,ssh-ed25519".into(),
654                span: Span::new(1),
655            }],
656        };
657        let findings = DeprecatedWeakAlgorithms.check(&config);
658        assert_eq!(findings.len(), 1);
659        assert!(findings[0].message.contains("ssh-rsa"));
660    }
661
662    #[test]
663    fn strong_algorithms_no_warning() {
664        let config = Config {
665            items: vec![
666                Item::Directive {
667                    key: "Ciphers".into(),
668                    value: "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com".into(),
669                    span: Span::new(1),
670                },
671                Item::Directive {
672                    key: "MACs".into(),
673                    value: "hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com".into(),
674                    span: Span::new(2),
675                },
676                Item::Directive {
677                    key: "KexAlgorithms".into(),
678                    value: "curve25519-sha256,diffie-hellman-group16-sha512".into(),
679                    span: Span::new(3),
680                },
681            ],
682        };
683        let findings = DeprecatedWeakAlgorithms.check(&config);
684        assert!(findings.is_empty());
685    }
686
687    #[test]
688    fn multiple_weak_algorithms_multiple_findings() {
689        let config = Config {
690            items: vec![Item::Directive {
691                key: "Ciphers".into(),
692                value: "3des-cbc,arcfour,blowfish-cbc".into(),
693                span: Span::new(1),
694            }],
695        };
696        let findings = DeprecatedWeakAlgorithms.check(&config);
697        assert_eq!(findings.len(), 3);
698    }
699
700    #[test]
701    fn weak_algo_inside_host_block() {
702        let config = Config {
703            items: vec![Item::HostBlock {
704                patterns: vec!["legacy-server".to_string()],
705                span: Span::new(1),
706                items: vec![Item::Directive {
707                    key: "Ciphers".into(),
708                    value: "arcfour256".into(),
709                    span: Span::new(2),
710                }],
711            }],
712        };
713        let findings = DeprecatedWeakAlgorithms.check(&config);
714        assert_eq!(findings.len(), 1);
715        assert!(findings[0].message.contains("arcfour256"));
716    }
717
718    #[test]
719    fn weak_algo_with_prefix_modifier() {
720        let config = Config {
721            items: vec![Item::Directive {
722                key: "Ciphers".into(),
723                value: "+3des-cbc".into(),
724                span: Span::new(1),
725            }],
726        };
727        let findings = DeprecatedWeakAlgorithms.check(&config);
728        assert_eq!(findings.len(), 1);
729        assert!(findings[0].message.contains("3des-cbc"));
730    }
731
732    #[test]
733    fn non_algorithm_directive_ignored() {
734        let config = Config {
735            items: vec![Item::Directive {
736                key: "HostName".into(),
737                value: "ssh-rsa.example.com".into(),
738                span: Span::new(1),
739            }],
740        };
741        let findings = DeprecatedWeakAlgorithms.check(&config);
742        assert!(findings.is_empty());
743    }
744
745    #[test]
746    fn weak_algo_has_hint() {
747        let config = Config {
748            items: vec![Item::Directive {
749                key: "MACs".into(),
750                value: "hmac-md5".into(),
751                span: Span::new(1),
752            }],
753        };
754        let findings = DeprecatedWeakAlgorithms.check(&config);
755        assert_eq!(findings.len(), 1);
756        let hint = findings[0].hint.as_deref().unwrap();
757        assert!(hint.contains("hmac-md5"));
758        assert!(hint.contains("stronger algorithm"));
759    }
760
761    // ── DuplicateDirectives tests ──
762
763    #[test]
764    fn duplicate_directives_at_root() {
765        let config = Config {
766            items: vec![
767                Item::Directive {
768                    key: "User".into(),
769                    value: "noah".into(),
770                    span: Span::new(1),
771                },
772                Item::Directive {
773                    key: "User".into(),
774                    value: "noah2".into(),
775                    span: Span::new(2),
776                },
777            ],
778        };
779        let findings = DuplicateDirectives.check(&config);
780        assert_eq!(findings.len(), 1);
781        assert_eq!(findings[0].rule, "duplicate-directives");
782        assert_eq!(findings[0].code, "DUP_DIRECTIVE");
783        assert!(findings[0].message.contains("User"));
784        assert!(findings[0].message.contains("first seen at line 1"));
785    }
786
787    #[test]
788    fn duplicate_directives_inside_host_block() {
789        let config = Config {
790            items: vec![Item::HostBlock {
791                patterns: vec!["example.com".to_string()],
792                span: Span::new(1),
793                items: vec![
794                    Item::Directive {
795                        key: "HostName".into(),
796                        value: "1.2.3.4".into(),
797                        span: Span::new(2),
798                    },
799                    Item::Directive {
800                        key: "HostName".into(),
801                        value: "5.6.7.8".into(),
802                        span: Span::new(3),
803                    },
804                ],
805            }],
806        };
807        let findings = DuplicateDirectives.check(&config);
808        assert_eq!(findings.len(), 1);
809        assert!(findings[0].message.contains("HostName"));
810    }
811
812    #[test]
813    fn duplicate_directives_case_insensitive() {
814        let config = Config {
815            items: vec![
816                Item::Directive {
817                    key: "User".into(),
818                    value: "alice".into(),
819                    span: Span::new(1),
820                },
821                Item::Directive {
822                    key: "user".into(),
823                    value: "bob".into(),
824                    span: Span::new(2),
825                },
826            ],
827        };
828        let findings = DuplicateDirectives.check(&config);
829        assert_eq!(findings.len(), 1);
830    }
831
832    #[test]
833    fn duplicate_directives_allows_identity_file() {
834        let config = Config {
835            items: vec![Item::HostBlock {
836                patterns: vec!["server".to_string()],
837                span: Span::new(1),
838                items: vec![
839                    Item::Directive {
840                        key: "IdentityFile".into(),
841                        value: "~/.ssh/id_ed25519".into(),
842                        span: Span::new(2),
843                    },
844                    Item::Directive {
845                        key: "IdentityFile".into(),
846                        value: "~/.ssh/id_rsa".into(),
847                        span: Span::new(3),
848                    },
849                ],
850            }],
851        };
852        let findings = DuplicateDirectives.check(&config);
853        assert!(findings.is_empty());
854    }
855
856    #[test]
857    fn duplicate_directives_allows_multi_value_directives() {
858        let config = Config {
859            items: vec![
860                Item::Directive {
861                    key: "SendEnv".into(),
862                    value: "LANG".into(),
863                    span: Span::new(1),
864                },
865                Item::Directive {
866                    key: "SendEnv".into(),
867                    value: "LC_*".into(),
868                    span: Span::new(2),
869                },
870                Item::Directive {
871                    key: "LocalForward".into(),
872                    value: "8080 localhost:80".into(),
873                    span: Span::new(3),
874                },
875                Item::Directive {
876                    key: "LocalForward".into(),
877                    value: "9090 localhost:90".into(),
878                    span: Span::new(4),
879                },
880            ],
881        };
882        let findings = DuplicateDirectives.check(&config);
883        assert!(findings.is_empty());
884    }
885
886    #[test]
887    fn no_duplicate_directives_no_findings() {
888        let config = Config {
889            items: vec![Item::HostBlock {
890                patterns: vec!["server".to_string()],
891                span: Span::new(1),
892                items: vec![
893                    Item::Directive {
894                        key: "User".into(),
895                        value: "git".into(),
896                        span: Span::new(2),
897                    },
898                    Item::Directive {
899                        key: "HostName".into(),
900                        value: "1.2.3.4".into(),
901                        span: Span::new(3),
902                    },
903                    Item::Directive {
904                        key: "Port".into(),
905                        value: "22".into(),
906                        span: Span::new(4),
907                    },
908                ],
909            }],
910        };
911        let findings = DuplicateDirectives.check(&config);
912        assert!(findings.is_empty());
913    }
914
915    #[test]
916    fn duplicate_directives_separate_scopes_ok() {
917        // Same directive in different Host blocks should NOT warn
918        let config = Config {
919            items: vec![
920                Item::HostBlock {
921                    patterns: vec!["a".to_string()],
922                    span: Span::new(1),
923                    items: vec![Item::Directive {
924                        key: "User".into(),
925                        value: "alice".into(),
926                        span: Span::new(2),
927                    }],
928                },
929                Item::HostBlock {
930                    patterns: vec!["b".to_string()],
931                    span: Span::new(4),
932                    items: vec![Item::Directive {
933                        key: "User".into(),
934                        value: "bob".into(),
935                        span: Span::new(5),
936                    }],
937                },
938            ],
939        };
940        let findings = DuplicateDirectives.check(&config);
941        assert!(findings.is_empty());
942    }
943
944    #[test]
945    fn duplicate_directives_has_hint() {
946        let config = Config {
947            items: vec![
948                Item::Directive {
949                    key: "Port".into(),
950                    value: "22".into(),
951                    span: Span::new(1),
952                },
953                Item::Directive {
954                    key: "Port".into(),
955                    value: "2222".into(),
956                    span: Span::new(2),
957                },
958            ],
959        };
960        let findings = DuplicateDirectives.check(&config);
961        assert_eq!(findings.len(), 1);
962        let hint = findings[0].hint.as_deref().unwrap();
963        assert!(hint.contains("first value takes effect"));
964    }
965
966    #[test]
967    fn duplicate_directives_inside_match_block() {
968        let config = Config {
969            items: vec![Item::MatchBlock {
970                criteria: "host example.com".into(),
971                span: Span::new(1),
972                items: vec![
973                    Item::Directive {
974                        key: "ForwardAgent".into(),
975                        value: "yes".into(),
976                        span: Span::new(2),
977                    },
978                    Item::Directive {
979                        key: "ForwardAgent".into(),
980                        value: "no".into(),
981                        span: Span::new(3),
982                    },
983                ],
984            }],
985        };
986        let findings = DuplicateDirectives.check(&config);
987        assert_eq!(findings.len(), 1);
988        assert!(findings[0].message.contains("ForwardAgent"));
989    }
990
991    // ── InsecureOption tests ──
992
993    #[test]
994    fn strict_host_key_checking_no_warns() {
995        let config = Config {
996            items: vec![Item::Directive {
997                key: "StrictHostKeyChecking".into(),
998                value: "no".into(),
999                span: Span::new(1),
1000            }],
1001        };
1002        let findings = InsecureOption.check(&config);
1003        assert_eq!(findings.len(), 1);
1004        assert_eq!(findings[0].code, "INSECURE_OPT");
1005        assert_eq!(findings[0].severity, Severity::Warning);
1006        assert!(findings[0].message.contains("MITM"));
1007    }
1008
1009    #[test]
1010    fn strict_host_key_checking_off_warns() {
1011        let config = Config {
1012            items: vec![Item::Directive {
1013                key: "StrictHostKeyChecking".into(),
1014                value: "off".into(),
1015                span: Span::new(1),
1016            }],
1017        };
1018        let findings = InsecureOption.check(&config);
1019        assert_eq!(findings.len(), 1);
1020        assert!(findings[0].message.contains("MITM"));
1021    }
1022
1023    #[test]
1024    fn strict_host_key_checking_ask_ok() {
1025        let config = Config {
1026            items: vec![Item::Directive {
1027                key: "StrictHostKeyChecking".into(),
1028                value: "ask".into(),
1029                span: Span::new(1),
1030            }],
1031        };
1032        let findings = InsecureOption.check(&config);
1033        assert!(findings.is_empty());
1034    }
1035
1036    #[test]
1037    fn strict_host_key_checking_accept_new_ok() {
1038        let config = Config {
1039            items: vec![Item::Directive {
1040                key: "StrictHostKeyChecking".into(),
1041                value: "accept-new".into(),
1042                span: Span::new(1),
1043            }],
1044        };
1045        let findings = InsecureOption.check(&config);
1046        assert!(findings.is_empty());
1047    }
1048
1049    #[test]
1050    fn user_known_hosts_dev_null_warns() {
1051        let config = Config {
1052            items: vec![Item::Directive {
1053                key: "UserKnownHostsFile".into(),
1054                value: "/dev/null".into(),
1055                span: Span::new(1),
1056            }],
1057        };
1058        let findings = InsecureOption.check(&config);
1059        assert_eq!(findings.len(), 1);
1060        assert!(findings[0].message.contains("known host keys"));
1061    }
1062
1063    #[test]
1064    fn loglevel_quiet_info() {
1065        let config = Config {
1066            items: vec![Item::Directive {
1067                key: "LogLevel".into(),
1068                value: "QUIET".into(),
1069                span: Span::new(1),
1070            }],
1071        };
1072        let findings = InsecureOption.check(&config);
1073        assert_eq!(findings.len(), 1);
1074        assert_eq!(findings[0].severity, Severity::Info);
1075    }
1076
1077    #[test]
1078    fn forward_agent_yes_on_wildcard_warns() {
1079        let config = Config {
1080            items: vec![Item::HostBlock {
1081                patterns: vec!["*".to_string()],
1082                span: Span::new(1),
1083                items: vec![Item::Directive {
1084                    key: "ForwardAgent".into(),
1085                    value: "yes".into(),
1086                    span: Span::new(2),
1087                }],
1088            }],
1089        };
1090        let findings = InsecureOption.check(&config);
1091        assert_eq!(findings.len(), 1);
1092        assert_eq!(findings[0].severity, Severity::Warning);
1093        assert!(findings[0].message.contains("global"));
1094    }
1095
1096    #[test]
1097    fn forward_agent_yes_on_specific_host_ok() {
1098        let config = Config {
1099            items: vec![Item::HostBlock {
1100                patterns: vec!["bastion.example.com".to_string()],
1101                span: Span::new(1),
1102                items: vec![Item::Directive {
1103                    key: "ForwardAgent".into(),
1104                    value: "yes".into(),
1105                    span: Span::new(2),
1106                }],
1107            }],
1108        };
1109        let findings = InsecureOption.check(&config);
1110        assert!(findings.is_empty());
1111    }
1112
1113    #[test]
1114    fn forward_x11_yes_on_wildcard_warns() {
1115        let config = Config {
1116            items: vec![Item::HostBlock {
1117                patterns: vec!["*".to_string()],
1118                span: Span::new(1),
1119                items: vec![Item::Directive {
1120                    key: "ForwardX11".into(),
1121                    value: "yes".into(),
1122                    span: Span::new(2),
1123                }],
1124            }],
1125        };
1126        let findings = InsecureOption.check(&config);
1127        assert_eq!(findings.len(), 1);
1128        assert!(findings[0].message.contains("X11"));
1129    }
1130
1131    #[test]
1132    fn forward_agent_at_root_level_warns() {
1133        // Root-level directives are implicitly global
1134        let config = Config {
1135            items: vec![Item::Directive {
1136                key: "ForwardAgent".into(),
1137                value: "yes".into(),
1138                span: Span::new(1),
1139            }],
1140        };
1141        let findings = InsecureOption.check(&config);
1142        assert_eq!(findings.len(), 1);
1143        assert!(findings[0].message.contains("global"));
1144    }
1145
1146    #[test]
1147    fn strict_host_key_inside_host_block_warns() {
1148        // Always-bad settings should warn even inside a specific host block
1149        let config = Config {
1150            items: vec![Item::HostBlock {
1151                patterns: vec!["dev-server".to_string()],
1152                span: Span::new(1),
1153                items: vec![Item::Directive {
1154                    key: "StrictHostKeyChecking".into(),
1155                    value: "no".into(),
1156                    span: Span::new(2),
1157                }],
1158            }],
1159        };
1160        let findings = InsecureOption.check(&config);
1161        assert_eq!(findings.len(), 1);
1162        assert!(findings[0].message.contains("MITM"));
1163    }
1164
1165    #[test]
1166    fn insecure_option_has_hint() {
1167        let config = Config {
1168            items: vec![Item::Directive {
1169                key: "StrictHostKeyChecking".into(),
1170                value: "no".into(),
1171                span: Span::new(1),
1172            }],
1173        };
1174        let findings = InsecureOption.check(&config);
1175        assert_eq!(findings.len(), 1);
1176        assert!(findings[0].hint.is_some());
1177        assert!(findings[0].hint.as_deref().unwrap().contains("accept-new"));
1178    }
1179
1180    #[test]
1181    fn case_insensitive_directive_and_value() {
1182        let config = Config {
1183            items: vec![Item::Directive {
1184                key: "stricthostkeychecking".into(),
1185                value: "NO".into(),
1186                span: Span::new(1),
1187            }],
1188        };
1189        let findings = InsecureOption.check(&config);
1190        assert_eq!(findings.len(), 1);
1191    }
1192
1193    #[test]
1194    fn multiple_insecure_settings() {
1195        let config = Config {
1196            items: vec![
1197                Item::Directive {
1198                    key: "StrictHostKeyChecking".into(),
1199                    value: "no".into(),
1200                    span: Span::new(1),
1201                },
1202                Item::Directive {
1203                    key: "UserKnownHostsFile".into(),
1204                    value: "/dev/null".into(),
1205                    span: Span::new(2),
1206                },
1207                Item::Directive {
1208                    key: "LogLevel".into(),
1209                    value: "QUIET".into(),
1210                    span: Span::new(3),
1211                },
1212                Item::Directive {
1213                    key: "ForwardAgent".into(),
1214                    value: "yes".into(),
1215                    span: Span::new(4),
1216                },
1217            ],
1218        };
1219        let findings = InsecureOption.check(&config);
1220        // StrictHostKeyChecking + UserKnownHostsFile + LogLevel + ForwardAgent (root=global)
1221        assert_eq!(findings.len(), 4);
1222    }
1223
1224    #[test]
1225    fn safe_config_no_findings() {
1226        let config = Config {
1227            items: vec![
1228                Item::Directive {
1229                    key: "StrictHostKeyChecking".into(),
1230                    value: "yes".into(),
1231                    span: Span::new(1),
1232                },
1233                Item::Directive {
1234                    key: "LogLevel".into(),
1235                    value: "VERBOSE".into(),
1236                    span: Span::new(2),
1237                },
1238                Item::HostBlock {
1239                    patterns: vec!["myhost".to_string()],
1240                    span: Span::new(3),
1241                    items: vec![Item::Directive {
1242                        key: "ForwardAgent".into(),
1243                        value: "yes".into(),
1244                        span: Span::new(4),
1245                    }],
1246                },
1247            ],
1248        };
1249        let findings = InsecureOption.check(&config);
1250        assert!(findings.is_empty());
1251    }
1252}