Log Redaction Pipeline
EasyAI-assisted coding · AI-Assisted Debugging
Log Redaction Pipeline
You've joined the observability team. Every service sends its log records through this redaction pipeline before they reach the central log store, so personal data (emails, phone numbers, card numbers, IP addresses) never leaves the host. The security team has filed one bug and two features.
Codebase
| File | What it does |
|---|---|
redact/api.py | run(operations) entry point and the Service dispatcher |
redact/pipeline.py | Redactor: applies a policy to each field of a record |
redact/patterns.py | Regex detectors (EMAIL, CARD, IPV4, PHONE), Luhn and IPv4 checks |
redact/scanner.py | Runs the detectors over a string and counts matches |
redact/policies.py | PolicyTable stub: every field is currently scanned |
redact/tokens.py | Empty placeholder for Part 3 |
redact/config.py | Default field policies, mask text, token salt |
redact/records.py | Record shape validation |
redact/logfmt.py | Renders a record as a logfmt line for preview |
redact/stats.py | Per-detector match counters |
redact/errors.py | Error codes |
tests/ | Existing tests (one currently fails) |
How your code is run
run(operations: list[list]) -> list processes the operations in order with a fresh pipeline and returns one result per operation. Errors are returned as "error: <code>" strings.
| Operation | Arguments | Returns | Errors |
|---|---|---|---|
redact | record | the redacted copy of the record | invalid record |
redact_batch | [record, ...] | list of redacted records (all-or-nothing) | invalid record |
preview | record | the redacted record as a logfmt line | invalid record |
stats | — | {"EMAIL": n, "CARD": n, "IPV4": n, "PHONE": n} matches redacted so far | |
reset_stats | — | true (all counters back to 0) |
- A record is a JSON object whose keys are non-empty strings (dots are just characters:
"auth.token"is one field) and whose values are strings, numbers, booleans ornull. Anything else isinvalid record. The input record is never modified. - Scanning a string runs the detectors in the order EMAIL, CARD, IPV4, PHONE; each replaces its matches with
[EMAIL],[CARD],[IPV4]or[PHONE]and adds one to its counter per match. Non-string values are never scanned. Today every field is scanned. - logfmt (
preview):field=valuepairs sorted by field name, joined by single spaces;null,true/falseand numbers are written as JSON; a string that is empty or contains a space,"or=is wrapped in double quotes (with"escaped as\"). - Unknown operation →
"error: unknown operation"; wrong number of arguments →"error: invalid arguments".
Worked example
[["redact", {"msg": "login ok for ana@example.com from 10.0.0.12", "status": 200, "user": null}],
["preview", {"msg": "card 4111 1111 1111 1111 declined", "retry": false}],
["stats"]]returns
[{"msg": "login ok for [EMAIL] from [IPV4]", "status": 200, "user": null},
"msg=\"card [CARD] declined\" retry=false",
{"EMAIL": 1, "CARD": 1, "IPV4": 1, "PHONE": 0}]Part 1 — Phone and email detection is wrong (SEC-412)
Goal: make the EMAIL and PHONE detectors match exactly the definitions below.
Reports from the security review: "call +1 415-555-0134 is logged as call +1 [PHONE], and +14155550134 is not redacted at all", and "user=bob@example.com; comes out as [EMAIL]; — we lose the user= key — and alice@a.io,bob@b.io is counted as one email".
Rules
- Email: a local part of one or more letters, digits or
. _ % + -, then@, then a domain of one or more labels (letters, digits,-) separated by dots, ending in a final label of 2 or more letters. Nothing else is part of the match, so surrounding characters such as=,:,<,>,(,), quotes,,,;and a sentence-ending.stay in the text. - Phone: an optional country code
+1(optionally followed by one space,-or.), then a 3-digit area code that may be wrapped in parentheses, then 3 digits, then 4 digits. Between the area code, the 3-digit group and the 4-digit group there may be one space,-or., or nothing. The whole match, including+1, becomes[PHONE]. - A phone number is not matched when it is directly preceded or followed by a letter, digit or
_. Other country codes (e.g.+44 ...) are out of scope and stay as they are. - Each email or phone number found counts once in
stats.
tests/test_patterns.py::test_phone_with_country_code_is_fully_redacted currently fails. The CARD and IPV4 detectors are known-good.
Part 2 — Per-field policies (LOG-77)
Goal: let operators choose what happens to each field instead of scanning everything.
| Operation | Arguments | Returns | Errors (in order) |
|---|---|---|---|
set_policy | selector, action | true | invalid selector, invalid action |
policy_for | field | the action that applies to field | invalid arguments (empty / non-string field) |
Rules
- Actions:
scan(current behaviour),mask(value becomes"[REDACTED]"),drop(field removed from the output),keep(value passed through untouched). Action names are case-sensitive. - A selector is either an exact field name (
"password") or a wildcard"<prefix>.*", which matches every field whose name starts with"<prefix>."("auth.*"matchesauth.tokenandauth.session.id, but notauthorauthz.role). The prefix must be non-empty and no other*may appear anywhere; anything else isinvalid selector. - Resolution for a field: an exact selector wins; otherwise the matching wildcard with the longest prefix; otherwise
scan. set_policyon a selector that already has a rule replaces it — including the defaults.- The defaults in
config.DEFAULT_POLICIESare in force from the start:password→drop,auth.*→mask. maskturns any non-null value (string, number or boolean) into"[REDACTED]";nullstaysnull.- Only
scanfields are scanned, so only they affectstats.
Example
[["redact", {"user": "ana@example.com", "password": "hunter2", "auth.token": "abc123", "authz": "admin"}],
["set_policy", "user", "keep"],
["set_policy", "debug.*", "drop"],
["redact", {"user": "ana@example.com", "debug.sql": "select 1", "msg": "ok"}],
["policy_for", "auth.session.id"]]returns
[{"user": "[EMAIL]", "auth.token": "[REDACTED]", "authz": "admin"}, true, true,
{"user": "ana@example.com", "msg": "ok"}, "mask"]Part 3 — Partial masking and deterministic tokens (LOG-81)
Goal: support two more actions so analysts can still join and debug on sensitive fields.
Rules
last4: replace every digit (0-9) in the value with*except the last four digits; all other characters are kept. If the value has 4 or fewer digits, all of them are replaced."4111-1111-1111-1234"→"****-****-****-1234","pin 12"→"pin **".token: replace the value with"tok_"+ the first 16 hex characters ofsha256(f"{TOKEN_SALT}:{normalized}")(UTF-8), wherenormalizedis the value with leading and trailing whitespace removed, lower-cased, andTOKEN_SALTcomes fromconfig.py. Equal inputs give equal tokens in every field, record and call.- Both actions accept strings and integers (an integer is first converted with
str(), so42and"42"give the same result). Any other non-null value (float, boolean) becomes"[REDACTED]";nullstaysnull. set_policyacceptslast4andtokenwith all the Part 2 selector and precedence rules. Fields using them are not scanned and do not affectstats.
Example
[["set_policy", "card_number", "last4"],
["set_policy", "customer.*", "token"],
["redact", {"card_number": "4111-1111-1111-1234", "customer.email": " Ana@Example.com", "customer.id": 1001}],
["redact", {"customer.email": "ana@example.com"}]]returns
[true, true,
{"card_number": "****-****-****-1234", "customer.email": "tok_1c617de3bd79dcd0", "customer.id": "tok_116398aef4a1de6e"},
{"customer.email": "tok_1c617de3bd79dcd0"}]Notes
scanner.py,records.py,logfmt.pyandstats.pyare known-good, as are the CARD and IPV4 detectors.- You may use the AI assistant freely; check its regexes against the rules above with real examples.
- Later parts must not break earlier ones; hidden tests re-check earlier behaviour.
Example 1
- Input:
- [["redact",{"msg":"login ok for ana@example.com from 10.0.0.12","status":200,"user":null}],["preview",{"msg":"card 4111 1111 1111 1111 declined","retry":false}],["stats"]]
- Output:
- [{"msg":"login ok for [EMAIL] from [IPV4]","status":200,"user":null},"msg=\"card [CARD] declined\" retry=false",{"EMAIL":1,"CARD":1,"IPV4":1,"PHONE":0}]
- Explanation
- String fields are scanned and each detected span is replaced by its placeholder; numbers and null pass through. preview renders the redacted record as a sorted logfmt line, and stats counts every redacted match.
Example 2
- Input:
- [["redact",{"user":"ana@example.com","password":"hunter2","auth.token":"abc123","authz":"admin"}],["set_policy","user","keep"],["set_policy","debug.*","drop"],["redact",{"user":"ana@example.com","debug.sql":"select 1","msg":"ok"}],["policy_for","auth.session.id"]]
- Output:
- [{"user":"[EMAIL]","auth.token":"[REDACTED]","authz":"admin"},true,true,{"user":"ana@example.com","msg":"ok"},"mask"]
- Explanation
- The default policies drop password and mask every auth.* field (authz is not under auth.). After the custom rules, user is kept verbatim and debug.* fields are dropped.
Example 3
- Input:
- [["set_policy","card_number","last4"],["set_policy","customer.*","token"],["redact",{"card_number":"4111-1111-1111-1234","customer.email":" Ana@Example.com","customer.id":1001}],["redact",{"customer.email":"ana@example.com"}]]
- Output:
- [true,true,{"card_number":"****-****-****-1234","customer.email":"tok_1c617de3bd79dcd0","customer.id":"tok_116398aef4a1de6e"},{"customer.email":"tok_1c617de3bd79dcd0"}]
- Explanation
- last4 hides all but the final four digits. Tokens are sha256 of "cg-logs-2024:<trimmed, lower-cased value>", so the email gets the same token in both records.
Guidelines
- Python 3 standard library only (re, hashlib, json are fine)
- Records are flat JSON objects: string field names mapped to strings, numbers, booleans or null
- Every operation returns exactly one value; errors are returned as "error: <code>" strings
- Output must be deterministic: tokens use SHA-256 with the salt from config.py