VenomousViper Labs
Portfolio Infrastructure
← Back to Infrastructure

sanitize.py

The pipeline: transform stages, the tripwire/review verification, and the content-addressed writer.

#!/usr/bin/env python3
"""
viper-docs-pipeline :: sanitize.py

Turns internal Obsidian infrastructure docs into publishable versions.

    ./sanitize.py --src ~/Obsidian/Homelab --out ./public --rules rules.yml

Exit codes:
    0  clean       - all docs sanitized, tripwires clear, safe to publish
    2  tripwire    - sanitized output still contains something sensitive.
                     NOTHING is written. Fix rules.yml or the source doc.
    3  config/io   - bad rules file, missing source, etc.

The tripwire pass is the point. Substitutions handle what you knew about when
you wrote the rules; tripwires catch the service you stood up last Tuesday and
forgot. A failing build is the system working.
"""

from __future__ import annotations

import argparse
import hashlib
import re
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path

try:
    import yaml
except ImportError:
    sys.exit("pyyaml required:  pip install --user pyyaml")


# --------------------------------------------------------------------------- #
#  Terminal output
# --------------------------------------------------------------------------- #

class C:
    OK, WARN, ERR, DIM, BOLD, OFF = (
        "\033[32m", "\033[33m", "\033[31m", "\033[2m", "\033[1m", "\033[0m"
    )

    @classmethod
    def strip(cls) -> None:
        for a in ("OK", "WARN", "ERR", "DIM", "BOLD", "OFF"):
            setattr(cls, a, "")


def say(msg: str = "") -> None:
    print(msg, file=sys.stderr)


# --------------------------------------------------------------------------- #
#  Rules
# --------------------------------------------------------------------------- #

@dataclass
class Rule:
    note: str
    pattern: re.Pattern
    replace: str = ""
    hits: int = 0


@dataclass
class Rules:
    banner: str = ""
    drop_sections: list[str] = field(default_factory=list)
    drop_lines: list[Rule] = field(default_factory=list)
    private_open: str = ""
    private_close: str = ""
    subs: list[Rule] = field(default_factory=list)
    tripwires: list[Rule] = field(default_factory=list)
    review: list[Rule] = field(default_factory=list)
    allowlist: list[re.Pattern] = field(default_factory=list)


def load_rules(path: Path) -> Rules:
    try:
        raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
    except (OSError, yaml.YAMLError) as exc:
        sys.exit(f"{C.ERR}cannot read rules{C.OFF}: {exc}")

    def compile_list(key: str) -> list[Rule]:
        out = []
        for i, item in enumerate(raw.get(key) or []):
            try:
                out.append(
                    Rule(
                        note=item.get("note", f"{key}[{i}]"),
                        pattern=re.compile(item["pattern"]),
                        replace=item.get("replace", ""),
                    )
                )
            except (KeyError, re.error) as exc:
                sys.exit(f"{C.ERR}bad {key}[{i}]{C.OFF}: {exc}")
        return out

    pb = raw.get("private_block") or {}
    return Rules(
        banner=(raw.get("meta") or {}).get("banner", "").rstrip(),
        drop_sections=[s.lower() for s in (raw.get("drop_sections") or [])],
        drop_lines=compile_list("drop_lines"),
        private_open=pb.get("open", ""),
        private_close=pb.get("close", ""),
        subs=compile_list("substitutions"),
        tripwires=compile_list("tripwires"),
        review=compile_list("review"),
        allowlist=[re.compile(p) for p in (raw.get("tripwire_allowlist") or [])],
    )


# --------------------------------------------------------------------------- #
#  Transform stages
# --------------------------------------------------------------------------- #

HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*$")


def strip_private_blocks(text: str, rules: Rules) -> tuple[str, int]:
    """Remove <!-- private --> ... <!-- /private --> regions."""
    if not (rules.private_open and rules.private_close):
        return text, 0
    pat = re.compile(
        re.escape(rules.private_open) + r".*?" + re.escape(rules.private_close),
        re.DOTALL,
    )
    text, n = pat.subn("", text)
    return text, n


def drop_sections(text: str, rules: Rules) -> tuple[str, list[str]]:
    """Drop whole heading sections whose title matches a drop rule."""
    if not rules.drop_sections:
        return text, []

    lines = text.splitlines()
    kept: list[str] = []
    dropped: list[str] = []
    skip_until_level: int | None = None

    for line in lines:
        m = HEADING.match(line)
        if m:
            level, title = len(m.group(1)), m.group(2).strip()
            # A heading at or above the skipped section's level ends the skip.
            if skip_until_level is not None and level <= skip_until_level:
                skip_until_level = None
            if skip_until_level is None:
                # Strip leading numbering: "## 11. Credentials" -> "credentials"
                bare = re.sub(r"^[\d.\s]+", "", title).strip().lower()
                # EXACT match, not substring. Substring matching on "media"
                # also killed "Files, documents & media" (Nextcloud/Paperless —
                # legitimate) and a disk-prep step containing "-L mediaN".
                # A drop rule that over-matches deletes content silently.
                if bare in rules.drop_sections:
                    skip_until_level = level
                    dropped.append(title)
                    continue
        if skip_until_level is None:
            kept.append(line)

    return "\n".join(kept), dropped


def drop_matching_lines(text: str, rules: Rules) -> tuple[str, int]:
    """Remove whole lines matching a drop rule — for table rows, where each row
    is a self-contained record. Runs before substitutions so a row can be
    matched on its original service name."""
    if not rules.drop_lines:
        return text, 0
    kept, n = [], 0
    for line in text.splitlines():
        for r in rules.drop_lines:
            if r.pattern.search(line):
                r.hits += 1
                n += 1
                break
        else:
            kept.append(line)
    return "\n".join(kept), n


def substitute(text: str, rules: Rules) -> str:
    for rule in rules.subs:
        text, n = rule.pattern.subn(rule.replace, text)
        rule.hits += n
    return text


def collapse_blank_runs(text: str) -> str:
    return re.sub(r"\n{4,}", "\n\n\n", text).strip() + "\n"


FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL)


def rewrite_frontmatter(text: str, rules: Rules, src_name: str) -> str:
    """Replace the vault frontmatter with publish metadata, then add banner."""
    m = FRONTMATTER.match(text)
    body = text[m.end():] if m else text

    published = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    fm = (
        "---\n"
        f"title: {src_name}\n"
        "layout: doc\n"
        f"published: {published}\n"
        "source: internal operations documentation (redacted for publication)\n"
        "---\n\n"
    )
    banner = (rules.banner + "\n\n") if rules.banner else ""
    return fm + banner + body.lstrip("\n")


# --------------------------------------------------------------------------- #
#  Verification
# --------------------------------------------------------------------------- #

@dataclass
class Finding:
    doc: str
    line_no: int
    note: str
    excerpt: str


def verify(text: str, rules: Rules, doc: str,
           which: str = "tripwires") -> list[Finding]:
    """Scan sanitized output for anything that must not ship.

    NOTE ON ALLOWLIST SEMANTICS: allowlist entries MASK the matched substring
    rather than exempting the whole line. Exempting the line was the original
    design and it was wrong: 'lab.example' appears on nearly every service row,
    so a line-level exemption silently disabled tripwire coverage across the
    entire document. Masking keeps the rest of the line under inspection.
    """
    findings: list[Finding] = []
    for i, raw_line in enumerate(text.splitlines(), start=1):
        line = raw_line
        for a in rules.allowlist:
            line = a.sub(lambda m: "\x00" * len(m.group(0)), line)
        for tw in getattr(rules, which):
            m = tw.pattern.search(line)
            if m:
                tw.hits += 1
                excerpt = raw_line.strip()
                if len(excerpt) > 100:
                    start = max(0, m.start() - 30)
                    excerpt = "..." + excerpt[start:start + 96] + "..."
                findings.append(Finding(doc, i, tw.note, excerpt))
                break  # one finding per line is enough to fail
    return findings


# --------------------------------------------------------------------------- #
#  Driver
# --------------------------------------------------------------------------- #

def process(src: Path, rules: Rules) -> tuple[str, dict]:
    text = src.read_text(encoding="utf-8")
    original_len = len(text)

    text, n_private = strip_private_blocks(text, rules)
    text, dropped = drop_sections(text, rules)
    text, n_lines = drop_matching_lines(text, rules)
    text = substitute(text, rules)
    text = collapse_blank_runs(text)
    text = rewrite_frontmatter(text, rules, src.stem)

    return text, {
        "private_blocks": n_private,
        "dropped_sections": dropped,
        "dropped_lines": n_lines,
        "shrink_pct": round(100 * (1 - len(text) / max(original_len, 1))),
    }


def main() -> int:
    ap = argparse.ArgumentParser(description="Sanitize infra docs for publication.")
    ap.add_argument("--src", required=True, type=Path,
                    help="source dir of internal .md docs (read-only)")
    ap.add_argument("--out", required=True, type=Path,
                    help="output dir for sanitized docs")
    ap.add_argument("--rules", default=Path("rules.yml"), type=Path)
    ap.add_argument("--glob", default="*infrastructure.md",
                    help="which files to publish (default: *infrastructure.md)")
    ap.add_argument("--dry-run", action="store_true",
                    help="verify only, write nothing")
    ap.add_argument("--no-color", action="store_true")
    args = ap.parse_args()

    if args.no_color or not sys.stderr.isatty():
        C.strip()

    if not args.src.is_dir():
        say(f"{C.ERR}source not a directory{C.OFF}: {args.src}")
        return 3

    rules = load_rules(args.rules)
    sources = sorted(args.src.glob(args.glob))
    if not sources:
        say(f"{C.ERR}no files matched{C.OFF} {args.glob} in {args.src}")
        return 3

    say(f"{C.BOLD}viper-docs-pipeline{C.OFF}  {len(sources)} doc(s)  "
        f"{len(rules.subs)} substitutions  {len(rules.tripwires)} tripwires\n")

    results: list[tuple[Path, str, dict]] = []
    all_findings: list[Finding] = []
    all_review: list[Finding] = []

    for src in sources:
        text, stats = process(src, rules)
        findings = verify(text, rules, src.name)
        all_review += verify(text, rules, src.name, which="review")
        all_findings += findings
        results.append((src, text, stats))

        mark = f"{C.ERR}FAIL{C.OFF}" if findings else f"{C.OK}ok{C.OFF}"
        say(f"  [{mark}] {src.name}  "
            f"{C.DIM}-{stats['shrink_pct']}% size, "
            f"{len(stats['dropped_sections'])} section(s) dropped, "
            f"{stats['dropped_lines']} line(s) dropped{C.OFF}")
        for d in stats["dropped_sections"]:
            say(f"         {C.DIM}dropped section: {d}{C.OFF}")

    # --- substitution report ------------------------------------------------
    fired = [r for r in rules.subs if r.hits]
    if fired:
        say(f"\n{C.BOLD}redactions applied{C.OFF}")
        for r in sorted(fired, key=lambda x: -x.hits):
            say(f"  {r.hits:>5}  {r.note}")
    idle = [r.note for r in rules.subs if not r.hits]
    if idle:
        say(f"\n{C.DIM}rules that never fired ({len(idle)}): "
            f"{', '.join(idle)}{C.OFF}")

    # --- tripwires ----------------------------------------------------------
    if all_findings:
        say(f"\n{C.ERR}{C.BOLD}TRIPWIRE — publication blocked{C.OFF}")
        say(f"{C.ERR}{len(all_findings)} finding(s). Nothing written.{C.OFF}\n")
        for f in all_findings[:40]:
            say(f"  {C.ERR}{f.doc}:{f.line_no}{C.OFF}  {f.note}")
            say(f"      {C.DIM}{f.excerpt}{C.OFF}")
        if len(all_findings) > 40:
            say(f"  {C.DIM}... and {len(all_findings) - 40} more{C.OFF}")
        say(f"\nFix: add a substitution to {args.rules}, wrap the text in "
            f"private markers, or edit the source doc.")
        return 2

    say(f"\n{C.OK}tripwires clear{C.OFF}")

    if all_review:
        say(f"\n{C.WARN}advisory — {len(all_review)} item(s) for a human glance "
            f"(does not block publication){C.OFF}")
        seen: dict[str, int] = {}
        for f in all_review:
            seen[f.note] = seen.get(f.note, 0) + 1
        for note, n in sorted(seen.items(), key=lambda kv: -kv[1]):
            say(f"  {C.WARN}{n:>5}{C.OFF}  {note}")

    if args.dry_run:
        say(f"{C.WARN}dry run — nothing written{C.OFF}")
        return 0

    # --- write --------------------------------------------------------------
    args.out.mkdir(parents=True, exist_ok=True)
    changed = 0
    for src, text, _ in results:
        dest = args.out / src.name
        new_hash = hashlib.sha256(text.encode()).hexdigest()
        old_hash = (
            hashlib.sha256(dest.read_bytes()).hexdigest() if dest.exists() else ""
        )
        if new_hash != old_hash:
            dest.write_text(text, encoding="utf-8")
            changed += 1
            say(f"  {C.OK}wrote{C.OFF}   {dest}")
        else:
            say(f"  {C.DIM}unchanged{C.OFF} {dest}")

    say(f"\n{C.OK}{C.BOLD}clean{C.OFF} — {changed} file(s) updated in {args.out}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

rules.example.yml

Example rules — substitutions, section/line drops, tripwires, and the tripwire allowlist. (Example values only; the live rules stay private.)

# rules.example.yml — viper-docs-pipeline
#
# TEMPLATE. Copy to rules.yml and fill in your own values:
#
#     cp rules.example.yml rules.yml
#
# rules.yml is gitignored, and it must stay that way. A live rules file lists
# the exact strings you redact — your real domain, your vault product, your
# serial formats. Publishing it publishes everything the tool exists to remove.
# This template is the same file with those values replaced by placeholders.
#
# Search for "example" and "your" to find what needs changing.

meta:
  # Prepended to every published doc so readers know it's redacted on purpose.
  banner: |
    > [!note] Published copy
    > This is a redacted public version of an internal operations document.
    > Hostnames, tunnel identifiers, mesh addresses, hardware serials and
    > credential-location references have been substituted or removed.
    > The technical content, failure analysis and remediation steps are unmodified.

# ---------------------------------------------------------------------------
# 1. Whole sections removed by heading. Matched case-insensitively against the
#    heading text of any ##/###-level section. The section runs until the next
#    heading of the same or higher level.
# ---------------------------------------------------------------------------
drop_sections:
  - "credentials"
  - "secrets"
  - "recovery keys"
  - "api keys"
  # Example: drop a whole category of services from published docs while
  # keeping the surrounding infrastructure content. Exact headings only.
  - "media"
  - "storage paths (media stack)"

# Inline block removal. Anything between these markers in the source is cut.
# Put these around a paragraph in Obsidian when you want to keep it private
# without dropping the whole section.
# Whole lines removed by pattern. Table rows are self-contained records, so
# removing the row is correct; use substitutions for mentions inside prose.
drop_lines:
  - note: "media service table row"
    pattern: '(?i)^\|.*\b(sonarr|radarr|prowlarr|bazarr|readarr|lidarr|qbittorrent|qbit|gluetun|flaresolverr|plex|seerr|jellyseerr|audiobookshelf|abba|audiobookbay|shelfarr|calibre)\b'
  - note: "media stack entry in a directory tree"
    pattern: '(?i)^\s*[├└│]?[─\s]*media/\s+#'
  - note: "media path / mount reference row"
    pattern: "(?i)^\\|.*`/mnt/media/(movies|tv|books|audiobooks|music|podcasts|downloads)"

private_block:
  open: "<!-- private -->"
  close: "<!-- /private -->"

# ---------------------------------------------------------------------------
# 2. Substitutions. Order matters — first match wins per line region.
#    `note` is shown in the run report so you can eyeball what fired.
# ---------------------------------------------------------------------------
substitutions:

  # --- Real domains -> reserved example domains (RFC 2606) -----------------
  # Subdomain label is preserved: 'plex.yourhandle.xyz' -> 'plex.lab.example'.
  # Keeps the doc readable without publishing a map of the live attack surface.
  - note: "primary tunnel domain"
    pattern: '\b([a-z0-9][a-z0-9-]*)\.yourhandle\.xyz\b'
    replace: '\1.lab.example'
  - note: "primary tunnel domain (apex)"
    pattern: '\byourhandle\.xyz\b'
    replace: 'lab.example'

  # --- Tailscale / CGNAT mesh addresses ------------------------------------
  - note: "tailscale 100.64.0.0/10 address"
    pattern: '\b100\.(?:6[4-9]|[7-9]\d|1[0-1]\d|12[0-7])\.\d{1,3}\.\d{1,3}\b'
    replace: '100.x.x.x'

  # --- Identifiers ----------------------------------------------------------
  - note: "UUID (tunnel / array / fstab)"
    pattern: '\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b'
    replace: '<uuid-redacted>'
  - note: "mdadm-style array UUID (colon form)"
    pattern: '\b[0-9a-f]{8}:[0-9a-f]{8}:[0-9a-f]{8}:[0-9a-f]{8}\b'
    replace: '<array-uuid-redacted>'
  - note: "drive serial (Seagate/WD style)"
    pattern: '\b(?:Z[A-Z0-9]{7}|WD-[A-Z0-9]{10,})\b'
    replace: '<serial-redacted>'
  - note: "MAC address"
    pattern: '\b(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}\b'
    replace: '<mac-redacted>'

  # --- Version pinning -> generalised --------------------------------------
  # Published docs shouldn't advertise exactly which unpatched kernel you run.
  - note: "kernel version"
    pattern: '\b\d+\.\d+\.\d+-\d+-generic\b'
    replace: '<kernel-version>'
  - note: "Ubuntu point release"
    pattern: '\bUbuntu (\d{2}\.\d{2})\.\d+ LTS\b'
    replace: 'Ubuntu \1 LTS'

  # --- Secrets that should never appear, but might --------------------------
  - note: "Discord webhook URL"
    pattern: 'https://discord(?:app)?\.com/api/webhooks/\S+'
    replace: '<discord-webhook-redacted>'
  - note: "bearer/API token literal"
    pattern: '\b(?:sk|pk|ghp|gho|glpat|xoxb|xoxp)-[A-Za-z0-9_\-]{16,}\b'
    replace: '<token-redacted>'
  - note: "email address"
    pattern: '\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b'
    replace: '<email-redacted>'



  # --- Media reframing ------------------------------------------------------
  # The MergerFS/SnapRAID pool is the strongest engineering content in these
  # docs and it stays. Only its framing changes: it is a bulk storage array
  # that happens to live on a legacy /mnt/media mount point.
  - note: "storage pool header -> generic"
    pattern: 'Media storage \(MergerFS \+ SnapRAID\)'
    replace: 'Bulk storage pool (MergerFS + SnapRAID)'
  - note: "media folder layout line"
    pattern: '(?m)^>? ?- \*\*Folder layout\*\*:.*$\n?'
    replace: ''

  # --- Inline media mentions in mixed lists ---------------------------------
  # The exposure map packs many services into one cell; drop the media entries
  # inline rather than losing the whole row.
  - note: "inline media service in a list"
    pattern: '(?i),?\s*`?(flaresolverr|gluetun)[^`]*`?[^,|]*(?=[,|])'
    replace: ''
  - note: "plex auxiliary port cluster"
    pattern: '(?i),?\s*plex auxiliary ports \([^)]*\)'
    replace: ''
  - note: "qbittorrent inline mention"
    pattern: '(?i),?\s*qBittorrent \(network_mode: service:gluetun\)'
    replace: ''

  # --- Public IPv4 ----------------------------------------------------------
  # Caught a real leak: a VPN exit-node address recorded during a leak test.
  # Excludes RFC1918, loopback, link-local, multicast, documentation ranges and
  # the well-known public resolvers (which are not disclosure).
  - note: "routable public IPv4"
    pattern: '(?<![\d.])(?!10\.)(?!127\.)(?!169\.254\.)(?!172\.(?:1[6-9]|2\d|3[01])\.)(?!192\.168\.)(?!192\.0\.2\.)(?!198\.51\.100\.)(?!203\.0\.113\.)(?!100\.x)(?!0\.)(?!22[4-9]\.)(?!2[3-5]\d\.)(?!1\.1\.1\.1)(?!1\.0\.0\.1)(?!8\.8\.8\.8)(?!8\.8\.4\.4)(?!9\.9\.9\.9)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?![\d.])'
    replace: '<public-ip-redacted>'

  # --- Unauthenticated-service disclosure -> risk statement -----------------
  # "No auth, protected by obscurity" published verbatim is an invitation.
  # Rewritten as a documented risk acceptance: same operational fact, framed
  # the way it would be in an audit finding rather than a target list.
  - note: "unauthenticated service disclosure -> risk acceptance"
    pattern: '(?i)No (?:native )?auth(?:entication)?\s*[—-]\s*protected by tunnel obscurity\.?'
    replace: 'No application-layer authentication; access is mediated solely at the tunnel edge — a documented risk acceptance, not a control.'

  # --- Credential-store references -----------------------------------------
  # POLICY: the product name stays. Self-hosting a password manager is a normal
  # architecture choice and hiding it makes the doc read evasively. What goes is
  # the MAPPING — which secret lives where. The Credentials section is dropped
  # wholesale upstream; this catches references scattered through the rest.
  #
  # Keep this replacement MINIMAL. An earlier version swallowed the surrounding
  # noun phrase and produced "will prompt for the — secret held in the vault."
  # Substitutions that consume more than they need corrupt prose silently.
  - note: "credential location -> generic vault"
    pattern: '(?i)\b(?:stored |held |)in YourVaultProduct\b'
    replace: 'in the credential vault'

  # --- Bare identifiers (org/user handles that leak the real domain) --------
  - note: "bare handle / org identifier"
    pattern: '\byourhandle\b'
    replace: 'homelab'

  # --- Obsidian-isms that break outside the vault ---------------------------
  - note: "wikilink -> plain text"
    pattern: '\[\[([^\]|]+)\|([^\]]+)\]\]'
    replace: '\2'
  - note: "wikilink (bare) -> plain text"
    pattern: '\[\[([^\]]+)\]\]'
    replace: '\1'

# ---------------------------------------------------------------------------
# 3. Tripwires. Run against the SANITIZED output. Any hit = build fails.
#    This is the safety net for services you add later and forget to redact.
# ---------------------------------------------------------------------------
tripwires:
  - note: "real tunnel domain survived a substitution"
    pattern: 'yourhandle'
  - note: "unredacted Tailscale address"
    pattern: '\b100\.(?:6[4-9]|[7-9]\d|1[0-1]\d|12[0-7])\.\d{1,3}\.\d{1,3}\b'
  - note: "unredacted UUID"
    pattern: '\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b'
  - note: "routable public IPv4 (excludes RFC1918 / loopback / link-local / doc ranges)"
    pattern: '(?<![\d.])(?!10\.)(?!127\.)(?!169\.254\.)(?!172\.(?:1[6-9]|2\d|3[01])\.)(?!192\.168\.)(?!192\.0\.2\.)(?!198\.51\.100\.)(?!203\.0\.113\.)(?!100\.x)(?!0\.)(?!22[4-9]\.)(?!2[3-5]\d\.)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?![\d.])'
  - note: "advertised unauthenticated service"
    pattern: '(?i)\bno auth(?:entication)?\b|\bsecurity through obscurity\b|\btunnel obscurity\b'
  - note: "unsanitized drive serial"
    pattern: '\b(?:Z[A-Z0-9]{7}|WD-[A-Z0-9]{10,})\b'

# Lines matching any of these are exempt from tripwires — for the banner text,
# the redaction placeholders themselves, and prose that legitimately discusses
# credential handling in the abstract.
# Advisory tier. Printed as warnings; does NOT block publication. Use for
# patterns that are inherently high-false-positive but worth a human glance —
# the word "password" legitimately appears in prose about password managers.
review:
  - note: "credential-adjacent wording — glance before publishing"
    pattern: '(?i)\b(password|passphrase|api[ _-]?key|secret key|private key|webhook url)\b'
  - note: "absolute path under a home directory"
    pattern: '/home/[a-z][a-z0-9_-]*/'

tripwire_allowlist:
  - 'redacted'
  - '<uuid-redacted>'
  - 'lab\.example'
  - 'the credential vault'
  # Descriptive use of the term in a service table, not a credential location.
  - '\| Password manager\.'
  - 'YourVaultProduct'
  # Well-known public DNS resolvers — not infrastructure disclosure.
  - '\b(?:1\.1\.1\.1|1\.0\.0\.1|8\.8\.8\.8|8\.8\.4\.4|9\.9\.9\.9)\b'