Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ Open `http://localhost:5173` in your browser.
| Method | Route | Description |
|---|---|---|
| `GET` | `/health` | Health check |
| `GET` | `/jobs` | List scan jobs (paginated) |
| `POST` | `/scan` | Upload ZIP and scan |
| `POST` | `/scan-url` | Import GitHub repo URL and scan |
| `POST` | `/fix` | Generate proposed fixes |
Expand Down
14 changes: 14 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,17 @@ SQLite database (`patchpilot.db`) is auto-created in `backend/` on first server
| `scanner` | TEXT | `semgrep`, `osv`, or `gitleaks` |
| `message` | TEXT | Description of the finding |
| `created_at` | TEXT | Timestamp of finding creation |

## API Reference

| Method | Route | Description |
|---|---|---|
| `GET` | `/health` | Health check |
| `GET` | `/jobs` | List scan jobs (paginated) |
| `GET` | `/jobs/{job_id}/findings` | Get findings for a specific job |
| `DELETE` | `/jobs/{job_id}` | Delete a job workspace |
| `POST` | `/scan` | Upload ZIP and scan |
| `POST` | `/scan-url` | Import GitHub repo URL and scan |
| `POST` | `/fix` | Generate proposed fixes |
| `POST` | `/verify` | Verify fixes |
| `POST` | `/evidence-pack` | Build and download evidence ZIP |
Binary file modified backend/app/__pycache__/main.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/__pycache__/main.cpython-313.pyc
Binary file not shown.
Binary file modified backend/app/__pycache__/models.cpython-312.pyc
Binary file not shown.
39 changes: 39 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import random
import re
import shutil
import sys
import tempfile
import threading
import uuid
Expand Down Expand Up @@ -82,6 +83,11 @@
except ImportError: # Python 3.10
import tomli as tomllib # type: ignore[no-redef]

# Prepend the virtual environment's executable directory to PATH to auto-detect scanners
venv_bin = os.path.dirname(sys.executable)
if venv_bin and venv_bin not in os.environ.get("PATH", ""):
os.environ["PATH"] = venv_bin + os.pathsep + os.environ.get("PATH", "")

_MAX_UPLOAD_MB_RAW = os.environ.get("MAX_UPLOAD_MB")
RANKER = load_ranker()

Expand Down Expand Up @@ -1215,6 +1221,38 @@ async def download_audit_pdf(job_id: str):
)


@app.get(
"/jobs",
dependencies=[Depends(verify_api_key)],
)
async def list_jobs(
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
db = await get_db()
try:
db.row_factory = aiosqlite.Row
cur = await db.execute("SELECT COUNT(*) as total FROM jobs")
total_row = await cur.fetchone()
total = total_row["total"] if total_row else 0

cur = await db.execute(
"""
SELECT job_id, project_name, scan_method, status,
finding_count, raw_finding_count, created_at
FROM jobs
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(limit, offset),
)
rows = await cur.fetchall()
finally:
await db.close()

return {"total": total, "jobs": [dict(r) for r in rows]}


@app.get(
"/jobs/{job_id}/findings",
dependencies=[Depends(verify_api_key)],
Expand Down Expand Up @@ -1242,6 +1280,7 @@ async def get_findings(job_id: str):

return {
"job_id": job_id,
"project_name": job_row.get("project_name") if job_row else None,
"raw_finding_count": raw_finding_count,
"finding_count": finding_count,
"findings": findings,
Expand Down
9 changes: 8 additions & 1 deletion backend/app/ml/deduplicator.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import logging
from collections import defaultdict

from sklearn.cluster import DBSCAN

logger = logging.getLogger(__name__)


def get_model():
return None
Expand Down Expand Up @@ -41,7 +44,11 @@ def deduplicate(
if not SENTENCE_TRANSFORMERS_AVAILABLE:
return findings

embeddings = embed_findings(findings)
try:
embeddings = embed_findings(findings)
except Exception as exc:
logger.warning("Finding deduplication skipped: %s", exc)
return findings

clustering = DBSCAN(
eps=epsilon,
Expand Down
34 changes: 24 additions & 10 deletions backend/app/ml/embedder.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
import logging

import numpy as np

logger = logging.getLogger(__name__)

try:
from sentence_transformers import SentenceTransformer

MODEL = SentenceTransformer("all-MiniLM-L6-v2")
except ImportError:
MODEL = None
SentenceTransformer = None

MODEL = None


def _get_model():
global MODEL

if SentenceTransformer is None:
raise RuntimeError(
"sentence-transformers is not installed. "
"Install it using: pip install sentence-transformers"
)

if MODEL is None:
logger.info("Loading sentence-transformers model for deduplication...")
MODEL = SentenceTransformer("all-MiniLM-L6-v2")

return MODEL


def embed_findings(findings: list[dict]) -> np.ndarray:
Expand All @@ -18,15 +38,9 @@ def embed_findings(findings: list[dict]) -> np.ndarray:
Returns:
np.ndarray of shape (n, 384)
"""
if MODEL is None:
raise RuntimeError(
"sentence-transformers is not installed. "
"Install it using: pip install sentence-transformers"
)

texts = [
f"{getattr(finding, 'title', '')} {getattr(finding, 'description', '')}"
for finding in findings
]

return MODEL.encode(texts, convert_to_numpy=True)
return _get_model().encode(texts, convert_to_numpy=True)
22 changes: 19 additions & 3 deletions backend/app/ml/fp_predictor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,17 @@
import os

import joblib
import torch
from transformers import AutoModel, AutoTokenizer

try:
import torch
from transformers import AutoModel, AutoTokenizer

TORCH_AND_TRANSFORMERS_AVAILABLE = True
except ImportError:
torch = None
AutoModel = None
AutoTokenizer = None
TORCH_AND_TRANSFORMERS_AVAILABLE = False

logger = logging.getLogger(__name__)

Expand All @@ -25,6 +34,13 @@ def _load_models(self):
if self._models_loaded:
return

if not TORCH_AND_TRANSFORMERS_AVAILABLE:
logger.warning(
"torch or transformers is not installed. ML inference will be skipped."
)
self._models_loaded = True
return

if not os.path.exists(CLASSIFIER_PATH):
logger.warning(
f"FP Classifier not found at {CLASSIFIER_PATH}. ML inference will be skipped."
Expand Down Expand Up @@ -66,7 +82,7 @@ def adjust_scores(self, findings: list[dict]) -> list[float]:
for f in findings
]

if not self.is_ready or not findings:
if not TORCH_AND_TRANSFORMERS_AVAILABLE or not self.is_ready or not findings:
return scores

try:
Expand Down
Binary file modified backend/app/remediation/__pycache__/engine.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/remediation/__pycache__/templates.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/reports/__pycache__/evidence_pack.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/sandbox/__pycache__/verify.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/scanners/__pycache__/gitleaks.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/scanners/__pycache__/osv.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/scanners/__pycache__/semgrep.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/utils/__pycache__/exec.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/utils/__pycache__/fs.cpython-312.pyc
Binary file not shown.
15 changes: 15 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import sys
from unittest.mock import MagicMock


class DummyTensor:
pass


# Mock out heavy ML libraries to allow running tests without them
mock_torch = MagicMock()
mock_torch.Tensor = DummyTensor
sys.modules["torch"] = mock_torch

sys.modules["transformers"] = MagicMock()
sys.modules["sentence_transformers"] = MagicMock()
57 changes: 57 additions & 0 deletions backend/tests/test_job_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,60 @@ def test_stream_job_completed(self):

if JOB_ID in ACTIVE_SCANS:
del ACTIVE_SCANS[JOB_ID]


class TestListJobs:
def test_list_jobs_happy_path(self):
mock_jobs = [
{
"job_id": "job1",
"project_name": "repo1",
"scan_method": "zip",
"status": "completed",
"finding_count": 5,
"raw_finding_count": 10,
"created_at": "2026-07-15 20:00:00",
},
{
"job_id": "job2",
"project_name": "repo2",
"scan_method": "url",
"status": "scanning",
"finding_count": None,
"raw_finding_count": None,
"created_at": "2026-07-15 19:00:00",
},
]

count_cur = AsyncMock()
count_cur.fetchone = AsyncMock(return_value={"total": 2})

jobs_cur = AsyncMock()
jobs_cur.fetchall = AsyncMock(return_value=mock_jobs)

db = AsyncMock()
db.execute = AsyncMock(side_effect=[count_cur, jobs_cur])
db.__aenter__ = AsyncMock(return_value=db)
db.__aexit__ = AsyncMock(return_value=False)

with patch("app.main.get_db", AsyncMock(return_value=db)):
res = client.get("/jobs?limit=20&offset=0")

assert res.status_code == 200
data = res.json()
assert data["total"] == 2
assert len(data["jobs"]) == 2
assert data["jobs"][0]["job_id"] == "job1"
assert data["jobs"][1]["job_id"] == "job2"

def test_list_jobs_invalid_limit_too_low(self):
res = client.get("/jobs?limit=0")
assert res.status_code == 422

def test_list_jobs_invalid_limit_too_high(self):
res = client.get("/jobs?limit=101")
assert res.status_code == 422

def test_list_jobs_invalid_offset(self):
res = client.get("/jobs?offset=-1")
assert res.status_code == 422
Loading
Loading