diff --git a/Cargo.lock b/Cargo.lock index aa16de2e..a57e5060 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1820,6 +1820,8 @@ dependencies = [ name = "tinysecurity-redact" version = "0.17.1" dependencies = [ + "base64 0.22.1", + "percent-encoding", "regex", "serde_json", "sha2", diff --git a/MODULE.md b/MODULE.md index 92ecc935..8f4c421c 100644 --- a/MODULE.md +++ b/MODULE.md @@ -397,13 +397,17 @@ compact) so space-padded single-token triggers still score. Its false-positive t recalled-memory transcripts that merely mention "system prompt" or a "token push", the plural "tokens" of language-model text, and a colleague named Dan. A base64-like run and an invisible character are reported (`obfuscation.base64_run`, `obfuscation.invisible_character`) but -carry no weight: they are common in benign content. The host's optional environment classifier is -not part of the module. A tier is context and never authorizes execution. Input is bounded at -64 KiB; larger input is refused, never truncated. Latency budget: about 20 us per KiB in release +carry no weight: they are common in benign content. The optional +`OPENHUMAN_PROMPT_INJECTION_CLASSIFIER=heuristic` host classifier is dropped; +the deterministic owner scorer is the only classifier signal. A tier is context +and never authorizes execution. Input is bounded at 64 KiB; larger `ScanPrompt2` +input returns `review` with `input.oversized`, never a partial score. Latency budget: about 20 us per KiB in release (1 KiB 19 us, 4 KiB 65 us, 64 KiB 1.0 ms on the reference machine), so p99 under 5 ms for any accepted input. -`ScanPrompt` and `ScanToolDefinition` keep their verdict wire. Their scanner now also reports a +`ScanPrompt` and `ScanToolDefinition` keep their verdict wire. The latter accepts a +serialized JSON object, which the MCP registry caller must build from the tool's +complete definition; arrays, scalars and malformed JSON are refused. Their scanner now also reports a space-padded `jailbreak` as `override.obfuscated_role`, so a remote tool description with spaced-out trigger words no longer slips through. diff --git a/crates/tinysecurity-bus/src/prompt_score.rs b/crates/tinysecurity-bus/src/prompt_score.rs index b4e5a65e..fde00ef5 100644 --- a/crates/tinysecurity-bus/src/prompt_score.rs +++ b/crates/tinysecurity-bus/src/prompt_score.rs @@ -5,7 +5,9 @@ //! `Allow`, `Review` or `Block` tier it falls in and the content-free rule codes that //! contributed, in detection order. The module owns the rules, the weights and the //! thresholds; the host only maps a tier to its own handling. A tier is context and -//! never authorizes execution. +//! never authorizes execution. Inputs above the 64 KiB scorer ceiling receive +//! `Review` with `input.oversized` and a zero score, so callers fail closed +//! without attempting a partial scan. use serde::{Deserialize, Serialize}; /// Score at or above which a prompt is `Review`, in thousandths. diff --git a/crates/tinysecurity-bus/src/redact.rs b/crates/tinysecurity-bus/src/redact.rs index c8f2010c..288c952b 100644 --- a/crates/tinysecurity-bus/src/redact.rs +++ b/crates/tinysecurity-bus/src/redact.rs @@ -84,13 +84,15 @@ pub struct ScanPromptRequest { /// Prompt to scan. pub text: String, } -/// `ScanToolDefinitionRequest` payload. +/// `ScanToolDefinitionRequest` payload. `definition_json` must serialize the +/// complete tool definition as a JSON object (including schema and metadata); +/// an MCP registry caller builds that object before making the bus call. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ScanToolDefinitionRequest { /// Tool identity. pub name: String, - /// Tool schema encoded as JSON. + /// Complete tool definition encoded as a JSON object, built by the caller. pub definition_json: String, } /// `ScanResponse` payload. diff --git a/crates/tinysecurity-redact/Cargo.toml b/crates/tinysecurity-redact/Cargo.toml index 9b5fcad8..e631b752 100644 --- a/crates/tinysecurity-redact/Cargo.toml +++ b/crates/tinysecurity-redact/Cargo.toml @@ -9,6 +9,8 @@ description = "Bounded native redaction and semantic intent scanning" publish = false [dependencies] +base64.workspace = true +percent-encoding = "2.3" tinysecurity-bus.workspace = true regex.workspace = true serde_json.workspace = true diff --git a/crates/tinysecurity-redact/README.md b/crates/tinysecurity-redact/README.md index 62217d79..5a1ef34e 100644 --- a/crates/tinysecurity-redact/README.md +++ b/crates/tinysecurity-redact/README.md @@ -12,13 +12,16 @@ prose while removing known token formats (OpenAI/Anthropic, GitHub, AWS access keys, Slack, Stripe, Google API, npm, GitLab, JWT, PEM private keys), credential assignments (including `Authorization` with its `Bearer`/`Basic`/`Digest` scheme and whole `Cookie`/`Set-Cookie` lists), email, -validated identifiers, URL capabilities and native home prefixes. Cards need -both an issuer prefix and Luhn validation; IBANs need mod-97 validation. These +validated identifiers, URL capabilities and native home prefixes. Card numbers +need a valid Luhn checksum plus an issued IIN/length or a nearby card keyword; +this leaves timestamp-like digit runs intact. IBANs need mod-97 validation. These local patterns reduce accidental disclosure and do not detect every identifier or establish that arbitrary text is safe for publication. -Structured arguments use approval count/type markers for sensitive content -keys; structured log arguments redact credential keys without hiding ordinary +Structured arguments use approval count/type markers for the host's sensitive +content keys (58 entries in the current OpenHuman approval table), including +presigned URL fields; embedded string values still pass through secret-pattern +redaction. Structured log arguments redact credential keys without hiding ordinary content. Credential keys compare after lowercasing and dropping `-` and `_` (`X-Api-Key`, `api_key` and `apiKey` are one key) and by exact name only, so `cancellation_token` stays ordinary content. Both scan unknown fields, keys and embedded JSON. Key collisions retain diff --git a/crates/tinysecurity-redact/src/extended_tests.rs b/crates/tinysecurity-redact/src/extended_tests.rs index 1ae06a8d..f322cdda 100644 --- a/crates/tinysecurity-redact/src/extended_tests.rs +++ b/crates/tinysecurity-redact/src/extended_tests.rs @@ -30,6 +30,27 @@ fn a_call_literal_is_removed_in_every_text_mode() -> Result<()> { Ok(()) } +#[test] +fn a_call_literal_is_removed_in_url_encoded_and_base64_forms() -> Result<()> { + let cache = ExtendedRedactors::new(); + let secret = "Composio-Key_123"; + let encoded = "Composio%2DKey%5F123"; + let base64 = "Q29tcG9zaW8tS2V5XzEyMw=="; + let url_base64 = "Q29tcG9zaW8tS2V5XzEyMw"; + let output = cache.redact( + &base(&[])?, + &request( + &format!("url={encoded} basic={base64} urlsafe={url_base64}"), + RedactMode::Text, + &[secret], + ), + )?; + for form in [encoded, base64, url_base64] { + assert!(!output.text.contains(form), "{}", output.text); + } + Ok(()) +} + #[test] fn a_call_literal_is_removed_from_structured_arguments_too() -> Result<()> { let cache = ExtendedRedactors::new(); diff --git a/crates/tinysecurity-redact/src/lib.rs b/crates/tinysecurity-redact/src/lib.rs index 61e3fdf4..f54495fc 100644 --- a/crates/tinysecurity-redact/src/lib.rs +++ b/crates/tinysecurity-redact/src/lib.rs @@ -30,6 +30,40 @@ pub(crate) const MAX_OUTPUT: usize = 131_072; pub(crate) const MAX_DEPTH: usize = 32; pub(crate) const MARKER: &str = "[REDACTED]"; pub use extended::ExtendedRedactors; +/// A JSON value after content sanitation, paired with a content-free change tally. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Sanitized { + /// The sanitized value. + pub value: T, + /// Summary of the edits made during sanitation. + pub report: SanitizationReport, +} +/// Counts of edits made by [`Redactor::sanitize_json`]. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SanitizationReport { + /// String nodes changed by text redaction. + pub text_redactions: usize, + /// Object entries dropped because their key is sensitive. + pub key_redactions: usize, + /// Whole private-key blocks found in string values. + pub blocked_secret_hits: usize, + /// Subtrees replaced at the JSON traversal depth limit. + pub depth_redactions: usize, + /// Validated personal-identifier matches in string values. + pub pii_redactions: usize, +} +impl SanitizationReport { + /// Whether sanitation changed at least one value or key. + #[must_use] + pub fn changed(&self) -> bool { + self.text_redactions + + self.key_redactions + + self.blocked_secret_hits + + self.depth_redactions + + self.pii_redactions + > 0 + } +} /// Immutable shared matching registry; Debug intentionally exposes no patterns or secrets. #[derive(Clone)] pub struct Redactor(Arc); @@ -98,6 +132,63 @@ impl Redactor { text, }) } + /// Sanitize JSON content, dropping entries with credential or personal-data keys. + /// String values are passed through the same bounded pattern registry as [`Self::redact`]. + /// + /// # Errors + /// Returns a content-free error if a string value exceeds the work/output bounds or + /// contains input that cannot be safely sanitized. + pub fn sanitize_json(&self, value: &serde_json::Value) -> Result> { + let mut report = SanitizationReport::default(); + let value = self.sanitize_json_node(value, 0, &mut report)?; + Ok(Sanitized { value, report }) + } + fn sanitize_json_node( + &self, + value: &serde_json::Value, + depth: usize, + report: &mut SanitizationReport, + ) -> Result { + if depth > MAX_DEPTH { + report.depth_redactions += 1; + return Ok(serde_json::Value::String(MARKER.into())); + } + match value { + serde_json::Value::Object(object) => { + let mut clean = serde_json::Map::new(); + for (key, value) in object { + if crate::structured::sensitive_json_key(key) { + report.key_redactions += 1; + } else { + clean.insert( + key.clone(), + self.sanitize_json_node(value, depth + 1, report)?, + ); + } + } + Ok(serde_json::Value::Object(clean)) + } + serde_json::Value::Array(items) => items + .iter() + .map(|item| self.sanitize_json_node(item, depth + 1, report)) + .collect::>>() + .map(serde_json::Value::Array), + serde_json::Value::String(text) => { + let clean = self.0.patterns.text(text)?; + if clean != *text { + report.text_redactions += 1; + if text.contains("-----BEGIN ") && text.contains("PRIVATE KEY-----") { + report.blocked_secret_hits += 1; + } + if clean.contains(MARKER) && text.bytes().any(|b| b.is_ascii_digit()) { + report.pii_redactions += 1; + } + } + Ok(serde_json::Value::String(clean)) + } + other => Ok(other.clone()), + } + } /// Scan original prompt intent before any redaction or lexical sanitation. /// Results provide context and never authorize execution. /// @@ -116,7 +207,13 @@ impl Redactor { &self, request: &ScanPromptScoredRequest2, ) -> Result { - bounded(&request.text)?; + if request.text.len() > MAX_INPUT { + return Ok(ScanPromptScoredResponse2 { + tier: tinysecurity_bus::PromptTier2::Review, + score_milli: 0, + rule_ids: vec!["input.oversized".into()], + }); + } Ok(self.0.scorer.score(&request.text)) } /// Scan a tool's name, keys, values and unknown metadata in the full JSON object. diff --git a/crates/tinysecurity-redact/src/lib_tests.rs b/crates/tinysecurity-redact/src/lib_tests.rs index 2cec8ae2..11e0066d 100644 --- a/crates/tinysecurity-redact/src/lib_tests.rs +++ b/crates/tinysecurity-redact/src/lib_tests.rs @@ -61,6 +61,52 @@ fn debug_and_bounds_are_content_free() -> Result<()> { Ok(()) } +#[test] +fn json_sanitization_drops_sensitive_keys_and_reports_changes() -> Result<()> { + let registry = Redactor::new(&RedactorConfig::default())?; + let input = serde_json::json!({ + "label": "safe title", + "api_key": "secret-value", + "nested": {"password": "private", "count": 4}, + }); + let sanitized = registry.sanitize_json(&input)?; + assert_eq!( + sanitized.value, + serde_json::json!({"label":"safe title","nested":{"count":4}}) + ); + assert_eq!(sanitized.report.key_redactions, 2); + assert!(sanitized.report.changed()); + let unchanged = registry.sanitize_json(&serde_json::json!({"count": 4}))?; + assert!(!unchanged.report.changed()); + Ok(()) +} + +#[test] +fn json_sanitization_reports_text_pii_private_keys_arrays_and_depth() -> Result<()> { + let registry = Redactor::new(&RedactorConfig::default())?; + let input = serde_json::json!([ + {"safe": "email private@example.com"}, + {"safe": "address 192.0.2.1"}, + {"safe": "-----BEGIN PRIVATE KEY-----\nprivate\n-----END PRIVATE KEY-----"} + ]); + let sanitized = registry.sanitize_json(&input)?; + assert_eq!(sanitized.value[0]["safe"], format!("email {MARKER}")); + assert_eq!(sanitized.value[1]["safe"], format!("address {MARKER}")); + assert_eq!(sanitized.value[2]["safe"], MARKER); + assert_eq!(sanitized.report.text_redactions, 3); + assert_eq!(sanitized.report.pii_redactions, 1); + assert_eq!(sanitized.report.blocked_secret_hits, 1); + + let mut deep = serde_json::Value::Null; + for _ in 0..=MAX_DEPTH { + deep = serde_json::Value::Array(vec![deep]); + } + let deep = registry.sanitize_json(&deep)?; + assert_eq!(deep.report.depth_redactions, 1); + assert!(deep.report.changed()); + Ok(()) +} + #[test] fn direct_credential_transfers_block_prompts() -> Result<()> { let registry = Redactor::new(&RedactorConfig::default())?; diff --git a/crates/tinysecurity-redact/src/patterns.rs b/crates/tinysecurity-redact/src/patterns.rs index b8729698..f1c81573 100644 --- a/crates/tinysecurity-redact/src/patterns.rs +++ b/crates/tinysecurity-redact/src/patterns.rs @@ -1,5 +1,6 @@ //! Fixed linear-time patterns and validated identifiers; registry contents stay private. use crate::{MARKER, output_bounded}; +use base64::Engine as _; use regex::Regex; use tinysecurity_bus::{Error, RedactorConfig, Result}; pub(crate) struct Patterns { @@ -12,7 +13,7 @@ pub(crate) struct Patterns { home: Regex, endpoint: Regex, } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Eq)] enum Validator { Always, Card, @@ -23,10 +24,14 @@ enum Validator { } impl Patterns { pub(crate) fn new(config: &RedactorConfig) -> Result { - let mut secrets: Vec<_> = config.secrets.iter().map(|s| s.0.as_str()).collect(); - if secrets.len() > 128 - || secrets.iter().map(|s| s.len()).sum::() > 16_384 - || secrets.iter().any(|s| { + let raw: Vec<_> = config + .secrets + .iter() + .map(|secret| secret.0.as_str()) + .collect(); + if raw.len() > 128 + || raw.iter().map(|s| s.len()).sum::() > 16_384 + || raw.iter().any(|s| { !(4..=4096).contains(&s.len()) || [ MARKER, @@ -44,6 +49,7 @@ impl Patterns { { return Err(Error::InvalidConfig); } + let mut secrets: Vec = raw.into_iter().flat_map(encoded_forms).collect(); secrets.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b))); secrets.dedup(); let literal = if secrets.is_empty() { @@ -162,9 +168,24 @@ impl Patterns { .into_owned(); out = self.home.replace_all(&out, "").into_owned(); for (regex, validator) in &self.rules { + let source = out; out = regex - .replace_all(&out, |caps: ®ex::Captures<'_>| { - if validator.valid(&caps[0]) { + .replace_all(&source, |caps: ®ex::Captures<'_>| { + let Some(matched) = caps.get(0) else { + return String::new(); + }; + let start = matched.start().saturating_sub(64); + let end = (matched.end() + 64).min(source.len()); + let mut start = start; + let mut end = end; + while !source.is_char_boundary(start) { + start -= 1; + } + while !source.is_char_boundary(end) { + end += 1; + } + let context = &source[start..end]; + if validator_valid(*validator, &caps[0], context) { MARKER.into() } else { caps[0].to_string() @@ -224,6 +245,38 @@ impl Patterns { .any(|(rule, validator)| rule.find_iter(text).any(|m| validator.valid(m.as_str()))) } } +fn encoded_forms(secret: &str) -> Vec { + use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD}; + let mut percent = String::with_capacity(secret.len() * 3); + let mut percent_lower = String::with_capacity(secret.len() * 3); + let mut form = String::with_capacity(secret.len() * 3); + for byte in secret.bytes() { + if byte.is_ascii_alphanumeric() { + percent.push(char::from(byte)); + percent_lower.push(char::from(byte)); + form.push(char::from(byte)); + } else { + use std::fmt::Write as _; + let _ = write!(percent, "%{byte:02X}"); + let _ = write!(percent_lower, "%{byte:02x}"); + if byte == b' ' { + form.push('+'); + } else { + let _ = write!(form, "%{byte:02X}"); + } + } + } + vec![ + secret.into(), + percent.clone(), + percent_lower, + form, + STANDARD.encode(secret), + STANDARD_NO_PAD.encode(secret), + URL_SAFE.encode(secret), + URL_SAFE_NO_PAD.encode(secret), + ] +} /// Decide whether a labelled value is a credential rather than source code. fn secret_like(label: &str, raw: &str, prefix: &str, rest: &str) -> bool { let value = raw.trim_matches(|c| c == '"' || c == '\''); @@ -371,9 +424,9 @@ impl Validator { .map(|b| b - b'0') .collect(); (13..=19).contains(&digits.len()) - && (matches!(digits.first(), Some(3..=6)) + && (matches!(digits.first(), Some(3..=7)) || (digits.first() == Some(&2) - && digits.get(1).is_some_and(|d| (2..=7).contains(d)))) + && digits.get(1).is_some_and(|digit| (2..=7).contains(digit)))) && digits .iter() .rev() @@ -401,6 +454,58 @@ impl Validator { } } } +fn validator_valid(validator: Validator, text: &str, context: &str) -> bool { + if validator != Validator::Card { + return validator.valid(text); + } + if validator.valid(text) && card_issuer_and_length(text) { + return true; + } + let lowered = context.to_ascii_lowercase(); + [ + "card", + "credit", + "debit", + "visa", + "mastercard", + "amex", + "discover", + ] + .iter() + .any(|keyword| lowered.contains(keyword)) + && luhn_checksum(text) +} +fn luhn_checksum(text: &str) -> bool { + let digits: Vec<_> = text + .bytes() + .filter(u8::is_ascii_digit) + .map(|b| b - b'0') + .collect(); + (13..=19).contains(&digits.len()) + && digits + .iter() + .rev() + .enumerate() + .map(|(i, &d)| { + let n = u32::from(d) * if i % 2 == 1 { 2 } else { 1 }; + if n > 9 { n - 9 } else { n } + }) + .sum::() + % 10 + == 0 +} +fn card_issuer_and_length(text: &str) -> bool { + let digits: String = text.chars().filter(char::is_ascii_digit).collect(); + let len = digits.len(); + let starts = |prefix: &str| digits.starts_with(prefix); + let visa = starts("4") && matches!(len, 13 | 16 | 19); + let mastercard = ((51..=55).any(|prefix| digits.starts_with(&prefix.to_string())) + || (2221..=2720).any(|prefix| digits.starts_with(&prefix.to_string()))) + && len == 16; + let amex = (starts("34") || starts("37")) && len == 15; + let discover = (starts("6011") || starts("65") || starts("644") || starts("645")) && len == 16; + visa || mastercard || amex || discover +} #[cfg(test)] #[path = "patterns_tests.rs"] mod tests; diff --git a/crates/tinysecurity-redact/src/patterns_tests.rs b/crates/tinysecurity-redact/src/patterns_tests.rs index cc3f14a4..2dc17a61 100644 --- a/crates/tinysecurity-redact/src/patterns_tests.rs +++ b/crates/tinysecurity-redact/src/patterns_tests.rs @@ -55,6 +55,24 @@ fn private_keys_urls_assignments_and_validators_are_selective() -> Result<()> { assert!(!Validator::Iban.valid("DE00370400440532013000")); Ok(()) } + +#[test] +fn card_numbers_need_issuer_or_nearby_context() -> Result<()> { + let patterns = Patterns::new(&RedactorConfig::default())?; + let timestamp = "1787178633773"; + assert_eq!(patterns.text(timestamp)?, timestamp); + let digits = ["7", "123456789012345"].concat(); + let candidate = (0..10) + .map(|check| format!("{digits}{check}")) + .find(|value| Validator::Card.valid(value)) + .ok_or(Error::Faulted)?; + assert_eq!(patterns.text(&candidate)?, candidate); + assert_eq!( + patterns.text(&format!("card number {candidate}"))?, + format!("card number {MARKER}") + ); + Ok(()) +} #[test] fn registry_rejects_literals_that_embed_generated_markers() { for secret in [ diff --git a/crates/tinysecurity-redact/src/score_tests.rs b/crates/tinysecurity-redact/src/score_tests.rs index 8cdebd34..da0290b2 100644 --- a/crates/tinysecurity-redact/src/score_tests.rs +++ b/crates/tinysecurity-redact/src/score_tests.rs @@ -675,12 +675,25 @@ fn rule_ids_follow_detection_order_and_stay_content_free() { fn registry_rejects_prompts_over_the_work_ceiling() { let registry = crate::Redactor::new(&tinysecurity_bus::RedactorConfig::default()) .unwrap_or_else(|_| unreachable!()); - let result = registry.scan_prompt_scored(&tinysecurity_bus::ScanPromptScoredRequest2 { + let result = registry.scan_prompt(&tinysecurity_bus::ScanPromptRequest { text: "a".repeat(crate::MAX_INPUT + 1), }); assert!(result.is_err()); } +#[test] +fn scan_prompt2_reviews_oversized_prompts_without_echoing_them() { + let registry = crate::Redactor::new(&tinysecurity_bus::RedactorConfig::default()) + .unwrap_or_else(|_| unreachable!()); + let result = registry + .scan_prompt_scored(&tinysecurity_bus::ScanPromptScoredRequest2 { + text: "a".repeat(crate::MAX_INPUT + 1), + }) + .unwrap_or_else(|_| unreachable!()); + assert_eq!(result.tier, PromptTier2::Review); + assert_eq!(result.rule_ids, ["input.oversized"]); +} + #[test] fn leet_and_cyrillic_confusables_fold_to_the_letters_they_imitate() { let scorer = Scorer::new().unwrap_or_else(|_| unreachable!()); diff --git a/crates/tinysecurity-redact/src/structured.rs b/crates/tinysecurity-redact/src/structured.rs index 58b14b2a..270aee77 100644 --- a/crates/tinysecurity-redact/src/structured.rs +++ b/crates/tinysecurity-redact/src/structured.rs @@ -56,6 +56,25 @@ const CONTENT_KEYS: &[&str] = &[ "public_url", "signed_url", "presigned_url", + // Query names commonly used as bearer-capability parts of S3, GCS and + // Azure signed URLs. These are sensitive even when callers split a URL + // into a structured argument map. + "x_amz_algorithm", + "x_amz_credential", + "x_amz_date", + "x_amz_expires", + "x_amz_security_token", + "x_amz_signature", + "x_amz_signedheaders", + "x_goog_algorithm", + "x_goog_credential", + "x_goog_date", + "x_goog_expires", + "x_goog_signature", + "x_goog_signedheaders", + "signature", + "sig", + "sas_token", ]; /// Matches after lowercasing and dropping `-`/`_`, so `X-Api-Key`, `api_key` and /// `apiKey` are one key. Exact match only: `cancellation_token` is not a credential. @@ -91,6 +110,23 @@ fn credential_key(key: &str) -> bool { | "setcookie" ) } +fn content_key(key: &str) -> bool { + let normalized: String = key + .chars() + .filter(|c| !matches!(c, '-' | '_')) + .map(|c| c.to_ascii_lowercase()) + .collect(); + CONTENT_KEYS.iter().any(|candidate| { + candidate + .chars() + .filter(|c| !matches!(c, '-' | '_')) + .map(|c| c.to_ascii_lowercase()) + .eq(normalized.chars()) + }) +} +pub(crate) fn sensitive_json_key(key: &str) -> bool { + credential_key(key) || content_key(key) +} fn marker(text: &str) -> bool { if text == MARKER { return true; @@ -195,10 +231,8 @@ impl Redactor { let mut out = Map::new(); let mut counters = std::collections::BTreeMap::new(); for (key, value) in map { - let lower = key.to_ascii_lowercase(); - let sensitive = credential_key(&lower) - || (*mode == RedactMode::StructuredArguments - && CONTENT_KEYS.contains(&lower.as_str())); + let sensitive = credential_key(key) + || (*mode == RedactMode::StructuredArguments && content_key(key)); let value = if sensitive { if *mode == RedactMode::StructuredArguments { let counted = count_marker(value); diff --git a/crates/tinysecurity-redact/src/structured_tests.rs b/crates/tinysecurity-redact/src/structured_tests.rs index a2214b54..192ee038 100644 --- a/crates/tinysecurity-redact/src/structured_tests.rs +++ b/crates/tinysecurity-redact/src/structured_tests.rs @@ -23,6 +23,116 @@ fn markers_are_fixed_points_and_lookalikes_are_not() { assert!(!uuid("00000000000004000800000000000000123")); assert!(!uuid("gggggggg-0000-4000-8000-000000000123")); } + +#[test] +fn structured_arguments_match_host_key_list_and_redact_nested_secret_patterns() -> Result<()> { + let registry = Redactor::new(&RedactorConfig::default())?; + let input = serde_json::json!({ + "file-url": "https://storage.example/download?sig=private", + "X-Amz-Signature": "private-signature", + "command": "curl -H \"Authorization: Bearer private-curl-credential\" https://example.test" + }); + let output = registry.redact(&tinysecurity_bus::RedactRequest { + text: input.to_string(), + mode: RedactMode::StructuredArguments, + })?; + let value: Value = serde_json::from_str(&output.text).map_err(|_| Error::InvalidArguments)?; + assert_eq!(value["file-url"], ""); + assert_eq!(value["X-Amz-Signature"], ""); + assert!(!output.text.contains("private-curl-credential")); + Ok(()) +} + +#[test] +fn structured_content_keys_cover_the_host_approval_key_set() { + const HOST_KEYS: &[&str] = &[ + "body", + "content", + "description", + "plaintext", + "text", + "message", + "messages", + "coverletter", + "note", + "reason", + "html", + "html_body", + "snippet", + "subject", + "title", + "recipient", + "recipients", + "to", + "cc", + "bcc", + "from", + "sender", + "address", + "email", + "phone", + "contact", + "contacts", + "name", + "first_name", + "last_name", + "full_name", + "displayname", + "bio", + "avatar", + "links", + "tags", + "channel_name", + "user", + "user_id", + "userid", + "username", + "thread_id", + "thread_ts", + "conversation_id", + "token", + "api_key", + "secret", + "password", + "authorization", + "auth", + "code", + "attachment", + "attachments", + "file_to_upload", + "file_url", + "public_url", + "signed_url", + "presigned_url", + ]; + for key in HOST_KEYS { + assert!(sensitive_json_key(key), "missing host content key: {key}"); + } + for key in [ + "X-Amz-Signature", + "x-goog-credential", + "file-url", + "firstName", + ] { + assert!(content_key(key), "missing presigned/canonical key: {key}"); + } +} + +#[test] +fn scan_tool_definition_requires_a_json_object() -> Result<()> { + let registry = Redactor::new(&RedactorConfig::default())?; + for definition in ["null", "[]", "\"schema\""] { + assert!( + registry + .scan_tool_definition(&ScanToolDefinitionRequest { + name: "safe".into(), + definition_json: definition.into(), + }) + .is_err() + ); + } + Ok(()) +} #[test] fn collision_keys_embedded_json_depth_and_browser_fixed_points() -> Result<()> { let registry = Redactor::new(&RedactorConfig { diff --git a/docs/adr/0007-boot-diagnostic-redaction-floor.md b/docs/adr/0007-boot-diagnostic-redaction-floor.md index 6fadfcf8..20739119 100644 --- a/docs/adr/0007-boot-diagnostic-redaction-floor.md +++ b/docs/adr/0007-boot-diagnostic-redaction-floor.md @@ -39,6 +39,12 @@ the module remains the single owner of every other redaction surface. display, audit, egress privacy checks and prompts call the module and deny their effect when it is unavailable. +The exception includes synchronous Sentry `before_send` and pre-module +request logging. Those hooks must scrub without awaiting module startup or +making a bus call, so they keep the fixed floor until the admitted redactor is +ready. Their early-boot use does not authorize the floor for ordinary product +surfaces or permit logging the original payload on refusal. + ## Consequences Boot diagnostics survive a missing module, with weaker coverage than the module