271 lines
11 KiB
Python
271 lines
11 KiB
Python
"""Security tests for Quizzical Beats."""
|
|
import pytest
|
|
import os
|
|
import re
|
|
import importlib
|
|
import musicround.config
|
|
|
|
|
|
class TestSecurityConfiguration:
|
|
"""Test security configuration and settings."""
|
|
|
|
def test_secret_key_required(self):
|
|
"""Test that SECRET_KEY must be set."""
|
|
# Remove SECRET_KEY from environment
|
|
secret_key = os.environ.get('SECRET_KEY')
|
|
if secret_key:
|
|
del os.environ['SECRET_KEY']
|
|
|
|
try:
|
|
# Force module reload to re-evaluate class-level checks
|
|
with pytest.raises(ValueError, match="SECRET_KEY environment variable must be set"):
|
|
importlib.reload(musicround.config)
|
|
finally:
|
|
# Restore environment
|
|
if secret_key:
|
|
os.environ['SECRET_KEY'] = secret_key
|
|
# Reload with correct env so other tests work
|
|
importlib.reload(musicround.config)
|
|
|
|
def test_automation_token_required(self):
|
|
"""Test that AUTOMATION_TOKEN must be set."""
|
|
# Remove AUTOMATION_TOKEN from environment
|
|
token = os.environ.get('AUTOMATION_TOKEN')
|
|
if token:
|
|
del os.environ['AUTOMATION_TOKEN']
|
|
|
|
try:
|
|
# Force module reload to re-evaluate class-level checks
|
|
with pytest.raises(ValueError, match="AUTOMATION_TOKEN environment variable must be set"):
|
|
importlib.reload(musicround.config)
|
|
finally:
|
|
# Restore environment
|
|
if token:
|
|
os.environ['AUTOMATION_TOKEN'] = token
|
|
# Reload with correct env so other tests work
|
|
importlib.reload(musicround.config)
|
|
|
|
def test_no_credentials_in_code(self):
|
|
"""Test that no credentials are hardcoded in Python files."""
|
|
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
|
musicround_dir = os.path.join(project_root, 'musicround')
|
|
|
|
# Patterns that indicate potential credentials
|
|
dangerous_patterns = [
|
|
r'password\s*=\s*["\'][^"\']{8,}["\']', # password = "something"
|
|
r'token\s*=\s*["\'][A-Za-z0-9]{20,}["\']', # token = "longstring"
|
|
r'api[_-]?key\s*=\s*["\'][A-Za-z0-9]{20,}["\']', # api_key = "key"
|
|
r'secret\s*=\s*["\'][A-Za-z0-9]{20,}["\']', # secret = "value"
|
|
]
|
|
|
|
issues = []
|
|
|
|
for root, dirs, files in os.walk(musicround_dir):
|
|
# Skip __pycache__ and test files
|
|
dirs[:] = [d for d in dirs if d != '__pycache__']
|
|
|
|
for file in files:
|
|
if not file.endswith('.py'):
|
|
continue
|
|
|
|
filepath = os.path.join(root, file)
|
|
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
|
content = f.read()
|
|
|
|
for pattern in dangerous_patterns:
|
|
matches = re.finditer(pattern, content, re.IGNORECASE)
|
|
for match in matches:
|
|
# Exclude common test/example patterns
|
|
matched_text = match.group(0)
|
|
if any(safe in matched_text.lower() for safe in [
|
|
'test', 'example', 'placeholder', 'changeme',
|
|
'your-', 'secret-key-here', 'getenvi'
|
|
]):
|
|
continue
|
|
|
|
issues.append({
|
|
'file': filepath,
|
|
'line': content[:match.start()].count('\n') + 1,
|
|
'match': matched_text
|
|
})
|
|
|
|
if issues:
|
|
message = "Found potential hardcoded credentials:\n"
|
|
for issue in issues[:5]: # Show first 5
|
|
message += f" {issue['file']}:{issue['line']}: {issue['match']}\n"
|
|
pytest.fail(message)
|
|
|
|
def test_env_example_exists(self):
|
|
"""Test that .env.example file exists."""
|
|
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
|
env_example = os.path.join(project_root, '.env.example')
|
|
assert os.path.exists(env_example), ".env.example file should exist"
|
|
|
|
def test_security_md_exists(self):
|
|
"""Test that SECURITY.md file exists."""
|
|
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
|
security_md = os.path.join(project_root, 'SECURITY.md')
|
|
assert os.path.exists(security_md), "SECURITY.md file should exist"
|
|
|
|
|
|
class TestDependencySecurity:
|
|
"""Test dependency security."""
|
|
|
|
def test_authlib_version(self):
|
|
"""Test that authlib is at least version 1.6.5."""
|
|
requirements_path = os.path.join(
|
|
os.path.dirname(__file__), '..', 'requirements.txt'
|
|
)
|
|
|
|
with open(requirements_path, 'r') as f:
|
|
content = f.read()
|
|
|
|
# Check for authlib with version constraint
|
|
assert 'authlib>=1.6.5' in content, \
|
|
"authlib should be pinned to >= 1.6.5 to fix known vulnerabilities"
|
|
|
|
def test_pyjwt_version(self):
|
|
"""Test that PyJWT is at least version 2.10.1."""
|
|
requirements_path = os.path.join(
|
|
os.path.dirname(__file__), '..', 'requirements.txt'
|
|
)
|
|
|
|
with open(requirements_path, 'r') as f:
|
|
content = f.read()
|
|
|
|
assert 'PyJWT>=2.10.1' in content, \
|
|
"PyJWT should be pinned to >= 2.10.1 to fix known vulnerabilities"
|
|
|
|
def test_idna_version(self):
|
|
"""Test that idna is at least version 3.11."""
|
|
requirements_path = os.path.join(
|
|
os.path.dirname(__file__), '..', 'requirements.txt'
|
|
)
|
|
|
|
with open(requirements_path, 'r') as f:
|
|
content = f.read()
|
|
|
|
assert 'idna>=3.11' in content, \
|
|
"idna should be pinned to >= 3.11 to fix known vulnerabilities"
|
|
|
|
|
|
class TestInputValidation:
|
|
"""Test that user input is properly validated."""
|
|
|
|
def test_sql_injection_prevention(self):
|
|
"""Test that SQLAlchemy ORM is used (not raw SQL)."""
|
|
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
|
musicround_dir = os.path.join(project_root, 'musicround')
|
|
|
|
# Look for dangerous SQL patterns
|
|
dangerous_patterns = [
|
|
r'\.execute\(["\'].*%s.*["\']', # .execute("... %s ...")
|
|
r'\.execute\(["\'].*\+.*["\']', # .execute("... " + var)
|
|
r'\.execute\(f["\']', # .execute(f"...")
|
|
]
|
|
|
|
issues = []
|
|
|
|
for root, dirs, files in os.walk(musicround_dir):
|
|
dirs[:] = [d for d in dirs if d != '__pycache__']
|
|
|
|
for file in files:
|
|
if not file.endswith('.py'):
|
|
continue
|
|
|
|
filepath = os.path.join(root, file)
|
|
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
|
content = f.read()
|
|
|
|
for pattern in dangerous_patterns:
|
|
matches = re.finditer(pattern, content)
|
|
for match in matches:
|
|
issues.append({
|
|
'file': filepath,
|
|
'line': content[:match.start()].count('\n') + 1,
|
|
'match': match.group(0)
|
|
})
|
|
|
|
# migrations are allowed to use raw SQL
|
|
issues = [i for i in issues if '/migrations/' not in i['file']]
|
|
|
|
if issues:
|
|
message = "Found potential SQL injection risks:\n"
|
|
for issue in issues[:5]:
|
|
message += f" {issue['file']}:{issue['line']}: {issue['match']}\n"
|
|
pytest.fail(message)
|
|
|
|
|
|
class TestSecureDefaults:
|
|
"""Test that secure defaults are in place."""
|
|
|
|
def test_debug_disabled_by_default(self):
|
|
"""Test that DEBUG is disabled by default in config."""
|
|
# Check .env.example
|
|
env_example_path = os.path.join(os.path.dirname(__file__), '..', '.env.example')
|
|
with open(env_example_path, 'r') as f:
|
|
content = f.read()
|
|
|
|
# Should have DEBUG=False
|
|
assert 'DEBUG=False' in content, ".env.example should have DEBUG=False"
|
|
|
|
def test_https_recommended(self):
|
|
"""Test that HTTPS is documented as recommended."""
|
|
security_md_path = os.path.join(os.path.dirname(__file__), '..', 'SECURITY.md')
|
|
with open(security_md_path, 'r') as f:
|
|
content = f.read()
|
|
|
|
assert 'HTTPS' in content or 'https' in content, \
|
|
"SECURITY.md should mention HTTPS"
|
|
assert 'USE_HTTPS' in content, \
|
|
"SECURITY.md should document USE_HTTPS setting"
|
|
|
|
|
|
class TestSecretManagement:
|
|
"""Test secret management practices."""
|
|
|
|
def test_gitignore_includes_env(self):
|
|
"""Test that .env is in .gitignore."""
|
|
gitignore_path = os.path.join(os.path.dirname(__file__), '..', '.gitignore')
|
|
with open(gitignore_path, 'r') as f:
|
|
content = f.read()
|
|
|
|
assert '.env' in content, ".env should be in .gitignore"
|
|
|
|
def test_no_env_files_committed(self):
|
|
"""Test that actual .env files are not in git."""
|
|
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
|
|
|
# .env.example is OK, but .env should not exist in repo
|
|
# (it will exist locally but should be gitignored)
|
|
# We're checking if demo files have placeholder values
|
|
|
|
demo_files = ['.env.demo', '.env.oauth', '.env.oauth.production']
|
|
for demo_file in demo_files:
|
|
demo_path = os.path.join(project_root, demo_file)
|
|
if os.path.exists(demo_path):
|
|
with open(demo_path, 'r') as f:
|
|
content = f.read()
|
|
|
|
# Check that these don't contain real-looking credentials
|
|
# Real API keys are usually 32+ characters of alphanumeric
|
|
patterns = [
|
|
r'[A-Za-z0-9]{40,}', # Very long alphanumeric strings
|
|
]
|
|
|
|
for pattern in patterns:
|
|
matches = re.findall(pattern, content)
|
|
for match in matches:
|
|
# Skip if it's obviously a placeholder
|
|
if any(x in match.lower() for x in [
|
|
'your-', 'example', 'changeme', 'placeholder'
|
|
]):
|
|
continue
|
|
|
|
# This might be a real credential
|
|
if len(match) > 40:
|
|
pytest.fail(
|
|
f"Found potential real credential in {demo_file}: "
|
|
f"{match[:20]}... (redacted)"
|
|
)
|