Phase 2 Step 1: Implement database and service layer

Complete the foundation for Phase 2 by implementing the service layer,
utilities, and comprehensive test suite. This establishes the core
business logic for scan management.

Service Layer:
- Add ScanService class with complete scan lifecycle management
  * trigger_scan() - Create scan record and prepare for execution
  * get_scan() - Retrieve scan with all related data (eager loading)
  * list_scans() - Paginated scan list with status filtering
  * delete_scan() - Remove scan from DB and delete all files
  * get_scan_status() - Poll current scan status and progress
  * _save_scan_to_db() - Persist scan results to database
  * _map_report_to_models() - Complex JSON-to-DB mapping logic

Database Mapping:
- Comprehensive mapping from scanner JSON output to normalized schema
- Handles nested relationships: sites → IPs → ports → services → certs → TLS
- Processes both TCP and UDP ports with expected/actual tracking
- Maps service detection results with HTTP/HTTPS information
- Stores SSL/TLS certificates with expiration tracking
- Records TLS version support and cipher suites
- Links screenshots to services

Utilities:
- Add pagination.py with PaginatedResult class
  * paginate() function for SQLAlchemy queries
  * validate_page_params() for input sanitization
  * Metadata: total, pages, has_prev, has_next, etc.

- Add validators.py with comprehensive validation functions
  * validate_config_file() - YAML structure and required fields
  * validate_scan_status() - Enum validation (running/completed/failed)
  * validate_scan_id() - Positive integer validation
  * validate_port() - Port range validation (1-65535)
  * validate_ip_address() - Basic IPv4 format validation
  * sanitize_filename() - Path traversal prevention

Database Migration:
- Add migration 002 for scan status index
- Optimizes queries filtering by scan status
- Timestamp index already exists from migration 001

Testing:
- Add pytest infrastructure with conftest.py
  * test_db fixture - Temporary SQLite database per test
  * sample_scan_report fixture - Realistic scanner output
  * sample_config_file fixture - Valid YAML config
  * sample_invalid_config_file fixture - For validation tests

- Add comprehensive test_scan_service.py (15 tests)
  * Test scan trigger with valid/invalid configs
  * Test scan retrieval (found/not found cases)
  * Test scan listing with pagination and filtering
  * Test scan deletion with cascade cleanup
  * Test scan status retrieval
  * Test database mapping from JSON to models
  * Test expected vs actual port flagging
  * Test certificate and TLS data mapping
  * Test full scan retrieval with all relationships
  * All tests passing

Files Added:
- web/services/__init__.py
- web/services/scan_service.py (545 lines)
- web/utils/pagination.py (153 lines)
- web/utils/validators.py (245 lines)
- migrations/versions/002_add_scan_indexes.py
- tests/__init__.py
- tests/conftest.py (142 lines)
- tests/test_scan_service.py (374 lines)

Next Steps (Step 2):
- Implement scan API endpoints in web/api/scans.py
- Add authentication decorators
- Integrate ScanService with API routes
- Test API endpoints with integration tests

Phase 2 Step 1 Complete ✓
This commit is contained in:
2025-11-14 00:26:06 -06:00
parent 9255233a74
commit d7c68a2be8
8 changed files with 1668 additions and 0 deletions

196
tests/conftest.py Normal file
View File

@@ -0,0 +1,196 @@
"""
Pytest configuration and fixtures for SneakyScanner tests.
"""
import os
import tempfile
from pathlib import Path
import pytest
import yaml
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from web.models import Base
@pytest.fixture(scope='function')
def test_db():
"""
Create a temporary test database.
Yields a SQLAlchemy session for testing, then cleans up.
"""
# Create temporary database file
db_fd, db_path = tempfile.mkstemp(suffix='.db')
# Create engine and session
engine = create_engine(f'sqlite:///{db_path}', echo=False)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
# Cleanup
session.close()
os.close(db_fd)
os.unlink(db_path)
@pytest.fixture
def sample_scan_report():
"""
Sample scan report matching the structure from scanner.py.
Returns a dictionary representing a typical scan output.
"""
return {
'title': 'Test Scan',
'scan_time': '2025-11-14T10:30:00Z',
'scan_duration': 125.5,
'config_file': '/app/configs/test.yaml',
'sites': [
{
'name': 'Test Site',
'ips': [
{
'address': '192.168.1.10',
'expected': {
'ping': True,
'tcp_ports': [22, 80, 443],
'udp_ports': [53],
'services': []
},
'actual': {
'ping': True,
'tcp_ports': [22, 80, 443, 8080],
'udp_ports': [53],
'services': [
{
'port': 22,
'service': 'ssh',
'product': 'OpenSSH',
'version': '8.9p1',
'extrainfo': 'Ubuntu',
'ostype': 'Linux'
},
{
'port': 443,
'service': 'https',
'product': 'nginx',
'version': '1.24.0',
'extrainfo': '',
'ostype': '',
'http_info': {
'protocol': 'https',
'screenshot': 'screenshots/192_168_1_10_443.png',
'certificate': {
'subject': 'CN=example.com',
'issuer': 'CN=Let\'s Encrypt Authority',
'serial_number': '123456789',
'not_valid_before': '2025-01-01T00:00:00Z',
'not_valid_after': '2025-12-31T23:59:59Z',
'days_until_expiry': 365,
'sans': ['example.com', 'www.example.com'],
'is_self_signed': False,
'tls_versions': {
'TLS 1.2': {
'supported': True,
'cipher_suites': [
'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384',
'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256'
]
},
'TLS 1.3': {
'supported': True,
'cipher_suites': [
'TLS_AES_256_GCM_SHA384',
'TLS_AES_128_GCM_SHA256'
]
}
}
}
}
},
{
'port': 80,
'service': 'http',
'product': 'nginx',
'version': '1.24.0',
'extrainfo': '',
'ostype': '',
'http_info': {
'protocol': 'http',
'screenshot': 'screenshots/192_168_1_10_80.png'
}
},
{
'port': 8080,
'service': 'http',
'product': 'Jetty',
'version': '9.4.48',
'extrainfo': '',
'ostype': ''
}
]
}
}
]
}
]
}
@pytest.fixture
def sample_config_file(tmp_path):
"""
Create a sample YAML config file for testing.
Args:
tmp_path: pytest temporary directory fixture
Returns:
Path to created config file
"""
config_data = {
'title': 'Test Scan',
'sites': [
{
'name': 'Test Site',
'ips': [
{
'address': '192.168.1.10',
'expected': {
'ping': True,
'tcp_ports': [22, 80, 443],
'udp_ports': [53],
'services': ['ssh', 'http', 'https']
}
}
]
}
]
}
config_file = tmp_path / 'test_config.yaml'
with open(config_file, 'w') as f:
yaml.dump(config_data, f)
return str(config_file)
@pytest.fixture
def sample_invalid_config_file(tmp_path):
"""
Create an invalid config file for testing validation.
Returns:
Path to invalid config file
"""
config_file = tmp_path / 'invalid_config.yaml'
with open(config_file, 'w') as f:
f.write("invalid: yaml: content: [missing closing bracket")
return str(config_file)