use crate::db::{find_permission, RiskLevel, PermissionEntry};
use crate::host::{classify_host_pattern, is_host_access_pattern, HostScope};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FindingKind {
Known,
HostPattern,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub token: String,
pub level: RiskLevel,
pub description: String,
pub kind: FindingKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuditReport {
pub findings: Vec<Finding>,
pub overall: RiskLevel,
pub critical_count: usize,
pub high_count: usize,
pub medium_count: usize,
pub low_count: usize,
pub manifest_version: Option<i64>,
}
impl AuditReport {
pub fn summary(&self) -> String {
format!(
"{} overall ({} critical, {} high, {} medium, {} low)",
self.overall.label(),
self.critical_count,
self.high_count,
self.medium_count,
self.low_count,
)
}
pub fn findings_at_or_above(&self, level: RiskLevel) -> Vec<&Finding> {
self.findings
.iter()
.filter(|f| f.level >= level)
.collect()
}
}
pub fn audit<I, S>(permissions: I) -> AuditReport
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
audit_with_manifest_version(permissions, None)
}
pub fn audit_with_manifest_version<I, S>(
permissions: I,
manifest_version: Option<i64>,
) -> AuditReport
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut findings: Vec<Finding> = Vec::new();
for item in permissions {
let token = item.as_ref();
let (finding, _scope) = classify_token(token);
findings.push(finding);
}
let mut critical_count = 0usize;
let mut high_count = 0usize;
let mut medium_count = 0usize;
let mut low_count = 0usize;
for f in &findings {
match f.level {
RiskLevel::Critical => critical_count += 1,
RiskLevel::High => high_count += 1,
RiskLevel::Medium => medium_count += 1,
RiskLevel::Low => low_count += 1,
}
}
let base_overall = findings
.iter()
.map(|f| f.level)
.max()
.unwrap_or(RiskLevel::Low);
let overall = if is_spyware_capability_set(&findings) {
RiskLevel::Critical
} else {
base_overall
};
AuditReport {
findings,
overall,
critical_count,
high_count,
medium_count,
low_count,
manifest_version,
}
}
fn classify_token(token: &str) -> (Finding, HostScope) {
if let Some(PermissionEntry { token: _, level, description }) = find_permission(token) {
return (
Finding {
token: token.to_string(),
level: *level,
description: description.to_string(),
kind: FindingKind::Known,
},
HostScope::Unknown,
);
}
if is_host_access_pattern(token) {
let scope = classify_host_pattern(token);
let (level, description) = host_finding(scope);
return (
Finding {
token: token.to_string(),
level,
description: description.to_string(),
kind: FindingKind::HostPattern,
},
scope,
);
}
(
Finding {
token: token.to_string(),
level: RiskLevel::Low,
description: "This permission is not in the curated risk database. It may \
be a private, enterprise, partner, or future API; treat as \
unclassified until manually reviewed."
.to_string(),
kind: FindingKind::Unknown,
},
HostScope::Unknown,
)
}
fn host_finding(scope: HostScope) -> (RiskLevel, &'static str) {
match scope {
HostScope::All => (
RiskLevel::Critical,
"A blanket host match-pattern (e.g. <all_urls> or *://*/*). Grants the \
extension read and script access to every site on the web — the \
canonical spyware grant.",
),
HostScope::Scheme => (
RiskLevel::Critical,
"A scheme-wide host match-pattern (e.g. https://*/*). Grants the \
extension read and script access to every URL under that scheme.",
),
HostScope::Scoped => (
RiskLevel::Medium,
"A scoped host match-pattern (e.g. *://*.example.com/*). Grants the \
extension read and script access to the matching sites only — less \
broad than <all_urls>, still sensitive.",
),
HostScope::Unknown => (
RiskLevel::Low,
"An unrecognized host-pattern-shaped token. Treat as unclassified \
until manually reviewed.",
),
}
}
fn is_spyware_capability_set(findings: &[Finding]) -> bool {
const CODE_OR_SESSION_TOKENS: &[&str] =
&["scripting", "debugger", "cookies", "webRequest", "webRequestBlocking"];
let has_broad_host = findings
.iter()
.any(|f| f.level == RiskLevel::Critical && f.kind == FindingKind::HostPattern)
|| findings.iter().any(|f| {
matches!(
f.token.as_str(),
"<all_urls>" | "*://*/*" | "https://*/*" | "http://*/*" | "*://*"
)
});
let has_code_or_session = findings
.iter()
.any(|f| CODE_OR_SESSION_TOKENS.contains(&f.token.as_str()));
has_broad_host && has_code_or_session
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_is_low() {
let r = audit(std::iter::empty::<&str>());
assert_eq!(r.overall, RiskLevel::Low);
assert!(r.findings.is_empty());
assert_eq!(r.summary(), "LOW overall (0 critical, 0 high, 0 medium, 0 low)");
}
#[test]
fn benign_permissions_are_low() {
let r = audit(&["activeTab", "storage", "notifications"]);
assert_eq!(r.overall, RiskLevel::Low);
assert_eq!(r.low_count, 3);
assert_eq!(r.critical_count, 0);
assert_eq!(r.high_count, 0);
}
#[test]
fn medium_permissions_dominate_low() {
let r = audit(&["activeTab", "tabs", "history"]);
assert_eq!(r.overall, RiskLevel::Medium);
assert_eq!(r.medium_count, 2);
assert_eq!(r.low_count, 1);
}
#[test]
fn high_permissions_dominate_medium() {
let r = audit(&["tabs", "cookies", "system.cpu"]);
assert_eq!(r.overall, RiskLevel::High);
assert!(r.high_count >= 2);
}
#[test]
fn all_urls_alone_is_critical() {
let r = audit(&["activeTab", "<all_urls>"]);
assert_eq!(r.overall, RiskLevel::Critical);
assert_eq!(r.critical_count, 1);
let all = r.findings.iter().find(|f| f.token == "<all_urls>").unwrap();
assert_eq!(all.level, RiskLevel::Critical);
assert_eq!(all.kind, FindingKind::Known);
}
#[test]
fn spyware_combo_escalates_to_critical() {
let r = audit(&["<all_urls>", "scripting", "cookies"]);
assert_eq!(r.overall, RiskLevel::Critical);
assert!(r.critical_count >= 1);
assert!(r.high_count >= 2); }
#[test]
fn scripting_without_host_access_stays_high() {
let r = audit(&["scripting"]);
assert_eq!(r.overall, RiskLevel::High);
assert_eq!(r.critical_count, 0);
}
#[test]
fn scoped_host_pattern_is_medium() {
let r = audit(&["https://*.example.com/*"]);
assert_eq!(r.overall, RiskLevel::Medium);
let f = &r.findings[0];
assert_eq!(f.level, RiskLevel::Medium);
assert_eq!(f.kind, FindingKind::HostPattern);
assert!(f.description.contains("scoped"));
}
#[test]
fn scheme_wildcard_pattern_is_critical() {
let r = audit(&["ftp://*/*"]);
assert_eq!(r.overall, RiskLevel::Critical);
let f = &r.findings[0];
assert_eq!(f.kind, FindingKind::HostPattern);
assert_eq!(f.level, RiskLevel::Critical);
}
#[test]
fn unknown_token_is_low_not_critical() {
let r = audit(&["somePrivateEnterpriseApi"]);
assert_eq!(r.overall, RiskLevel::Low);
let f = &r.findings[0];
assert_eq!(f.level, RiskLevel::Low);
assert_eq!(f.kind, FindingKind::Unknown);
assert!(f.description.contains("not in the curated"));
}
#[test]
fn findings_preserve_input_order() {
let r = audit(&["cookies", "activeTab", "tabs"]);
assert_eq!(
r.findings.iter().map(|f| f.token.as_str()).collect::<Vec<_>>(),
["cookies", "activeTab", "tabs"]
);
}
#[test]
fn works_with_owned_strings() {
let perms = vec!["activeTab".to_string(), "storage".to_string()];
let r = audit(perms);
assert_eq!(r.overall, RiskLevel::Low);
assert_eq!(r.findings.len(), 2);
}
#[test]
fn manifest_version_round_trips() {
let r = audit_with_manifest_version(&["activeTab"], Some(3));
assert_eq!(r.manifest_version, Some(3));
}
#[test]
fn findings_at_or_above_filters() {
let r = audit(&["activeTab", "tabs", "cookies", "<all_urls>"]);
let high_or_worse = r.findings_at_or_above(RiskLevel::High);
assert!(high_or_worse.iter().all(|f| f.level >= RiskLevel::High));
assert!(high_or_worse.iter().all(|f| f.token != "activeTab"));
assert!(high_or_worse.iter().all(|f| f.token != "tabs"));
assert!(high_or_worse.iter().any(|f| f.token == "cookies"));
assert!(high_or_worse.iter().any(|f| f.token == "<all_urls>"));
}
#[test]
fn summary_contains_counts_and_label() {
let r = audit(&["activeTab", "tabs", "cookies", "<all_urls>"]);
let s = r.summary();
assert!(s.contains("CRITICAL"), "{}", s);
assert!(s.contains("critical"), "{}", s);
assert!(s.contains("high"), "{}", s);
}
#[test]
fn realistic_manifest_audits_correctly() {
let adblock = audit(&[
"declarativeNetRequest",
"storage",
"tabs",
"activeTab",
"*://*/*", ]);
assert_eq!(adblock.overall, RiskLevel::Critical);
assert!(adblock.critical_count >= 1);
let helper = audit(&["activeTab", "storage", "sidePanel"]);
assert_eq!(helper.overall, RiskLevel::Low);
}
#[test]
fn full_findings_have_descriptions() {
let r = audit(&["activeTab", "cookies", "https://*.example.com/*", "??unknown??"]);
for f in &r.findings {
assert!(
f.description.len() >= 25,
"thin description for {:?}: {}",
f.token,
f.description
);
}
}
}