Skip to content

Commit 04f3969

Browse files
committed
examples: harden secret redaction to >=95% in code-review agent
Slice 4 (criterion 5). redact() now layers full-token provider regexes (AWS, GitHub, GitLab, Slack, Stripe, Google, SendGrid, npm, JWT, PEM, bearer, basic-auth URLs) with a Shannon-entropy catch-all for generic base64/hex secrets. A leak-test corpus (16 secret types) verifies >=95% masking and zero plaintext survivors, plus a benign-code corpus verifies no false positives. detect-secrets stays as the secret-leakage *scanner* but is not used for redaction: its scan_line returns partial/benign values that hurt precision. The regex + entropy layers reach 100% on the corpus cleanly. Updates #92 RELEASE NOTES: NONE
1 parent 34ce31c commit 04f3969

3 files changed

Lines changed: 123 additions & 27 deletions

File tree

examples/skills_code_review_agent/README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ Implemented: deterministic pipeline, DB persistence (incl. sandbox-run rows), 8
8181
self-test, CLI, the fake-model agent loop (`run_agent.py`), and **sandbox execution**
8282
(`--runtime local` runs the scanners in a subprocess sandbox with timeout + output cap and records
8383
each run; `--runtime container` runs them in a Docker workspace — see `skills/code-review/Dockerfile`
84-
— and is skipped in tests when Docker is absent). Baseline secret redaction is in place.
84+
— and is skipped in tests when Docker is absent). **Secret redaction** is hardened: `redact()` layers
85+
provider-token regexes + a Shannon-entropy catch-all and hits 100% on the leak-test corpus with zero
86+
false positives (criterion 5, ≥95%).
8587

8688
The **Filter gate** (criterion 7) is in place: `pipeline/policy.py::ReviewPolicy` decides
8789
allow / deny / needs-human-review for a sandbox action (high-risk command, forbidden path,
@@ -90,6 +92,7 @@ deterministic sandbox gate (a denied action never launches; the block is recorde
9092
the report's Filter-interception section) and the framework `agent/filter.py::ReviewGuardFilter`
9193
(TOOL-scoped, attached on the review tool).
9294

93-
Planned follow-up slices: redaction hardening to ≥95% (criterion 5) and OpenTelemetry metrics wiring
94-
(criterion 9). Note: the default runtime is `inprocess` for fast dry-runs — pass `--runtime local`
95-
or `--runtime container` to exercise the sandbox path.
95+
Remaining: exporting the monitoring metrics through an OpenTelemetry reader (they are collected today
96+
in the report's monitoring section), an independent labelled eval set to prove the hidden-set
97+
thresholds, and verifying the container runtime on a Docker host. Note: the default runtime is
98+
`inprocess` for fast dry-runs — pass `--runtime local` or `--runtime container` for the sandbox path.

examples/skills_code_review_agent/pipeline/redaction.py

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,25 @@
33
# Copyright (C) 2026 Tencent. All rights reserved.
44
#
55
# tRPC-Agent-Python is licensed under Apache-2.0.
6-
76
"""Secret redaction — the single choke-point (issue #92, requirement 7 & criterion 5).
87
9-
Every string that enters the DB or a rendered report MUST pass through ``redact()``. Centralizing
10-
it here is the load-bearing decision: criterion 5 is binary-checked (one plaintext key/token/
11-
password in the report or DB = fail), so redaction can never be sprinkled across call sites.
8+
Every string that enters the DB or a rendered report MUST pass through ``redact()``. Criterion 5 is
9+
binary-checked (one plaintext key/token/password in the report or DB = fail), so redaction is
10+
centralized here, never sprinkled across call sites.
11+
12+
Layers, applied in order:
13+
1. a regex layer that masks the *full* token for common providers and labeled assignments;
14+
2. a Shannon-entropy catch-all for long high-entropy tokens (generic base64/hex secrets).
1215
13-
MVP: regex baseline covering the common shapes. Slice 4 hardens this with ``detect-secrets`` spans
14-
and a leak-test corpus proving >=95% and zero plaintext survivors.
16+
(detect-secrets is used as a *scanner* for the secret-leakage finding category, but not for
17+
redaction: its scan_line returns partial/benign values that hurt precision here. The regex + entropy
18+
layers reach 100% on the leak-test corpus with zero false positives.)
19+
20+
The leak-test corpus in the test-suite asserts >=95% masking and zero plaintext survivors.
1521
"""
1622
from __future__ import annotations
1723

24+
import math
1825
import re
1926
from typing import TYPE_CHECKING
2027

@@ -23,31 +30,73 @@
2330

2431
_MASK = "***REDACTED***"
2532

26-
# Keeps a leading label group \1 and masks the secret value \2.
27-
_KV = (r"(?ix)\b(password|passwd|pwd|secret|api[_-]?key|apikey|access[_-]?token|token|auth"
28-
r"|client[_-]?secret|private[_-]?key)\b\s*[:=]\s*['\"]?([^\s'\"]{4,})['\"]?")
29-
30-
_STANDALONE = [
31-
("aws_access_key_id", re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b")),
32-
("aws_secret", re.compile(r"(?i)aws_secret_access_key\s*[:=]\s*['\"]?([A-Za-z0-9/+=]{40})")),
33-
("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")),
34-
("bearer", re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*")),
35-
("pem", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.DOTALL)),
36-
("slack", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")),
37-
("github_pat", re.compile(r"\bghp_[A-Za-z0-9]{36}\b")),
33+
# --- Layer 1: full-token regexes -------------------------------------------------------------
34+
35+
# Labeled assignment: keep the label \1, mask the value.
36+
_KV = re.compile(r"(?ix)\b(password|passwd|pwd|secret|api[_-]?key|apikey|access[_-]?token|token|auth"
37+
r"|client[_-]?secret|private[_-]?key)\b\s*[:=]\s*['\"]?([^\s'\"]{4,})['\"]?")
38+
39+
# Standalone provider tokens — mask the whole match.
40+
_PROVIDER = [
41+
re.compile(r"\b(AKIA|ASIA|AGPA|AIDA|AROA|ANPA|ANVA|ASCA)[A-Z0-9]{16}\b"),
42+
re.compile(r"(?i)aws_secret_access_key\s*[:=]\s*['\"]?[A-Za-z0-9/+=]{40}"),
43+
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"),
44+
re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}\b"),
45+
re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"),
46+
re.compile(r"\b[rs]k_(live|test)_[A-Za-z0-9]{16,}\b"),
47+
re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
48+
re.compile(r"\bya29\.[0-9A-Za-z_-]{20,}\b"),
49+
re.compile(r"\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b"),
50+
re.compile(r"\bnpm_[A-Za-z0-9]{36}\b"),
51+
re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
52+
re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*"),
53+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.DOTALL),
3854
]
3955

40-
_KV_RE = re.compile(_KV)
56+
# Basic-auth in a URL: mask the password segment only.
57+
_URL_AUTH = re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://[^\s:@/]+:)([^\s@/]+)(@)")
58+
59+
# Layer 3 catch-all: long base64/hex tokens; mask only if high-entropy (a real secret, not an id).
60+
_HIGH_ENTROPY_CANDIDATE = re.compile(r"\b[A-Za-z0-9+/=_-]{20,}\b")
61+
_B64_ENTROPY_MIN = 4.0
62+
_HEX_ENTROPY_MIN = 3.0
63+
_HEX_ONLY = re.compile(r"\A[0-9a-fA-F]+\Z")
64+
65+
66+
def _shannon(s: str) -> float:
67+
if not s:
68+
return 0.0
69+
counts: dict[str, int] = {}
70+
for ch in s:
71+
counts[ch] = counts.get(ch, 0) + 1
72+
n = len(s)
73+
return -sum((c / n) * math.log2(c / n) for c in counts.values())
74+
75+
76+
def _looks_secret(tok: str) -> bool:
77+
if _MASK in tok:
78+
return False
79+
threshold = _HEX_ENTROPY_MIN if _HEX_ONLY.match(tok) else _B64_ENTROPY_MIN
80+
return _shannon(tok) >= threshold
81+
82+
83+
def _regex_layer(text: str) -> str:
84+
out = _KV.sub(lambda m: f"{m.group(1)}={_MASK}", text)
85+
out = _URL_AUTH.sub(lambda m: f"{m.group(1)}{_MASK}{m.group(3)}", out)
86+
for pat in _PROVIDER:
87+
out = pat.sub(_MASK, out)
88+
return out
4189

4290

4391
def redact(text: str | None) -> str:
4492
"""Mask secrets in a free-text string. Idempotent and safe on None/empty."""
4593
if not text:
4694
return text or ""
47-
out = _KV_RE.sub(lambda m: f"{m.group(1)}={_MASK}", text)
48-
for _name, pat in _STANDALONE:
49-
out = pat.sub(_MASK, out)
50-
return out
95+
lines = []
96+
for line in _regex_layer(text).split("\n"):
97+
line = _HIGH_ENTROPY_CANDIDATE.sub(lambda m: _MASK if _looks_secret(m.group()) else m.group(), line)
98+
lines.append(line)
99+
return "\n".join(lines)
51100

52101

53102
def redact_finding(f: "Finding") -> "Finding":

tests/examples/test_skills_code_review_agent.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,47 @@ def test_report_renders_filter_block_section() -> None:
222222
md = report_mod.render_md(result.report)
223223
assert "## 4. Filter interception summary" in md
224224
assert "over budget" in md
225+
226+
227+
# (text containing a secret, the raw secret that must not survive redaction) — the leak-test corpus.
228+
_LEAK_CORPUS = [
229+
('password = "hunter2supersecret"', "hunter2supersecret"),
230+
('API_KEY: "SK_LIVE_EXAMPLE_TEST_KEY"', "SK_LIVE_EXAMPLE_TEST_KEY"),
231+
('aws_key = "AKIA1234567890ABCDEF"', "AKIA1234567890ABCDEF"),
232+
('aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY1"',
233+
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY1"),
234+
('gh = "ghp_16CharsExampleTokenABCDEFabcdef012345"', "ghp_16CharsExampleTokenABCDEFabcdef012345"),
235+
('gitlab = "GLPAT_EXAMPLE_TEST_TOKEN"', "GLPAT_EXAMPLE_TEST_TOKEN"),
236+
('slack = "xoxb-1234567890-ABCDEFxyz0987"', "xoxb-1234567890-ABCDEFxyz0987"),
237+
('google = "AIzaSyD-1234567890abcdefGHIJKLmnopqrstuv"', "AIzaSyD-1234567890abcdefGHIJKLmnopqrstuv"),
238+
('npm = "npm_abcdefABCDEF0123456789abcdefABCDEF01"', "npm_abcdefABCDEF0123456789abcdefABCDEF01"),
239+
('jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"',
240+
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"),
241+
('auth = "Bearer abcdefghijklmnopqrstuvwxyz012345"', "abcdefghijklmnopqrstuvwxyz012345"),
242+
('token = "8f14e45fceea167a5a36dedd4bea2543f1a2b3c4d5e6f708"', "8f14e45fceea167a5a36dedd4bea2543f1a2b3c4d5e6f708"),
243+
('secret = "aGVsbG9zZWNyZXRrZXkxMjM0NTY3ODkwYWJjZGVm"', "aGVsbG9zZWNyZXRrZXkxMjM0NTY3ODkwYWJjZGVm"),
244+
('conn = "postgres_conn_example_redacted"', "S3cr3tP4ssw0rd"),
245+
('DB_PASSWORD=pl4inTextP@ss99', "pl4inTextP@ss99"),
246+
('X-Api-Key: 3f9a2b1c8d7e6f5a4b3c2d1e0f9a8b7c', "3f9a2b1c8d7e6f5a4b3c2d1e0f9a8b7c"),
247+
]
248+
_BENIGN = [
249+
"def add(a, b): return a + b",
250+
"import os",
251+
"version = 1.2.3",
252+
"result = compute(x, y)",
253+
"for i in range(100):",
254+
"use ast.literal_eval instead of eval",
255+
]
256+
257+
258+
def test_redaction_meets_95pct_and_no_plaintext() -> None:
259+
masked = sum(1 for text, secret in _LEAK_CORPUS if secret not in redact(text))
260+
rate = masked / len(_LEAK_CORPUS)
261+
assert rate >= 0.95, f"redaction rate {rate:.0%} < 95%"
262+
for text, secret in _LEAK_CORPUS:
263+
assert secret not in redact(text)
264+
265+
266+
def test_redaction_does_not_mangle_benign_code() -> None:
267+
for line in _BENIGN:
268+
assert "***REDACTED***" not in redact(line), f"false positive on: {line}"

0 commit comments

Comments
 (0)