repotoire 0.9.0

Graph-powered code analysis CLI. 110 detectors for security, architecture, bus factor, and code quality.
Documentation
//! The blocking-tier allowlist — the single auditable list of detectors that may ever block.
//!
//! See `docs/superpowers/specs/2026-05-12-blocking-tier-design.md` §2. The mechanism: a detector is
//! *on the allowlist* (and overrides `RegisteredDetector::max_tier()` to `Blocking`); a *finding*
//! from it is actually `Blocking` only if it carries the matching [`crate::models::Evidence`] and
//! clears `enforce_blocking_invariant`. Nothing outside this list can ever be `Blocking`,
//! regardless of what a detector emits. The entire blocking surface is `grep BLOCKING_ALLOWLIST`.

use crate::models::Tier;

/// Detectors allowed to emit `Tier::Blocking`. Entries are *already normalized* via
/// [`crate::detectors::normalize_detector_id`] — i.e. lowercase, separators stripped, a trailing
/// `"detector"` removed. (Verify with the `allowlist_is_normalized_and_lookup_works` test.)
///
/// **Each entry must equal `normalize_detector_id(<the string that detector stamps on
/// `Finding.detector`>)`** — that is what `enforce_blocking_invariant` keys off, *not* `name()`.
/// For every detector below the emitted `Finding.detector` is the struct name (e.g.
/// `"SecretDetector"`), so the normalized form is the struct name minus the trailing `"detector"`
/// (`"secret"`). Note: a detector's `name()` can normalize differently from what it *emits* —
/// `SecretDetector::name()` is `"secret-detection"` (→ `"secretdetection"`), but it emits
/// `detector: "SecretDetector"` (→ `"secret"`), and the allowlist must match the *emitted* value.
/// The `blocking_allowlist_matches_emitted_detector_field` test guards this.
///
/// Adding an entry = also teach that detector the narrow predicate + the `Evidence` payload, and
/// override its `max_tier()` to `Blocking`.
pub const BLOCKING_ALLOWLIST: &[&str] = &[
    // Group A — tainted-flow-to-dangerous-sink. The SSA taint engine produces the path; the sink
    // must be in DANGEROUS_SINKS; no sanitizer on the path. (Each emits Finding.detector = its
    // struct name; normalized form shown.)
    //   CommandInjectionDetector   -> "commandinjection"
    //   SQLInjectionDetector       -> "sqlinjection"
    //   XssDetector                -> "xss"
    //   SsrfDetector               -> "ssrf"
    //   PathTraversalDetector      -> "pathtraversal"
    //   EvalDetector               -> "eval"
    //   UnsafeTemplateDetector     -> "unsafetemplate"
    //   NosqlInjectionDetector     -> "nosqlinjection"
    "commandinjection",
    "sqlinjection",
    "xss",
    "ssrf",
    "pathtraversal",
    "eval",
    "unsafetemplate",
    "nosqlinjection",
    // Group B — hardcoded real secret (known format + structure, or high entropy; not in a
    // redaction-list / test-fixture / doc-example / placeholder context).
    //   SecretDetector  emits detector: "SecretDetector"  -> "secret"
    "secret",
    // Group C — config-fact security misconfigs (the exact anti-pattern is syntactically present).
    //   InsecureTlsDetector        -> "insecuretls"
    //   JwtWeakDetector            -> "jwtweak"
    //   GHActionsInjectionDetector -> "ghactionsinjection"
    "insecuretls",
    "jwtweak",
    "ghactionsinjection",
];

/// Whether `detector_name` (in any spelling — `name()` form, kebab, snake, normalized) is on the
/// blocking allowlist.
pub fn is_blocking_allowlisted(detector_name: &str) -> bool {
    let n = crate::detectors::normalize_detector_id(detector_name);
    BLOCKING_ALLOWLIST.contains(&n.as_str())
}

/// The static blocking ceiling for a detector by name (no config / deep-set membership applied).
/// Returns `Tier::Blocking` for allowlisted detectors, else `Tier::Advisory`. Deep-set membership
/// is layered on top by the engine's `tier_cap` closure (it caps deep-only detectors at `Deep`).
pub fn detector_max_tier(detector_name: &str) -> Tier {
    if is_blocking_allowlisted(detector_name) {
        Tier::Blocking
    } else {
        Tier::Advisory
    }
}

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

    #[test]
    fn allowlist_is_normalized_and_lookup_works() {
        for name in BLOCKING_ALLOWLIST {
            assert_eq!(
                *name,
                &crate::detectors::normalize_detector_id(name)[..],
                "{name} is not in normalized form"
            );
        }
        // Lookup tolerates the various spellings.
        assert_eq!(detector_max_tier("SQLInjectionDetector"), Tier::Blocking);
        assert_eq!(detector_max_tier("sql-injection"), Tier::Blocking);
        assert_eq!(detector_max_tier("sql_injection"), Tier::Blocking);
        assert_eq!(detector_max_tier("sqlinjection"), Tier::Blocking);
        assert_eq!(detector_max_tier("SecretDetector"), Tier::Blocking);
        assert_eq!(
            detector_max_tier("GHActionsInjectionDetector"),
            Tier::Blocking
        );
        // Not on the allowlist, not deep -> Advisory (the default ceiling).
        assert_eq!(detector_max_tier("MagicNumbersDetector"), Tier::Advisory);
        assert!(!is_blocking_allowlisted("god-class"));
    }

    /// Regression: the allowlist must match the string each detector actually stamps on
    /// `Finding.detector` (what `enforce_blocking_invariant` keys off), which for every allowlisted
    /// detector is its struct name — *not* `name()`, which can normalize differently (e.g.
    /// `SecretDetector::name()` is `"secret-detection"` → `"secretdetection"`, but it emits
    /// `detector: "SecretDetector"` → `"secret"`). If a detector's emitted id changes, update both
    /// this list and `BLOCKING_ALLOWLIST`.
    #[test]
    fn blocking_allowlist_matches_emitted_detector_field() {
        // The exact strings these detectors put in `Finding.detector` (grep `detector: "..."` in
        // their `detect()` bodies — they all use the struct name).
        const EMITTED_DETECTOR_IDS: &[&str] = &[
            "CommandInjectionDetector",
            "SQLInjectionDetector",
            "XssDetector",
            "SsrfDetector",
            "PathTraversalDetector",
            "EvalDetector",
            "UnsafeTemplateDetector",
            "NosqlInjectionDetector",
            "SecretDetector",
            "InsecureTlsDetector",
            "JwtWeakDetector",
            "GHActionsInjectionDetector",
        ];
        assert_eq!(EMITTED_DETECTOR_IDS.len(), BLOCKING_ALLOWLIST.len());
        for id in EMITTED_DETECTOR_IDS {
            assert!(
                is_blocking_allowlisted(id),
                "{id} emits Finding.detector = \"{id}\" but is not covered by BLOCKING_ALLOWLIST \
                 (normalized: \"{}\") — a Blocking finding from it would be silently downgraded",
                crate::detectors::normalize_detector_id(id)
            );
        }
    }
}