verifyos_cli/rules/
ats.rs1use 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}