mirror of
https://github.com/pocketpaw/pocketpaw.git
synced 2026-05-13 21:21:53 +00:00
* feat(security): add PII detection and masking protocol (#167) Opt-in PII scanning layer that detects sensitive data (SSN, email, phone, credit card, IP, DOB) before it hits disk or logs. Integrates at memory writes, audit log, and log output. Off by default. * feat(dashboard): add PII protection toggle to settings UI Adds PII section under Behavior & Safety with: - Master enable/disable toggle - Action selector (mask, hash, log only) - Sub-toggles for memory, audit log, and log output scanning - Settings search index entry - WebSocket save/load wiring
328 lines
12 KiB
Python
328 lines
12 KiB
Python
# Tests for Feature 3: Security Audit CLI
|
|
# Created: 2026-02-06
|
|
|
|
import json
|
|
import os
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from pocketpaw.security.audit_cli import (
|
|
_check_audit_log,
|
|
_check_bypass_permissions,
|
|
_check_config_permissions,
|
|
_check_file_jail,
|
|
_check_guardian_reachable,
|
|
_check_plaintext_api_keys,
|
|
_check_tool_profile,
|
|
_fix_audit_log,
|
|
_fix_config_permissions,
|
|
run_security_audit,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_config_dir():
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
yield Path(tmpdir)
|
|
|
|
|
|
class TestConfigPermissions:
|
|
"""Tests for config file permission checks."""
|
|
|
|
def test_no_config_file(self):
|
|
with patch("pocketpaw.security.audit_cli.get_config_path") as mock:
|
|
mock.return_value = Path("/nonexistent/config.json")
|
|
ok, msg, fixable = _check_config_permissions()
|
|
assert ok is True
|
|
|
|
def test_secure_permissions(self, temp_config_dir):
|
|
config = temp_config_dir / "config.json"
|
|
config.write_text("{}")
|
|
os.chmod(config, stat.S_IRUSR | stat.S_IWUSR)
|
|
|
|
with patch("pocketpaw.security.audit_cli.get_config_path", return_value=config):
|
|
ok, msg, fixable = _check_config_permissions()
|
|
assert ok is True
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="NTFS doesn't support Unix permissions")
|
|
def test_world_readable(self, temp_config_dir):
|
|
config = temp_config_dir / "config.json"
|
|
config.write_text("{}")
|
|
os.chmod(config, stat.S_IRUSR | stat.S_IWUSR | stat.S_IROTH)
|
|
|
|
with patch("pocketpaw.security.audit_cli.get_config_path", return_value=config):
|
|
ok, msg, fixable = _check_config_permissions()
|
|
assert ok is False
|
|
assert fixable is True
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="NTFS doesn't support Unix permissions")
|
|
def test_fix_permissions(self, temp_config_dir):
|
|
config = temp_config_dir / "config.json"
|
|
config.write_text("{}")
|
|
os.chmod(config, stat.S_IRUSR | stat.S_IWUSR | stat.S_IROTH)
|
|
|
|
with patch("pocketpaw.security.audit_cli.get_config_path", return_value=config):
|
|
_fix_config_permissions()
|
|
mode = config.stat().st_mode
|
|
assert not (mode & stat.S_IROTH)
|
|
assert not (mode & stat.S_IRGRP)
|
|
|
|
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test")
|
|
def test_windows_skips_permission_check(self, temp_config_dir):
|
|
config = temp_config_dir / "config.json"
|
|
config.write_text("{}")
|
|
|
|
with patch("pocketpaw.security.audit_cli.get_config_path", return_value=config):
|
|
ok, msg, fixable = _check_config_permissions()
|
|
assert ok is True
|
|
assert "Windows" in msg
|
|
|
|
|
|
class TestPlaintextApiKeys:
|
|
"""Tests for plaintext API key checks."""
|
|
|
|
def test_no_config_file(self):
|
|
with patch("pocketpaw.security.audit_cli.get_config_path") as mock:
|
|
mock.return_value = Path("/nonexistent/config.json")
|
|
ok, msg, fixable = _check_plaintext_api_keys()
|
|
assert ok is True
|
|
|
|
def test_no_keys_in_config(self, temp_config_dir):
|
|
config = temp_config_dir / "config.json"
|
|
config.write_text(json.dumps({"agent_backend": "claude_agent_sdk"}))
|
|
|
|
with patch("pocketpaw.security.audit_cli.get_config_path", return_value=config):
|
|
ok, msg, fixable = _check_plaintext_api_keys()
|
|
assert ok is True
|
|
|
|
def test_keys_in_config(self, temp_config_dir):
|
|
config = temp_config_dir / "config.json"
|
|
config.write_text(json.dumps({"anthropic_api_key": "sk-ant-123"}))
|
|
|
|
with patch("pocketpaw.security.audit_cli.get_config_path", return_value=config):
|
|
ok, msg, fixable = _check_plaintext_api_keys()
|
|
assert ok is False
|
|
assert "anthropic_api_key" in msg
|
|
|
|
|
|
class TestAuditLog:
|
|
"""Tests for audit log checks."""
|
|
|
|
def test_audit_log_missing(self, temp_config_dir):
|
|
with patch("pocketpaw.security.audit_cli.get_config_dir", return_value=temp_config_dir):
|
|
ok, msg, fixable = _check_audit_log()
|
|
assert ok is False
|
|
assert fixable is True
|
|
|
|
def test_audit_log_exists(self, temp_config_dir):
|
|
audit = temp_config_dir / "audit.jsonl"
|
|
audit.touch()
|
|
|
|
with patch("pocketpaw.security.audit_cli.get_config_dir", return_value=temp_config_dir):
|
|
ok, msg, fixable = _check_audit_log()
|
|
assert ok is True
|
|
|
|
def test_fix_creates_audit_log(self, temp_config_dir):
|
|
with patch("pocketpaw.security.audit_cli.get_config_dir", return_value=temp_config_dir):
|
|
_fix_audit_log()
|
|
audit = temp_config_dir / "audit.jsonl"
|
|
assert audit.exists()
|
|
|
|
|
|
class TestGuardianReachable:
|
|
"""Tests for guardian agent check."""
|
|
|
|
def test_no_api_key(self):
|
|
with patch("pocketpaw.security.audit_cli.get_settings") as mock:
|
|
mock.return_value = MagicMock(anthropic_api_key=None)
|
|
ok, msg, fixable = _check_guardian_reachable()
|
|
assert ok is False
|
|
|
|
def test_api_key_set(self):
|
|
with patch("pocketpaw.security.audit_cli.get_settings") as mock:
|
|
mock.return_value = MagicMock(anthropic_api_key="sk-ant-123")
|
|
ok, msg, fixable = _check_guardian_reachable()
|
|
assert ok is True
|
|
|
|
|
|
class TestFileJail:
|
|
"""Tests for file jail check."""
|
|
|
|
def test_valid_jail(self, temp_config_dir):
|
|
with patch("pocketpaw.security.audit_cli.get_settings") as mock:
|
|
mock.return_value = MagicMock(file_jail_path=temp_config_dir)
|
|
ok, msg, fixable = _check_file_jail()
|
|
assert ok is True
|
|
|
|
def test_nonexistent_jail(self):
|
|
with patch("pocketpaw.security.audit_cli.get_settings") as mock:
|
|
mock.return_value = MagicMock(file_jail_path=Path("/nonexistent/path"))
|
|
ok, msg, fixable = _check_file_jail()
|
|
assert ok is False
|
|
|
|
|
|
class TestToolProfile:
|
|
"""Tests for tool profile check."""
|
|
|
|
def test_full_profile_warns(self):
|
|
with patch("pocketpaw.security.audit_cli.get_settings") as mock:
|
|
mock.return_value = MagicMock(tool_profile="full")
|
|
ok, msg, fixable = _check_tool_profile()
|
|
assert ok is False
|
|
|
|
def test_coding_profile_ok(self):
|
|
with patch("pocketpaw.security.audit_cli.get_settings") as mock:
|
|
mock.return_value = MagicMock(tool_profile="coding")
|
|
ok, msg, fixable = _check_tool_profile()
|
|
assert ok is True
|
|
|
|
|
|
class TestBypassPermissions:
|
|
"""Tests for bypass permissions check."""
|
|
|
|
def test_bypass_enabled_warns(self):
|
|
with patch("pocketpaw.security.audit_cli.get_settings") as mock:
|
|
mock.return_value = MagicMock(bypass_permissions=True)
|
|
ok, msg, fixable = _check_bypass_permissions()
|
|
assert ok is False
|
|
|
|
def test_bypass_disabled_ok(self):
|
|
with patch("pocketpaw.security.audit_cli.get_settings") as mock:
|
|
mock.return_value = MagicMock(bypass_permissions=False)
|
|
ok, msg, fixable = _check_bypass_permissions()
|
|
assert ok is True
|
|
|
|
|
|
class TestRunSecurityAudit:
|
|
"""Tests for the full audit runner."""
|
|
|
|
async def test_all_pass(self):
|
|
with (
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_config_permissions",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_plaintext_api_keys",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_audit_log",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_guardian_reachable",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_file_jail",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_tool_profile",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_bypass_permissions",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_pii_protection",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
):
|
|
exit_code = await run_security_audit()
|
|
assert exit_code == 0
|
|
|
|
async def test_issues_found(self):
|
|
with (
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_config_permissions",
|
|
return_value=(False, "Bad perms", True),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_plaintext_api_keys",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_audit_log",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_guardian_reachable",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_file_jail",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_tool_profile",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_bypass_permissions",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_pii_protection",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
):
|
|
exit_code = await run_security_audit()
|
|
assert exit_code == 1
|
|
|
|
async def test_fix_mode(self):
|
|
fix_called = False
|
|
|
|
def mock_fix():
|
|
nonlocal fix_called
|
|
fix_called = True
|
|
|
|
with (
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_config_permissions",
|
|
return_value=(False, "Bad perms", True),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._fix_config_permissions",
|
|
side_effect=mock_fix,
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_plaintext_api_keys",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_audit_log",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_guardian_reachable",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_file_jail",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_tool_profile",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_bypass_permissions",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
patch(
|
|
"pocketpaw.security.audit_cli._check_pii_protection",
|
|
return_value=(True, "OK", False),
|
|
),
|
|
):
|
|
exit_code = await run_security_audit(fix=True)
|
|
assert exit_code == 0 # Fixed, so 0
|
|
assert fix_called is True
|