from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FIXTURES = ROOT / "xtask" / "secret_debug_fixtures"
SECRET_WORD = re.compile(
r"password|passwd|passphrase|secret|token|credential|private_key|access_key|"
r"secret_key|signing_key|auth_bytes|hmac"
)
METADATA_SUFFIX = re.compile(
r"(_endpoint|_id|_ids|_path|_type|_name|_principal_name|_principal_type|"
r"_fetches|_failures|_latency|_epoch_ms|_count|_len|_infos)$"
)
ALLOWLIST: dict[tuple[str, str], str] = {
("AckObservation", "token"): (
"test-only: the interceptor's correlation token, not a credential"
),
("ScramCredentialUserResult", "credential_infos"): (
"SCRAM credential *metadata* (mechanism + iteration count); Kafka never "
"returns the salt or stored key over this API"
),
("DescribeUserScramCredentialsResultEntry", "credential_infos"): (
"same: mechanism and iterations only, no secret material"
),
}
SELF_REDACTING_TYPES = (
"AssertionSource",
"AwsMskIamCredentialProviderHandle",
"AwsMskIamCredentials",
"ClientCredentials",
"DelegationToken",
"OAuthBearerToken",
"OAuthBearerTokenProviderHandle",
"PlainCredentials",
"ScramCredentialInfo",
"ScramCredentials",
"TlsConfig",
)
def strip_comments(text: str) -> str:
out = list(text)
i, n = 0, len(text)
while i < n:
if text.startswith("//", i):
while i < n and text[i] != "\n":
out[i] = " "
i += 1
elif text.startswith("/*", i):
depth = 1
out[i] = out[i + 1] = " "
i += 2
while i < n and depth:
if text.startswith("/*", i):
depth += 1
out[i] = out[i + 1] = " "
i += 2
elif text.startswith("*/", i):
depth -= 1
out[i] = out[i + 1] = " "
i += 2
else:
out[i] = " " if text[i] != "\n" else "\n"
i += 1
else:
i += 1
return "".join(out)
ITEM_RE = re.compile(
r"#\[derive\(([^)]*)\)\]\s*(?:#\[[^\]]*\]\s*)*"
r"(?:pub(?:\([^)]*\))?\s+)?(struct|enum)\s+(\w+)\s*(?:<[^>{(;]*>)?\s*"
r"(?:where[^{(;]*)?([{(;])"
)
OPEN = {"{": "}", "(": ")", "<": ">", "[": "]"}
def snake(name: str) -> str:
return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name).lower()
def is_secret_name(name: str) -> bool:
name = snake(name)
return bool(SECRET_WORD.search(name)) and not METADATA_SUFFIX.search(name)
def matching(text: str, start: int) -> int | None:
stack = []
for i in range(start, len(text)):
c = text[i]
if c in OPEN:
stack.append(OPEN[c])
elif stack and c == stack[-1]:
stack.pop()
if not stack:
return i
elif c in ")}]" and stack:
return None
return None
def split_top(body: str) -> list[str]:
parts, depth, cur = [], 0, []
for c in body:
if c in "{(<[":
depth += 1
elif c in "})>]":
depth -= 1
if c == "," and depth == 0:
parts.append("".join(cur))
cur = []
else:
cur.append(c)
parts.append("".join(cur))
return [p.strip() for p in parts if p.strip()]
ATTR_RE = re.compile(r"#\[[^\]]*\]")
VIS_RE = re.compile(r"^pub(?:\([^)]*\))?\s+")
def clean(part: str) -> str:
return VIS_RE.sub("", ATTR_RE.sub("", part).strip()).strip()
def secret_type(ty: str, by_name: bool) -> bool:
if any(t in ty for t in SELF_REDACTING_TYPES):
return False
if "Zeroizing" in ty:
return True
return by_name and any(is_secret_name(i) for i in re.findall(r"[A-Z]\w*", ty))
def findings(item: str, body: str, kind: str) -> list[tuple[str, str]]:
out = []
def named_fields(fields: str, prefix: str = "") -> None:
for part in split_top(fields):
part = clean(part)
if ":" not in part:
continue
name, ty = (s.strip() for s in part.split(":", 1))
if any(t in ty for t in SELF_REDACTING_TYPES):
continue
if is_secret_name(name) or secret_type(ty, by_name=False):
out.append((prefix + name, f"{name}: {ty}"))
def tuple_fields(fields: str, owner: str) -> None:
for i, part in enumerate(split_top(fields)):
ty = clean(part)
if secret_type(ty, by_name=True) or (
is_secret_name(owner) and not any(t in ty for t in SELF_REDACTING_TYPES)
):
out.append((f"{owner}.{i}", f"{owner}({ty})"))
if kind == "{":
named_fields(body)
elif kind == "(":
tuple_fields(body, item)
elif kind == "enum":
i = 0
while i < len(body):
m = re.compile(r"\s*(?:#\[[^\]]*\]\s*)*(\w+)\s*").match(body, i)
if not m:
break
variant, j = m.group(1), m.end()
payload = ""
if j < len(body) and body[j] in "{(":
end = matching(body, j)
if end is None:
break
payload, opener, j = body[j + 1 : end], body[j], end + 1
if opener == "(":
tuple_fields(payload, variant)
else:
named_fields(payload, prefix=f"{variant}::")
if is_secret_name(variant) and payload.strip() and not any(
t in payload for t in SELF_REDACTING_TYPES
):
out.append((variant, f"variant {variant} {{ .. }}"))
nxt = body.find(",", j)
if nxt == -1:
break
i = nxt + 1
return out
def scan(path: Path) -> tuple[int, list[str]]:
raw = path.read_text()
text = strip_comments(raw)
failures: list[str] = []
checked = 0
for m in ITEM_RE.finditer(text):
derives, keyword, name, opener = m.groups()
if "Debug" not in [d.strip() for d in derives.split(",")]:
continue
if opener == ";":
continue
start = m.end() - 1
end = matching(text, start)
if end is None:
continue
checked += 1
kind = "enum" if keyword == "enum" else opener
for member, what in findings(name, text[start + 1 : end], kind):
field = member.split("::")[-1]
if (name, field) in ALLOWLIST or (name, member) in ALLOWLIST:
continue
line = raw[: m.start()].count("\n") + 1
try:
shown = path.relative_to(ROOT)
except ValueError:
shown = path
failures.append(
f"{shown}:{line} `{name}` derives Debug and carries `{what}`.\n"
" A credential in a derived Debug reaches every log line, error\n"
" context and panic message that formats the enclosing value.\n"
" Write a manual `Debug` that reports a length or `[REDACTED]`,\n"
" or add the pair to ALLOWLIST in this script with a reason.\n"
)
return checked, failures
def self_test() -> list[str]:
problems = []
fixtures = sorted(FIXTURES.glob("*.rs"))
if not any(f.name != "clean.rs" for f in fixtures):
return [f"no planted fixtures found in {FIXTURES}"]
for fixture in fixtures:
_, failures = scan(fixture)
if fixture.name == "clean.rs" and failures:
problems.append(f"{fixture.name}: flagged a type with a redacting Debug")
elif fixture.name != "clean.rs" and not failures:
problems.append(f"{fixture.name}: planted violation not detected")
return problems
def main() -> int:
problems = self_test()
if problems:
print("Secret-in-Debug self-test FAILED: the guard is blind\n", file=sys.stderr)
for p in problems:
print(f" - {p}", file=sys.stderr)
return 1
checked, failures = 0, []
for path in sorted(ROOT.glob("src/**/*.rs")):
n, f = scan(path)
checked += n
failures += f
if failures:
print("Secret-in-Debug check FAILED\n", file=sys.stderr)
for failure in failures:
print(f" - {failure}", file=sys.stderr)
return 1
planted = sum(1 for f in FIXTURES.glob("*.rs") if f.name != "clean.rs")
print(
f"✓ Secret-in-Debug: {planted} planted fixtures rejected; {checked} "
f"Debug-deriving types scanned, {len(ALLOWLIST)} documented exceptions"
)
return 0
if __name__ == "__main__":
sys.exit(main())