#!/usr/bin/env python3
"""
snapshot_scrub.py -- credential-shape scrubber for cloud-snapshot captures.
Companion to scripts/cloud-snapshot.sh (DOCFIX-102). Exported bundles and
model config can embed credential-shaped option values; captures must never
carry secrets into the repo (see docs/as-executed-log-convention.md for the
stance). This rewrites each given YAML/JSON capture IN PLACE, replacing the
VALUE of any credential-shaped key (password/passwd/secret/token/apikey/
api-key/private-key/credential/auth-key/access-key, as key or key fragment)
with a REDACTED marker. Benign placeholders are kept to avoid false positives
(e.g. juju model-config `secret-backend: auto`): empty, auto/none/null/
default/internal/true/false/unset, values shorter than 8 chars, and values
already redacted.
Usage: python3 snapshot_scrub.py [--check] FILE [FILE...]
--check: report only, write nothing (used to verify redactions stuck --
"sed is not a verifier").
Prints one line per hit: "REDACTED <file>:<lineno> key=<key>". Never prints
the value. Line-oriented: YAML block-scalar/multiline values are NOT scanned
(documented limitation; flag such shapes to the operator if seen).
Exit: 0 scan complete (with or without redactions) | 2 usage/IO error.
ASCII + LF.
"""
import re
import sys
MARKER = "**REDACTED-BY-CLOUD-SNAPSHOT**"
KEY_RX = re.compile(
r'^(?P<lead>[\s>"\'#,-]*)'
r'(?P<key>[A-Za-z0-9_.-]*'
r'(?:password|passwd|secret|token|api[-_]?key|private[-_]?key|'
r'credential|auth[-_]?key|access[-_]?key)'
r'[A-Za-z0-9_.-]*)'
r'(?P<kq>["\']?)'
r'(?P<sep>\s*[:=]\s*)'
r'(?P<val>\S.*)$',
re.IGNORECASE,
)
BENIGN = {"", "auto", "none", "null", "default", "internal", "true", "false",
"unset", "~"}
def scrub_line(line):
"""Return (new_line, key) -- key is None when nothing was redacted."""
m = KEY_RX.match(line)
if not m:
return line, None
raw = m.group("val").rstrip()
trail = ""
if raw.endswith(","):
raw, trail = raw[:-1], ","
core = raw.strip()
quote = ""
if len(core) >= 2 and core[0] == core[-1] and core[0] in "\"'":
quote, core = core[0], core[1:-1]
if (core.lower() in BENIGN or len(core) < 8
or core.startswith("**REDACTED")):
return line, None
new = (m.group("lead") + m.group("key") + m.group("kq") + m.group("sep")
+ quote + MARKER + quote + trail)
return new, m.group("key")
def main(argv):
check = False
files = []
for a in argv:
if a == "--check":
check = True
elif a.startswith("-"):
print("snapshot_scrub: unknown flag %s" % a, file=sys.stderr)
return 2
else:
files.append(a)
if not files:
print("usage: snapshot_scrub.py [--check] FILE [FILE...]",
file=sys.stderr)
return 2
for path in files:
try:
with open(path, "r", encoding="ascii", errors="replace") as fh:
lines = fh.read().splitlines(True)
except OSError as e:
print("snapshot_scrub: cannot read %s: %s" % (path, e),
file=sys.stderr)
return 2
out = []
dirty = False
for i, line in enumerate(lines, 1):
eol = "\n" if line.endswith("\n") else ""
new, key = scrub_line(line.rstrip("\n"))
if key is not None:
print("REDACTED %s:%d key=%s" % (path, i, key))
dirty = True
out.append(new + eol)
if dirty and not check:
try:
with open(path, "w", encoding="ascii", errors="replace") as fh:
fh.writelines(out)
except OSError as e:
print("snapshot_scrub: cannot write %s: %s" % (path, e),
file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))