Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f37ccb3
test(redact): cover encoded secrets, json sanitization and oversized …
senamakel Oct 25, 2026
567cf92
feat(redact): add JSON sanitization with change reporting
senamakel Oct 25, 2026
6142f1b
test(redact): cover structured argument redaction and tool definition…
senamakel Oct 25, 2026
880e0fb
fix(tinysecurity-redact): validate card numbers against the source text
senamakel Oct 25, 2026
c401a75
docs: document redaction and scan contract changes
senamakel Oct 25, 2026
712236a
fix(tinysecurity-redact): derive PartialEq and Eq for Validator
senamakel Oct 25, 2026
8806c4a
fix(redact): tighten card detection and encoded form matching
senamakel Oct 25, 2026
291ef9c
test(redact): correct expected redacted length in structured test
senamakel Oct 25, 2026
1135bbb
fix(redact): avoid panics on non-char-boundary context slices
senamakel Oct 25, 2026
327acde
fix(redact): accept 2-series card numbers in the luhn validator
senamakel Oct 25, 2026
17ed7d5
test(redact): cover host approval key set in structured content keys
senamakel Oct 25, 2026
2c2eb47
docs(prompt_score): document oversized input handling
senamakel Oct 25, 2026
f265fcf
style(tinysecurity-redact): reformat code to satisfy rustfmt
senamakel Oct 25, 2026
bc7353c
docs(tinysecurity-redact): document sanitize_json error conditions
senamakel Oct 25, 2026
ef2c249
docs(tinysecurity-redact): note approval table size for structured keys
senamakel Oct 25, 2026
1f52900
test(redact): cover sanitizer report paths
senamakel Oct 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 8 additions & 4 deletions MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion crates/tinysecurity-bus/src/prompt_score.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions crates/tinysecurity-bus/src/redact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions crates/tinysecurity-redact/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions crates/tinysecurity-redact/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions crates/tinysecurity-redact/src/extended_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
99 changes: 98 additions & 1 deletion crates/tinysecurity-redact/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
/// 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<Registry>);
Expand Down Expand Up @@ -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<Sanitized<serde_json::Value>> {
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<serde_json::Value> {
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::<Result<Vec<_>>>()
.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.
///
Expand All @@ -116,7 +207,13 @@ impl Redactor {
&self,
request: &ScanPromptScoredRequest2,
) -> Result<ScanPromptScoredResponse2> {
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.
Expand Down
46 changes: 46 additions & 0 deletions crates/tinysecurity-redact/src/lib_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())?;
Expand Down
Loading
Loading