diff --git a/scripts/check-credential-leaks.sh b/scripts/check-credential-leaks.sh new file mode 100755 index 0000000..a3a66f8 --- /dev/null +++ b/scripts/check-credential-leaks.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# check-credential-leaks — falla si journald reciente contiene patrones de credencial. +# Regla permanente: los secretos jamás en logs. Ejecutar manualmente o desde cron +# (sin timer propio: no fue autorizado). +set -euo pipefail +SINCE="${1:-2 hours ago}" +HITS=$(journalctl --since "$SINCE" --no-pager 2>/dev/null | grep -cE "(postgres|postgresql)://[A-Za-z0-9_]+:[^@[:space:]]{8,}@|nsec1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{50,}" || true) +if [ "$HITS" -gt 0 ]; then + echo "ALERTA: $HITS líneas de journald (desde $SINCE) contienen patrones de credencial" >&2 + journalctl --since "$SINCE" --no-pager 2>/dev/null | grep -nE "(postgres|postgresql)://[A-Za-z0-9_]+:[^@[:space:]]{8,}@|nsec1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{50,}" | sed -E "s#(://[A-Za-z0-9_]+:)[^@[:space:]]{8,}@#\1***@#" | head -5 + exit 1 +fi +echo "OK: sin patrones de credencial en journald desde $SINCE" diff --git a/tests/test_no_credential_patterns.py b/tests/test_no_credential_patterns.py new file mode 100644 index 0000000..496c466 --- /dev/null +++ b/tests/test_no_credential_patterns.py @@ -0,0 +1,68 @@ +"""Regla permanente (2026-10-31): ningún fichero versionado contiene patrones +de credenciales. Placeholders de .env.example están permitidos. + +Fallará si alguien mete un DSN con contraseña real, un token en claro, o +cualquier valor que deba vivir solo en .env / tokens.json. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SCAN_DIRS = ["runtime", "deploy", "docs", "scripts", "prompts"] +SCAN_GLOBS = ["*.py", "*.mjs", "*.js", "*.ts", "*.md", "*.yml", "*.yaml", "*.sh", "*.sql"] + +# DSN con credencial literal (user:pass@host) — excluye placeholders conocidos +DSN = re.compile(r"(postgres|postgresql|mysql|mongodb)(\+[a-z]+)?://[A-Za-z0-9_]+:(?!password\b|change-me\b|<[^>]+>)[^@\s/]{8,}@", re.I) +# tokens hex largos fuera de tests (los fixtures de tests están allowlistados por diseño) +HEXTOKEN = re.compile(r"\b[0-9a-f]{64}\b") +# nsec bech32 +NSEC = re.compile(r"nsec1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{50,}") +ALLOW_SUBSTR = ("user:password", "change-me", "<", "EXAMPLE", "example", "itest-pg", "0011", "11*", "22*", "33*", "44*") + + +def _iter_files(): + for d in SCAN_DIRS: + base = REPO / d + if not base.exists(): + continue + for pat in SCAN_GLOBS: + for f in base.rglob(pat): + if ".git" in f.parts or "node_modules" in f.parts: + continue + yield f + + +def test_no_dsn_with_credentials(): + hits = [] + for f in _iter_files(): + for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1): + if DSN.search(line) and not any(a in line for a in ALLOW_SUBSTR): + hits.append(f"{f}:{i}") + assert not hits, f"DSN con credencial literal en: {hits}" + + +def test_no_nsec_in_versioned_files(): + hits = [] + for f in _iter_files(): + if "/tests/" in str(f): + continue + for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1): + if NSEC.search(line): + hits.append(f"{f}:{i}") + assert not hits, f"nsec en fichero versionado: {hits}" + + +def test_no_raw_hex_tokens_outside_tests(): + """Tokens hex de 64 chars fuera de tests/ y del conftest (las claves de + test viven solo en tests/). El outbox no debe contener secretos.""" + hits = [] + for f in _iter_files(): + if "/tests/" in str(f) or f.name == "cayce-identity.md": + continue + for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1): + if HEXTOKEN.search(line): + hits.append(f"{f}:{i}") + assert not hits, f"posible token hex versionado: {hits}"