Spaces:
Sleeping
Sleeping
File size: 7,304 Bytes
2e658e7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | #!/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
|