diff --git a/MODULE.md b/MODULE.md index a03b8ae..fa4df38 100644 --- a/MODULE.md +++ b/MODULE.md @@ -334,3 +334,32 @@ The X25519/HKDF device tunnel remains the `EvaluateDevice2`/`ExecuteDevice2` surface above. The OS keyring test runs on every platform and is required to use the real credential store when `TINYSECURITY_REQUIRE_OS_KEYRING=1`; it always removes its entry. + +## Credentials-only redaction and open public egress (contract 1.15) + +Both additions are additive. A 1.14 host keeps working against a 1.15 module, +and a 1.15 host refuses a 1.14 module. + +`RedactMode::CredentialsOnly` (`credentials_only`) is for free-form tool output, +where `Text` over-redacts. It removes the exact initialization registry, provider +token shapes (`sk-`, `gh*_`, `github_pat_`, `AKIA`/`ASIA`, `xox*`, Stripe, Google, +npm, GitLab), JWTs, PEM private keys, `Cookie`/`Set-Cookie` headers, and labelled +values (`token`, `api_key`, `password`, `secret`, `Authorization: Bearer ...`) that +look like a credential. It keeps e-mail, phone, card, address, IP, URL and home-path +content, and keeps labelled values that read as source code (`token = get_token()`, +`accessToken: string`, `password = None`, numbers, ``). It is not a +privacy surface: use `Text` for logs and audit. Rotating a credential is a policy +`Reinit` with the new `RedactorConfig`; the registry is immutable per generation. + +`EgressPolicy.open_public` registers every `http`/`https` destination, so +`destinations` and `local_capabilities` must be empty and `LocalOnly` is refused. +The address floor is unchanged: loopback, private, link-local, metadata, CGNAT and +reserved addresses are refused for literals and for every resolved candidate, and +the privacy mode and data-category checks still run first. + +`EgressPolicy.preflight_transports` (`browser`, `proxy`; no duplicates) lets those +transports receive a verdict. The module cannot verify that a browser or proxy +pins the returned sockets, so the grant is a preflight: DNS rebinding after the +verdict is the executor's risk. `PinnedDirect` stays the only pinned transport, and +a local capability never accepts a preflight transport. Both fields are omitted from +the wire when unset, so existing policies serialize unchanged. diff --git a/README.md b/README.md index b056bb9..f65bcb7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Blocked original content denies; suspicious content denies with approval-unavailable in unbound policy methods. Scan verdicts never grant permission, and redaction never clears an existing scan finding. -Contract 1.14 adds confidential `PasswordCrypt2`, `KeyringAccess2` and `PairingControl2` (see [MODULE.md](MODULE.md)). Contract 1.13 added `EvaluateScopedAudited` and `CheckScopedAudited` for registered policies under required audit (see [`docs/specs/scoped-policies.md`](docs/specs/scoped-policies.md)). +Contract 1.15 adds `RedactMode::CredentialsOnly` and the `EgressPolicy` fields `open_public` and `preflight_transports` (see [MODULE.md](MODULE.md#credentials-only-redaction-and-open-public-egress-contract-115)). Contract 1.14 adds confidential `PasswordCrypt2`, `KeyringAccess2` and `PairingControl2` (see [MODULE.md](MODULE.md)). Contract 1.13 added `EvaluateScopedAudited` and `CheckScopedAudited` for registered policies under required audit (see [`docs/specs/scoped-policies.md`](docs/specs/scoped-policies.md)). Contract 1.6 supplies `Audit2`, `EvaluateAudited2` and `CheckAudited2` with authenticated exact audit callbacks and a bounded [native journal](crates/tinysecurity-audit/README.md). diff --git a/crates/tinysecurity-bus/src/egress.rs b/crates/tinysecurity-bus/src/egress.rs index e8a9c26..9808e40 100644 --- a/crates/tinysecurity-bus/src/egress.rs +++ b/crates/tinysecurity-bus/src/egress.rs @@ -153,6 +153,20 @@ pub struct EgressPolicy { pub local_capabilities: Vec, /// Bounded validity in milliseconds, between 1 and 30000. pub validity_ms: u32, + /// Open public-internet mode (contract 1.15): any `http`/`https` destination + /// is registered, so `destinations` must be empty. The unconditional address + /// floor still applies to the literal and to every resolved candidate, so + /// loopback, private, link-local, metadata, CGNAT and reserved addresses stay + /// refused. Privacy mode and data-category checks are unchanged. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub open_public: bool, + /// Transports other than [`EgressTransport::PinnedDirect`] this policy accepts + /// for a preflight verdict (contract 1.15): only `Browser` and `Proxy`. The + /// module cannot verify those executors pin the returned sockets, so the + /// authorization is a best-effort verdict and DNS rebinding after it is the + /// executor's risk. Local capabilities never use them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub preflight_transports: Vec, } /// Scoped URL request, version 1.4. Registration and invocation require a confidential host bus. /// Debug deliberately suppresses all payload, destination and identity values. diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 9c19013..8e9602f 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -42,7 +42,7 @@ pub use redact::RedactorConfig; pub use scoped_policy::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. -pub const CONTRACT_VERSION: (u32, u32) = (1, 14); +pub const CONTRACT_VERSION: (u32, u32) = (1, 15); /// Whether a remote module's advertised version supplies this host's vocabulary. /// /// `version` is the provider version received by the host, not a host requirement diff --git a/crates/tinysecurity-bus/src/redact.rs b/crates/tinysecurity-bus/src/redact.rs index 1781b82..e1eacf8 100644 --- a/crates/tinysecurity-bus/src/redact.rs +++ b/crates/tinysecurity-bus/src/redact.rs @@ -24,6 +24,11 @@ pub enum RedactMode { IdentifierHash, /// Historical first-four-character mask; existing masks are fixed points. LegacyMask, + /// Credentials only, for free-form tool output: the exact registry, provider + /// token shapes, JWTs, private keys, cookie headers and labelled values that + /// look like a credential. Contact data, URLs and paths are retained, and a + /// labelled value that reads as source code is not rewritten. + CredentialsOnly, } /// `ScanVerdict` vocabulary. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/tinysecurity-bus/tests/egress_wire.rs b/crates/tinysecurity-bus/tests/egress_wire.rs index afe54c9..6ca48fc 100644 --- a/crates/tinysecurity-bus/tests/egress_wire.rs +++ b/crates/tinysecurity-bus/tests/egress_wire.rs @@ -27,3 +27,26 @@ fn scoped_contracts_reject_legacy_or_invocation_authority_fields() { ); assert!(serde_json::from_str::(r#"{"owner":{"user":"u","workspace":"w","agent":"a"},"ceiling":"full","privacy":"standard","destinations":[],"data_categories":[],"local_capabilities":[],"validity_ms":1000,"allow_private":true}"#).is_err()); } +#[test] +fn open_public_and_preflight_fields_round_trip_and_stay_off_the_legacy_wire() +-> std::result::Result<(), Box> { + let json = r#"{"owner":{"user":"u","workspace":"w","agent":"a"},"ceiling":"full","privacy":"standard","destinations":[],"data_categories":["user_content"],"local_capabilities":[],"validity_ms":1000,"open_public":true,"preflight_transports":["browser","proxy"]}"#; + let policy: EgressPolicy = serde_json::from_str(json)?; + assert!(policy.open_public); + assert_eq!( + policy.preflight_transports, + [EgressTransport::Browser, EgressTransport::Proxy] + ); + assert_eq!( + serde_json::to_value(&policy)?, + serde_json::from_str::(json)? + ); + let closed = EgressPolicy { + open_public: false, + preflight_transports: vec![], + ..policy + }; + let wire = serde_json::to_value(&closed)?; + assert!(wire.get("open_public").is_none() && wire.get("preflight_transports").is_none()); + Ok(()) +} diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index c61a994..ce24022 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -82,10 +82,11 @@ fn new_members_are_advertised_and_named_by_contract_constants() { #[test] fn contract_bump_is_additive_for_older_hosts_and_modules() { - assert_eq!(CONTRACT_VERSION, (1, 14)); - // A host built against 1.14 accepts this module and newer minors, not a 1.13 module. - assert!(is_compatible((1, 14))); + assert_eq!(CONTRACT_VERSION, (1, 15)); + // A host built against 1.15 accepts this module and newer minors, not a 1.14 module. assert!(is_compatible((1, 15))); + assert!(is_compatible((1, 16))); + assert!(!is_compatible((1, 14))); assert!(!is_compatible((1, 13))); // A 1.12 module cannot supply the audited scoped members. assert!(!is_compatible((1, 12))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index a45762b..9fde23f 100644 --- a/crates/tinysecurity-bus/tests/wire.rs +++ b/crates/tinysecurity-bus/tests/wire.rs @@ -343,7 +343,7 @@ fn redactor_init_is_additive_and_secrets_never_enter_public_info() serde_json::to_value(&config)?["redactor"]["secrets"][0], "literal-private" ); - assert_eq!(CONTRACT_VERSION, (1, 14)); + assert_eq!(CONTRACT_VERSION, (1, 15)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), @@ -358,6 +358,7 @@ fn redactor_init_is_additive_and_secrets_never_enter_public_info() "structured_log_arguments", ), (RedactMode::BrowserResult, "browser_result"), + (RedactMode::CredentialsOnly, "credentials_only"), ] { assert_eq!(serde_json::to_value(mode)?, wire); } diff --git a/crates/tinysecurity-egress/src/guard_corpus_tests.rs b/crates/tinysecurity-egress/src/guard_corpus_tests.rs index f258c04..db5c598 100644 --- a/crates/tinysecurity-egress/src/guard_corpus_tests.rs +++ b/crates/tinysecurity-egress/src/guard_corpus_tests.rs @@ -45,6 +45,8 @@ fn policy(validity_ms: u32) -> EgressPolicy { data_categories: vec![DataCategory::UserContent], local_capabilities: vec![], validity_ms, + open_public: false, + preflight_transports: vec![], } } fn request(id: &EgressPolicyId, url: &str) -> ScopedEgressRequest { diff --git a/crates/tinysecurity-egress/src/open_public_tests.rs b/crates/tinysecurity-egress/src/open_public_tests.rs new file mode 100644 index 0000000..3d1d085 --- /dev/null +++ b/crates/tinysecurity-egress/src/open_public_tests.rs @@ -0,0 +1,269 @@ +use super::*; +use std::{net::IpAddr, time::Duration}; +use tinysecurity_bus::{AccessTier, DestinationRule, LocalEgressCapability, PrivacyMode}; + +#[derive(Debug)] +struct Clock; +impl EgressClock for Clock { + fn now_ms(&self) -> u64 { + 42 + } +} +#[derive(Debug)] +struct Dns(Vec); +impl Resolver for Dns { + fn resolve( + &self, + _: &str, + _: Duration, + _: usize, + ) -> std::result::Result, crate::ResolveFailure> { + Ok(self.0.clone()) + } +} +fn owner() -> SubjectScope { + SubjectScope { + user: "user".into(), + workspace: "workspace".into(), + agent: "agent".into(), + } +} +fn open_policy() -> EgressPolicy { + EgressPolicy { + owner: owner(), + ceiling: AccessTier::Full, + privacy: PrivacyMode::Standard, + destinations: vec![], + data_categories: vec![DataCategory::UserContent, DataCategory::HostMetadata], + local_capabilities: vec![], + validity_ms: 1000, + open_public: true, + preflight_transports: vec![], + } +} +fn request(id: EgressPolicyId, url: &str) -> ScopedEgressRequest { + ScopedEgressRequest { + policy_id: id, + subject: owner(), + tier: AccessTier::Full, + url: url.into(), + method: "GET".into(), + category: DataCategory::UserContent, + payload: String::new(), + headers: vec![], + transport: EgressTransport::PinnedDirect, + local_capability: None, + transformation: None, + } +} +type R = std::result::Result<(), Box>; +fn registry( + policy: EgressPolicy, +) -> std::result::Result<(EgressPolicyRegistry, EgressPolicyId), Box> { + let mut registry = EgressPolicyRegistry::with_clock(Arc::new(Clock)); + let id = registry.register(policy)?; + Ok((registry, id)) +} +fn public() -> Dns { + Dns(vec![IpAddr::from([93, 184, 216, 34])]) +} + +#[test] +fn open_public_authorizes_any_public_host_with_pinned_sockets() -> R { + let (registry, id) = registry(open_policy())?; + for url in [ + "https://example.org/a", + "http://news.example.net:8080/x?q=1", + "https://93.184.216.34/", + ] { + let result = registry + .get_owned(&id)? + .check(&request(id.clone(), url), 1, &public()); + assert_eq!(result.denial, None, "{url}"); + let auth = result.authorization.ok_or("authorization")?; + assert_eq!(auth.addresses.len(), 1); + } + Ok(()) +} +#[test] +fn open_public_still_applies_the_address_floor_to_literals_and_resolved_names() -> R { + let (registry, id) = registry(open_policy())?; + let scope = registry.get_owned(&id)?; + for url in [ + "http://127.0.0.1/", + "http://169.254.169.254/latest/meta-data", + "http://10.0.0.5/", + "http://100.64.0.1/", + "http://[::1]/", + "http://[fd00::1]/", + ] { + let result = scope.check(&request(id.clone(), url), 1, &public()); + assert_eq!(result.denial, Some(EgressDenial::Address), "{url}"); + } + for hostile in ["127.0.0.1", "169.254.169.254", "10.1.2.3", "fe80::1"] { + let dns = Dns(vec![IpAddr::from([93, 184, 216, 34]), hostile.parse()?]); + let result = scope.check(&request(id.clone(), "https://rebind.example.org/"), 1, &dns); + assert_eq!(result.denial, Some(EgressDenial::Address), "{hostile}"); + } + Ok(()) +} +#[test] +fn closed_policy_still_refuses_unregistered_destinations() -> R { + let mut closed = open_policy(); + closed.open_public = false; + closed.destinations = vec![DestinationRule { + scheme: "https".into(), + hostname: "example.com".into(), + port: 443, + include_subdomains: false, + }]; + let (registry, id) = registry(closed)?; + let result = + registry + .get_owned(&id)? + .check(&request(id.clone(), "https://example.org/"), 1, &public()); + assert_eq!(result.denial, Some(EgressDenial::Destination)); + Ok(()) +} +#[test] +fn browser_and_proxy_need_an_explicit_preflight_opt_in() -> R { + let (strict, strict_id) = registry(open_policy())?; + let mut browser = request(strict_id.clone(), "https://example.org/"); + browser.transport = EgressTransport::Browser; + assert_eq!( + strict + .get_owned(&strict_id)? + .check(&browser, 1, &public()) + .denial, + Some(EgressDenial::Transport) + ); + let mut policy = open_policy(); + policy.preflight_transports = vec![EgressTransport::Browser]; + let (registry, id) = registry(policy)?; + let scope = registry.get_owned(&id)?; + let mut browser = request(id.clone(), "https://example.org/"); + browser.transport = EgressTransport::Browser; + assert_eq!(scope.check(&browser, 1, &public()).denial, None); + // Proxy was not opted in, and Unsupported can never be. + for transport in [EgressTransport::Proxy, EgressTransport::Unsupported] { + let mut other = request(id.clone(), "https://example.org/"); + other.transport = transport; + assert_eq!( + scope.check(&other, 1, &public()).denial, + Some(EgressDenial::Transport) + ); + } + // The floor binds the browser preflight too. + let mut metadata = request(id.clone(), "http://169.254.169.254/"); + metadata.transport = EgressTransport::Browser; + assert_eq!( + scope.check(&metadata, 1, &public()).denial, + Some(EgressDenial::Address) + ); + Ok(()) +} +#[test] +fn local_capabilities_never_use_a_preflight_transport() -> R { + let mut policy = open_policy(); + policy.open_public = false; + policy.privacy = PrivacyMode::LocalOnly; + policy.preflight_transports = vec![EgressTransport::Browser]; + policy.local_capabilities = vec![LocalEgressCapability { + origin: "http://127.0.0.1:9000/".into(), + path: "/health".into(), + category: DataCategory::HostMetadata, + }]; + let (registry, id) = registry(policy)?; + let mut local = request(id.clone(), "http://127.0.0.1:9000/health"); + local.category = DataCategory::HostMetadata; + local.local_capability = Some(0); + let scope = registry.get_owned(&id)?; + assert_eq!(scope.check(&local, 1, &public()).denial, None); + local.transport = EgressTransport::Browser; + assert_eq!( + scope.check(&local, 1, &public()).denial, + Some(EgressDenial::Transport) + ); + Ok(()) +} +#[test] +fn registration_rejects_ambiguous_or_unsound_open_and_preflight_settings() { + let mut cases = Vec::new(); + let mut with_rules = open_policy(); + with_rules.destinations = vec![DestinationRule { + scheme: "https".into(), + hostname: "example.com".into(), + port: 443, + include_subdomains: false, + }]; + cases.push(with_rules); + let mut local_only = open_policy(); + local_only.privacy = PrivacyMode::LocalOnly; + cases.push(local_only); + let mut with_local = open_policy(); + with_local.local_capabilities = vec![LocalEgressCapability { + origin: "http://127.0.0.1:9000/".into(), + path: "/health".into(), + category: DataCategory::HostMetadata, + }]; + cases.push(with_local); + for bad in [ + vec![EgressTransport::PinnedDirect], + vec![EgressTransport::Unsupported], + vec![EgressTransport::Browser, EgressTransport::Browser], + vec![ + EgressTransport::Browser, + EgressTransport::Proxy, + EgressTransport::Browser, + ], + ] { + let mut policy = open_policy(); + policy.preflight_transports = bad; + cases.push(policy); + } + for policy in cases { + let mut registry = EgressPolicyRegistry::with_clock(Arc::new(Clock)); + assert!(registry.register(policy).is_err()); + } + let mut both = open_policy(); + both.preflight_transports = vec![EgressTransport::Browser, EgressTransport::Proxy]; + let mut registry = EgressPolicyRegistry::with_clock(Arc::new(Clock)); + assert!(registry.register(both).is_ok()); +} +#[test] +fn sensitive_privacy_still_inspects_open_public_requests() -> R { + let mut policy = open_policy(); + policy.privacy = PrivacyMode::Sensitive; + let (registry, id) = registry(policy)?; + let scope = registry.get_owned(&id)?; + let mut leaking = request(id.clone(), "https://example.org/"); + leaking.method = "POST".into(); + leaking.payload = "mail alice@example.com".into(); + assert_eq!( + scope.check(&leaking, 1, &public()).denial, + Some(EgressDenial::Privacy) + ); + assert_eq!( + scope + .check(&request(id.clone(), "https://example.org/"), 1, &public()) + .denial, + None + ); + Ok(()) +} +#[test] +fn legacy_policy_json_without_the_new_fields_deserializes_closed() -> R { + let json = serde_json::json!({ + "owner": {"user": "u", "workspace": "w", "agent": "a"}, + "ceiling": "full", + "privacy": "standard", + "destinations": [], + "data_categories": ["user_content"], + "local_capabilities": [], + "validity_ms": 1000 + }); + let policy: EgressPolicy = serde_json::from_value(json)?; + assert!(!policy.open_public); + assert_eq!(policy.preflight_transports.len(), 0); + Ok(()) +} diff --git a/crates/tinysecurity-egress/src/registry.rs b/crates/tinysecurity-egress/src/registry.rs index c7d22e2..7431ec8 100644 --- a/crates/tinysecurity-egress/src/registry.rs +++ b/crates/tinysecurity-egress/src/registry.rs @@ -13,7 +13,7 @@ use std::{ time::{Duration, Instant}, }; use tinysecurity_bus::{ - DataCategory, EgressDenial, EgressPolicy, EgressPolicyId, EgressTransport, Error, + DataCategory, EgressDenial, EgressPolicy, EgressPolicyId, EgressTransport, Error, PrivacyMode, RedactorConfig, Result, ScopedEgressRequest, ScopedEgressResponse, SubjectScope, TransportAuthorization, }; @@ -103,7 +103,7 @@ impl RegisteredEgressPolicy { if !self.matches_scope(request) { return Err(EgressDenial::Scope); } - if request.transport != EgressTransport::PinnedDirect { + if !self.transport_allowed(request) { return Err(EgressDenial::Transport); } privacy::check(&self.policy, request, redactor).map_err(|_| EgressDenial::Privacy)?; @@ -134,12 +134,7 @@ impl RegisteredEgressPolicy { if endpoint.ip.is_some_and(url_guard::forbidden) { return Err(EgressDenial::Address); } - if !self - .policy - .destinations - .iter() - .any(|rule| url_guard::matches(rule, &endpoint)) - { + if !self.destination_registered(&endpoint) { return Err(EgressDenial::Destination); } let addresses = match endpoint.ip { @@ -273,6 +268,24 @@ impl RegisteredEgressPolicy { state.issued.swap_remove(index); true } + /// Pinned direct always; Browser/Proxy only as an opted-in preflight, never for local capabilities. + fn transport_allowed(&self, request: &ScopedEgressRequest) -> bool { + request.transport == EgressTransport::PinnedDirect + || (request.local_capability.is_none() + && self + .policy + .preflight_transports + .contains(&request.transport)) + } + /// Open public mode registers every destination; the address floor still decides afterwards. + fn destination_registered(&self, endpoint: &Endpoint) -> bool { + self.policy.open_public + || self + .policy + .destinations + .iter() + .any(|rule| url_guard::matches(rule, endpoint)) + } fn matches_scope(&self, request: &ScopedEgressRequest) -> bool { request.policy_id == self.id && request.subject == self.policy.owner @@ -390,6 +403,21 @@ impl EgressPolicyRegistry { { return Err(Error::InvalidArguments); } + if policy.preflight_transports.len() > 2 + || policy + .preflight_transports + .iter() + .enumerate() + .any(|(index, transport)| { + !matches!(transport, EgressTransport::Browser | EgressTransport::Proxy) + || policy.preflight_transports[..index].contains(transport) + }) + || (policy.open_public + && (!policy.destinations.is_empty() || !policy.local_capabilities.is_empty())) + || (policy.open_public && policy.privacy == PrivacyMode::LocalOnly) + { + return Err(Error::InvalidArguments); + } policy.destinations = policy .destinations .iter() @@ -450,5 +478,8 @@ impl EgressPolicyRegistry { #[path = "guard_corpus_tests.rs"] mod guard_corpus_tests; #[cfg(test)] +#[path = "open_public_tests.rs"] +mod open_public_tests; +#[cfg(test)] #[path = "registry_tests.rs"] mod tests; diff --git a/crates/tinysecurity-egress/src/registry_tests.rs b/crates/tinysecurity-egress/src/registry_tests.rs index c1181ee..d88d627 100644 --- a/crates/tinysecurity-egress/src/registry_tests.rs +++ b/crates/tinysecurity-egress/src/registry_tests.rs @@ -49,6 +49,8 @@ fn policy() -> EgressPolicy { data_categories: vec![DataCategory::UserContent, DataCategory::HostMetadata], local_capabilities: vec![], validity_ms: 1000, + open_public: false, + preflight_transports: vec![], } } fn request(id: EgressPolicyId) -> ScopedEgressRequest { diff --git a/crates/tinysecurity-module/examples/audit_native_contract.rs b/crates/tinysecurity-module/examples/audit_native_contract.rs index 68c0530..86894e1 100644 --- a/crates/tinysecurity-module/examples/audit_native_contract.rs +++ b/crates/tinysecurity-module/examples/audit_native_contract.rs @@ -418,6 +418,8 @@ async fn verify_unbound_authorizations( category: DataCategory::HostMetadata, }], validity_ms: 30000, + open_public: false, + preflight_transports: vec![], },), ) .await?; diff --git a/crates/tinysecurity-module/examples/verify_module.rs b/crates/tinysecurity-module/examples/verify_module.rs index 19bec84..cabce5d 100644 --- a/crates/tinysecurity-module/examples/verify_module.rs +++ b/crates/tinysecurity-module/examples/verify_module.rs @@ -458,6 +458,11 @@ async fn verify_native_redaction( "native-private", ), (r"C:\Users\alice\report.txt", RedactMode::HomePath, "alice"), + ( + "export TOKEN=ghp_nativeprivate0123456789", + RedactMode::CredentialsOnly, + "ghp_nativeprivate0123456789", + ), ] { let out: RedactResponse = proxy .call( @@ -734,6 +739,8 @@ async fn verify_native_egress( data_categories: vec![DataCategory::UserContent], local_capabilities: vec![], validity_ms: 30000, + open_public: false, + preflight_transports: vec![], }; let id: EgressPolicyId = proxy .call(names::methods::REGISTER_EGRESS_POLICY, (policy.clone(),)) diff --git a/crates/tinysecurity-module/src/adapter_tests.rs b/crates/tinysecurity-module/src/adapter_tests.rs index bd30ff0..35e651d 100644 --- a/crates/tinysecurity-module/src/adapter_tests.rs +++ b/crates/tinysecurity-module/src/adapter_tests.rs @@ -1317,6 +1317,8 @@ async fn native_egress_dispatch_binds_scope_and_denies_legacy_or_generic_local_c category: DataCategory::HostMetadata, }], validity_ms: 30000, + open_public: false, + preflight_transports: vec![], }; let id: EgressPolicyId = proxy .call(names::methods::REGISTER_EGRESS_POLICY, (policy,)) @@ -1368,6 +1370,105 @@ async fn native_egress_dispatch_binds_scope_and_denies_legacy_or_generic_local_c Ok(()) } +#[tokio::test] +async fn native_open_public_policy_and_credentials_only_redaction_dispatch() +-> std::result::Result<(), Box> { + let bus = MemoryBus::new(); + let task = Broker::new().spawn(bus.clone()); + let server = Connection::connect(bus.connect().await?).await?; + server + .serve_at( + names::OBJECT_PATH.try_into()?, + PolicyService { + device: None, + crypto: None, + secret: None, + secret_cipher: None, + sandbox_clock: Arc::new(tinysecurity::approval::SystemClock::default()), + sandbox: None, + approvals: None, + audit: None, + policy: Arc::new(RwLock::new(Policy::new(ModuleConfig::default())?)), + paths: Arc::new(Mutex::new(PathPolicyRegistry::default())), + commands: Arc::new(Mutex::new(CommandPolicyRegistry::default())), + egress: Arc::new(Mutex::new(EgressPolicyRegistry::default())), + scoped: Arc::new(Mutex::new(ScopedPolicyRegistry::default())), + signals: None, + }, + ) + .await?; + server.request_name(names::INTERFACE).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; + let subject = SubjectScope { + user: "user".into(), + workspace: "workspace".into(), + agent: "agent".into(), + }; + let policy = EgressPolicy { + owner: subject.clone(), + ceiling: AccessTier::Full, + privacy: PrivacyMode::Standard, + destinations: vec![], + data_categories: vec![DataCategory::UserContent], + local_capabilities: vec![], + validity_ms: 30000, + open_public: true, + preflight_transports: vec![EgressTransport::Browser], + }; + let id: EgressPolicyId = proxy + .call(names::methods::REGISTER_EGRESS_POLICY, (policy,)) + .await?; + let request = |url: &str, transport| ScopedEgressRequest { + policy_id: id.clone(), + subject: subject.clone(), + tier: AccessTier::Full, + url: url.into(), + method: "GET".into(), + category: DataCategory::UserContent, + payload: String::new(), + headers: vec![], + transport, + local_capability: None, + transformation: None, + }; + for (url, transport, denial) in [ + ( + "https://93.184.216.34/a", + EgressTransport::PinnedDirect, + None, + ), + ("https://93.184.216.34/a", EgressTransport::Browser, None), + ( + "https://93.184.216.34/a", + EgressTransport::Proxy, + Some(EgressDenial::Transport), + ), + ( + "http://169.254.169.254/", + EgressTransport::Browser, + Some(EgressDenial::Address), + ), + ] { + let response: ScopedEgressResponse = proxy + .call(names::methods::SCOPED_URL_GUARD, (request(url, transport),)) + .await?; + assert_eq!(response.denial, denial, "{url}"); + } + let redacted: RedactResponse = proxy + .call( + names::methods::REDACT, + (RedactRequest { + text: "token: ghp_abcdefghijklmnop1234 mail a@example.com".into(), + mode: RedactMode::CredentialsOnly, + },), + ) + .await?; + assert_eq!(redacted.text, "token: [REDACTED] mail a@example.com"); + assert!(redacted.changed); + task.abort(); + Ok(()) +} async fn assert_legacy_egress_is_denied( proxy: &tinybus::Proxy, ) -> std::result::Result<(), Box> { @@ -1456,6 +1557,8 @@ async fn consumption_work_runs_off_the_sole_sdk_worker_without_registry_locks() category: DataCategory::HostMetadata, }], validity_ms: 30000, + open_public: false, + preflight_transports: vec![], }) .await?; let request = ScopedEgressRequest { @@ -1537,6 +1640,8 @@ async fn assert_native_review_regressions( data_categories: vec![DataCategory::UserContent], local_capabilities: vec![], validity_ms: 30000, + open_public: false, + preflight_transports: vec![], }; let id: EgressPolicyId = proxy .call(names::methods::REGISTER_EGRESS_POLICY, (policy,)) @@ -1618,6 +1723,8 @@ async fn issue_reinit_egress_grants( category: DataCategory::HostMetadata, }], validity_ms: 30000, + open_public: false, + preflight_transports: vec![], }; let id: EgressPolicyId = proxy .call(names::methods::REGISTER_EGRESS_POLICY, (policy,)) diff --git a/crates/tinysecurity-redact/src/lib.rs b/crates/tinysecurity-redact/src/lib.rs index 7064f4b..04bfd91 100644 --- a/crates/tinysecurity-redact/src/lib.rs +++ b/crates/tinysecurity-redact/src/lib.rs @@ -81,6 +81,7 @@ impl Redactor { format!("{}***", request.text.chars().take(4).collect::()) } } + RedactMode::CredentialsOnly => self.0.patterns.credentials(&request.text)?, _ => self.structured_or_text(&request.text, &request.mode, 0)?, }; output_bounded(&text)?; diff --git a/crates/tinysecurity-redact/src/lib_tests.rs b/crates/tinysecurity-redact/src/lib_tests.rs index 80d08ee..617516a 100644 --- a/crates/tinysecurity-redact/src/lib_tests.rs +++ b/crates/tinysecurity-redact/src/lib_tests.rs @@ -104,3 +104,85 @@ fn direct_credential_transfers_block_unknown_tool_json() -> Result<()> { } Ok(()) } + +fn credentials(registry: &Redactor, text: &str) -> Result { + Ok(registry + .redact(&RedactRequest { + text: text.into(), + mode: RedactMode::CredentialsOnly, + })? + .text) +} +#[test] +fn credentials_only_removes_credentials_and_keeps_ordinary_content() -> Result<()> { + let registry = Redactor::new(&RedactorConfig::default())?; + for secret in [ + "key sk-ant-abcdefghijklmnop1234", + "export GITHUB_TOKEN=ghp_abcdefghijklmnop1234", + "aws AKIAIOSFODNN7EXAMPLE", + "jwt eyJhbGciOiJIUzI1.eyJzdWIiOiIxMjM0.c2lnbmF0dXJlMTIz", + "Authorization: Bearer abcdef0123456789", + "api_key = \"s3cr3t-value-123\"", + "password: hunter2hunter2", + "Cookie: sid=abc123; theme=dark", + "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + ] { + let out = credentials(®istry, secret)?; + assert!(out.contains("[REDACTED]"), "{secret} -> {out}"); + for leaked in [ + "abcdefghijklmnop1234", + "AKIAIOSFODNN7EXAMPLE", + "c2lnbmF0dXJlMTIz", + "abcdef0123456789", + "s3cr3t-value-123", + "hunter2hunter2", + "sid=abc123", + ] { + assert!(!out.contains(leaked), "{secret} -> {out}"); + } + } + for kept in [ + "mail alice@example.com or call 415-555-0134", + "card 4111 1111 1111 1111 at 12 Main Street from 10.1.2.3", + "see https://user@example.com/path?q=1 in /home/alice/notes", + "token = get_token()", + "self.token = self.vocab.token_id", + "accessToken: string", + "token_count = 4096", + "if token == expected_token {", + "let token = \"\";", + "password = None", + "secret: 1.5e-3", + "api_key=[REDACTED]", + ] { + assert_eq!(credentials(®istry, kept)?, kept, "{kept}"); + } + Ok(()) +} +#[test] +fn credentials_only_applies_the_exact_registry_and_is_idempotent() -> Result<()> { + let registry = Redactor::new(&RedactorConfig { + secrets: vec![tinysecurity_bus::Secret("plain-word-credential".into())], + })?; + let once = credentials( + ®istry, + "the plain-word-credential is here, bob@example.com", + )?; + assert_eq!(once, "the [REDACTED] is here, bob@example.com"); + assert_eq!(credentials(®istry, &once)?, once); + // JSON-shaped tool output is treated as text; quoting does not hide a value. + let json = credentials(®istry, r#"{"token":"abcdefgh12345678","n":4}"#)?; + assert!( + !json.contains("abcdefgh12345678") && json.contains("\"n\":4"), + "{json}" + ); + assert!( + registry + .redact(&RedactRequest { + text: "x".repeat(MAX_INPUT + 1), + mode: RedactMode::CredentialsOnly, + }) + .is_err() + ); + Ok(()) +} diff --git a/crates/tinysecurity-redact/src/patterns.rs b/crates/tinysecurity-redact/src/patterns.rs index 8f3a14f..474d72b 100644 --- a/crates/tinysecurity-redact/src/patterns.rs +++ b/crates/tinysecurity-redact/src/patterns.rs @@ -7,6 +7,7 @@ pub(crate) struct Patterns { rules: Vec<(Regex, Validator)>, pem: Regex, assignment: Regex, + gated_assignment: Regex, cookie: Regex, home: Regex, endpoint: Regex, @@ -101,6 +102,9 @@ impl Patterns { assignment: compile( r#"(?i)\b(authorization["']?[ \t]*[=:][ \t]*(?:(?:bearer|basic|digest|token)[ \t]+)?|bearer[ \t]+|(?:x-api-key|api[_-]?(?:key|secret)|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|client[_-]?secret|proxy-authorization|passwd|password|secret|token)["']?[ \t]*[=:][ \t]*)(?:\[REDACTED\]|"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;\}\]]+)"#, )?, + gated_assignment: compile( + r#"(?i)\b(authorization["']?[ \t]*[=:][ \t]*(?:(?:bearer|basic|digest|token)[ \t]+)?|bearer[ \t]+|(?:x-api-key|api[_-]?(?:key|secret)|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|client[_-]?secret|proxy-authorization|passwd|password|secret|token)["']?[ \t]*[=:][ \t]*)(\[REDACTED\]|"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;\}\]]+)"#, + )?, // A Cookie header is a `;`-separated list; the whole line is the credential. cookie: compile( r#"(?i)\b((?:set-)?cookie["']?[ \t]*[=:][ \t]*)(?:\[REDACTED\]|"[^"\r\n]*"|'[^'\r\n]*'|[^\r\n]+)"#, @@ -175,6 +179,40 @@ impl Patterns { output_bounded(&out)?; Ok(out) } + /// Credential-only sanitation for free-form tool output: the exact registry, + /// provider token shapes, JWTs, private keys, cookie headers and labelled + /// values that look like a credential. Contact data, cards, addresses, + /// home paths and URLs are left alone, and a labelled value that reads as + /// ordinary source code (`token = get_token()`, `token: string`) is kept. + pub(crate) fn credentials(&self, text: &str) -> Result { + let mut out = self.literal(text); + out = self.pem.replace_all(&out, MARKER).into_owned(); + for (regex, _) in self.rules.iter().take(2) { + out = regex.replace_all(&out, MARKER).into_owned(); + } + let source = out.clone(); + out = self + .gated_assignment + .replace_all(&source, |caps: ®ex::Captures<'_>| { + let whole = caps.get(0).map_or(0, |m| m.end()); + if secret_like(&caps[1], &caps[2], &source[whole..]) { + format!("{}{MARKER}", &caps[1]) + } else { + caps[0].to_string() + } + }) + .into_owned(); + out = self + .cookie + .replace_all(&out, |caps: ®ex::Captures<'_>| { + format!("{}{MARKER}", &caps[1]) + }) + .into_owned(); + output_bounded(&out)?; + let out = self.literal(&out); + output_bounded(&out)?; + Ok(out) + } pub(crate) fn retained_endpoint_is_sensitive(&self, text: &str) -> bool { self.literal.as_ref().is_some_and(|r| r.is_match(text)) || self.assignment.is_match(text) @@ -185,6 +223,68 @@ impl Patterns { .any(|(rule, validator)| rule.find_iter(text).any(|m| validator.valid(m.as_str()))) } } +/// Decide whether a labelled value is a credential rather than source code. +fn secret_like(label: &str, raw: &str, rest: &str) -> bool { + let value = raw.trim_matches(|c| c == '"' || c == '\''); + if value == MARKER { + return false; + } + if [ + "sk-", + "ghp_", + "gho_", + "ghu_", + "ghs_", + "ghr_", + "github_pat_", + "AKIA", + "ASIA", + "eyJ", + "xox", + ] + .iter() + .any(|prefix| value.starts_with(prefix)) + { + return true; + } + let scheme = label.to_ascii_lowercase(); + let scheme = scheme.trim_end(); + if scheme.starts_with("bearer") || scheme.ends_with("bearer") || scheme.ends_with("basic") { + return value.len() >= 8; + } + if value.len() < 8 || value.chars().any(char::is_whitespace) { + return false; + } + if value + .find('<') + .is_some_and(|open| value[open..].contains('>')) + { + return false; + } + if value.starts_with('=') { + return false; + } + let mut after = rest.chars(); + match after.next() { + Some('(' | '[') => return false, + Some('.') if after.next().is_some_and(|c| c.is_alphabetic() || c == '_') => return false, + _ => {} + } + if value.contains(['(', ')', '{', '[']) { + return false; + } + // A digitless identifier or dotted member path reads a value in code. A + // credential of that shape is caught by the exact registry instead. + let unquoted = raw.len() == value.len(); + if unquoted + && value + .chars() + .all(|c| c.is_ascii_alphabetic() || matches!(c, '_' | '.')) + { + return false; + } + value.parse::().is_err() +} fn marker_collision(secret: &str) -> bool { let skeleton: String = secret.chars().filter(|c| !c.is_ascii_digit()).collect(); if skeleton.is_empty() {