The Sanitizer
viper-docs-pipeline turns internal Obsidian infrastructure docs into the published copies you can read here. It strips private blocks, drops whole sections and table rows, and applies regex substitutions — then runs a tripwire verification pass over its own output. If anything sensitive survives the redaction, it exits non-zero and writes nothing: a failing build is the system working. Substitutions handle what you knew about; tripwires catch the service you stood up last Tuesday and forgot. The two files below are the tool and its rules config.
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'