droidsaw 2.0.0

DROIDSAW — unified Android reverse engineering CLI. Hermes, DEX, APK signing. JSON output, MCP server. Bytecode is not a security layer.
Documentation
//! Detector-local content-shape FP suppressors for trufflehog credential hits.
//!
//! Trufflehog's per-detector regexes are intentionally permissive — they
//! prefix-match a detector keyword (e.g. "circle") and then capture a
//! generic charset window (e.g. `[0-9a-fA-F]{40}`). Active verification
//! against the upstream API would weed out FPs in principle, but in
//! practice (a) verification is rate-limited / network-gated, and (b) the
//! findings table records `verified: false` Critical hits regardless. On
//! crypto-wallet apps the corpus is
//! saturated with strings that match Circle's `[a-fA-F0-9]{40}` shape:
//! - **EIP-55 checksummed Ethereum addresses** — public addresses, not credentials
//!   (anyone can derive billions; the address IS the cryptographic public identity).
//! - **ASN.1 DER-encoded ECDSA signature prefixes** — `30(44|45)02(20|21)` framing
//!   followed by the `r` integer; appears in test vectors and constants in
//!   secp256k1 verification code.
//!
//! This module classifies the `Raw` field of a Circle/CircleCI trufflehog
//! NDJSON line into one of these well-known FP shapes and returns a tag so
//! the ingestion path (`commands::write_credentials_db`) can downgrade the
//! finding from Critical → Info and rewrite the `id_tag` to make the FP
//! shape visible in the audit DB.
//!
//! **Detector-local, not provenance-aware.** This is content-shape
//! suppression: it keys on the `Raw` text bytes of the credential hit, not
//! on the originating SDK class FQN. Orthogonal to the umbrella
//! `provenance-aware-suppression-layer` (which keys on origin class FQN)
//! per R5. The two are independent — a Circle EIP-55 FP can surface from
//! app-vendor code OR a third-party wallet SDK and provenance gates would
//! catch neither.
//!
//! **Safe by construction for Circle.** Real Circle Pay / CCTP API keys
//! are colon-segmented `LIVE_API_KEY:<id>:<secret>` / `TEST_API_KEY:<id>:<secret>`
//! tokens — they cannot match the shape gates here (which require a
//! contiguous 40-hex window, no colons, no underscore-uppercase prefix).
//! Circle's Bearer-auth contract is the source corpus reference for
//! the false-positive shape.

use std::sync::LazyLock;

use regex::Regex;

/// Detector names this module suppresses on. Lowercased to fold trufflehog's
/// variants (`Circle`, `CircleCI`, `circleci`, etc.) into one match.
const CIRCLE_DETECTORS: &[&str] = &["circle", "circleci"];

/// FP-shape classifications for trufflehog Circle credential hits.
///
/// `EthAddress` and `DerEcdsa` are the two documented FP shapes for
/// Circle credential hits in crypto-wallet apps. New shapes are added as
/// additional crypto-wallet / RN apps surface FPs; each variant carries a
/// stable string representation used as the audit-DB `extra.fp_shape`
/// value and as the `id_tag` suffix.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FpShape {
    /// 40-char hex string with EIP-55-style mixed-case capitalization.
    /// Public Ethereum address, not a credential.
    EthAddress,
    /// 40-char hex starting with `30(44|45)02(20|21)` — ASN.1 DER ECDSA
    /// signature prefix. Test vector or signature constant, not a credential.
    DerEcdsa,
}

impl FpShape {
    /// Stable tag used in the audit-DB `extra.fp_shape` JSON field.
    pub fn as_tag(self) -> &'static str {
        match self {
            FpShape::EthAddress => "ETH_ADDRESS",
            FpShape::DerEcdsa => "DER_ECDSA_SIGNATURE",
        }
    }
}

/// Returns true when `detector` (the trufflehog `DetectorName` field) is
/// one this module suppresses on. Case-insensitive — trufflehog emits
/// both `Circle` and `CircleCI` and we want to fold them.
pub fn is_circle_detector(detector: &str) -> bool {
    let lower = detector.to_ascii_lowercase();
    CIRCLE_DETECTORS.iter().any(|n| lower == *n)
}

/// Lazy-compiled regex for the EIP-55 Ethereum-address shape.
/// Matches a 40-char hex string optionally prefixed with `0x`.
static ETH_ADDR_RE: LazyLock<Result<Regex, regex::Error>> =
    LazyLock::new(|| Regex::new(r"^(?:0x)?([0-9a-fA-F]{40})$"));

/// Lazy-compiled regex for the ASN.1 DER ECDSA signature prefix.
///
/// Matches a hex string starting with `30(44|45)02(20|21)` (the
/// SEQUENCE-and-length-and-INTEGER-and-length tag sequence), followed
/// by at least 32 hex chars representing the `r` value's first 16
/// bytes — enough for the trufflehog 40-char capture window to overlap.
static DER_ECDSA_RE: LazyLock<Result<Regex, regex::Error>> =
    LazyLock::new(|| Regex::new(r"^30(?:44|45)02(?:20|21)[0-9a-fA-F]{32,}$"));

/// Classify the `Raw` field of a Circle/CircleCI trufflehog hit into a
/// known FP shape, or return `None` if no shape matches (the finding
/// stays Critical).
///
/// **Behavior contract:**
/// - Returns `Some(FpShape::DerEcdsa)` if `raw` matches the DER ECDSA
///   signature prefix — checked first because the prefix is more
///   structurally specific (it pins the first 8 hex chars to `30(44|45)02(20|21)`).
/// - Returns `Some(FpShape::EthAddress)` if `raw` matches the 40-hex
///   shape AND has both uppercase AND lowercase hex letters (EIP-55
///   checksummed). Pure-lowercase or pure-uppercase 40-hex strings are
///   left as Critical because legitimate Circle keys don't have that
///   shape either, but the additional case-mix gate avoids over-suppressing
///   non-checksummed hex blobs that might genuinely be credential material
///   if Circle ever changes its key format.
/// - Returns `None` for any other shape, including legitimate Circle
///   `LIVE_API_KEY:<id>:<secret>` / `TEST_API_KEY:<id>:<secret>` tokens.
///
/// **Typed-Err on regex compilation failure** — if either lazy regex
/// failed to compile (which is a static contract violation, since the
/// patterns are compile-time constants), we return `None` (no
/// suppression) rather than panic. In practice the `LazyLock` resolves
/// to `Ok(Regex)` deterministically; the `Result` wrapper is defensive.
pub fn classify_circle_raw_shape(raw: &str) -> Option<FpShape> {
    // DER ECDSA signature prefix — checked first (more specific).
    let der_ok = match &*DER_ECDSA_RE {
        Ok(re) => re.is_match(raw),
        Err(_) => false,
    };
    if der_ok {
        return Some(FpShape::DerEcdsa);
    }

    // EIP-55 mixed-case Ethereum address.
    let eth_ok = match &*ETH_ADDR_RE {
        Ok(re) => re.is_match(raw) && has_mixed_hex_case(raw),
        Err(_) => false,
    };
    if eth_ok {
        return Some(FpShape::EthAddress);
    }

    None
}

/// True when `s` contains at least one uppercase hex letter (`A-F`) AND
/// at least one lowercase hex letter (`a-f`). Digit-only and pure-case
/// hex strings return false.
///
/// This is a coarse approximation of EIP-55 checksumming. Full EIP-55
/// verification would require keccak256 over the lowercase address (a
/// non-trivial dependency); the mixed-case heuristic is sufficient
/// because legitimate Circle API keys are colon-segmented bearer tokens
/// with `LIVE_API_KEY:` / `TEST_API_KEY:` ALL_CAPS prefixes — they
/// cannot pass the leading regex guard (no hex-only 40-char window).
fn has_mixed_hex_case(s: &str) -> bool {
    let mut has_upper = false;
    let mut has_lower = false;
    for c in s.bytes() {
        match c {
            b'A'..=b'F' => has_upper = true,
            b'a'..=b'f' => has_lower = true,
            _ => {}
        }
    }
    has_upper && has_lower
}

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

    // ── Detector-name classifier ────────────────────────────────────

    #[test]
    fn circle_detector_lowercase_match() {
        assert!(is_circle_detector("Circle"));
        assert!(is_circle_detector("CIRCLE"));
        assert!(is_circle_detector("circle"));
        assert!(is_circle_detector("CircleCI"));
        assert!(is_circle_detector("circleci"));
    }

    #[test]
    fn circle_detector_rejects_non_circle() {
        assert!(!is_circle_detector("Slack"));
        assert!(!is_circle_detector("PrivateKey"));
        assert!(!is_circle_detector("CircleCo")); // not exact match
        assert!(!is_circle_detector(""));
    }

    // ── EIP-55 Ethereum address shape ───────────────────────────────

    /// Representative EIP-55 addresses (documented FP shapes).
    /// All MUST classify as `EthAddress`.
    #[test]
    fn classify_documented_eip55_addresses() {
        let documented = [
            "515669d308f887Fd83a471C7764F5d084886D34D",
            "E5CAeF4Af8780E59Df925470b050Fb23C43CA68C",
            "9D86b1B2554ec410ecCFfBf111A6994910111340",
            "504cDe95dBC5d90d09B802F43B371971adbEcf79",
        ];
        for raw in documented {
            assert_eq!(
                classify_circle_raw_shape(raw),
                Some(FpShape::EthAddress),
                "expected EIP-55 classification for {raw}"
            );
        }
    }

    #[test]
    fn classify_eip55_with_0x_prefix() {
        let raw = "0x515669d308f887Fd83a471C7764F5d084886D34D";
        assert_eq!(classify_circle_raw_shape(raw), Some(FpShape::EthAddress));
    }

    #[test]
    fn classify_pure_lowercase_hex_is_not_eip55() {
        // 40-char all-lowercase hex — Circle real keys never have this
        // shape (they're colon-segmented `LIVE_API_KEY:...`), but neither
        // does it indicate an Ethereum-address checksum, so keep as
        // Critical (None) per the case-mix gate.
        let raw = "515669d308f887fd83a471c7764f5d084886d34d";
        assert_eq!(classify_circle_raw_shape(raw), None);
    }

    #[test]
    fn classify_pure_uppercase_hex_is_not_eip55() {
        let raw = "515669D308F887FD83A471C7764F5D084886D34D";
        assert_eq!(classify_circle_raw_shape(raw), None);
    }

    #[test]
    fn classify_short_hex_is_not_eip55() {
        // 39 hex chars — too short.
        let raw = "515669d308f887Fd83a471C7764F5d084886D34";
        assert_eq!(classify_circle_raw_shape(raw), None);
    }

    #[test]
    fn classify_long_hex_is_not_eip55() {
        // 41 hex chars — too long.
        let raw = "515669d308f887Fd83a471C7764F5d084886D34D5";
        assert_eq!(classify_circle_raw_shape(raw), None);
    }

    // ── ASN.1 DER ECDSA signature prefix ────────────────────────────

    /// Representative ASN.1 DER ECDSA prefixes (documented FP shapes).
    /// All MUST classify as `DerEcdsa`.
    #[test]
    fn classify_documented_der_signatures() {
        let documented = [
            "3045022100d2e5f6c19461ccab51618430816711",
            "3044022027356aa12903409a3881cfce99c38b09",
            "3045022100fad45d5ed82b06577fc79b368cfe9b",
        ];
        for raw in documented {
            assert_eq!(
                classify_circle_raw_shape(raw),
                Some(FpShape::DerEcdsa),
                "expected DerEcdsa classification for {raw}"
            );
        }
    }

    #[test]
    fn classify_der_with_30_46_is_not_match() {
        // `30(46)` length-70 is rare but the FP shape is documented as
        // `30(44|45)`. We don't generalize the gate beyond the documented
        // shapes because we want strictly fewer false-suppressions of
        // real credentials.
        let raw = "3046022100d2e5f6c19461ccab51618430816711";
        assert_eq!(classify_circle_raw_shape(raw), None);
    }

    #[test]
    fn classify_der_with_02_22_is_not_match() {
        // INTEGER length 0x22 (34 bytes) — not in the documented shape.
        let raw = "3045022200d2e5f6c19461ccab51618430816711";
        assert_eq!(classify_circle_raw_shape(raw), None);
    }

    #[test]
    fn classify_der_short_is_not_match() {
        // Less than 8 prefix + 32 r-value = 40 hex; needs at least the
        // INTEGER tag worth of payload.
        let raw = "3045022100";
        assert_eq!(classify_circle_raw_shape(raw), None);
    }

    // ── Real Circle key fixtures (must stay Critical) ───────────────

    #[test]
    fn classify_real_live_circle_key_is_not_fp() {
        // Real Circle Pay / W3S / CCTP API keys are colon-segmented
        // bearer tokens. The shape gate cannot match because the regex
        // anchors require a contiguous 40-hex (or 0x + 40-hex) string
        // with no other chars — colons and the ALL_CAPS `LIVE_API_KEY`
        // prefix cause `is_match` to return false.
        let raw = "LIVE_API_KEY:abcdef1234567890:ABCDEF1234567890";
        assert_eq!(classify_circle_raw_shape(raw), None);
    }

    #[test]
    fn classify_real_test_circle_key_is_not_fp() {
        let raw = "TEST_API_KEY:7e54-ef21-aa11:9f8b3c2d1e4a5b6c7d8e";
        assert_eq!(classify_circle_raw_shape(raw), None);
    }

    #[test]
    fn classify_circle_uuid_secret_is_not_fp() {
        // Hypothetical Circle key carrying a UUID-shaped secret segment
        // — colons + dashes in the UUID prevent the 40-hex regex from
        // matching.
        let raw = "LIVE_API_KEY:550e8400-e29b-41d4-a716-446655440000:fedcba98";
        assert_eq!(classify_circle_raw_shape(raw), None);
    }

    // ── Pure-noise edge cases ───────────────────────────────────────

    #[test]
    fn classify_empty_string_is_not_fp() {
        assert_eq!(classify_circle_raw_shape(""), None);
    }

    #[test]
    fn classify_short_hex_is_not_fp() {
        assert_eq!(classify_circle_raw_shape("deadbeef"), None);
    }

    #[test]
    fn classify_random_text_is_not_fp() {
        // Trufflehog occasionally emits raw with surrounding text in
        // the capture; if the raw is not a pure 40-hex window the gate
        // returns None and the finding stays Critical.
        let raw = "circle_token_value=abcdef1234567890ABCDEF1234567890abcdef12";
        assert_eq!(classify_circle_raw_shape(raw), None);
    }

    // ── has_mixed_hex_case unit tests ───────────────────────────────

    #[test]
    fn mixed_hex_case_smoke() {
        assert!(has_mixed_hex_case("aB"));
        assert!(has_mixed_hex_case("123Aa456"));
        assert!(!has_mixed_hex_case("AB"));
        assert!(!has_mixed_hex_case("ab"));
        assert!(!has_mixed_hex_case("123456"));
        assert!(!has_mixed_hex_case(""));
    }

    #[test]
    fn fp_shape_tags_are_stable() {
        assert_eq!(FpShape::EthAddress.as_tag(), "ETH_ADDRESS");
        assert_eq!(FpShape::DerEcdsa.as_tag(), "DER_ECDSA_SIGNATURE");
    }
}