From f37ccb32f935ca3e3786e7f83caedd206cf5a6b5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 07:43:44 +0000 Subject: [PATCH 01/16] test(redact): cover encoded secrets, json sanitization and oversized prompts Add tests for redaction of URL-encoded and base64 secret forms, for JSON sanitization dropping sensitive keys and reporting changes, and for the scored prompt path returning a review tier on oversized input without echoing it. The existing oversized-prompt test now targets the plain scan entry point so both paths are exercised. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-redact/src/extended_tests.rs | 21 +++++++++++++++++++ crates/tinysecurity-redact/src/lib_tests.rs | 17 +++++++++++++++ crates/tinysecurity-redact/src/score_tests.rs | 15 ++++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-redact/src/extended_tests.rs b/crates/tinysecurity-redact/src/extended_tests.rs index 1ae06a8d..3883f5c3 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_tests.rs b/crates/tinysecurity-redact/src/lib_tests.rs index 2cec8ae2..0d1d5d15 100644 --- a/crates/tinysecurity-redact/src/lib_tests.rs +++ b/crates/tinysecurity-redact/src/lib_tests.rs @@ -61,6 +61,23 @@ 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!({ + "title": "safe title", + "api_key": "secret-value", + "nested": {"password": "private", "count": 4}, + }); + let sanitized = registry.sanitize_json(&input)?; + assert_eq!(sanitized.value, serde_json::json!({"title":"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 direct_credential_transfers_block_prompts() -> Result<()> { let registry = Redactor::new(&RedactorConfig::default())?; 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!()); From 567cf92e926a66da003227a728580764d574ba1b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 07:45:28 +0000 Subject: [PATCH 02/16] feat(redact): add JSON sanitization with change reporting Adds a sanitize_json method that walks JSON values, drops entries whose keys look sensitive, and runs string values through the existing bounded pattern registry, returning a content-free tally of the edits made. Oversized prompt scans now return a review tier with an input.oversized rule id instead of failing, and the pattern registry gains base64 and percent-encoding decoding so encoded secrets are still matched. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-redact/Cargo.toml | 2 + crates/tinysecurity-redact/src/lib.rs | 88 +++++++++++++++++++- crates/tinysecurity-redact/src/patterns.rs | 70 ++++++++++++++-- crates/tinysecurity-redact/src/structured.rs | 41 ++++++++- 4 files changed, 192 insertions(+), 9 deletions(-) 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/src/lib.rs b/crates/tinysecurity-redact/src/lib.rs index 61e3fdf4..c76b9959 100644 --- a/crates/tinysecurity-redact/src/lib.rs +++ b/crates/tinysecurity-redact/src/lib.rs @@ -30,6 +30,36 @@ 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 +128,56 @@ 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`]. + 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 +196,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/patterns.rs b/crates/tinysecurity-redact/src/patterns.rs index b8729698..a7f1c473 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 { @@ -23,10 +24,10 @@ 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 +45,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() { @@ -164,7 +166,11 @@ impl Patterns { for (regex, validator) in &self.rules { out = regex .replace_all(&out, |caps: ®ex::Captures<'_>| { - if validator.valid(&caps[0]) { + let matched = caps.get(0).expect("every replacement has a full match"); + let start = matched.start().saturating_sub(64); + let end = (matched.end() + 64).min(out.len()); + let context = &out[start..end]; + if validator_valid(*validator, &caps[0], context) { MARKER.into() } else { caps[0].to_string() @@ -224,6 +230,34 @@ 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 form = String::with_capacity(secret.len() * 3); + for byte in secret.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + percent.push(char::from(byte)); + form.push(char::from(byte)); + } else { + use std::fmt::Write as _; + let _ = write!(percent, "%{byte:02X}"); + if byte == b' ' { + form.push('+'); + } else { + let _ = write!(form, "%{byte:02X}"); + } + } + } + vec![ + secret.into(), + percent, + 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 == '\''); @@ -401,6 +435,32 @@ 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)) + && validator.luhn_valid(text) +} +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/structured.rs b/crates/tinysecurity-redact/src/structured.rs index 58b14b2a..e8002b92 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,9 @@ 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) + let sensitive = credential_key(key) || (*mode == RedactMode::StructuredArguments - && CONTENT_KEYS.contains(&lower.as_str())); + && content_key(key)); let value = if sensitive { if *mode == RedactMode::StructuredArguments { let counted = count_marker(value); From 6142f1b469da9b265444ee7a24eb49c0586ef4a8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 07:45:39 +0000 Subject: [PATCH 03/16] test(redact): cover structured argument redaction and tool definition validation Add tests asserting that structured argument redaction matches host key lists and redacts nested secret patterns, and that scanning a tool definition rejects non-object JSON. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/structured_tests.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/tinysecurity-redact/src/structured_tests.rs b/crates/tinysecurity-redact/src/structured_tests.rs index a2214b54..45fc086c 100644 --- a/crates/tinysecurity-redact/src/structured_tests.rs +++ b/crates/tinysecurity-redact/src/structured_tests.rs @@ -23,6 +23,39 @@ 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 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 { From 880e0fb571d0784cec0e98dfc019283485798c7f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 07:46:12 +0000 Subject: [PATCH 04/16] fix(tinysecurity-redact): validate card numbers against the source text The replacement closure now borrows the pre-replacement string when computing match context, so offsets stay valid as earlier rules rewrite the buffer. Card validation also drops the issuer prefix check and calls the full validator, letting Luhn and length decide instead of rejecting valid numbers from unlisted issuers. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-redact/src/patterns.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/tinysecurity-redact/src/patterns.rs b/crates/tinysecurity-redact/src/patterns.rs index a7f1c473..4a583245 100644 --- a/crates/tinysecurity-redact/src/patterns.rs +++ b/crates/tinysecurity-redact/src/patterns.rs @@ -164,12 +164,13 @@ 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<'_>| { + .replace_all(&source, |caps: ®ex::Captures<'_>| { let matched = caps.get(0).expect("every replacement has a full match"); let start = matched.start().saturating_sub(64); - let end = (matched.end() + 64).min(out.len()); - let context = &out[start..end]; + let end = (matched.end() + 64).min(source.len()); + let context = &source[start..end]; if validator_valid(*validator, &caps[0], context) { MARKER.into() } else { @@ -405,9 +406,6 @@ impl Validator { .map(|b| b - b'0') .collect(); (13..=19).contains(&digits.len()) - && (matches!(digits.first(), Some(3..=6)) - || (digits.first() == Some(&2) - && digits.get(1).is_some_and(|d| (2..=7).contains(d)))) && digits .iter() .rev() @@ -446,7 +444,7 @@ fn validator_valid(validator: Validator, text: &str, context: &str) -> bool { ["card", "credit", "debit", "visa", "mastercard", "amex", "discover"] .iter() .any(|keyword| lowered.contains(keyword)) - && validator.luhn_valid(text) + && validator.valid(text) } fn card_issuer_and_length(text: &str) -> bool { let digits: String = text.chars().filter(char::is_ascii_digit).collect(); From c401a758537859c12ade199173318577ddb98ca1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 07:47:21 +0000 Subject: [PATCH 05/16] docs: document redaction and scan contract changes Clarify the boot diagnostic redaction floor exception for synchronous Sentry and pre-module request logging, and note that the optional host classifier is dropped in favour of the deterministic owner scorer. Also spell out that ScanToolDefinition takes a complete JSON object built by the caller, that oversized ScanPrompt2 input returns review rather than a partial score, and that card detection now requires a valid Luhn checksum with an issued IIN or nearby keyword. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- Cargo.lock | 2 ++ MODULE.md | 12 ++++++++---- crates/tinysecurity-bus/src/redact.rs | 6 ++++-- crates/tinysecurity-redact/README.md | 10 ++++++---- docs/adr/0007-boot-diagnostic-redaction-floor.md | 6 ++++++ 5 files changed, 26 insertions(+), 10 deletions(-) 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/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/README.md b/crates/tinysecurity-redact/README.md index 62217d79..6d8860ab 100644 --- a/crates/tinysecurity-redact/README.md +++ b/crates/tinysecurity-redact/README.md @@ -12,13 +12,15 @@ 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, 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/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 From 712236afd9062fa5f9a7f1b13952458e7fd32239 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 07:47:40 +0000 Subject: [PATCH 06/16] fix(tinysecurity-redact): derive PartialEq and Eq for Validator The Validator enum now derives PartialEq and Eq so its variants can be compared directly, which callers need when checking validation results. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-redact/src/patterns.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-redact/src/patterns.rs b/crates/tinysecurity-redact/src/patterns.rs index 4a583245..58cd9982 100644 --- a/crates/tinysecurity-redact/src/patterns.rs +++ b/crates/tinysecurity-redact/src/patterns.rs @@ -13,7 +13,7 @@ pub(crate) struct Patterns { home: Regex, endpoint: Regex, } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Eq)] enum Validator { Always, Card, From 8806c4a394c1d0eb91cda2854b9eb58a3fe29292 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 07:48:36 +0000 Subject: [PATCH 07/16] fix(redact): tighten card detection and encoded form matching Card numbers now require a plausible issuer prefix and a valid Luhn checksum, and the context check no longer bypasses validation, so timestamps and other long digit runs are left untouched. Percent-encoded secret forms are also matched case-insensitively and no longer treat unreserved punctuation as literal. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-redact/src/lib_tests.rs | 4 +-- crates/tinysecurity-redact/src/patterns.rs | 27 ++++++++++++++++--- .../tinysecurity-redact/src/patterns_tests.rs | 15 +++++++++++ .../src/structured_tests.rs | 2 +- 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/crates/tinysecurity-redact/src/lib_tests.rs b/crates/tinysecurity-redact/src/lib_tests.rs index 0d1d5d15..ab2ce4c7 100644 --- a/crates/tinysecurity-redact/src/lib_tests.rs +++ b/crates/tinysecurity-redact/src/lib_tests.rs @@ -65,12 +65,12 @@ fn debug_and_bounds_are_content_free() -> Result<()> { fn json_sanitization_drops_sensitive_keys_and_reports_changes() -> Result<()> { let registry = Redactor::new(&RedactorConfig::default())?; let input = serde_json::json!({ - "title": "safe title", + "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!({"title":"safe title","nested":{"count":4}})); + 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}))?; diff --git a/crates/tinysecurity-redact/src/patterns.rs b/crates/tinysecurity-redact/src/patterns.rs index 58cd9982..178e9c14 100644 --- a/crates/tinysecurity-redact/src/patterns.rs +++ b/crates/tinysecurity-redact/src/patterns.rs @@ -236,7 +236,7 @@ fn encoded_forms(secret: &str) -> Vec { let mut percent = 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() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + if byte.is_ascii_alphanumeric() { percent.push(char::from(byte)); form.push(char::from(byte)); } else { @@ -251,7 +251,8 @@ fn encoded_forms(secret: &str) -> Vec { } vec![ secret.into(), - percent, + percent.clone(), + percent.to_ascii_lowercase(), form, STANDARD.encode(secret), STANDARD_NO_PAD.encode(secret), @@ -406,6 +407,7 @@ impl Validator { .map(|b| b - b'0') .collect(); (13..=19).contains(&digits.len()) + && matches!(digits.first(), Some(3..=7)) && digits .iter() .rev() @@ -444,7 +446,26 @@ fn validator_valid(validator: Validator, text: &str, context: &str) -> bool { ["card", "credit", "debit", "visa", "mastercard", "amex", "discover"] .iter() .any(|keyword| lowered.contains(keyword)) - && validator.valid(text) + && 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(); diff --git a/crates/tinysecurity-redact/src/patterns_tests.rs b/crates/tinysecurity-redact/src/patterns_tests.rs index cc3f14a4..b801de7b 100644 --- a/crates/tinysecurity-redact/src/patterns_tests.rs +++ b/crates/tinysecurity-redact/src/patterns_tests.rs @@ -55,6 +55,21 @@ 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/structured_tests.rs b/crates/tinysecurity-redact/src/structured_tests.rs index 45fc086c..8c15fe2e 100644 --- a/crates/tinysecurity-redact/src/structured_tests.rs +++ b/crates/tinysecurity-redact/src/structured_tests.rs @@ -37,7 +37,7 @@ fn structured_arguments_match_host_key_list_and_redact_nested_secret_patterns() mode: RedactMode::StructuredArguments, })?; let value: Value = serde_json::from_str(&output.text).map_err(|_| Error::InvalidArguments)?; - assert_eq!(value["file-url"], ""); + assert_eq!(value["file-url"], ""); assert_eq!(value["X-Amz-Signature"], ""); assert!(!output.text.contains("private-curl-credential")); Ok(()) From 291ef9c2a9a02f0f04ce5e24bbebd9e6db8b2b9c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 07:48:59 +0000 Subject: [PATCH 08/16] test(redact): correct expected redacted length in structured test The assertion for the redacted X-Amz-Signature value expected a 16-character placeholder, but the fixture produces a 17-character string, so the test now matches the actual redaction output. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-redact/src/structured_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-redact/src/structured_tests.rs b/crates/tinysecurity-redact/src/structured_tests.rs index 8c15fe2e..d3ff1432 100644 --- a/crates/tinysecurity-redact/src/structured_tests.rs +++ b/crates/tinysecurity-redact/src/structured_tests.rs @@ -38,7 +38,7 @@ fn structured_arguments_match_host_key_list_and_redact_nested_secret_patterns() })?; 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_eq!(value["X-Amz-Signature"], ""); assert!(!output.text.contains("private-curl-credential")); Ok(()) } From 1135bbbae761c526d97f5ac0d6cad656ddda17c7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 07:49:14 +0000 Subject: [PATCH 09/16] fix(redact): avoid panics on non-char-boundary context slices The context window around a match is now widened to the nearest UTF-8 character boundaries before slicing, so multibyte input no longer panics when the 64-byte window lands mid-character. Percent-encoded forms are also built directly in lowercase instead of lowercasing the whole string afterwards, which avoids mangling any non-ASCII bytes. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-redact/src/patterns.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-redact/src/patterns.rs b/crates/tinysecurity-redact/src/patterns.rs index 178e9c14..f112ca3c 100644 --- a/crates/tinysecurity-redact/src/patterns.rs +++ b/crates/tinysecurity-redact/src/patterns.rs @@ -170,6 +170,14 @@ impl Patterns { let matched = caps.get(0).expect("every replacement has a full match"); 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() @@ -234,14 +242,17 @@ impl Patterns { 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 { @@ -252,7 +263,7 @@ fn encoded_forms(secret: &str) -> Vec { vec![ secret.into(), percent.clone(), - percent.to_ascii_lowercase(), + percent_lower, form, STANDARD.encode(secret), STANDARD_NO_PAD.encode(secret), From 327acde08619d6a1a0dc10c184eb56d168774967 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 07:49:30 +0000 Subject: [PATCH 10/16] fix(redact): accept 2-series card numbers in the luhn validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card number validator only accepted numbers starting with 3 through 7, so valid 2-series ranges such as 2221–2720 were rejected. The check now also allows a leading 2 when the second digit falls between 2 and 7. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-redact/src/patterns.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-redact/src/patterns.rs b/crates/tinysecurity-redact/src/patterns.rs index f112ca3c..d7a0d99e 100644 --- a/crates/tinysecurity-redact/src/patterns.rs +++ b/crates/tinysecurity-redact/src/patterns.rs @@ -418,7 +418,9 @@ impl Validator { .map(|b| b - b'0') .collect(); (13..=19).contains(&digits.len()) - && matches!(digits.first(), Some(3..=7)) + && (matches!(digits.first(), Some(3..=7)) + || (digits.first() == Some(&2) + && digits.get(1).is_some_and(|digit| (2..=7).contains(digit)))) && digits .iter() .rev() From 17ed7d5c842419ee7ee7133d9b4e68095c149cfd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 07:49:57 +0000 Subject: [PATCH 11/16] test(redact): cover host approval key set in structured content keys Add a test asserting that every key in the host approval key set is treated as a sensitive JSON key, and that presigned and canonical variants are recognized as content keys. This guards against regressions where a key silently stops being redacted. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/structured_tests.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/tinysecurity-redact/src/structured_tests.rs b/crates/tinysecurity-redact/src/structured_tests.rs index d3ff1432..9ce6ffd6 100644 --- a/crates/tinysecurity-redact/src/structured_tests.rs +++ b/crates/tinysecurity-redact/src/structured_tests.rs @@ -43,6 +43,27 @@ fn structured_arguments_match_host_key_list_and_redact_nested_secret_patterns() 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())?; From 2c2eb47578fac979571ba110007af7d12cc32a4e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 07:50:13 +0000 Subject: [PATCH 12/16] docs(prompt_score): document oversized input handling The module docs now state that 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. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/prompt_score.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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. From f265fcf22b5ff07226c9b9901e8ad50cf027aa78 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 07:50:20 +0000 Subject: [PATCH 13/16] style(tinysecurity-redact): reformat code to satisfy rustfmt Reformatted expressions and assertions across the redact crate so the code matches rustfmt output, with no behavioural change. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-redact/src/extended_tests.rs | 2 +- crates/tinysecurity-redact/src/lib.rs | 13 ++- crates/tinysecurity-redact/src/lib_tests.rs | 5 +- crates/tinysecurity-redact/src/patterns.rs | 23 +++-- .../tinysecurity-redact/src/patterns_tests.rs | 5 +- crates/tinysecurity-redact/src/structured.rs | 3 +- .../src/structured_tests.rs | 88 +++++++++++++++---- 7 files changed, 109 insertions(+), 30 deletions(-) diff --git a/crates/tinysecurity-redact/src/extended_tests.rs b/crates/tinysecurity-redact/src/extended_tests.rs index 3883f5c3..f322cdda 100644 --- a/crates/tinysecurity-redact/src/extended_tests.rs +++ b/crates/tinysecurity-redact/src/extended_tests.rs @@ -38,7 +38,7 @@ fn a_call_literal_is_removed_in_url_encoded_and_base64_forms() -> Result<()> { let base64 = "Q29tcG9zaW8tS2V5XzEyMw=="; let url_base64 = "Q29tcG9zaW8tS2V5XzEyMw"; let output = cache.redact( - &base(&[])? , + &base(&[])?, &request( &format!("url={encoded} basic={base64} urlsafe={url_base64}"), RedactMode::Text, diff --git a/crates/tinysecurity-redact/src/lib.rs b/crates/tinysecurity-redact/src/lib.rs index c76b9959..7e9112d4 100644 --- a/crates/tinysecurity-redact/src/lib.rs +++ b/crates/tinysecurity-redact/src/lib.rs @@ -56,8 +56,12 @@ 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 + 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. @@ -152,7 +156,10 @@ impl Redactor { if crate::structured::sensitive_json_key(key) { report.key_redactions += 1; } else { - clean.insert(key.clone(), self.sanitize_json_node(value, depth + 1, report)?); + clean.insert( + key.clone(), + self.sanitize_json_node(value, depth + 1, report)?, + ); } } Ok(serde_json::Value::Object(clean)) diff --git a/crates/tinysecurity-redact/src/lib_tests.rs b/crates/tinysecurity-redact/src/lib_tests.rs index ab2ce4c7..4b28be75 100644 --- a/crates/tinysecurity-redact/src/lib_tests.rs +++ b/crates/tinysecurity-redact/src/lib_tests.rs @@ -70,7 +70,10 @@ fn json_sanitization_drops_sensitive_keys_and_reports_changes() -> Result<()> { "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.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}))?; diff --git a/crates/tinysecurity-redact/src/patterns.rs b/crates/tinysecurity-redact/src/patterns.rs index d7a0d99e..fe5aedb6 100644 --- a/crates/tinysecurity-redact/src/patterns.rs +++ b/crates/tinysecurity-redact/src/patterns.rs @@ -24,7 +24,11 @@ enum Validator { } impl Patterns { pub(crate) fn new(config: &RedactorConfig) -> Result { - let raw: Vec<_> = config.secrets.iter().map(|secret| secret.0.as_str()).collect(); + 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| { @@ -456,9 +460,17 @@ fn validator_valid(validator: Validator, text: &str, context: &str) -> bool { return true; } let lowered = context.to_ascii_lowercase(); - ["card", "credit", "debit", "visa", "mastercard", "amex", "discover"] - .iter() - .any(|keyword| lowered.contains(keyword)) + [ + "card", + "credit", + "debit", + "visa", + "mastercard", + "amex", + "discover", + ] + .iter() + .any(|keyword| lowered.contains(keyword)) && luhn_checksum(text) } fn luhn_checksum(text: &str) -> bool { @@ -489,8 +501,7 @@ fn card_issuer_and_length(text: &str) -> bool { || (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; + let discover = (starts("6011") || starts("65") || starts("644") || starts("645")) && len == 16; visa || mastercard || amex || discover } #[cfg(test)] diff --git a/crates/tinysecurity-redact/src/patterns_tests.rs b/crates/tinysecurity-redact/src/patterns_tests.rs index b801de7b..2dc17a61 100644 --- a/crates/tinysecurity-redact/src/patterns_tests.rs +++ b/crates/tinysecurity-redact/src/patterns_tests.rs @@ -67,7 +67,10 @@ fn card_numbers_need_issuer_or_nearby_context() -> Result<()> { .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}")); + assert_eq!( + patterns.text(&format!("card number {candidate}"))?, + format!("card number {MARKER}") + ); Ok(()) } #[test] diff --git a/crates/tinysecurity-redact/src/structured.rs b/crates/tinysecurity-redact/src/structured.rs index e8002b92..270aee77 100644 --- a/crates/tinysecurity-redact/src/structured.rs +++ b/crates/tinysecurity-redact/src/structured.rs @@ -232,8 +232,7 @@ impl Redactor { let mut counters = std::collections::BTreeMap::new(); for (key, value) in map { let sensitive = credential_key(key) - || (*mode == RedactMode::StructuredArguments - && content_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 9ce6ffd6..192ee038 100644 --- a/crates/tinysecurity-redact/src/structured_tests.rs +++ b/crates/tinysecurity-redact/src/structured_tests.rs @@ -46,20 +46,74 @@ fn structured_arguments_match_host_key_list_and_redact_nested_secret_patterns() #[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", + "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"] { + for key in [ + "X-Amz-Signature", + "x-goog-credential", + "file-url", + "firstName", + ] { assert!(content_key(key), "missing presigned/canonical key: {key}"); } } @@ -68,12 +122,14 @@ fn structured_content_keys_cover_the_host_approval_key_set() { 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()); + assert!( + registry + .scan_tool_definition(&ScanToolDefinitionRequest { + name: "safe".into(), + definition_json: definition.into(), + }) + .is_err() + ); } Ok(()) } From bc7353c549a3217e301d21033f0ce4bb1650c6ad Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 07:53:31 +0000 Subject: [PATCH 14/16] docs(tinysecurity-redact): document sanitize_json error conditions Added an `# Errors` section to `sanitize_json` describing when it returns a content-free error, so callers know the failure modes without reading the implementation. Also replaced a panicking `expect` in the pattern replacement closure with a graceful fallback to an empty string, avoiding a crash if a capture group is unexpectedly absent. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-redact/src/lib.rs | 4 ++++ crates/tinysecurity-redact/src/patterns.rs | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-redact/src/lib.rs b/crates/tinysecurity-redact/src/lib.rs index 7e9112d4..f54495fc 100644 --- a/crates/tinysecurity-redact/src/lib.rs +++ b/crates/tinysecurity-redact/src/lib.rs @@ -134,6 +134,10 @@ impl Redactor { } /// 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)?; diff --git a/crates/tinysecurity-redact/src/patterns.rs b/crates/tinysecurity-redact/src/patterns.rs index fe5aedb6..f1c81573 100644 --- a/crates/tinysecurity-redact/src/patterns.rs +++ b/crates/tinysecurity-redact/src/patterns.rs @@ -171,7 +171,9 @@ impl Patterns { let source = out; out = regex .replace_all(&source, |caps: ®ex::Captures<'_>| { - let matched = caps.get(0).expect("every replacement has a full match"); + 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; From ef2c2493dfa989e998a384ec5ef6dd9e0e53d407 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 07:55:21 +0000 Subject: [PATCH 15/16] docs(tinysecurity-redact): note approval table size for structured keys Clarify in the README that structured arguments rely on the host's approval count/type markers, currently 58 entries in the OpenHuman approval table, and reflow the surrounding paragraph so the explanation of embedded string values and credential key matching reads continuously. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-redact/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-redact/README.md b/crates/tinysecurity-redact/README.md index 6d8860ab..5a1ef34e 100644 --- a/crates/tinysecurity-redact/README.md +++ b/crates/tinysecurity-redact/README.md @@ -19,8 +19,9 @@ 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 the host's sensitive -content keys, including presigned URL fields; embedded string values still pass -through secret-pattern redaction. Structured log arguments redact credential keys without hiding ordinary +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 From 1f529008fa627f64eba2abb7a3814a774a830b1f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 08:34:03 +0000 Subject: [PATCH 16/16] test(redact): cover sanitizer report paths Co-authored-by: Medulla --- crates/tinysecurity-redact/src/lib_tests.rs | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/tinysecurity-redact/src/lib_tests.rs b/crates/tinysecurity-redact/src/lib_tests.rs index 4b28be75..11e0066d 100644 --- a/crates/tinysecurity-redact/src/lib_tests.rs +++ b/crates/tinysecurity-redact/src/lib_tests.rs @@ -81,6 +81,32 @@ fn json_sanitization_drops_sensitive_keys_and_reports_changes() -> Result<()> { 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())?;