Skip to main content

verifyos_cli/rules/
ats.rs

1use crate::rules::core::{
2    AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
3};
4
5pub struct AtsAuditRule;
6
7impl AppStoreRule for AtsAuditRule {
8    fn id(&self) -> &'static str {
9        "RULE_ATS_AUDIT"
10    }
11
12    fn name(&self) -> &'static str {
13        "ATS Exceptions Detected"
14    }
15
16    fn category(&self) -> RuleCategory {
17        RuleCategory::Ats
18    }
19
20    fn severity(&self) -> Severity {
21        Severity::Warning
22    }
23
24    fn recommendation(&self) -> &'static str {
25        "Remove ATS exceptions or scope them to specific domains with justification."
26    }
27
28    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
29        let Some(plist) = artifact.info_plist else {
30            return Ok(RuleReport {
31                status: RuleStatus::Skip,
32                message: Some("Info.plist not found".to_string()),
33                evidence: None,
34            });
35        };
36
37        let Some(ats_dict) = plist.get_dictionary("NSAppTransportSecurity") else {
38            return Ok(RuleReport {
39                status: RuleStatus::Pass,
40                message: None,
41                evidence: None,
42            });
43        };
44
45        let mut issues = Vec::new();
46
47        if let Some(true) = ats_dict
48            .get("NSAllowsArbitraryLoads")
49            .and_then(|v| v.as_boolean())
50        {
51            issues.push("NSAllowsArbitraryLoads=true".to_string());
52        }
53
54        if let Some(true) = ats_dict
55            .get("NSAllowsArbitraryLoadsInWebContent")
56            .and_then(|v| v.as_boolean())
57        {
58            issues.push("NSAllowsArbitraryLoadsInWebContent=true".to_string());
59        }
60
61        if let Some(domains) = ats_dict
62            .get("NSExceptionDomains")
63            .and_then(|v| v.as_dictionary())
64        {
65            for (domain, config) in domains {
66                if let Some(true) = config
67                    .as_dictionary()
68                    .and_then(|d| d.get("NSExceptionAllowsInsecureHTTPLoads"))
69                    .and_then(|v| v.as_boolean())
70                {
71                    issues.push(format!("NSExceptionAllowsInsecureHTTPLoads for {domain}"));
72                }
73            }
74        }
75
76        if issues.is_empty() {
77            return Ok(RuleReport {
78                status: RuleStatus::Pass,
79                message: None,
80                evidence: None,
81            });
82        }
83
84        Ok(RuleReport {
85            status: RuleStatus::Fail,
86            message: Some("ATS exceptions detected".to_string()),
87            evidence: Some(issues.join("; ")),
88        })
89    }
90}
91
92pub struct AtsExceptionsGranularityRule;
93
94impl AppStoreRule for AtsExceptionsGranularityRule {
95    fn id(&self) -> &'static str {
96        "RULE_ATS_GRANULARITY"
97    }
98
99    fn name(&self) -> &'static str {
100        "ATS Exceptions Too Broad"
101    }
102
103    fn category(&self) -> RuleCategory {
104        RuleCategory::Ats
105    }
106
107    fn severity(&self) -> Severity {
108        Severity::Warning
109    }
110
111    fn recommendation(&self) -> &'static str {
112        "Avoid global ATS relaxations; scope exceptions to specific domains without IncludesSubdomains unless required."
113    }
114
115    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
116        let Some(plist) = artifact.info_plist else {
117            return Ok(RuleReport {
118                status: RuleStatus::Skip,
119                message: Some("Info.plist not found".to_string()),
120                evidence: None,
121            });
122        };
123
124        let Some(ats_dict) = plist.get_dictionary("NSAppTransportSecurity") else {
125            return Ok(RuleReport {
126                status: RuleStatus::Pass,
127                message: None,
128                evidence: None,
129            });
130        };
131
132        let mut issues = Vec::new();
133
134        if is_true(ats_dict, "NSAllowsArbitraryLoads") {
135            issues.push("NSAllowsArbitraryLoads=true".to_string());
136        }
137
138        if is_true(ats_dict, "NSAllowsArbitraryLoadsInWebContent") {
139            issues.push("NSAllowsArbitraryLoadsInWebContent=true".to_string());
140        }
141
142        if is_true(ats_dict, "NSAllowsArbitraryLoadsForMedia") {
143            issues.push("NSAllowsArbitraryLoadsForMedia=true".to_string());
144        }
145
146        if is_true(ats_dict, "NSAllowsArbitraryLoadsForWebContent") {
147            issues.push("NSAllowsArbitraryLoadsForWebContent=true".to_string());
148        }
149
150        if let Some(domains) = ats_dict
151            .get("NSExceptionDomains")
152            .and_then(|v| v.as_dictionary())
153        {
154            for (domain, config) in domains {
155                let Some(domain_dict) = config.as_dictionary() else {
156                    continue;
157                };
158
159                if is_true(domain_dict, "NSIncludesSubdomains") {
160                    issues.push(format!("NSIncludesSubdomains=true for {domain}"));
161                }
162
163                if is_true(domain_dict, "NSExceptionAllowsInsecureHTTPLoads") {
164                    issues.push(format!("NSExceptionAllowsInsecureHTTPLoads for {domain}"));
165                }
166
167                if !is_true(domain_dict, "NSExceptionRequiresForwardSecrecy")
168                    && domain_dict.contains_key("NSExceptionRequiresForwardSecrecy")
169                {
170                    issues.push(format!(
171                        "NSExceptionRequiresForwardSecrecy=false for {domain}"
172                    ));
173                }
174
175                if !is_true(domain_dict, "NSRequiresCertificateTransparency")
176                    && domain_dict.contains_key("NSRequiresCertificateTransparency")
177                {
178                    issues.push(format!(
179                        "NSRequiresCertificateTransparency=false for {domain}"
180                    ));
181                }
182            }
183        }
184
185        if issues.is_empty() {
186            return Ok(RuleReport {
187                status: RuleStatus::Pass,
188                message: Some("ATS exceptions look scoped".to_string()),
189                evidence: None,
190            });
191        }
192
193        Ok(RuleReport {
194            status: RuleStatus::Fail,
195            message: Some("ATS exceptions are overly broad".to_string()),
196            evidence: Some(issues.join(" | ")),
197        })
198    }
199}
200
201fn is_true(dict: &plist::Dictionary, key: &str) -> bool {
202    dict.get(key).and_then(|v| v.as_boolean()) == Some(true)
203}