Skip to main content

permission_auditor/
report.rs

1//! The audit engine: turn a manifest's permission list into a structured
2//! [`AuditReport`].
3
4use crate::db::{find_permission, RiskLevel, PermissionEntry};
5use crate::host::{classify_host_pattern, is_host_access_pattern, HostScope};
6
7/// Whether a finding came from the curated named-token database or was
8/// synthesised for a host match-pattern / unknown token.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum FindingKind {
11    /// Looked up directly in [`crate::RISK_DATABASE`].
12    Known,
13    /// A host match-pattern synthesised by classifying its scope.
14    HostPattern,
15    /// An unrecognised token (private, partner, or future API). Surfaced
16    /// rather than silently escalated.
17    Unknown,
18}
19
20/// A single audited permission: the token, the resolved risk level, a
21/// description, and where the classification came from.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Finding {
24    /// The exact manifest token that produced this finding.
25    pub token: String,
26    /// The risk level assigned.
27    pub level: RiskLevel,
28    /// Plain-English description of what the grant allows.
29    pub description: String,
30    /// The classification provenance.
31    pub kind: FindingKind,
32}
33
34/// A complete audit of one extension's permission list.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct AuditReport {
37    /// One [`Finding`] per input token, in input order.
38    pub findings: Vec<Finding>,
39    /// The overall (highest) risk level across all findings.
40    pub overall: RiskLevel,
41    /// Count of `Critical` findings.
42    pub critical_count: usize,
43    /// Count of `High` findings.
44    pub high_count: usize,
45    /// Count of `Medium` findings.
46    pub medium_count: usize,
47    /// Count of `Low` findings.
48    pub low_count: usize,
49    /// The manifest version the audit assumed (2 = MV2, 3 = MV3, None =
50    /// unspecified). Affects how `webRequest`-style permissions are
51    /// interpreted and which tokens are even legal.
52    pub manifest_version: Option<i64>,
53}
54
55impl AuditReport {
56    /// A one-line summary suitable for a list view.
57    ///
58    /// ```
59    /// use permission_auditor::audit;
60    /// let r = audit(&["activeTab", "cookies"]);
61    /// let s = r.summary();
62    /// assert!(s.contains("HIGH") || s.contains("CRITICAL"));
63    /// ```
64    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    /// Findings at or above the given level, in input order. Useful for a
76    /// "what should I worry about" view.
77    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
85/// Audit a list of manifest permission tokens and return a structured
86/// [`AuditReport`].
87///
88/// Accepts any iterable of string-like items (`&[&str]`, `Vec<&str>`,
89/// `Vec<String>`, an array, ...). For each token:
90///
91/// 1. If it is in [`crate::RISK_DATABASE`], the entry's level + description
92///    are used directly.
93/// 2. Otherwise, if it looks like a host match-pattern, its
94///    [`HostScope`] is used to assign a level (All/Scheme → Critical,
95///    Scoped → Medium) and a synthesised description.
96/// 3. Otherwise it is surfaced as an `Unknown` Low finding with a
97///    "review manually" note — never silently escalated.
98///
99/// The overall report level is the highest finding's level, with one
100/// escalation rule: an extension that requests **any blanket/critical host
101/// access** together with **`scripting`, `debugger`, `cookies`, or
102/// `webRequest`** is the canonical spyware capability set and is escalated
103/// to `Critical` regardless of how the individual tokens were classified.
104///
105/// ```
106/// use permission_auditor::{audit, RiskLevel};
107///
108/// // The full spyware set.
109/// let r = audit(&["<all_urls>", "scripting", "cookies"]);
110/// assert_eq!(r.overall, RiskLevel::Critical);
111/// assert!(r.critical_count >= 1);
112///
113/// // Benign.
114/// let r = audit(&["activeTab", "storage"]);
115/// assert_eq!(r.overall, RiskLevel::Low);
116/// ```
117pub 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
125/// Like [`audit`] but also takes the manifest version, which is surfaced on
126/// the report. A missing/unknown version is treated permissively (no
127/// downgrades), since MV2 extensions are still in the wild and we'd rather
128/// over-report than miss a `webRequest`-blocking grant.
129pub 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    // Aggregate counts.
145    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    // Overall = max of finding levels, escalated to Critical when the spyware
159    // capability combo (broad host + code/cookie access) is present.
160    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
182/// Classify a single token into a [`Finding`], returning the finding plus
183/// the [`HostScope`] (if it was a host pattern).
184fn classify_token(token: &str) -> (Finding, HostScope) {
185    // 1. Direct database hit.
186    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    // 2. Host match-pattern.
198    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    // 3. Unknown token.
212    (
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
226/// Resolve a [`HostScope`] to a risk level + description for a synthesised
227/// finding. Blanket and scheme-wide patterns are Critical; scoped patterns
228/// are Medium (sensitive but bounded to one site family); unknowns fall back
229/// to Low.
230fn 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
257/// The spyware capability detector: an extension that requests arbitrary
258/// host access (Critical) AND the ability to run code or read session state
259/// on those hosts is, for practical purposes, a surveillance tool.
260fn 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        // <all_urls> + scripting + cookies is the full surveillance set.
327        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); // scripting + cookies are High
331    }
332
333    #[test]
334    fn scripting_without_host_access_stays_high() {
335        // scripting alone (no host access) is High, not escalated to Critical.
336        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        // ftp://*/* is not in the database but classifies as scheme-wide.
355        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        // activeTab (Low) and tabs (Medium) excluded.
400        assert!(high_or_worse.iter().all(|f| f.token != "activeTab"));
401        assert!(high_or_worse.iter().all(|f| f.token != "tabs"));
402        // cookies + <all_urls> present.
403        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        // A plausible ad-blocker-shaped manifest.
419        let adblock = audit(&[
420            "declarativeNetRequest",
421            "storage",
422            "tabs",
423            "activeTab",
424            "*://*/*", // blanket host — Critical
425        ]);
426        assert_eq!(adblock.overall, RiskLevel::Critical);
427        // Note: *://*/* is in the database as Critical.
428        assert!(adblock.critical_count >= 1);
429
430        // A plausible benign devtools helper.
431        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}