stage-11: security hardening

- Prompt injection protection: external messages wrapped in [EXTERNAL CONTENT]
  markers, system prompt instructs Claude to never follow external instructions
- Invocation logging: all Claude CLI calls logged with channel, model, duration,
  token counts to echo-core.invoke logger
- Security logging: separate echo-core.security logger for unauthorized access
  attempts (DMs from non-admins, unauthorized admin/owner commands)
- Security log routed to logs/security.log in addition to main log
- Extended echo doctor: Claude CLI functional check, config.json secret scan,
  .gitignore completeness, file permissions, Ollama reachability, bot process
- Subprocess env stripping logged at debug level

373 tests pass (10 new security tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MoltBot Service
2026-02-13 18:01:31 +00:00
parent 85c72e4b3d
commit d1bb67abc1
6 changed files with 326 additions and 12 deletions

66
cli.py
View File

@@ -82,11 +82,14 @@ def _load_sessions_file() -> dict:
def cmd_doctor(args):
"""Run diagnostic checks."""
import re
import subprocess
checks = []
# 1. Discord token present
token = get_secret("discord_token")
checks.append(("Discord token", bool(token)))
checks.append(("Discord token in keyring", bool(token)))
# 2. Keyring working
try:
@@ -96,9 +99,17 @@ def cmd_doctor(args):
except Exception:
checks.append(("Keyring accessible", False))
# 3. Claude CLI found
# 3. Claude CLI found and functional
claude_found = shutil.which("claude") is not None
checks.append(("Claude CLI found", claude_found))
if claude_found:
try:
result = subprocess.run(
["claude", "--version"], capture_output=True, text=True, timeout=10,
)
checks.append(("Claude CLI functional", result.returncode == 0))
except Exception:
checks.append(("Claude CLI functional", False))
# 4. Disk space (warn if <1GB free)
try:
@@ -108,11 +119,19 @@ def cmd_doctor(args):
except OSError:
checks.append(("Disk space", False))
# 5. config.json valid
# 5. config.json valid + no tokens/secrets in plain text
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
json.load(f)
config_text = f.read()
json.loads(config_text)
checks.append(("config.json valid", True))
# Scan for token-like patterns
token_patterns = re.compile(
r'(sk-[a-zA-Z0-9]{20,}|xoxb-|xoxp-|ghp_|gho_|discord.*token.*["\']:\s*["\'][A-Za-z0-9._-]{20,})',
re.IGNORECASE,
)
has_tokens = bool(token_patterns.search(config_text))
checks.append(("config.json no plain text secrets", not has_tokens))
except (FileNotFoundError, json.JSONDecodeError, OSError):
checks.append(("config.json valid", False))
@@ -127,6 +146,45 @@ def cmd_doctor(args):
except OSError:
checks.append(("Logs dir writable", False))
# 7. .gitignore correct (must contain key entries)
gitignore = PROJECT_ROOT / ".gitignore"
required_gitignore = {"sessions/", "logs/", ".env", "*.sqlite"}
try:
gi_text = gitignore.read_text(encoding="utf-8")
gi_lines = {l.strip() for l in gi_text.splitlines()}
missing = required_gitignore - gi_lines
checks.append((".gitignore complete", len(missing) == 0))
if missing:
print(f" (missing from .gitignore: {', '.join(sorted(missing))})")
except FileNotFoundError:
checks.append((".gitignore exists", False))
# 8. File permissions: sessions/ and config.json not world-readable
for sensitive in [PROJECT_ROOT / "sessions", CONFIG_FILE]:
if sensitive.exists():
mode = sensitive.stat().st_mode
world_read = mode & 0o004
checks.append((f"{sensitive.name} not world-readable", not world_read))
# 9. Ollama reachable
try:
import urllib.request
req = urllib.request.urlopen("http://10.0.20.161:11434/api/tags", timeout=5)
checks.append(("Ollama reachable", req.status == 200))
except Exception:
checks.append(("Ollama reachable", False))
# 10. Discord connection (bot PID running)
pid_ok = False
if PID_FILE.exists():
try:
pid = int(PID_FILE.read_text().strip())
os.kill(pid, 0)
pid_ok = True
except (ValueError, OSError):
pass
checks.append(("Bot process running", pid_ok))
# Print results
all_pass = True
for label, passed in checks: