Skip to main content

security_events/
mobile_redaction.rs

1//! Mobile-specific log sanitization for MASVS-STORAGE-2 and MASVS-CODE-2.
2//!
3//! Provides:
4//! - [`MobileRedactionEngine`] — scrubs device identifiers (IMEI, IDFV, GAID/IDFA,
5//!   MAC addresses) and GPS coordinates from [`SecurityEvent`] labels.
6//! - [`LogLevelEnforcer`] — compile-time log level enforcement that suppresses
7//!   debug/trace events in release builds.
8//! - [`LogLevel`] — log verbosity levels for event filtering.
9//!
10//! Integrates with the existing [`RedactionEngine`](crate::redact::RedactionEngine)
11//! pipeline: mobile scrubbing runs *before* classification-driven redaction.
12
13use crate::event::{EventValue, SecurityEvent};
14use std::collections::BTreeMap;
15
16/// Log verbosity levels for [`LogLevelEnforcer`].
17///
18/// Ordered from most verbose (`Trace`) to least verbose (`Error`).
19#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub enum LogLevel {
21    /// Most verbose — fine-grained tracing.
22    Trace = 0,
23    /// Debug-level diagnostic output.
24    Debug = 1,
25    /// Informational messages.
26    Info = 2,
27    /// Warnings that may need attention.
28    Warn = 3,
29    /// Errors requiring action.
30    Error = 4,
31}
32
33/// Enforces a minimum log level, suppressing events below the threshold.
34///
35/// Use [`LogLevelEnforcer::release()`] for production builds (strips `Trace` and
36/// `Debug`) and [`LogLevelEnforcer::debug()`] for development (allows all levels).
37#[derive(Clone, Debug)]
38pub struct LogLevelEnforcer {
39    min_level: LogLevel,
40}
41
42impl LogLevelEnforcer {
43    /// Creates an enforcer for release builds — suppresses `Trace` and `Debug`.
44    #[must_use]
45    pub fn release() -> Self {
46        Self {
47            min_level: LogLevel::Info,
48        }
49    }
50
51    /// Creates an enforcer for debug builds — allows all log levels.
52    #[must_use]
53    pub fn debug() -> Self {
54        Self {
55            min_level: LogLevel::Trace,
56        }
57    }
58
59    /// Creates an enforcer with a custom minimum log level.
60    #[must_use]
61    pub fn with_min_level(min_level: LogLevel) -> Self {
62        Self { min_level }
63    }
64
65    /// Returns `true` if the given log level should be emitted (i.e. it is
66    /// at or above the configured minimum).
67    #[must_use]
68    pub fn should_emit(&self, level: LogLevel) -> bool {
69        level >= self.min_level
70    }
71}
72
73/// Keys whose UUID values should NOT be treated as device/advertising IDs.
74const NON_DEVICE_UUID_KEYS: &[&str] = &[
75    "event_id",
76    "parent_event_id",
77    "request_id",
78    "trace_id",
79    "correlation_id",
80    "session_id",
81    "transaction_id",
82    "span_id",
83];
84
85/// Scrubs mobile-specific device identifiers and location data from
86/// [`SecurityEvent`] labels.
87///
88/// Pattern matching order:
89/// 1. IMEI — exactly 15 digits
90/// 2. MAC address — `XX:XX:XX:XX:XX:XX` or `XX-XX-XX-XX-XX-XX`
91/// 3. GPS coordinates — decimal latitude/longitude pair
92/// 4. UUID-format device IDs (IDFV, GAID, IDFA) — only when the label key
93///    suggests a device or advertising identifier
94#[derive(Clone, Debug)]
95pub struct MobileRedactionEngine {
96    _private: (),
97}
98
99impl MobileRedactionEngine {
100    /// Creates a new [`MobileRedactionEngine`] with default patterns.
101    #[must_use]
102    pub fn new() -> Self {
103        Self { _private: () }
104    }
105
106    /// Processes a [`SecurityEvent`], scrubbing mobile device identifiers and
107    /// location coordinates from label values.
108    #[must_use]
109    pub fn scrub_event(&self, mut event: SecurityEvent) -> SecurityEvent {
110        let mut new_labels = BTreeMap::new();
111        for (key, value) in event.labels {
112            match value {
113                EventValue::Classified {
114                    value: v,
115                    classification,
116                } => {
117                    let scrubbed = self.scrub_value(&key, &v);
118                    new_labels.insert(
119                        key,
120                        EventValue::Classified {
121                            value: scrubbed,
122                            classification,
123                        },
124                    );
125                }
126            }
127        }
128        event.labels = new_labels;
129        event
130    }
131
132    /// Scrubs a single value, returning the replacement string.
133    fn scrub_value(&self, key: &str, value: &str) -> String {
134        let trimmed = value.trim();
135
136        // 1. Check for IMEI (exactly 15 digits)
137        if is_imei(trimmed) {
138            return "[DEVICE_ID_REDACTED]".to_string();
139        }
140
141        // 2. Check for MAC address
142        if is_mac_address(trimmed) {
143            return "[DEVICE_ID_REDACTED]".to_string();
144        }
145
146        // 3. Check for GPS coordinates
147        if is_gps_coordinates(trimmed) {
148            return "[LOCATION_REDACTED]".to_string();
149        }
150
151        // 4. Check for UUID-format device/advertising IDs
152        if is_uuid(trimmed) && is_device_id_key(key) {
153            return if is_advertising_id_key(key) {
154                "[AD_ID_REDACTED]".to_string()
155            } else {
156                "[DEVICE_ID_REDACTED]".to_string()
157            };
158        }
159
160        value.to_string()
161    }
162}
163
164impl Default for MobileRedactionEngine {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170/// Returns `true` if the string is exactly 15 ASCII digits (IMEI format).
171fn is_imei(s: &str) -> bool {
172    s.len() == 15 && s.bytes().all(|b| b.is_ascii_digit())
173}
174
175/// Returns `true` if the string matches MAC address format:
176/// `XX:XX:XX:XX:XX:XX` or `XX-XX-XX-XX-XX-XX` where X is a hex digit.
177fn is_mac_address(s: &str) -> bool {
178    if s.len() != 17 {
179        return false;
180    }
181    let bytes = s.as_bytes();
182    let separator = bytes[2];
183    if separator != b':' && separator != b'-' {
184        return false;
185    }
186    for (i, &b) in bytes.iter().enumerate() {
187        let pos_in_group = i % 3;
188        if pos_in_group == 2 {
189            if i == 17 - 1 {
190                // Last char should be hex
191                if !b.is_ascii_hexdigit() {
192                    return false;
193                }
194            } else if b != separator {
195                return false;
196            }
197        } else if !b.is_ascii_hexdigit() {
198            return false;
199        }
200    }
201    true
202}
203
204/// Returns `true` if the string looks like a GPS coordinate pair:
205/// `[-]DD.DDDD, [-]DDD.DDDD` (latitude, longitude with decimal points).
206fn is_gps_coordinates(s: &str) -> bool {
207    let parts: Vec<&str> = s.split(',').collect();
208    if parts.len() != 2 {
209        return false;
210    }
211    is_decimal_coordinate(parts[0].trim()) && is_decimal_coordinate(parts[1].trim())
212}
213
214/// Returns `true` if the string is a decimal number with a fractional part,
215/// optionally prefixed with `-`.
216fn is_decimal_coordinate(s: &str) -> bool {
217    let s = s.strip_prefix('-').unwrap_or(s);
218    let dot_parts: Vec<&str> = s.split('.').collect();
219    if dot_parts.len() != 2 {
220        return false;
221    }
222    let integer = dot_parts[0];
223    let fraction = dot_parts[1];
224    if integer.is_empty() || fraction.is_empty() {
225        return false;
226    }
227    // Latitude integer part: 1-3 digits; fraction: at least 1 digit
228    if integer.len() > 3 || integer.is_empty() {
229        return false;
230    }
231    integer.bytes().all(|b| b.is_ascii_digit()) && fraction.bytes().all(|b| b.is_ascii_digit())
232}
233
234/// Returns `true` if the string is a standard UUID format:
235/// `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX` (case-insensitive).
236fn is_uuid(s: &str) -> bool {
237    if s.len() != 36 {
238        return false;
239    }
240    let bytes = s.as_bytes();
241    // Hyphens at positions 8, 13, 18, 23
242    if bytes[8] != b'-' || bytes[13] != b'-' || bytes[18] != b'-' || bytes[23] != b'-' {
243        return false;
244    }
245    for (i, &b) in bytes.iter().enumerate() {
246        if i == 8 || i == 13 || i == 18 || i == 23 {
247            continue;
248        }
249        if !b.is_ascii_hexdigit() {
250            return false;
251        }
252    }
253    true
254}
255
256/// Returns `true` if the label key suggests a device identifier.
257fn is_device_id_key(key: &str) -> bool {
258    let lower = key.to_ascii_lowercase();
259    // Exclude keys that are clearly non-device IDs
260    if NON_DEVICE_UUID_KEYS.iter().any(|&k| lower == k) {
261        return false;
262    }
263    lower.contains("idfv")
264        || lower.contains("idfa")
265        || lower.contains("gaid")
266        || lower.contains("ad_id")
267        || lower.contains("advertising")
268        || lower.contains("device")
269        || lower.contains("vendor")
270        || lower.contains("hardware")
271}
272
273/// Returns `true` if the label key suggests an advertising identifier.
274fn is_advertising_id_key(key: &str) -> bool {
275    let lower = key.to_ascii_lowercase();
276    lower.contains("ad_id")
277        || lower.contains("idfa")
278        || lower.contains("gaid")
279        || lower.contains("advertising")
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_is_imei() {
288        assert!(is_imei("353456789012345"));
289        assert!(!is_imei("12345678901234")); // 14 digits
290        assert!(!is_imei("1234567890123456")); // 16 digits
291        assert!(!is_imei("35345678901234a")); // non-digit
292    }
293
294    #[test]
295    fn test_is_mac_address() {
296        assert!(is_mac_address("AA:BB:CC:DD:EE:FF"));
297        assert!(is_mac_address("aa:bb:cc:dd:ee:ff"));
298        assert!(is_mac_address("AA-BB-CC-DD-EE-FF"));
299        assert!(!is_mac_address("AA:BB:CC:DD:EE")); // too short
300        assert!(!is_mac_address("AABBCCDDEEFF")); // no separators
301        assert!(!is_mac_address("GG:BB:CC:DD:EE:FF")); // invalid hex
302    }
303
304    #[test]
305    fn test_is_gps_coordinates() {
306        assert!(is_gps_coordinates("37.7749, -122.4194"));
307        assert!(is_gps_coordinates("-33.8688, 151.2093"));
308        assert!(is_gps_coordinates("0.0, 0.0"));
309        assert!(!is_gps_coordinates("hello, world"));
310        assert!(!is_gps_coordinates("37.7749"));
311        assert!(!is_gps_coordinates(""));
312    }
313
314    #[test]
315    fn test_is_uuid() {
316        assert!(is_uuid("E621E1F8-C36C-495A-93FC-0C247A3E6E5F"));
317        assert!(is_uuid("38400000-8cf0-11bd-b23e-10b96e40000d"));
318        assert!(!is_uuid("not-a-uuid"));
319        assert!(!is_uuid("E621E1F8C36C495A93FC0C247A3E6E5F")); // no hyphens
320    }
321
322    #[test]
323    fn test_log_level_ordering() {
324        assert!(LogLevel::Error > LogLevel::Warn);
325        assert!(LogLevel::Warn > LogLevel::Info);
326        assert!(LogLevel::Info > LogLevel::Debug);
327        assert!(LogLevel::Debug > LogLevel::Trace);
328    }
329}