permission-auditor 0.1.0

Audit a list of Chrome / Manifest V3 extension permissions against a curated risk database: every MV3 permission + host-access patterns + plain-English risk descriptions, summarized into a per-extension report. Powers the zovo.one extension security scanner.
Documentation
//! Recognition and classification of Manifest V3 host-access match-patterns.
//!
//! Chrome extensions request site access via match-patterns in
//! `host_permissions` and `content_scripts.matches`. Patterns fall into three
//! scopes:
//!
//! - `All` — blanket access to every site (`<all_urls>`, `*://*/*`, scheme
//!   wildcards). These are the most dangerous and map to
//!   [`crate::RiskLevel::Critical`].
//! - `Scheme` — every URL under a scheme (`http://*/*`, `https://*/*`,
//!   `file:///*`). Also Critical.
//! - `Scoped` — a specific domain family (`*://*.example.com/*`), the
//!   recommended least-privileged form. Medium.
//!
//! Unknown-shaped strings that aren't named tokens and don't parse as
//! patterns are `Unknown`.

/// The scope of a host-access match pattern.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HostScope {
    /// Blanket access to every site (`<all_urls>`, `*://*/*`).
    All,
    /// Every URL under a single scheme (`https://*/*`, `file:///*`).
    Scheme,
    /// A specific domain family (`*://*.example.com/*`).
    Scoped,
    /// Not a recognizable host pattern (likely a named permission token).
    Unknown,
}

/// Heuristic: does `token` look like a Manifest V3 host match-pattern
/// (`scheme://host/path`) or a special broad-access token, rather than a
/// named permission token?
///
/// ```
/// use permission_auditor::is_host_access_pattern;
/// assert!(is_host_access_pattern("https://*.example.com/*"));
/// assert!(is_host_access_pattern("*://*/*"));
/// assert!(is_host_access_pattern("<all_urls>"));
/// assert!(!is_host_access_pattern("tabs"));
/// ```
pub fn is_host_access_pattern(token: &str) -> bool {
    if token == "<all_urls>" {
        return true;
    }
    token.contains("://")
}

/// Classify a token into one of the host-pattern scopes.
///
/// Named tokens that are already in the risk database (`<all_urls>`,
/// `*://*/*`, ...) are recognised as `All`/`Scheme` directly from their
/// database entry via the broader audit; this function provides a structural
/// classification for arbitrary tokens that are *not* in the database (e.g.
/// `https://mail.example.com/*`, `ftp://*/*`).
///
/// ```
/// use permission_auditor::{classify_host_pattern, HostScope};
///
/// // Blanket.
/// assert_eq!(classify_host_pattern("https://*/*"), HostScope::Scheme);
/// // A scoped per-site pattern.
/// assert_eq!(classify_host_pattern("*://*.example.com/*"), HostScope::Scoped);
/// // Not a pattern.
/// assert_eq!(classify_host_pattern("tabs"), HostScope::Unknown);
/// ```
pub fn classify_host_pattern(token: &str) -> HostScope {
    if token == "<all_urls>" {
        return HostScope::All;
    }
    // Split scheme://rest.
    let Some((scheme, rest)) = token.split_once("://") else {
        return HostScope::Unknown;
    };
    let scheme_is_wildcard = scheme == "*";
    // The host portion is everything up to the first '/'.
    let host = match rest.split_once('/') {
        Some((h, _)) => h,
        None => rest,
    };
    // Blanket host: '*' alone, or empty (the `file:///` form, where the host
    // is absent and the path begins immediately). If the scheme is also '*'
    // (*://*/*) this is every site on the web → All; a concrete scheme
    // (https://*/*, file:///*) still covers every site under that scheme →
    // Scheme.
    if host == "*" || host.is_empty() {
        return if scheme_is_wildcard {
            HostScope::All
        } else {
            HostScope::Scheme
        };
    }
    // A '*.tld' or concrete hostname → Scoped.
    if host.starts_with("*.") && host.len() > 2 {
        return HostScope::Scoped;
    }
    if !host.is_empty() {
        // Concrete hostname like 'example.com'.
        return HostScope::Scoped;
    }
    HostScope::Unknown
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn recognizes_blanket_patterns() {
        assert_eq!(classify_host_pattern("<all_urls>"), HostScope::All);
        assert_eq!(classify_host_pattern("*://*/*"), HostScope::All);
        assert_eq!(classify_host_pattern("https://*/*"), HostScope::Scheme);
        assert_eq!(classify_host_pattern("http://*/*"), HostScope::Scheme);
        assert_eq!(classify_host_pattern("file:///*"), HostScope::Scheme);
    }

    #[test]
    fn recognizes_scoped_patterns() {
        assert_eq!(classify_host_pattern("https://*.example.com/*"), HostScope::Scoped);
        assert_eq!(classify_host_pattern("*://mail.example.com/*"), HostScope::Scoped);
        assert_eq!(
            classify_host_pattern("https://example.com/path/*"),
            HostScope::Scoped
        );
    }

    #[test]
    fn non_patterns_are_unknown() {
        assert_eq!(classify_host_pattern("tabs"), HostScope::Unknown);
        assert_eq!(classify_host_pattern("cookies"), HostScope::Unknown);
        assert_eq!(classify_host_pattern(""), HostScope::Unknown);
    }

    #[test]
    fn detector_agrees_with_classifier() {
        for tok in [
            "https://*.example.com/*",
            "*://*/*",
            "<all_urls>",
            "file:///*",
        ] {
            assert!(is_host_access_pattern(tok), "{tok} should be a pattern");
        }
        for tok in ["tabs", "cookies", "activeTab", ""] {
            assert!(!is_host_access_pattern(tok), "{tok:?} should not be a pattern");
        }
    }
}