Newer
Older
openstack-caracal-ipv4 / scripts / snapshot_scrub.py
#!/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:]))