1use crate::db::{find_permission, RiskLevel, PermissionEntry};
5use crate::host::{classify_host_pattern, is_host_access_pattern, HostScope};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum FindingKind {
11 Known,
13 HostPattern,
15 Unknown,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Finding {
24 pub token: String,
26 pub level: RiskLevel,
28 pub description: String,
30 pub kind: FindingKind,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct AuditReport {
37 pub findings: Vec<Finding>,
39 pub overall: RiskLevel,
41 pub critical_count: usize,
43 pub high_count: usize,
45 pub medium_count: usize,
47 pub low_count: usize,
49 pub manifest_version: Option<i64>,
53}
54
55impl AuditReport {
56 pub fn summary(&self) -> String {
65 format!(
66 "{} overall ({} critical, {} high, {} medium, {} low)",
67 self.overall.label(),
68 self.critical_count,
69 self.high_count,
70 self.medium_count,
71 self.low_count,
72 )
73 }
74
75 pub fn findings_at_or_above(&self, level: RiskLevel) -> Vec<&Finding> {
78 self.findings
79 .iter()
80 .filter(|f| f.level >= level)
81 .collect()
82 }
83}
84
85pub fn audit<I, S>(permissions: I) -> AuditReport
118where
119 I: IntoIterator<Item = S>,
120 S: AsRef<str>,
121{
122 audit_with_manifest_version(permissions, None)
123}
124
125pub fn audit_with_manifest_version<I, S>(
130 permissions: I,
131 manifest_version: Option<i64>,
132) -> AuditReport
133where
134 I: IntoIterator<Item = S>,
135 S: AsRef<str>,
136{
137 let mut findings: Vec<Finding> = Vec::new();
138 for item in permissions {
139 let token = item.as_ref();
140 let (finding, _scope) = classify_token(token);
141 findings.push(finding);
142 }
143
144 let mut critical_count = 0usize;
146 let mut high_count = 0usize;
147 let mut medium_count = 0usize;
148 let mut low_count = 0usize;
149 for f in &findings {
150 match f.level {
151 RiskLevel::Critical => critical_count += 1,
152 RiskLevel::High => high_count += 1,
153 RiskLevel::Medium => medium_count += 1,
154 RiskLevel::Low => low_count += 1,
155 }
156 }
157
158 let base_overall = findings
161 .iter()
162 .map(|f| f.level)
163 .max()
164 .unwrap_or(RiskLevel::Low);
165 let overall = if is_spyware_capability_set(&findings) {
166 RiskLevel::Critical
167 } else {
168 base_overall
169 };
170
171 AuditReport {
172 findings,
173 overall,
174 critical_count,
175 high_count,
176 medium_count,
177 low_count,
178 manifest_version,
179 }
180}
181
182fn classify_token(token: &str) -> (Finding, HostScope) {
185 if let Some(PermissionEntry { token: _, level, description }) = find_permission(token) {
187 return (
188 Finding {
189 token: token.to_string(),
190 level: *level,
191 description: description.to_string(),
192 kind: FindingKind::Known,
193 },
194 HostScope::Unknown,
195 );
196 }
197 if is_host_access_pattern(token) {
199 let scope = classify_host_pattern(token);
200 let (level, description) = host_finding(scope);
201 return (
202 Finding {
203 token: token.to_string(),
204 level,
205 description: description.to_string(),
206 kind: FindingKind::HostPattern,
207 },
208 scope,
209 );
210 }
211 (
213 Finding {
214 token: token.to_string(),
215 level: RiskLevel::Low,
216 description: "This permission is not in the curated risk database. It may \
217 be a private, enterprise, partner, or future API; treat as \
218 unclassified until manually reviewed."
219 .to_string(),
220 kind: FindingKind::Unknown,
221 },
222 HostScope::Unknown,
223 )
224}
225
226fn host_finding(scope: HostScope) -> (RiskLevel, &'static str) {
231 match scope {
232 HostScope::All => (
233 RiskLevel::Critical,
234 "A blanket host match-pattern (e.g. <all_urls> or *://*/*). Grants the \
235 extension read and script access to every site on the web — the \
236 canonical spyware grant.",
237 ),
238 HostScope::Scheme => (
239 RiskLevel::Critical,
240 "A scheme-wide host match-pattern (e.g. https://*/*). Grants the \
241 extension read and script access to every URL under that scheme.",
242 ),
243 HostScope::Scoped => (
244 RiskLevel::Medium,
245 "A scoped host match-pattern (e.g. *://*.example.com/*). Grants the \
246 extension read and script access to the matching sites only — less \
247 broad than <all_urls>, still sensitive.",
248 ),
249 HostScope::Unknown => (
250 RiskLevel::Low,
251 "An unrecognized host-pattern-shaped token. Treat as unclassified \
252 until manually reviewed.",
253 ),
254 }
255}
256
257fn is_spyware_capability_set(findings: &[Finding]) -> bool {
261 const CODE_OR_SESSION_TOKENS: &[&str] =
262 &["scripting", "debugger", "cookies", "webRequest", "webRequestBlocking"];
263 let has_broad_host = findings
264 .iter()
265 .any(|f| f.level == RiskLevel::Critical && f.kind == FindingKind::HostPattern)
266 || findings.iter().any(|f| {
267 matches!(
268 f.token.as_str(),
269 "<all_urls>" | "*://*/*" | "https://*/*" | "http://*/*" | "*://*"
270 )
271 });
272 let has_code_or_session = findings
273 .iter()
274 .any(|f| CODE_OR_SESSION_TOKENS.contains(&f.token.as_str()));
275 has_broad_host && has_code_or_session
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn empty_input_is_low() {
284 let r = audit(std::iter::empty::<&str>());
285 assert_eq!(r.overall, RiskLevel::Low);
286 assert!(r.findings.is_empty());
287 assert_eq!(r.summary(), "LOW overall (0 critical, 0 high, 0 medium, 0 low)");
288 }
289
290 #[test]
291 fn benign_permissions_are_low() {
292 let r = audit(&["activeTab", "storage", "notifications"]);
293 assert_eq!(r.overall, RiskLevel::Low);
294 assert_eq!(r.low_count, 3);
295 assert_eq!(r.critical_count, 0);
296 assert_eq!(r.high_count, 0);
297 }
298
299 #[test]
300 fn medium_permissions_dominate_low() {
301 let r = audit(&["activeTab", "tabs", "history"]);
302 assert_eq!(r.overall, RiskLevel::Medium);
303 assert_eq!(r.medium_count, 2);
304 assert_eq!(r.low_count, 1);
305 }
306
307 #[test]
308 fn high_permissions_dominate_medium() {
309 let r = audit(&["tabs", "cookies", "system.cpu"]);
310 assert_eq!(r.overall, RiskLevel::High);
311 assert!(r.high_count >= 2);
312 }
313
314 #[test]
315 fn all_urls_alone_is_critical() {
316 let r = audit(&["activeTab", "<all_urls>"]);
317 assert_eq!(r.overall, RiskLevel::Critical);
318 assert_eq!(r.critical_count, 1);
319 let all = r.findings.iter().find(|f| f.token == "<all_urls>").unwrap();
320 assert_eq!(all.level, RiskLevel::Critical);
321 assert_eq!(all.kind, FindingKind::Known);
322 }
323
324 #[test]
325 fn spyware_combo_escalates_to_critical() {
326 let r = audit(&["<all_urls>", "scripting", "cookies"]);
328 assert_eq!(r.overall, RiskLevel::Critical);
329 assert!(r.critical_count >= 1);
330 assert!(r.high_count >= 2); }
332
333 #[test]
334 fn scripting_without_host_access_stays_high() {
335 let r = audit(&["scripting"]);
337 assert_eq!(r.overall, RiskLevel::High);
338 assert_eq!(r.critical_count, 0);
339 }
340
341 #[test]
342 fn scoped_host_pattern_is_medium() {
343 let r = audit(&["https://*.example.com/*"]);
344 assert_eq!(r.overall, RiskLevel::Medium);
345 let f = &r.findings[0];
346 assert_eq!(f.level, RiskLevel::Medium);
347 assert_eq!(f.kind, FindingKind::HostPattern);
348 assert!(f.description.contains("scoped"));
349 }
350
351 #[test]
352 fn scheme_wildcard_pattern_is_critical() {
353 let r = audit(&["ftp://*/*"]);
354 assert_eq!(r.overall, RiskLevel::Critical);
356 let f = &r.findings[0];
357 assert_eq!(f.kind, FindingKind::HostPattern);
358 assert_eq!(f.level, RiskLevel::Critical);
359 }
360
361 #[test]
362 fn unknown_token_is_low_not_critical() {
363 let r = audit(&["somePrivateEnterpriseApi"]);
364 assert_eq!(r.overall, RiskLevel::Low);
365 let f = &r.findings[0];
366 assert_eq!(f.level, RiskLevel::Low);
367 assert_eq!(f.kind, FindingKind::Unknown);
368 assert!(f.description.contains("not in the curated"));
369 }
370
371 #[test]
372 fn findings_preserve_input_order() {
373 let r = audit(&["cookies", "activeTab", "tabs"]);
374 assert_eq!(
375 r.findings.iter().map(|f| f.token.as_str()).collect::<Vec<_>>(),
376 ["cookies", "activeTab", "tabs"]
377 );
378 }
379
380 #[test]
381 fn works_with_owned_strings() {
382 let perms = vec!["activeTab".to_string(), "storage".to_string()];
383 let r = audit(perms);
384 assert_eq!(r.overall, RiskLevel::Low);
385 assert_eq!(r.findings.len(), 2);
386 }
387
388 #[test]
389 fn manifest_version_round_trips() {
390 let r = audit_with_manifest_version(&["activeTab"], Some(3));
391 assert_eq!(r.manifest_version, Some(3));
392 }
393
394 #[test]
395 fn findings_at_or_above_filters() {
396 let r = audit(&["activeTab", "tabs", "cookies", "<all_urls>"]);
397 let high_or_worse = r.findings_at_or_above(RiskLevel::High);
398 assert!(high_or_worse.iter().all(|f| f.level >= RiskLevel::High));
399 assert!(high_or_worse.iter().all(|f| f.token != "activeTab"));
401 assert!(high_or_worse.iter().all(|f| f.token != "tabs"));
402 assert!(high_or_worse.iter().any(|f| f.token == "cookies"));
404 assert!(high_or_worse.iter().any(|f| f.token == "<all_urls>"));
405 }
406
407 #[test]
408 fn summary_contains_counts_and_label() {
409 let r = audit(&["activeTab", "tabs", "cookies", "<all_urls>"]);
410 let s = r.summary();
411 assert!(s.contains("CRITICAL"), "{}", s);
412 assert!(s.contains("critical"), "{}", s);
413 assert!(s.contains("high"), "{}", s);
414 }
415
416 #[test]
417 fn realistic_manifest_audits_correctly() {
418 let adblock = audit(&[
420 "declarativeNetRequest",
421 "storage",
422 "tabs",
423 "activeTab",
424 "*://*/*", ]);
426 assert_eq!(adblock.overall, RiskLevel::Critical);
427 assert!(adblock.critical_count >= 1);
429
430 let helper = audit(&["activeTab", "storage", "sidePanel"]);
432 assert_eq!(helper.overall, RiskLevel::Low);
433 }
434
435 #[test]
436 fn full_findings_have_descriptions() {
437 let r = audit(&["activeTab", "cookies", "https://*.example.com/*", "??unknown??"]);
438 for f in &r.findings {
439 assert!(
440 f.description.len() >= 25,
441 "thin description for {:?}: {}",
442 f.token,
443 f.description
444 );
445 }
446 }
447}