SimpleChatbot / scripts /backup_allowlist.py
Amin
Deploy: HermesFace finalized project to HF Space
2e658e7
Raw
History Blame Contribute Delete
7.3 kB
#!/usr/bin/env python3
"""P0-T05: strict backup allowlist and secret-pattern scanning.
Used by save_to_dataset.py / save_to_dataset_atomic.py / sync_hf persistence
paths so that .env, exchange keys, tokens, cookies, sessions with secrets,
and private configuration never enter a Dataset backup.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import Iterable, List, Optional, Sequence, Tuple
# Directories under HERMES_HOME that may be included in a backup.
# Anything not listed is excluded by default (fail-closed).
ALLOWED_DIR_NAMES = frozenset({
"memories",
"skills",
"plans",
"skins",
"cron",
"hooks",
})
# Explicit file names (basename) that may be included when they sit at the
# HERMES_HOME root or under an allowed directory.
ALLOWED_FILE_NAMES = frozenset({
"telegram_state.json",
"futures_symbols_cache.json",
"SOUL.md",
"soul.md",
"AGENTS.md",
"agents.md",
"preferences.json",
"hermes_domain.sqlite3",
})
# Basename patterns that are always excluded.
DENIED_BASENAME_PATTERNS = (
re.compile(r"^\.env($|\.)", re.I),
re.compile(r".*\.pem$", re.I),
re.compile(r".*\.key$", re.I),
re.compile(r".*credential.*", re.I),
re.compile(r".*secret.*", re.I),
re.compile(r".*token.*", re.I),
re.compile(r".*cookie.*", re.I),
re.compile(r".*password.*", re.I),
re.compile(r"^id_rsa", re.I),
re.compile(r"^id_ed25519", re.I),
re.compile(r".*\.p12$", re.I),
re.compile(r".*\.pfx$", re.I),
)
# Content patterns that cause a file to be rejected (secret-like).
SECRET_CONTENT_PATTERNS = (
re.compile(r'''(?i)['"]?(api[_-]?key|api[_-]?secret|access[_-]?token)['"]?\s*[:=]\s*['"]?[A-Za-z0-9_\-]{16,}'''),
re.compile(r'''(?i)['"]?(password|passwd|secret)['"]?\s*[:=]\s*['"]?[^\s'"]{8,}'''),
re.compile(r"(?i)-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----"),
re.compile(r"(?i)(hf_|sk-|xox[baprs]-)[A-Za-z0-9]{20,}"),
re.compile(r"(?i)bearer\s+[A-Za-z0-9\-_\.]{20,}"),
)
# Max bytes scanned per file for secret content (avoid huge binary reads).
_SECRET_SCAN_LIMIT = 64 * 1024
class BackupAllowlistError(RuntimeError):
"""Raised when a backup must abort because secret-like content was found."""
def is_denied_basename(name: str) -> bool:
base = os.path.basename(name)
return any(p.search(base) for p in DENIED_BASENAME_PATTERNS)
def is_path_allowed(rel_path: str, *, root_allowed_dirs: Optional[Sequence[str]] = None) -> bool:
"""Return True if relative path is within the strict allowlist."""
rel = rel_path.replace("\\", "/").lstrip("./")
if not rel or rel in (".",):
return True # directory root marker
if is_denied_basename(rel):
return False
parts = [p for p in rel.split("/") if p and p != "."]
if not parts:
return True
allowed_dirs = frozenset(root_allowed_dirs) if root_allowed_dirs is not None else ALLOWED_DIR_NAMES
# Single file at root
if len(parts) == 1:
return parts[0] in ALLOWED_FILE_NAMES or parts[0] in allowed_dirs
# Nested: first component must be an allowed directory
if parts[0] not in allowed_dirs:
return False
# Still deny secret-named files deeper in the tree
if is_denied_basename(parts[-1]):
return False
return True
def scan_file_for_secrets(path: Path) -> Optional[str]:
"""Return a short reason if the file appears to contain secrets, else None."""
try:
if not path.is_file():
return None
if path.stat().st_size == 0:
return None
# Skip obvious binaries by extension
if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".zip", ".gz", ".tar", ".pyc", ".so"}:
return None
with open(path, "rb") as f:
raw = f.read(_SECRET_SCAN_LIMIT)
try:
text = raw.decode("utf-8", errors="ignore")
except Exception:
return None
for pat in SECRET_CONTENT_PATTERNS:
if pat.search(text):
return f"secret_pattern:{pat.pattern[:40]}"
except OSError:
return None
return None
def filter_tarinfo(info, *, root: Optional[str] = None):
"""tarfile filter: return None to exclude, or the TarInfo to keep.
Compatible with tarfile.TarFile.add(..., filter=...).
"""
name = info.name
# Always drop locks/tmp/pycache
if name.endswith((".lock", ".tmp", ".pid", ".socket")):
return None
if "__pycache__" in name or name.endswith(".pyc"):
return None
if not is_path_allowed(name):
return None
return info
def collect_allowed_paths(state_dir: str) -> Tuple[List[Path], List[str]]:
"""Walk state_dir and return (allowed_files, rejection_reasons).
If any allowed candidate fails secret content scan, raise BackupAllowlistError
so the backup aborts rather than uploading partial secret material.
"""
root = Path(state_dir)
allowed: List[Path] = []
rejections: List[str] = []
if not root.is_dir():
return allowed, rejections
for dirpath, dirnames, filenames in os.walk(root):
# Prune denied / non-allowlisted directories early
rel_dir = os.path.relpath(dirpath, root).replace("\\", "/")
if rel_dir == ".":
# Only descend into allowlisted top-level dirs
dirnames[:] = [d for d in dirnames if d in ALLOWED_DIR_NAMES and not is_denied_basename(d)]
else:
if not is_path_allowed(rel_dir):
dirnames[:] = []
continue
dirnames[:] = [d for d in dirnames if not is_denied_basename(d)]
for fn in filenames:
full = Path(dirpath) / fn
rel = os.path.relpath(full, root).replace("\\", "/")
if not is_path_allowed(rel):
rejections.append(f"excluded:{rel}")
continue
secret_reason = scan_file_for_secrets(full)
if secret_reason:
raise BackupAllowlistError(
f"Refusing backup: secret-like content in {rel} ({secret_reason})"
)
allowed.append(full)
return allowed, rejections
def tar_filter_factory(state_dir: str):
"""Return a tar filter that enforces the allowlist relative to state_dir."""
root = Path(state_dir)
def _filter(info):
# info.name is arcname-relative (often ".")
name = info.name
if name in (".", ""):
return info
# When arcname is ".", members look like "./sessions/..." or "sessions/..."
rel = name.lstrip("./")
if rel.endswith((".lock", ".tmp", ".pid", ".socket")):
return None
if "__pycache__" in rel or rel.endswith(".pyc"):
return None
if not is_path_allowed(rel):
return None
# Content scan for regular files
if info.isfile():
candidate = root / rel
if candidate.is_file():
reason = scan_file_for_secrets(candidate)
if reason:
raise BackupAllowlistError(
f"Refusing backup: secret-like content in {rel} ({reason})"
)
return info
return _filter