Skip to content

Commit acdc29f

Browse files
committed
Enhance project dependencies and CI workflows
- Added `asyncpg` as a dependency for PostgreSQL support in the project. - Updated the GitHub Actions workflow to include additional test runs for contract schema and benchmark smoke tests. - Introduced a new worker script for handling background tasks in the SecNode API. - Enhanced the Docker Compose configuration for improved service management and health checks. - Added a benchmark suite for performance evaluation with new fixtures and scoring logic. - Refactored the attack graph engine to support multi-step traversal and scoring of paths.
1 parent bb62c0e commit acdc29f

31 files changed

Lines changed: 2096 additions & 128 deletions

.github/workflows/secnode-pentest.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,13 @@ jobs:
2424
run: uv run ruff check src tests
2525

2626
- name: Run tests with coverage
27-
run: uv run pytest
27+
run: uv run pytest --cov=src/secnodeapi --cov-report=term-missing --cov-fail-under=50
28+
29+
- name: Contract schema tests
30+
run: uv run pytest tests/test_contracts.py -v
31+
32+
- name: Benchmark smoke tests
33+
run: uv run pytest tests/test_benchmarks.py -v
2834

2935
- name: Build package
3036
run: uv build

benchmarks/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Benchmark suite for precision/recall calibration."""

benchmarks/fixtures.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Benchmark fixtures: known-vulnerable API scenarios with expected findings."""
2+
from __future__ import annotations
3+
4+
from dataclasses import dataclass, field
5+
from typing import List
6+
7+
8+
@dataclass
9+
class ExpectedFinding:
10+
vulnerability_class: str
11+
endpoint: str
12+
method: str = "GET"
13+
min_confidence: float = 0.5
14+
15+
16+
@dataclass
17+
class BenchmarkScenario:
18+
name: str
19+
description: str
20+
target: str
21+
expected_findings: List[ExpectedFinding] = field(default_factory=list)
22+
23+
24+
SCENARIOS: List[BenchmarkScenario] = [
25+
BenchmarkScenario(
26+
name="bola_basic",
27+
description="BOLA on user resource endpoint",
28+
target="https://vuln-api.local/api/v1",
29+
expected_findings=[
30+
ExpectedFinding(
31+
vulnerability_class="BOLA",
32+
endpoint="/api/v1/users/1",
33+
min_confidence=0.7,
34+
),
35+
],
36+
),
37+
BenchmarkScenario(
38+
name="sqli_param",
39+
description="SQL injection on query parameter",
40+
target="https://vuln-api.local/api/v1",
41+
expected_findings=[
42+
ExpectedFinding(
43+
vulnerability_class="sql-injection",
44+
endpoint="/api/v1/search?q=",
45+
min_confidence=0.8,
46+
),
47+
],
48+
),
49+
BenchmarkScenario(
50+
name="hidden_admin",
51+
description="Hidden admin endpoint discovery",
52+
target="https://vuln-api.local/api/v1",
53+
expected_findings=[
54+
ExpectedFinding(
55+
vulnerability_class="hidden-endpoint",
56+
endpoint="/api/v1/admin",
57+
min_confidence=0.6,
58+
),
59+
],
60+
),
61+
BenchmarkScenario(
62+
name="mass_assignment",
63+
description="Mass assignment on user update",
64+
target="https://vuln-api.local/api/v1",
65+
expected_findings=[
66+
ExpectedFinding(
67+
vulnerability_class="mass-assignment",
68+
endpoint="/api/v1/users",
69+
method="PUT",
70+
min_confidence=0.6,
71+
),
72+
],
73+
),
74+
BenchmarkScenario(
75+
name="auth_differential",
76+
description="Privilege escalation via auth differential",
77+
target="https://vuln-api.local/api/v1",
78+
expected_findings=[
79+
ExpectedFinding(
80+
vulnerability_class="bfla",
81+
endpoint="/api/v1/admin/settings",
82+
min_confidence=0.7,
83+
),
84+
],
85+
),
86+
]

benchmarks/scorer.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Benchmark scoring: compute precision and recall against expected findings."""
2+
from __future__ import annotations
3+
4+
import json
5+
from dataclasses import dataclass
6+
from pathlib import Path
7+
from typing import List
8+
9+
from .fixtures import BenchmarkScenario, ExpectedFinding
10+
11+
12+
@dataclass
13+
class ScenarioResult:
14+
scenario_name: str
15+
true_positives: int
16+
false_positives: int
17+
false_negatives: int
18+
precision: float
19+
recall: float
20+
f1: float
21+
22+
23+
def score_scenario(
24+
scenario: BenchmarkScenario,
25+
actual_findings: List[dict],
26+
) -> ScenarioResult:
27+
matched_expected: set[int] = set()
28+
matched_actual: set[int] = set()
29+
30+
for ei, expected in enumerate(scenario.expected_findings):
31+
for ai, actual in enumerate(actual_findings):
32+
if ai in matched_actual:
33+
continue
34+
if _matches(expected, actual):
35+
matched_expected.add(ei)
36+
matched_actual.add(ai)
37+
break
38+
39+
tp = len(matched_expected)
40+
fp = len(actual_findings) - len(matched_actual)
41+
fn = len(scenario.expected_findings) - tp
42+
43+
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
44+
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
45+
f1 = (2 * precision * recall / (precision + recall)) if (precision + recall) > 0 else 0.0
46+
47+
return ScenarioResult(
48+
scenario_name=scenario.name,
49+
true_positives=tp,
50+
false_positives=fp,
51+
false_negatives=fn,
52+
precision=round(precision, 4),
53+
recall=round(recall, 4),
54+
f1=round(f1, 4),
55+
)
56+
57+
58+
def _matches(expected: ExpectedFinding, actual: dict) -> bool:
59+
vuln_class = actual.get("vulnerability_class", "")
60+
endpoint = actual.get("endpoint", "")
61+
confidence = actual.get("confidence", 0.0)
62+
63+
if expected.vulnerability_class.lower() not in vuln_class.lower():
64+
return False
65+
if expected.endpoint not in endpoint:
66+
return False
67+
if confidence < expected.min_confidence:
68+
return False
69+
return True
70+
71+
72+
def write_metrics(results: List[ScenarioResult], output_path: str = "benchmarks/metrics.json"):
73+
data = {
74+
"scenarios": [
75+
{
76+
"name": r.scenario_name,
77+
"true_positives": r.true_positives,
78+
"false_positives": r.false_positives,
79+
"false_negatives": r.false_negatives,
80+
"precision": r.precision,
81+
"recall": r.recall,
82+
"f1": r.f1,
83+
}
84+
for r in results
85+
],
86+
"aggregate": {
87+
"avg_precision": round(
88+
sum(r.precision for r in results) / len(results), 4
89+
)
90+
if results
91+
else 0.0,
92+
"avg_recall": round(
93+
sum(r.recall for r in results) / len(results), 4
94+
)
95+
if results
96+
else 0.0,
97+
},
98+
}
99+
Path(output_path).write_text(json.dumps(data, indent=2), encoding="utf-8")
100+
return data

deploy/docker-compose.yml

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
version: "3.9"
22

3+
x-worker-common: &worker-common
4+
build:
5+
context: ..
6+
dockerfile: docker/Dockerfile
7+
environment:
8+
- SECNODE_QUEUE_BACKEND=redis
9+
- SECNODE_STORAGE_BACKEND=memory
10+
- SECNODE_REDIS_URL=redis://redis:6379/0
11+
depends_on:
12+
- redis
13+
314
services:
415
api-gateway:
516
build:
@@ -11,9 +22,17 @@ services:
1122
- SECNODE_LLM=${SECNODE_LLM:-openai/gpt-4o}
1223
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
1324
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
25+
- SECNODE_QUEUE_BACKEND=redis
26+
- SECNODE_STORAGE_BACKEND=memory
27+
- SECNODE_REDIS_URL=redis://redis:6379/0
1428
depends_on:
1529
- redis
1630
- postgres
31+
healthcheck:
32+
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/healthz').raise_for_status()"]
33+
interval: 10s
34+
timeout: 5s
35+
retries: 3
1736

1837
redis:
1938
image: redis:7-alpine
@@ -30,33 +49,17 @@ services:
3049
- "5432:5432"
3150

3251
recon-worker:
33-
build:
34-
context: ..
35-
dockerfile: docker/Dockerfile
36-
command: ["python", "-m", "secnodeapi.cli", "--help"]
37-
depends_on:
38-
- redis
52+
<<: *worker-common
53+
command: ["python", "-m", "secnodeapi.workers.loop", "--kind", "recon"]
3954

4055
discovery-worker:
41-
build:
42-
context: ..
43-
dockerfile: docker/Dockerfile
44-
command: ["python", "-m", "secnodeapi.cli", "--help"]
45-
depends_on:
46-
- redis
56+
<<: *worker-common
57+
command: ["python", "-m", "secnodeapi.workers.loop", "--kind", "discovery"]
4758

4859
fuzzing-worker:
49-
build:
50-
context: ..
51-
dockerfile: docker/Dockerfile
52-
command: ["python", "-m", "secnodeapi.cli", "--help"]
53-
depends_on:
54-
- redis
60+
<<: *worker-common
61+
command: ["python", "-m", "secnodeapi.workers.loop", "--kind", "fuzzing"]
5562

5663
exploit-worker:
57-
build:
58-
context: ..
59-
dockerfile: docker/Dockerfile
60-
command: ["python", "-m", "secnodeapi.cli", "--help"]
61-
depends_on:
62-
- redis
64+
<<: *worker-common
65+
command: ["python", "-m", "secnodeapi.workers.loop", "--kind", "exploit"]

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ dependencies = [
1818
"uvicorn==0.30.6",
1919
"redis==5.0.8",
2020
"networkx==3.3",
21+
"asyncpg==0.30.0",
2122
]
2223

2324
[project.optional-dependencies]
@@ -32,6 +33,7 @@ dev = [
3233
[project.scripts]
3334
secnodeapi = "secnodeapi.cli:entrypoint"
3435
secnodeapi-server = "secnodeapi.api.server:run"
36+
secnodeapi-worker = "secnodeapi.workers.loop:main"
3537

3638
[tool.setuptools]
3739
package-dir = {"" = "src"}

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ fastapi==0.115.0
77
uvicorn==0.30.6
88
redis==5.0.8
99
networkx==3.3
10+
asyncpg==0.30.0
1011
pytest==8.3.5
1112
pytest-asyncio==0.25.3
1213
pytest-cov==6.0.0

src/secnodeapi/api/server.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""FastAPI gateway for microservice runtime control."""
2+
from __future__ import annotations
3+
24
from fastapi import FastAPI, HTTPException
35
from pydantic import BaseModel
46
import uvicorn
57

8+
from ..infra.config import get_redis_url, get_queue_backend
69
from ..services.controller import ControllerService
710

811
app = FastAPI(title="SecNode API Pentest Platform", version="0.2.0")
@@ -18,6 +21,23 @@ async def healthz():
1821
return {"status": "ok"}
1922

2023

24+
@app.get("/readyz")
25+
async def readyz():
26+
checks: dict = {"controller": True}
27+
if get_queue_backend() == "redis":
28+
try:
29+
import redis.asyncio as aioredis
30+
31+
r = aioredis.from_url(get_redis_url())
32+
await r.ping()
33+
await r.aclose()
34+
checks["redis"] = True
35+
except Exception:
36+
checks["redis"] = False
37+
ready = all(checks.values())
38+
return {"ready": ready, "checks": checks}
39+
40+
2141
@app.post("/sessions")
2242
async def create_session(payload: SessionCreateRequest):
2343
session = controller.create_session(payload.target)

0 commit comments

Comments
 (0)