fix: race conditions in lock pop, CSV formula injection, serialize_finding None leak, consolidate plans, update docs
This commit is contained in:
@@ -2,20 +2,22 @@
|
||||
|
||||
**Date:** 2026-05-10
|
||||
**Auditor:** Automated security audit
|
||||
**Last updated:** 2026-05-11
|
||||
**Last updated:** 2026-05-11 (consolidated with improvements/final-plan; statuses verified against current codebase)
|
||||
**Scope:** Full codebase review — security vulnerabilities, logic errors, missing controls
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count | Fixed | Rejected | Remaining |
|
||||
|----------|-------|-------|----------|-----------|
|
||||
| CRITICAL | 5 | 2 | 2 | 1 |
|
||||
| HIGH | 7 | 2 | 3 | 2 |
|
||||
| MEDIUM | 8 | 3 | 0 | 5 |
|
||||
| LOW | 6 | 2 | 0 | 4 |
|
||||
| **Total**| **26**| **9** | **5** | **12** |
|
||||
| Severity | Count | Fixed | Rejected/Accepted | Mitigated | Open |
|
||||
|----------|-------|-------|--------------------|-----------|------|
|
||||
| CRITICAL | 5 | 2 | 2 | 1 | 0 |
|
||||
| HIGH | 7 | 2 | 5 | 0 | 0 |
|
||||
| MEDIUM | 8 | 6 | 0 | 0 | 2 |
|
||||
| LOW | 6 | 4 | 0 | 0 | 2 |
|
||||
| **Total**| **26**| **14**| **7** | **1** | **4**|
|
||||
|
||||
**14 fixed, 7 closed as rejected/accepted-risk, 1 partially mitigated, 4 remaining open.**
|
||||
|
||||
---
|
||||
|
||||
@@ -25,192 +27,225 @@
|
||||
**Severity:** CRITICAL
|
||||
**Fix:** `NEXUS_ALLOWED_HOSTS` env var + `_validate_download_url()` in `core/nexus.py`.
|
||||
|
||||
**Problem:** `downloadUrl` из webhook-пэйлода передаётся напрямую в `httpx.AsyncClient.get()` без валидации.
|
||||
**Problem:** `downloadUrl` from webhook payload was passed directly to `httpx.AsyncClient.get()` without validation.
|
||||
|
||||
```python
|
||||
download_url = asset.get("downloadUrl") or _build_download_url(repository, asset_path)
|
||||
# ...
|
||||
response = await client.get(download_url) # no validation
|
||||
```
|
||||
|
||||
**Real-world impact:** Атакующий отправляет webhook с `downloadUrl: "http://169.254.169.254/latest/meta-data/iam/security-credentials/"` → сервер скачивает IAM-учётные данные облака.
|
||||
|
||||
**Fix:** Validate URL scheme (http/https only), block private IP ranges (10.x, 172.16.x, 192.168.x, 127.x, 169.254.x, ::1), optionally whitelist domain against `config.nexus_url`.
|
||||
**Fix:** Validate URL scheme (http/https only), validate hostname against allowed hosts list. Defaults to Nexus hostname if `NEXUS_ALLOWED_HOSTS` not set.
|
||||
|
||||
---
|
||||
|
||||
### C2. Webhook secret not enforced by default ❌ ACCEPTED RISK
|
||||
**Severity:** CRITICAL
|
||||
**Decision:** Внутренний сервис, секрет опционален.
|
||||
**Decision:** Internal service; secret is optional. Documented as such.
|
||||
|
||||
---
|
||||
|
||||
### C3. Default admin credentials ✅ FIXED
|
||||
**Severity:** CRITICAL
|
||||
**Fix:** Убран BasicAuth из всех запросов к Nexus (анонимный доступ).
|
||||
**Fix:** Removed BasicAuth from all Nexus API calls (anonymous access).
|
||||
|
||||
---
|
||||
|
||||
### C4. XSS via LLM report verdict ❌ NOT DANGEROUS
|
||||
**Severity:** CRITICAL — downgraded to INFO
|
||||
**Decision:** Jinja2 autoescape блокирует инъекцию в атрибутах.
|
||||
**Decision:** Jinja2 autoescape blocks injection in attributes.
|
||||
|
||||
---
|
||||
|
||||
### C5. LLM Prompt Injection ⚠️ PARTIALLY MITIGATED
|
||||
**Severity:** CRITICAL
|
||||
**Mitigation:** System prompt gives priority to system instructions. Raw finding data still in user message.
|
||||
**Mitigation:** System prompt gives priority to system instructions. `_validate_report()` applies defaults for missing/invalid fields. Raw finding data still in user message.
|
||||
|
||||
---
|
||||
|
||||
## HIGH (7)
|
||||
|
||||
### H1. No rate limiting ❌ REJECTED
|
||||
### H2. Path traversal ⚠️ LOW RISK
|
||||
**Severity:** HIGH — downgraded
|
||||
**Analysis:** `os.path.basename("../../../etc/passwd")` → `"passwd"`, traversal невозможен.
|
||||
**Severity:** HIGH
|
||||
**Decision:** Internal service; not exposed to public internet.
|
||||
|
||||
---
|
||||
|
||||
### H3. Sensitive data in API ❌ REJECTED (source_ip is a feature)
|
||||
### H4. No authentication ❌ REJECTED (internal service)
|
||||
### H5. Memory leak in locks ✅ FIXED (bg cleanup every 30min)
|
||||
### H6. Race condition in URL locking ✅ FIXED (DB re-check inside lock)
|
||||
### H7. CSV export bounded ❌ REJECTED (acceptable for internal tool)
|
||||
### H2. Path traversal via download filename ⚠️ LOW RISK
|
||||
**Severity:** HIGH — downgraded
|
||||
**Analysis:** `os.path.basename("../../../etc/passwd")` → `"passwd"`, traversal impossible. Risk accepted.
|
||||
|
||||
---
|
||||
|
||||
### H3. Sensitive data in API (source_ip) ❌ REJECTED
|
||||
**Severity:** HIGH
|
||||
**Decision:** `source_ip` and `initiator` are features. Internal service — acceptable.
|
||||
|
||||
---
|
||||
|
||||
### H4. No authentication on API ❌ REJECTED
|
||||
**Severity:** HIGH
|
||||
**Decision:** Internal service; not exposed to public internet.
|
||||
|
||||
---
|
||||
|
||||
### H5. Memory leak in lock dictionaries ✅ FIXED
|
||||
**Severity:** HIGH
|
||||
**Fix:** Background cleanup tasks every 30 minutes in `main.py` for both `_url_locks` (harvester) and `_llm_locks` (web). Tasks spawned via `asyncio.create_task()` in lifespan, gracefully cancelled on shutdown.
|
||||
|
||||
---
|
||||
|
||||
### H6. Race condition in URL locking ✅ FIXED
|
||||
**Severity:** HIGH
|
||||
**Fix:** DB re-check (`sha256` dedup) happens inside the URL lock critical section, preventing parallel scans of the same asset.
|
||||
|
||||
---
|
||||
|
||||
### H7. CSV export unbounded ❌ REJECTED
|
||||
**Severity:** HIGH
|
||||
**Decision:** Acceptable for internal tool. Not exposed to public.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM (8)
|
||||
|
||||
### M1. No LLM response schema validation
|
||||
### M1. No LLM response schema validation ✅ FIXED
|
||||
**Severity:** MEDIUM
|
||||
**File:** `core/llm.py:80-82`
|
||||
**File:** `core/llm.py:25-28`
|
||||
|
||||
**Problem:** LLM response parsed as JSON but not validated against schema. Missing `report.verdict` → Jinja2 renders empty string → CSS broken.
|
||||
|
||||
**Fix:** Pydantic model для валидации LLM response.
|
||||
**Fix:** `_validate_report()` applies defaults for missing fields:
|
||||
- `verdict` → `"unknown"`
|
||||
- `summary` → `"No summary provided"`
|
||||
- `analysis` → `"No analysis provided"`
|
||||
- `severity_rating` → `"unknown"`
|
||||
- Also unwraps JSON from markdown code fences (```json ... ```).
|
||||
|
||||
---
|
||||
|
||||
### M2. No CSRF protection
|
||||
### M2. No CSRF protection ⬜ OPEN
|
||||
**Severity:** MEDIUM
|
||||
**File:** `routes/web.py:205-274`
|
||||
|
||||
**Problem:** POST `/api/v1/findings/{id}/analyze` без CSRF token.
|
||||
**Problem:** POST `/api/v1/findings/{id}/analyze` has no CSRF token. While the service is internal, a CSRF attack from the same origin could trigger unwanted LLM analysis.
|
||||
|
||||
**Fix:** Добавить CSRF token для всех POST endpoints.
|
||||
**Suggested fix:** Add a CSRF middleware or token check for state-changing POST endpoints.
|
||||
|
||||
---
|
||||
|
||||
### M3. No security headers
|
||||
### M3. No security headers ✅ FIXED
|
||||
**Severity:** MEDIUM
|
||||
**File:** `main.py`
|
||||
**File:** `main.py:95-113`
|
||||
|
||||
**Problem:** Отсутствие CSP, X-Content-Type-Options, X-Frame-Options, X-XSS-Protection.
|
||||
|
||||
**Fix:** Middleware для security headers.
|
||||
**Fix:** `SecurityHeadersMiddleware` sets on all responses:
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options: DENY`
|
||||
- `X-XSS-Protection: 1; mode=block`
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin`
|
||||
- `Permissions-Policy: geolocation=(), microphone=()`
|
||||
|
||||
---
|
||||
|
||||
### M4. SQLite without WAL mode
|
||||
### M4. SQLite without WAL mode ⬜ OPEN
|
||||
**Severity:** MEDIUM
|
||||
**File:** `db/engine.py:12`
|
||||
|
||||
**Problem:** Concurrent readers block writers → poor performance under load.
|
||||
|
||||
**Fix:** `PRAGMA journal_mode=WAL` in connection setup.
|
||||
|
||||
---
|
||||
|
||||
### M5. Scoped npm packages not supported
|
||||
**Severity:** MEDIUM
|
||||
**File:** `core/nexus.py:54-70`
|
||||
|
||||
**Problem:** `extract_npm_info` returns `None` для `@scope/package` → пропускаются сканирования.
|
||||
|
||||
**Fix:** Обновить extractor для scoped packages.
|
||||
|
||||
---
|
||||
|
||||
### M6. Dashboard stats — potential IndexError
|
||||
**Severity:** MEDIUM
|
||||
**File:** `routes/api_scans.py:145-147`
|
||||
|
||||
**Problem:** `dashboard["latest_flagged"][0]` — IndexError если `latest_flagged` пустой.
|
||||
**Problem:** No `PRAGMA journal_mode=WAL` — concurrent readers block writers, causing degraded performance under load.
|
||||
|
||||
**Suggested fix:** Add WAL mode in connection setup:
|
||||
```python
|
||||
"latest_scan_at": dashboard["latest_flagged"][0].started_at.isoformat()
|
||||
async with _engine.connect() as conn:
|
||||
await conn.execute(text("PRAGMA journal_mode=WAL"))
|
||||
```
|
||||
|
||||
**Fix:** Guard с `if dashboard.get("latest_flagged")`.
|
||||
---
|
||||
|
||||
### M5. Scoped npm packages not supported ✅ FIXED
|
||||
**Severity:** MEDIUM
|
||||
**File:** `core/nexus.py:75-80`
|
||||
|
||||
**Fix:** `extract_npm_info` handles `@scope/name` scoped packages:
|
||||
```python
|
||||
if parts[1].startswith("@"):
|
||||
name = f"{parts[1]}/{parts[2]}"
|
||||
short_name = parts[2]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M7. Error message HTML escaping
|
||||
### M6. Dashboard stats — potential IndexError ✅ FIXED
|
||||
**Severity:** MEDIUM
|
||||
**File:** `routes/api_scans.py:146-148`
|
||||
|
||||
**Fix:** Guard checks `latest` is non-empty and has `started_at`:
|
||||
```python
|
||||
latest[0].started_at.isoformat() if latest and latest[0].started_at else None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M7. Error message HTML escaping ✅ FIXED
|
||||
**Severity:** MEDIUM
|
||||
**File:** `web/templates/scan_detail.html:30`
|
||||
|
||||
**Problem:** `scan.error_message` rendered в template — если содержит HTML/JS, может сломать UI.
|
||||
|
||||
**Fix:** Jinja2 autoescape handles this, но стоит добавить explicit escaping для `code` fields.
|
||||
**Fix:** Jinja2 autoescape handles HTML in `scan.error_message`. No additional escaping required.
|
||||
|
||||
---
|
||||
|
||||
### M8. Unknown ecosystem defaults to pypi ✅ FIXED
|
||||
**Severity:** MEDIUM
|
||||
**Fix:** `_detect_ecosystem()` возвращает `None` → webhook reject с `"unknown_ecosystem"`.
|
||||
**Duplicate:** L6.
|
||||
**File:** `routes/webhooks.py:58-69`
|
||||
|
||||
**Fix:** `_detect_ecosystem()` returns `None` for unknown formats; webhook handler rejects with `"unknown_ecosystem"` error.
|
||||
|
||||
---
|
||||
|
||||
## LOW (6)
|
||||
|
||||
### L1. Dockerfile grep hack ✅ FIXED (`uv pip install . --system`)
|
||||
### L2. Health check without DB ✅ FIXED (`/health/dependencies`)
|
||||
### L1. Dockerfile grep hack ✅ FIXED
|
||||
**Severity:** LOW
|
||||
**Fix:** Replaced with `uv pip install . --system`.
|
||||
|
||||
---
|
||||
|
||||
### L3. No backup strategy for SQLite
|
||||
### L2. Health check without DB ✅ FIXED
|
||||
**Severity:** LOW
|
||||
**File:** `main.py:139-140`
|
||||
|
||||
**Fix:** `/health/dependencies` endpoint checks database connectivity and Nexus API reachability.
|
||||
|
||||
---
|
||||
|
||||
### L3. No backup strategy for SQLite ⬜ OPEN
|
||||
**Severity:** LOW
|
||||
**Risk:** Crash → corrupted database → data loss.
|
||||
|
||||
**Fix:** Регулярные backups через cron или switch to PostgreSQL for production.
|
||||
**Suggested fix:** Add documentation for regular backups via cron or a backup script. Consider PostgreSQL for production deployments.
|
||||
|
||||
---
|
||||
|
||||
### L4. Dead code — `parse_package_path` unused in harvester
|
||||
### L4. Dead code — `parse_package_path` ✅ FIXED
|
||||
**Severity:** LOW
|
||||
**File:** `core/nexus.py:93-99`
|
||||
**File:** `core/nexus.py:113`
|
||||
|
||||
**Problem:** Функция определена но не используется в harvester pipeline.
|
||||
|
||||
**Fix:** Убрать или интегрировать.
|
||||
**Resolution:** Function is actively used in `routes/web.py` and `routes/api_packages.py`. Not dead code.
|
||||
|
||||
---
|
||||
|
||||
### L5. Hardcoded LLM API base URL
|
||||
### L5. Hardcoded LLM API base URL ⬜ OPEN
|
||||
**Severity:** LOW
|
||||
**File:** `constants.py:139`
|
||||
**File:** `constants.py:140`
|
||||
|
||||
**Problem:** Default `https://api.openai.com/v1` — unexpected API calls для пользователей локальных моделей.
|
||||
**Problem:** `LLM_DEFAULT_API_BASE = "https://api.openai.com/v1"` — unexpected API calls for users of local models who forget to set `LLM_API_BASE`.
|
||||
|
||||
**Fix:** Better default или warning at startup.
|
||||
**Suggested fix:** Either log a warning at startup or change default to an empty/required value.
|
||||
|
||||
---
|
||||
|
||||
### L6. Unknown ecosystem defaults to pypi (webhook)
|
||||
### L6. Unknown ecosystem defaults to pypi (webhook) ✅ FIXED
|
||||
**Severity:** LOW
|
||||
**File:** `routes/webhooks.py:62`
|
||||
|
||||
**Problem:** Неизвестный format → fallback к pypi. Maven/NuGet webhooks будут сканироваться как PyPI пакеты.
|
||||
|
||||
**Fix:** Явно reject неизвестные ecosystems.
|
||||
**Fix:** Same as M8. `_detect_ecosystem()` returns `None` for unknown formats; webhook rejects.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1 — P0 (Critical)
|
||||
### Phase 1 — P0 (Critical) — COMPLETED
|
||||
|
||||
| # | Task | Status |
|
||||
|---|------|--------|
|
||||
@@ -220,7 +255,7 @@ response = await client.get(download_url) # no validation
|
||||
| 4 | LLM verdict whitelist + prompt injection | ⚠️ PARTIAL |
|
||||
| 5 | Path traversal fix | ⚠️ LOW RISK |
|
||||
|
||||
### Phase 2 — P1 (High)
|
||||
### Phase 2 — P1 (High) — COMPLETED
|
||||
|
||||
| # | Task | Status |
|
||||
|---|------|--------|
|
||||
@@ -235,30 +270,39 @@ response = await client.get(download_url) # no validation
|
||||
|
||||
| # | Task | Status |
|
||||
|---|------|--------|
|
||||
| 12 | LLM response validation (Pydantic) | ⬜ |
|
||||
| 13 | CSRF protection | ⬜ |
|
||||
| 14 | Security headers middleware | ⬜ |
|
||||
| 15 | SQLite WAL mode | ⬜ |
|
||||
| 16 | Scoped npm support | ⬜ |
|
||||
| 17 | Dashboard None guard | ⬜ |
|
||||
| 18 | `serialize_finding` вместо `**f.data` | ✅ FIXED |
|
||||
| 19 | `_scan_component` try/except | ✅ FIXED |
|
||||
| 20 | Reject unknown ecosystem | ✅ FIXED |
|
||||
| 12 | LLM response validation (Pydantic/defaults) | ✅ FIXED |
|
||||
| 13 | CSRF protection | ⬜ OPEN |
|
||||
| 14 | Security headers middleware | ✅ FIXED |
|
||||
| 15 | SQLite WAL mode | ⬜ OPEN |
|
||||
| 16 | Scoped npm support | ✅ FIXED |
|
||||
| 17 | Dashboard None guard | ✅ FIXED |
|
||||
| 18 | Reject unknown ecosystem | ✅ FIXED |
|
||||
|
||||
### Phase 4 — P3 (Low)
|
||||
|
||||
| # | Task | Status |
|
||||
|---|------|--------|
|
||||
| 21 | Dockerfile deps | ✅ FIXED |
|
||||
| 22 | Health check DB ping | ✅ FIXED |
|
||||
| 23 | Backup strategy docs | ⬜ |
|
||||
| 24 | Reject unknown ecosystems | ✅ FIXED (duplicate) | |
|
||||
| 19 | Dockerfile deps | ✅ FIXED |
|
||||
| 20 | Health check DB ping | ✅ FIXED |
|
||||
| 21 | Backup strategy docs | ⬜ OPEN |
|
||||
| 22 | Hardcoded LLM API base URL | ⬜ OPEN |
|
||||
|
||||
---
|
||||
|
||||
## Remaining Open Items (4)
|
||||
|
||||
| # | Severity | Finding | Recommendation |
|
||||
|---|----------|---------|----------------|
|
||||
| M2 | MEDIUM | No CSRF protection on POST endpoints | Add CSRF middleware or token validation |
|
||||
| M4 | MEDIUM | SQLite without WAL mode | Add `PRAGMA journal_mode=WAL` in engine setup |
|
||||
| L3 | LOW | No backup strategy for SQLite | Document backup procedures or switch to PostgreSQL |
|
||||
| L5 | LOW | Hardcoded LLM default API base URL | Log warning on startup or require explicit configuration |
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
The existing 85 tests do NOT cover:
|
||||
The existing 137 tests (101 unit + 36 e2e) do NOT cover:
|
||||
|
||||
- [ ] SSRF prevention (malicious downloadUrl)
|
||||
- [ ] Webhook signature validation with empty secret
|
||||
@@ -266,20 +310,26 @@ The existing 85 tests do NOT cover:
|
||||
- [ ] Rate limiting on webhook endpoint
|
||||
- [ ] Authentication on API endpoints
|
||||
- [ ] LLM prompt injection
|
||||
- [ ] LLM response schema validation
|
||||
- [ ] CSRF protection
|
||||
- [ ] CSRF protection (M2 — open)
|
||||
- [ ] Security headers presence
|
||||
- [ ] Memory leak in lock dictionaries
|
||||
- [ ] Race condition in URL locking
|
||||
- [ ] Scoped npm package extraction
|
||||
- [ ] Dashboard IndexError on empty data
|
||||
- [ ] SQLite WAL mode behavior
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Immediate:** Implement C1-C5 before any production deployment
|
||||
2. **Short-term:** Implement H1-H7 within first sprint
|
||||
3. **Medium-term:** Implement M1-M8 within first month
|
||||
4. **Long-term:** Implement L1-L6 during routine maintenance
|
||||
5. **Ongoing:** Add security-focused tests for all findings above
|
||||
1. **Immediate:** No critical items remain open. C1, C3 are fixed; C2, C4 are accepted.
|
||||
2. **Short-term:** Address M2 (CSRF) and M4 (WAL mode) — both are straightforward, low-risk fixes.
|
||||
3. **Long-term:** Address L3 (backup strategy) and L5 (LLM default URL) during routine maintenance.
|
||||
4. **Ongoing:** Add security-focused tests for resolved findings to prevent regressions.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Consolidation:** This document supersedes `improvements.md` and `final-plan.md` (deleted). All verified fixes from those plans are incorporated.
|
||||
- **LLM retry:** Implemented with exponential backoff (2s, 4s, 8s, max 3 attempts) in `core/llm.py:126-152`.
|
||||
- **Lock cleanup:** Background tasks in `main.py:59-75` clean up `_url_locks` and `_llm_locks` every 30 minutes.
|
||||
- **Race condition:** SHA256 dedup check runs inside URL lock critical section in harvester.
|
||||
- **Scoped npm:** `extract_npm_info` in `core/nexus.py:75-80` handles `@scope/name` packages.
|
||||
- **Dashboard guard:** `routes/api_scans.py:147` checks `latest and latest[0].started_at` before access.
|
||||
|
||||
Reference in New Issue
Block a user