285 lines
8.9 KiB
Markdown
285 lines
8.9 KiB
Markdown
# How The Scanner Is Built
|
|
|
|
This doc explains the architecture decisions. Not how to use it, but why it works the way it does.
|
|
|
|
## The Layer Cake
|
|
|
|
The application follows a standard layered architecture.
|
|
|
|
```
|
|
┌─────────────────────────────────────┐
|
|
│ Routes (API) │ FastAPI endpoints
|
|
├─────────────────────────────────────┤
|
|
│ Services │ Business logic
|
|
├─────────────────────────────────────┤
|
|
│ Repositories / Scanners │ Data access / Scanning
|
|
├─────────────────────────────────────┤
|
|
│ Models │ SQLAlchemy ORM
|
|
├─────────────────────────────────────┤
|
|
│ Database │ PostgreSQL
|
|
└─────────────────────────────────────┘
|
|
```
|
|
|
|
**Routes** handle HTTP requests, validate input with Pydantic, and return responses. They do not contain business logic.
|
|
|
|
**Services** orchestrate operations. ScanService coordinates which scanners to run and saves results. AuthService handles user registration and login.
|
|
|
|
**Repositories** abstract database operations. This keeps SQL queries out of services and makes testing easier.
|
|
|
|
**Scanners** are the security testing engines. Each scanner inherits from BaseScanner and implements a scan() method.
|
|
|
|
## The Scanner Pattern
|
|
|
|
All scanners inherit from BaseScanner which provides common functionality.
|
|
|
|
```python
|
|
class BaseScanner(ABC):
|
|
def __init__(self, target_url, auth_token, max_requests):
|
|
self.target_url = target_url
|
|
self.session = self._create_session()
|
|
# ...
|
|
|
|
def make_request(self, method, endpoint, **kwargs):
|
|
# Rate limiting, retries, timing
|
|
pass
|
|
|
|
@abstractmethod
|
|
def scan(self) -> TestResultCreate:
|
|
# Implemented by each scanner
|
|
pass
|
|
```
|
|
|
|
This design means:
|
|
- Common HTTP logic lives in one place
|
|
- Request spacing and retry logic is consistent
|
|
- Each scanner focuses only on its detection logic
|
|
- Adding a new scanner is straightforward
|
|
|
|
### Request Spacing
|
|
|
|
Scanners do not blast requests at targets. Each request is spaced to avoid overwhelming the target or triggering defensive rate limits.
|
|
|
|
```python
|
|
required_delay = 1.0 / (max_requests / window_seconds)
|
|
```
|
|
|
|
With default settings of 100 requests per 60 second window, that is about 600ms between requests. Random jitter is added to avoid predictable patterns.
|
|
|
|
This matters because:
|
|
1. You do not want to DoS your own production systems during testing
|
|
2. Aggressive scanning triggers alerts and gets you blocked
|
|
3. Some timing attacks need consistent baseline measurements
|
|
|
|
### Retry Logic
|
|
|
|
Requests that fail get retried with exponential backoff.
|
|
|
|
```python
|
|
retry_count = 0
|
|
backoff_factor = 2.0
|
|
|
|
while retry_count <= max_retries:
|
|
response = session.request(method, url)
|
|
|
|
if response.status_code == 429:
|
|
wait_time = int(response.headers.get("Retry-After", default_wait))
|
|
time.sleep(wait_time)
|
|
retry_count += 1
|
|
continue
|
|
|
|
if response.status_code >= 500:
|
|
wait_time = backoff_factor ** retry_count
|
|
time.sleep(wait_time)
|
|
retry_count += 1
|
|
continue
|
|
|
|
return response
|
|
```
|
|
|
|
429 responses respect the Retry-After header. 5xx errors trigger exponential backoff. This keeps the scanner resilient against temporary failures.
|
|
|
|
## Evidence Collection
|
|
|
|
Every scan result includes evidence. This is not just for debugging. Proper evidence is required for professional security reports.
|
|
|
|
```python
|
|
evidence = {
|
|
"status_code": response.status_code,
|
|
"response_time_ms": elapsed * 1000,
|
|
"response_length": len(response.text),
|
|
"headers": self._redact_sensitive_headers(dict(response.headers)),
|
|
"payload": str(payload),
|
|
}
|
|
```
|
|
|
|
Sensitive headers are automatically redacted. You do not want authorization tokens showing up in scan reports.
|
|
|
|
```python
|
|
sensitive_headers = [
|
|
"authorization",
|
|
"cookie",
|
|
"x-api-key",
|
|
"x-auth-token",
|
|
]
|
|
```
|
|
|
|
## The Service Layer
|
|
|
|
ScanService coordinates scans. It maps test types to scanner classes and handles the workflow.
|
|
|
|
```python
|
|
scanner_mapping = {
|
|
TestType.RATE_LIMIT: RateLimitScanner,
|
|
TestType.AUTH: AuthScanner,
|
|
TestType.SQLI: SQLiScanner,
|
|
TestType.IDOR: IDORScanner,
|
|
}
|
|
|
|
for test_type in scan_request.tests_to_run:
|
|
scanner_class = scanner_mapping.get(test_type)
|
|
scanner = scanner_class(target_url, auth_token, max_requests)
|
|
result = scanner.scan()
|
|
results.append(result)
|
|
```
|
|
|
|
If a scanner throws an exception, the service catches it and creates an error result. One failing scanner does not kill the entire scan.
|
|
|
|
```python
|
|
except Exception as e:
|
|
results.append(
|
|
TestResultCreate(
|
|
test_name=test_type,
|
|
status="error",
|
|
details=f"Scanner error: {str(e)}",
|
|
)
|
|
)
|
|
```
|
|
|
|
## Database Design
|
|
|
|
Three main tables.
|
|
|
|
**users**: Account information. Passwords are bcrypt hashed.
|
|
|
|
**scans**: Metadata about each scan (who ran it, when, target URL).
|
|
|
|
**test_results**: Individual test outcomes linked to scans.
|
|
|
|
```
|
|
users (1) ──── (*) scans (1) ──── (*) test_results
|
|
```
|
|
|
|
Cascade deletes are configured so deleting a user removes their scans, and deleting a scan removes its test results.
|
|
|
|
## Rate Limiting The Scanner API
|
|
|
|
The scanner API itself is rate limited using slowapi.
|
|
|
|
```python
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
|
|
@router.post("/")
|
|
@limiter.limit("5/minute")
|
|
async def create_scan(...):
|
|
pass
|
|
```
|
|
|
|
Running security scans is expensive. Without rate limiting, someone could hammer your scanner with requests and either run up your bills or use it to attack third parties.
|
|
|
|
The scan endpoint has stricter limits than read endpoints. Creating a scan triggers potentially hundreds of requests to the target.
|
|
|
|
## Authentication Flow
|
|
|
|
JWT tokens with bcrypt password hashing.
|
|
|
|
```
|
|
1. User registers with email/password
|
|
2. Password is hashed with bcrypt (cost factor 12)
|
|
3. User logs in with credentials
|
|
4. Server validates password against hash
|
|
5. Server issues JWT with user ID and expiration
|
|
6. Client sends JWT in Authorization header
|
|
7. Server validates JWT on each request
|
|
```
|
|
|
|
The JWT contains minimal claims. Just enough to identify the user.
|
|
|
|
```python
|
|
{
|
|
"sub": "user_id",
|
|
"exp": expiration_timestamp
|
|
}
|
|
```
|
|
|
|
No roles or permissions in the token. Those are checked against the database on each request. This means you can revoke permissions instantly without waiting for token expiration.
|
|
|
|
## Error Handling Strategy
|
|
|
|
The application uses HTTPException for expected errors and lets unexpected errors bubble up.
|
|
|
|
```python
|
|
if not scan:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Scan not found",
|
|
)
|
|
|
|
if scan.user_id != user_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Not authorized to access this scan",
|
|
)
|
|
```
|
|
|
|
FastAPI catches HTTPException and returns proper JSON error responses. Unhandled exceptions return 500 with minimal details (no stack traces in production).
|
|
|
|
## Configuration
|
|
|
|
Settings are loaded from environment variables with pydantic_settings.
|
|
|
|
```python
|
|
class Settings(BaseSettings):
|
|
BACKEND_HOST: str = "0.0.0.0"
|
|
BACKEND_PORT: int = 8000
|
|
DEBUG: bool = False
|
|
DATABASE_URL: str
|
|
JWT_SECRET_KEY: str
|
|
# ...
|
|
|
|
model_config = SettingsConfigDict(env_file=".env")
|
|
```
|
|
|
|
Defaults exist for development convenience but secrets like JWT_SECRET_KEY have no default. The application fails fast if required settings are missing.
|
|
|
|
## Why This Structure
|
|
|
|
The layered approach might seem like overkill for a scanner, but it pays off.
|
|
|
|
**Testing**: Each layer can be tested independently. Mock the repository to test services. Mock services to test routes.
|
|
|
|
**Extensibility**: Adding a new scanner means creating one file that inherits from BaseScanner. No changes to services or routes needed.
|
|
|
|
**Maintainability**: Database logic stays in repositories. HTTP logic stays in routes. Business logic stays in services. Changes are localized.
|
|
|
|
**Security**: Separation of concerns makes security review easier. Auth checks happen in one place. Input validation happens in one place.
|
|
|
|
## Adding A New Scanner
|
|
|
|
To add a new vulnerability scanner:
|
|
|
|
1. Create a new file in `scanners/`
|
|
2. Inherit from BaseScanner
|
|
3. Implement the scan() method
|
|
4. Add payloads to payloads.py if needed
|
|
5. Add the test type to core/enums.py
|
|
6. Add mapping in ScanService
|
|
|
|
```python
|
|
class XSSScanner(BaseScanner):
|
|
def scan(self) -> TestResultCreate:
|
|
# Detection logic here
|
|
pass
|
|
```
|
|
|
|
The scanner pattern handles HTTP requests, retries, and evidence collection. You focus on the detection logic.
|