diff --git a/.cora.yaml b/.cora.yaml index 39a6cba6..5e35a11c 100644 --- a/.cora.yaml +++ b/.cora.yaml @@ -28,6 +28,9 @@ ignore: - "yarn.lock" - "vendor/" - "node_modules/" + # Test vectors of the privacy filter (#1464): inputs are secret-shaped on + # purpose (split into fragments, never a real credential). + - "docs/privacy-filter-cases.json" rules: - "Hardcoded password or secret" - "Command injection via exec/system with dynamic input" diff --git a/CHANGELOG.md b/CHANGELOG.md index fdc1c0b2..afb70f70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Changed +- Internal: `docs/privacy-filter-cases.json`, the shared test vectors of the privacy filter (35 cases: every kind and 23 inputs that must stay untouched; inputs are split into fragments so no complete token appears, and the file is excluded from Cora review in `.cora.yaml` because its secret-shaped assignments are the point of the test). `uteke-core` pins every case, and uteke-cloud pins parity against the same file (uteke-cloud#218). Part 2a of #1464. - Internal: `memories.author`, part 1 of #1427 phase 2 (schema v22): an optional, free-text author on the memory itself, usable without a room. The first open by a build with this change migrates the store in place (additive `ALTER TABLE`; a memory that belongs to exactly one room takes that room link's author, the rest stay unset). `Memory` gains `author` (omitted from JSON when unset) and `Uteke::set_author` validates it (trimmed, 1 to 128 characters, no control characters). No HTTP, MCP or CLI input accepts it yet: that follows once uteke-cloud#219 is answered. - Internal: privacy filter detection module, part 1 of #1464 (`uteke_core::privacy`): `redact` masks AWS access keys, GitHub tokens, `sk-` API keys, bearer headers, JWTs, PEM private key blocks and long secret-named assignments (`API_KEY=...`, with an entropy check and exceptions for hashes, UUIDs, paths and placeholders), keeping the shape (`ghp_[REDACTED:github-token]`), with an allowlist for false positives. Nothing calls it yet: it is wired into the write paths, behind `[privacy] mode`, in part 2, after the cloud contract answer (uteke-cloud#218). - Internal: scope-isolation regression gate, parts 1 and 2 (#1467): the HTTP suite runs 20 scoped read requests through `route` (every recall strategy, search, list, recent, export, graph, room, orphans, tags, stats, doc) and a test fails when a read route is added to the registry without an isolation check or a documented exemption. Part 1: a two-namespace fixture with overlapping vocabulary and a core suite asserting that `recall` (every strategy), unified recall, `search`, `list`, tags, export, room recall and graph views never return a record from another namespace. The fixture ships behind the `test-support` feature of `uteke-core` for the server, MCP and CLI suites that follow. diff --git a/crates/uteke-core/src/privacy.rs b/crates/uteke-core/src/privacy.rs index d128c5e0..630fe106 100644 --- a/crates/uteke-core/src/privacy.rs +++ b/crates/uteke-core/src/privacy.rs @@ -431,6 +431,54 @@ mod tests { } } + /// `docs/privacy-filter-cases.json` is the shared test-vector file uteke-cloud + /// pins parity against (uteke-cloud#218): every case must hold here too. + #[test] + fn the_shared_cases_file_matches_the_implementation() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../docs/privacy-filter-cases.json" + ); + let doc: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).expect("cases file")).unwrap(); + assert_eq!(doc["version"], 1); + let cases = doc["cases"].as_array().unwrap(); + assert!(cases.len() >= 30, "only {} cases", cases.len()); + let mut masked = 0; + for case in cases { + let name = case["name"].as_str().unwrap(); + let input: String = case["input"] + .as_array() + .unwrap() + .iter() + .map(|f| f.as_str().unwrap()) + .collect(); + let r = redact(&input, &[]); + assert_eq!(r.text, case["expected"].as_str().unwrap(), "{name}"); + let kinds: Vec<(String, u64)> = r + .redactions + .iter() + .map(|x| (x.kind.to_string(), x.count as u64)) + .collect(); + let expected: Vec<(String, u64)> = case["kinds"] + .as_array() + .unwrap() + .iter() + .map(|k| (k[0].as_str().unwrap().to_string(), k[1].as_u64().unwrap())) + .collect(); + assert_eq!(kinds, expected, "{name}"); + if !expected.is_empty() { + masked += 1; + } + // The expected text is a fixed point of the filter. + assert!(redact(&r.text, &[]).is_clean(), "{name}: not idempotent"); + } + assert!( + masked >= 10, + "the file must exercise every kind, not only clean text" + ); + } + #[test] fn entropy_matches_known_values() { assert_eq!(entropy("aaaa"), 0.0); diff --git a/docs/privacy-filter-cases.json b/docs/privacy-filter-cases.json new file mode 100644 index 00000000..7a4049e1 --- /dev/null +++ b/docs/privacy-filter-cases.json @@ -0,0 +1,397 @@ +{ + "format": "Shared test vectors of the OSS privacy filter (uteke_core::privacy::redact, #1464) so uteke-cloud can pin parity in CI. For each case: join the strings of `input` (no separator) to get the text, run the filter with an empty allowlist, and expect exactly `expected` and the list `kinds` of [kind, count] pairs ordered by kind (empty when nothing is masked). Inputs are split into fragments so no complete token appears; none of the values is a real credential, and secret scanners may still flag the assignment-shaped lines.", + "version": 1, + "cases": [ + { + "name": "github token keeps its prefix", + "input": [ + "use ", + "ghp_", + "aB3dE5gH7jK9mN1pQ3rS5tU7vW9xY1zA3bC5", + " for the repo" + ], + "expected": "use ghp_[REDACTED:github-token] for the repo", + "kinds": [ + [ + "github-token", + 1 + ] + ] + }, + { + "name": "fine-grained github token", + "input": [ + "github_pat_", + "11ABCDEFG0abcdefghij_klmnopqrstuvwxyz0123456789" + ], + "expected": "github_pat_[REDACTED:github-token]", + "kinds": [ + [ + "github-token", + 1 + ] + ] + }, + { + "name": "aws access key keeps its prefix", + "input": [ + "key id ", + "AKIA", + "IOSFODNN7EXAMPLQ" + ], + "expected": "key id AKIA[REDACTED:aws-access-key]", + "kinds": [ + [ + "aws-access-key", + 1 + ] + ] + }, + { + "name": "bearer header with a jwt is a bearer token", + "input": [ + "Authorization: Bearer ", + "eyJhbGciOiJIUzI1NiJ9", + ".", + "eyJzdWIiOiIxMjM0NTY3ODkwIn0", + ".", + "dBjftJeZ4CVPmB92K27uhbUJU1p1r" + ], + "expected": "Authorization: Bearer [REDACTED:bearer-token]", + "kinds": [ + [ + "bearer-token", + 1 + ] + ] + }, + { + "name": "bare jwt", + "input": [ + "cookie ", + "eyJhbGciOiJIUzI1NiJ9", + ".", + "eyJzdWIiOiIxMjM0NTY3ODkwIn0", + ".", + "dBjftJeZ4CVPmB92K27uhbUJU1p1r" + ], + "expected": "cookie [REDACTED:jwt]", + "kinds": [ + [ + "jwt", + 1 + ] + ] + }, + { + "name": "private key block is removed whole", + "input": [ + "before\n", + "-----BEGIN ", + "RSA PRIVATE", + " KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASC\nabcdefgh\n-----END ", + "RSA PRIVATE", + " KEY-----", + "\nafter" + ], + "expected": "before\n[REDACTED:private-key]\nafter", + "kinds": [ + [ + "private-key", + 1 + ] + ] + }, + { + "name": "sk- api key", + "input": [ + "sk-", + "proj-Ab3dE5gH7jK9mN1pQ3rS5tU7" + ], + "expected": "sk-[REDACTED:api-key]", + "kinds": [ + [ + "api-key", + 1 + ] + ] + }, + { + "name": "env assignment", + "input": [ + "API_KEY=", + "Zq8vN2xL5mP9", + "aR4tW7yB3cD6" + ], + "expected": "API_KEY=[REDACTED:env-secret]", + "kinds": [ + [ + "env-secret", + 1 + ] + ] + }, + { + "name": "quoted export", + "input": [ + "export DB_PASSWORD=\"", + "Zq8vN2xL5mP9", + "aR4tW7yB3cD6", + "\"" + ], + "expected": "export DB_PASSWORD=\"[REDACTED:env-secret]\"", + "kinds": [ + [ + "env-secret", + 1 + ] + ] + }, + { + "name": "json field", + "input": [ + "{\"client_secret\": \"", + "Zq8vN2xL5mP9", + "aR4tW7yB3cD6", + "\"}" + ], + "expected": "{\"client_secret\": \"[REDACTED:env-secret]\"}", + "kinds": [ + [ + "env-secret", + 1 + ] + ] + }, + { + "name": "colon form", + "input": [ + "auth_token: ", + "Zq8vN2xL5mP9", + "aR4tW7yB3cD6" + ], + "expected": "auth_token: [REDACTED:env-secret]", + "kinds": [ + [ + "env-secret", + 1 + ] + ] + }, + { + "name": "counts are per kind", + "input": [ + "ghp_", + "aB3dE5gH7jK9mN1pQ3rS5tU7vW9xY1zA3bC5", + " and ", + "ghp_", + "aB3dE5gH7jK9mN1pQ3rS5tU7vW9xY1zA3bC5", + " and ", + "AKIA", + "IOSFODNN7EXAMPLQ", + " plus API_KEY=", + "Zq8vN2xL5mP9", + "aR4tW7yB3cD6" + ], + "expected": "ghp_[REDACTED:github-token] and ghp_[REDACTED:github-token] and AKIA[REDACTED:aws-access-key] plus API_KEY=[REDACTED:env-secret]", + "kinds": [ + [ + "aws-access-key", + 1 + ], + [ + "env-secret", + 1 + ], + [ + "github-token", + 2 + ] + ] + }, + { + "name": "left alone: TOKEN_LIMIT=10000", + "input": [ + "TOKEN_LIMIT=10000" + ], + "expected": "TOKEN_LIMIT=10000", + "kinds": [] + }, + { + "name": "left alone: PASSWORD_MIN_LENGTH=12", + "input": [ + "PASSWORD_MIN_LENGTH=12" + ], + "expected": "PASSWORD_MIN_LENGTH=12", + "kinds": [] + }, + { + "name": "left alone: API_KEY=short", + "input": [ + "API_KEY=short" + ], + "expected": "API_KEY=short", + "kinds": [] + }, + { + "name": "left alone: KEY_FILE=/etc/ssl/private/server.key", + "input": [ + "KEY_FILE=/etc/ssl/private/server.key" + ], + "expected": "KEY_FILE=/etc/ssl/private/server.key", + "kinds": [] + }, + { + "name": "left alone: SECRET_PATH=./config/secret.yaml", + "input": [ + "SECRET_PATH=./config/secret.yaml" + ], + "expected": "SECRET_PATH=./config/secret.yaml", + "kinds": [] + }, + { + "name": "left alone: SSH_KEY=~/.ssh/id_ed25519_work_laptop", + "input": [ + "SSH_KEY=~/.ssh/id_ed25519_work_laptop" + ], + "expected": "SSH_KEY=~/.ssh/id_ed25519_work_laptop", + "kinds": [] + }, + { + "name": "left alone: API_KEY=${VAULT_API_KEY_PRODUCTION_ENV}", + "input": [ + "API_KEY=${VAULT_API_KEY_PRODUCTION_ENV}" + ], + "expected": "API_KEY=${VAULT_API_KEY_PRODUCTION_ENV}", + "kinds": [] + }, + { + "name": "left alone: api_key=", + "input": [ + "api_key=" + ], + "expected": "api_key=", + "kinds": [] + }, + { + "name": "left alone: API_KEY=your_api_key_goes_here_please", + "input": [ + "API_KEY=your_api_key_goes_here_please" + ], + "expected": "API_KEY=your_api_key_goes_here_please", + "kinds": [] + }, + { + "name": "left alone: TOKEN=aaaaaaaaaaaaaaaaaaaaaaaa", + "input": [ + "TOKEN=aaaaaaaaaaaaaaaaaaaaaaaa" + ], + "expected": "TOKEN=aaaaaaaaaaaaaaaaaaaaaaaa", + "kinds": [] + }, + { + "name": "left alone: COMMIT_KEY=3f786850e387550fdab836ed7e6dc881de230", + "input": [ + "COMMIT_KEY=3f786850e387550fdab836ed7e6dc881de23001b" + ], + "expected": "COMMIT_KEY=3f786850e387550fdab836ed7e6dc881de23001b", + "kinds": [] + }, + { + "name": "left alone: CACHE_KEY=2f9c1d3e-8a4b-4c6d-9e1f-0a1b2c3d4e5f", + "input": [ + "CACHE_KEY=2f9c1d3e-8a4b-4c6d-9e1f-0a1b2c3d4e5f" + ], + "expected": "CACHE_KEY=2f9c1d3e-8a4b-4c6d-9e1f-0a1b2c3d4e5f", + "kinds": [] + }, + { + "name": "left alone: TOKEN_URL=https://auth.example.com/oauth/token/e", + "input": [ + "TOKEN_URL=https://auth.example.com/oauth/token/endpoint" + ], + "expected": "TOKEN_URL=https://auth.example.com/oauth/token/endpoint", + "kinds": [] + }, + { + "name": "left alone: keyboard shortcuts: press the key combination ag", + "input": [ + "keyboard shortcuts: press the key combination again to toggle" + ], + "expected": "keyboard shortcuts: press the key combination again to toggle", + "kinds": [] + }, + { + "name": "left alone: Bearer token handling is documented", + "input": [ + "Bearer token handling is documented" + ], + "expected": "Bearer token handling is documented", + "kinds": [] + }, + { + "name": "left alone: sk-short", + "input": [ + "sk-short" + ], + "expected": "sk-short", + "kinds": [] + }, + { + "name": "left alone: task-description-for-the-weekly-sync-meeting", + "input": [ + "task-description-for-the-weekly-sync-meeting" + ], + "expected": "task-description-for-the-weekly-sync-meeting", + "kinds": [] + }, + { + "name": "left alone: AKIAshort", + "input": [ + "AKIAshort" + ], + "expected": "AKIAshort", + "kinds": [] + }, + { + "name": "left alone: -----BEGIN CERTIFICATE-----", + "input": [ + "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----" + ], + "expected": "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----", + "kinds": [] + }, + { + "name": "left alone: api_key: self.api_key.clone(),", + "input": [ + "api_key: self.api_key.clone()," + ], + "expected": "api_key: self.api_key.clone(),", + "kinds": [] + }, + { + "name": "left alone: let key = self.make_key(query, namespace, limit)", + "input": [ + "let key = self.make_key(query, namespace, limit);" + ], + "expected": "let key = self.make_key(query, namespace, limit);", + "kinds": [] + }, + { + "name": "left alone: api_key: config.embedding.api_key", + "input": [ + "api_key: config.embedding.api_key" + ], + "expected": "api_key: config.embedding.api_key", + "kinds": [] + }, + { + "name": "left alone: SECRET_KEY_BASE_NAME_FOR_PRODUCTION", + "input": [ + "SECRET_KEY_BASE_NAME_FOR_PRODUCTION" + ], + "expected": "SECRET_KEY_BASE_NAME_FOR_PRODUCTION", + "kinds": [] + } + ] +}