from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SECRET_FIELD = re.compile(
r"^(password|passwd|secret|client_secret|sasl_password|credential|credentials|"
r"private_key|passphrase|session_token|access_key|secret_key|api_token|"
r"auth_bytes|hmac|token_value|bearer_token)$"
)
ALLOWLIST: dict[tuple[str, str], str] = {
("CreateDelegationTokenResult", "token"): (
"holds a DelegationToken, which has its own redacting Debug impl"
),
("DescribeDelegationTokenResponse", "tokens"): (
"holds DelegationTokenDetail values, which redact their own hmac"
),
("OidcTokenProviderBuilder", "credentials"): (
"holds ClientCredentials, whose manual Debug redacts the secret"
),
("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 = (
"DelegationToken",
"ClientCredentials",
"AssertionSource",
"ScramCredentialInfo",
)
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)
STRUCT_RE = re.compile(
r"#\[derive\(([^)]*)\)\]\s*(?:#\[[^\]]*\]\s*)*"
r"(?:pub(?:\([^)]*\))?\s+)?struct\s+(\w+)\s*(?:<[^>]*>)?\s*\{"
)
FIELD_RE = re.compile(r"(?:pub(?:\([^)]*\))?\s+)?(\w+)\s*:\s*([^,\n]+)")
def main() -> int:
failures: list[str] = []
checked = 0
for path in sorted(ROOT.glob("src/**/*.rs")):
raw = path.read_text()
text = strip_comments(raw)
for m in STRUCT_RE.finditer(text):
derives, name = m.group(1), m.group(2)
if "Debug" not in [d.strip() for d in derives.split(",")]:
continue
depth, start, end = 0, m.end() - 1, None
for i in range(start, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
end = i
break
if end is None:
continue
checked += 1
for fm in FIELD_RE.finditer(text[start + 1 : end]):
field, ty = fm.group(1), fm.group(2).strip()
if not SECRET_FIELD.match(field):
continue
if (name, field) in ALLOWLIST:
continue
if any(t in ty for t in SELF_REDACTING_TYPES):
continue
line = raw[: m.start()].count("\n") + 1
failures.append(
f"{path.relative_to(ROOT)}:{line} "
f"`{name}` derives Debug and has field `{field}: {ty}`.\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"
)
if failures:
print("Secret-in-Debug check FAILED\n", file=sys.stderr)
for failure in failures:
print(f" - {failure}", file=sys.stderr)
return 1
print(
f"✓ Secret-in-Debug: {checked} Debug-deriving structs scanned, "
f"{len(ALLOWLIST)} documented exceptions"
)
return 0
if __name__ == "__main__":
sys.exit(main())