vibe-coding-qa 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dashboard/__init__.py +10 -0
- dashboard/backend/__init__.py +11 -0
- dashboard/backend/__main__.py +6 -0
- dashboard/backend/ai/__init__.py +6 -0
- dashboard/backend/ai/provider.py +102 -0
- dashboard/backend/ai/service.py +757 -0
- dashboard/backend/api/__init__.py +5 -0
- dashboard/backend/api/ai.py +142 -0
- dashboard/backend/api/dashboard.py +34 -0
- dashboard/backend/api/deps.py +51 -0
- dashboard/backend/api/findings.py +102 -0
- dashboard/backend/api/intel.py +41 -0
- dashboard/backend/api/repositories.py +62 -0
- dashboard/backend/api/scans.py +168 -0
- dashboard/backend/api/system.py +69 -0
- dashboard/backend/config.py +180 -0
- dashboard/backend/main.py +183 -0
- dashboard/backend/persistence/__init__.py +5 -0
- dashboard/backend/persistence/database.py +278 -0
- dashboard/backend/persistence/store.py +600 -0
- dashboard/backend/profiles.py +141 -0
- dashboard/backend/schemas.py +452 -0
- dashboard/backend/security.py +158 -0
- dashboard/backend/services/__init__.py +7 -0
- dashboard/backend/services/dashboard.py +172 -0
- dashboard/backend/services/intel.py +341 -0
- dashboard/backend/services/paths.py +38 -0
- dashboard/backend/services/repositories.py +123 -0
- dashboard/backend/services/scans.py +328 -0
- dashboard/backend/services/settings.py +49 -0
- dashboard/frontend/dist/assets/index-COC-CdqD.js +492 -0
- dashboard/frontend/dist/assets/index-hWDLRaLr.css +1 -0
- dashboard/frontend/dist/index.html +29 -0
- dashboard/frontend/dist/phone-mascots.gif +0 -0
- dashboard/launcher.py +48 -0
- vibe_coding_qa/__init__.py +140 -0
- vibe_coding_qa/__main__.py +5 -0
- vibe_coding_qa/ai_review.py +165 -0
- vibe_coding_qa/cli.py +157 -0
- vibe_coding_qa/engine.py +860 -0
- vibe_coding_qa/fixes.py +222 -0
- vibe_coding_qa/gates.py +300 -0
- vibe_coding_qa/profile.py +471 -0
- vibe_coding_qa/repo.py +188 -0
- vibe_coding_qa/reporters/__init__.py +253 -0
- vibe_coding_qa/scanners/__init__.py +195 -0
- vibe_coding_qa/scanners/compliance.py +167 -0
- vibe_coding_qa/scanners/database.py +316 -0
- vibe_coding_qa/scanners/dependencies.py +595 -0
- vibe_coding_qa/scanners/infrastructure.py +125 -0
- vibe_coding_qa/scanners/performance.py +84 -0
- vibe_coding_qa/scanners/quality.py +129 -0
- vibe_coding_qa/scanners/security.py +521 -0
- vibe_coding_qa-0.5.0.dist-info/METADATA +323 -0
- vibe_coding_qa-0.5.0.dist-info/RECORD +58 -0
- vibe_coding_qa-0.5.0.dist-info/WHEEL +5 -0
- vibe_coding_qa-0.5.0.dist-info/entry_points.txt +3 -0
- vibe_coding_qa-0.5.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Runtime configuration for the dashboard backend.
|
|
2
|
+
|
|
3
|
+
All settings are environment-driven so that credentials never live in the
|
|
4
|
+
repository or reach the browser. ``get_settings`` is cached; tests call
|
|
5
|
+
``reset_settings`` after mutating the environment.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
BACKEND_DIR = Path(__file__).resolve().parent
|
|
16
|
+
DASHBOARD_DIR = BACKEND_DIR.parent
|
|
17
|
+
PROJECT_ROOT = DASHBOARD_DIR.parent
|
|
18
|
+
|
|
19
|
+
# When the dashboard is installed as a package it lives in site-packages, so user
|
|
20
|
+
# data (the SQLite database) must live in a writable, per-user location on the
|
|
21
|
+
# user's own machine - never inside the installed package. Override with the
|
|
22
|
+
# ``VCQA_DASHBOARD_DB`` environment variable.
|
|
23
|
+
USER_DATA_DIR = Path.home() / ".vibe-coding-qa"
|
|
24
|
+
DEFAULT_DB_PATH = USER_DATA_DIR / "vcqa.sqlite3"
|
|
25
|
+
|
|
26
|
+
# The dashboard reuses the existing analysis engine in place; it is not a copy.
|
|
27
|
+
# Put the project root on ``sys.path`` so ``import vibe_coding_qa`` works no
|
|
28
|
+
# matter how the app is launched (``uvicorn``, ``python -m``, tests).
|
|
29
|
+
if str(PROJECT_ROOT) not in sys.path:
|
|
30
|
+
sys.path.insert(0, str(PROJECT_ROOT))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _flag(name: str, default: bool = False) -> bool:
|
|
34
|
+
raw = os.environ.get(name)
|
|
35
|
+
if raw is None:
|
|
36
|
+
return default
|
|
37
|
+
return raw.strip().lower() in ("1", "true", "yes", "on")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _int(name: str, default: int) -> int:
|
|
41
|
+
raw = os.environ.get(name)
|
|
42
|
+
if raw is None or not raw.strip():
|
|
43
|
+
return default
|
|
44
|
+
try:
|
|
45
|
+
return int(raw)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return default
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _csv(name: str, default: list[str]) -> list[str]:
|
|
51
|
+
raw = os.environ.get(name)
|
|
52
|
+
if raw is None or not raw.strip():
|
|
53
|
+
return list(default)
|
|
54
|
+
return [part.strip() for part in raw.split(",") if part.strip()]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _roots(name: str, default: list[Path]) -> list[Path]:
|
|
58
|
+
raw = os.environ.get(name)
|
|
59
|
+
if raw is None or not raw.strip():
|
|
60
|
+
return list(default)
|
|
61
|
+
roots: list[Path] = []
|
|
62
|
+
for part in raw.split(os.pathsep):
|
|
63
|
+
part = part.strip()
|
|
64
|
+
if part:
|
|
65
|
+
roots.append(Path(part).expanduser())
|
|
66
|
+
return roots or list(default)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Settings:
|
|
71
|
+
"""Immutable-ish runtime settings resolved from the environment."""
|
|
72
|
+
|
|
73
|
+
host: str = "127.0.0.1"
|
|
74
|
+
port: int = 8756
|
|
75
|
+
db_path: Path = field(default_factory=lambda: DEFAULT_DB_PATH)
|
|
76
|
+
|
|
77
|
+
# Only localhost origins are allowed by default. The frontend dev server and
|
|
78
|
+
# the packaged static build are both served from localhost.
|
|
79
|
+
cors_origins: list[str] = field(
|
|
80
|
+
default_factory=lambda: [
|
|
81
|
+
"http://localhost:5173",
|
|
82
|
+
"http://127.0.0.1:5173",
|
|
83
|
+
"http://localhost:8756",
|
|
84
|
+
"http://127.0.0.1:8756",
|
|
85
|
+
]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Registering a local repository is restricted to these roots to prevent
|
|
89
|
+
# arbitrary filesystem access. Paths are resolved and checked against them.
|
|
90
|
+
allowed_repo_roots: list[Path] = field(
|
|
91
|
+
default_factory=lambda: [Path.home().resolve(), PROJECT_ROOT.resolve()]
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
max_concurrent_scans: int = 2
|
|
95
|
+
finding_page_size: int = 50
|
|
96
|
+
max_page_size: int = 200
|
|
97
|
+
|
|
98
|
+
# AI is entirely optional; the scanner and gate work without it.
|
|
99
|
+
ai_provider: str = ""
|
|
100
|
+
ai_api_key: str = ""
|
|
101
|
+
ai_base_url: str = "https://openrouter.ai/api/v1"
|
|
102
|
+
ai_model: str = "openai/gpt-3.5-turbo"
|
|
103
|
+
ai_timeout: int = 30
|
|
104
|
+
ai_max_output_tokens: int = 3000
|
|
105
|
+
ai_enable_source_context: bool = False
|
|
106
|
+
ai_max_requests_per_minute: int = 20
|
|
107
|
+
ai_max_input_chars: int = 40_000
|
|
108
|
+
|
|
109
|
+
github_token: str = ""
|
|
110
|
+
|
|
111
|
+
# Defaults surfaced to the scan UI (mirrors the CLI defaults).
|
|
112
|
+
default_scan_profile: str = "standard"
|
|
113
|
+
allow_test_execution: bool = False
|
|
114
|
+
default_test_image: str = "python:3.12-slim"
|
|
115
|
+
default_test_timeout: int = 300
|
|
116
|
+
default_max_memory_mb: int = 512
|
|
117
|
+
default_max_cpu_seconds: int = 60
|
|
118
|
+
default_max_snapshot_mb: int = 256
|
|
119
|
+
default_max_write_mb: int = 64
|
|
120
|
+
default_osv_enabled: bool = True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _build_settings() -> Settings:
|
|
124
|
+
return Settings(
|
|
125
|
+
host=os.environ.get("VCQA_DASHBOARD_HOST", "127.0.0.1"),
|
|
126
|
+
port=_int("VCQA_DASHBOARD_PORT", 8756),
|
|
127
|
+
db_path=Path(os.environ.get("VCQA_DASHBOARD_DB", str(DEFAULT_DB_PATH))).expanduser(),
|
|
128
|
+
cors_origins=_csv(
|
|
129
|
+
"VCQA_DASHBOARD_CORS",
|
|
130
|
+
[
|
|
131
|
+
"http://localhost:5173",
|
|
132
|
+
"http://127.0.0.1:5173",
|
|
133
|
+
"http://localhost:8756",
|
|
134
|
+
"http://127.0.0.1:8756",
|
|
135
|
+
],
|
|
136
|
+
),
|
|
137
|
+
allowed_repo_roots=_roots(
|
|
138
|
+
"VCQA_ALLOWED_REPO_ROOTS",
|
|
139
|
+
[Path.home().resolve(), PROJECT_ROOT.resolve()],
|
|
140
|
+
),
|
|
141
|
+
max_concurrent_scans=max(1, _int("VCQA_MAX_CONCURRENT_SCANS", 2)),
|
|
142
|
+
finding_page_size=_int("VCQA_FINDING_PAGE_SIZE", 50),
|
|
143
|
+
max_page_size=_int("VCQA_MAX_PAGE_SIZE", 200),
|
|
144
|
+
ai_provider=os.environ.get("VCQA_AI_PROVIDER", ""),
|
|
145
|
+
ai_api_key=os.environ.get("VCQA_AI_API_KEY", ""),
|
|
146
|
+
ai_base_url=os.environ.get("VCQA_AI_BASE_URL", "https://openrouter.ai/api/v1"),
|
|
147
|
+
ai_model=os.environ.get("VCQA_AI_MODEL", "openai/gpt-3.5-turbo"),
|
|
148
|
+
ai_timeout=_int("VCQA_AI_TIMEOUT", 30),
|
|
149
|
+
ai_max_output_tokens=_int("VCQA_AI_MAX_OUTPUT_TOKENS", 3000),
|
|
150
|
+
ai_enable_source_context=_flag("VCQA_AI_ENABLE_SOURCE_CONTEXT", False),
|
|
151
|
+
ai_max_requests_per_minute=_int("VCQA_AI_MAX_REQUESTS_PER_MINUTE", 20),
|
|
152
|
+
ai_max_input_chars=_int("VCQA_AI_MAX_INPUT_CHARS", 40_000),
|
|
153
|
+
github_token=os.environ.get("VCQA_GITHUB_TOKEN", "") or os.environ.get("GITHUB_TOKEN", ""),
|
|
154
|
+
default_scan_profile=os.environ.get("VCQA_DEFAULT_SCAN_PROFILE", "standard"),
|
|
155
|
+
allow_test_execution=_flag("VCQA_ALLOW_TEST_EXECUTION", False),
|
|
156
|
+
default_test_image=os.environ.get("VCQA_TEST_CONTAINER_IMAGE", "python:3.12-slim"),
|
|
157
|
+
default_test_timeout=_int("VCQA_TEST_TIMEOUT", 300),
|
|
158
|
+
default_max_memory_mb=_int("VCQA_MAX_MEMORY_MB", 512),
|
|
159
|
+
default_max_cpu_seconds=_int("VCQA_MAX_CPU_SECONDS", 60),
|
|
160
|
+
default_max_snapshot_mb=_int("VCQA_MAX_SNAPSHOT_MB", 256),
|
|
161
|
+
default_max_write_mb=_int("VCQA_MAX_WRITE_MB", 64),
|
|
162
|
+
default_osv_enabled=_flag("VCQA_OSV_ENABLED", True),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@lru_cache(maxsize=1)
|
|
167
|
+
def get_settings() -> Settings:
|
|
168
|
+
return _build_settings()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def reset_settings() -> Settings:
|
|
172
|
+
"""Clear the cache (used by tests and on SIGHUP-style reloads)."""
|
|
173
|
+
get_settings.cache_clear()
|
|
174
|
+
return get_settings()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def ai_configured(settings: Settings | None = None) -> bool:
|
|
178
|
+
settings = settings or get_settings()
|
|
179
|
+
provider = settings.ai_provider.strip().lower()
|
|
180
|
+
return bool(settings.ai_api_key) and provider not in ("", "none", "false", "0")
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""FastAPI application factory for the Vibe Coding QA dashboard.
|
|
2
|
+
|
|
3
|
+
Run it with ``python -m dashboard.backend`` (or
|
|
4
|
+
``uvicorn dashboard.backend.main:create_app --factory``).
|
|
5
|
+
|
|
6
|
+
Security posture:
|
|
7
|
+
* bound to localhost by default;
|
|
8
|
+
* CORS restricted to the configured local origins;
|
|
9
|
+
* a repository must live inside an allow-listed root to be registered;
|
|
10
|
+
* the AI provider key stays on the server and is never returned;
|
|
11
|
+
* the release gate is computed by the engine, never by the API or the AI layer.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import binascii
|
|
18
|
+
import hmac
|
|
19
|
+
import ipaddress
|
|
20
|
+
import os
|
|
21
|
+
from contextlib import asynccontextmanager
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from urllib.parse import urlsplit
|
|
24
|
+
|
|
25
|
+
from fastapi import FastAPI, Request
|
|
26
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
27
|
+
from fastapi.responses import JSONResponse
|
|
28
|
+
from fastapi.staticfiles import StaticFiles
|
|
29
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
30
|
+
|
|
31
|
+
from . import __version__
|
|
32
|
+
from .ai.provider import AIError
|
|
33
|
+
from .api import ai, dashboard, findings, intel, repositories, scans, system
|
|
34
|
+
from .api.deps import build_services
|
|
35
|
+
from .config import Settings, get_settings
|
|
36
|
+
from .security import ValidationError, safe_error
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BasicAuthMiddleware:
|
|
40
|
+
"""Optional HTTP Basic Auth gate for a tunnelled/public deployment.
|
|
41
|
+
|
|
42
|
+
It is inert unless ``VCQA_BASIC_AUTH`` is set (``user:password``), so the
|
|
43
|
+
default localhost-only posture is unchanged. Implemented as pure ASGI
|
|
44
|
+
middleware rather than ``BaseHTTPMiddleware`` so streaming responses (the
|
|
45
|
+
SSE scan-progress feed) are never buffered.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, app, *, credential: str, realm: str = "Vibe Coding QA Dashboard"):
|
|
49
|
+
self.app = app
|
|
50
|
+
self.credential = credential.encode("utf-8")
|
|
51
|
+
self.realm = realm
|
|
52
|
+
|
|
53
|
+
async def __call__(self, scope, receive, send):
|
|
54
|
+
if scope["type"] != "http":
|
|
55
|
+
await self.app(scope, receive, send)
|
|
56
|
+
return
|
|
57
|
+
raw = dict(scope.get("headers") or []).get(b"authorization", b"")
|
|
58
|
+
if raw[:6].lower() == b"basic ":
|
|
59
|
+
token = raw[6:].strip()
|
|
60
|
+
try:
|
|
61
|
+
supplied = base64.b64decode(token + b"=" * (-len(token) % 4))
|
|
62
|
+
except (binascii.Error, ValueError):
|
|
63
|
+
supplied = b""
|
|
64
|
+
if hmac.compare_digest(supplied, self.credential):
|
|
65
|
+
await self.app(scope, receive, send)
|
|
66
|
+
return
|
|
67
|
+
body = b"Authentication required."
|
|
68
|
+
await send({
|
|
69
|
+
"type": "http.response.start",
|
|
70
|
+
"status": 401,
|
|
71
|
+
"headers": [
|
|
72
|
+
(b"content-type", b"text/plain; charset=utf-8"),
|
|
73
|
+
(b"www-authenticate", f'Basic realm="{self.realm}", charset="UTF-8"'.encode("ascii")),
|
|
74
|
+
(b"content-length", str(len(body)).encode("ascii")),
|
|
75
|
+
],
|
|
76
|
+
})
|
|
77
|
+
await send({"type": "http.response.body", "body": body})
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _SpaStaticFiles(StaticFiles):
|
|
81
|
+
"""Serve the built dashboard and fall back to index.html for client routes."""
|
|
82
|
+
|
|
83
|
+
async def get_response(self, path: str, scope):
|
|
84
|
+
try:
|
|
85
|
+
return await super().get_response(path, scope)
|
|
86
|
+
except StarletteHTTPException as exc:
|
|
87
|
+
if exc.status_code == 404:
|
|
88
|
+
return await super().get_response("index.html", scope)
|
|
89
|
+
raise
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
93
|
+
resolved = settings or get_settings()
|
|
94
|
+
try:
|
|
95
|
+
host_is_loopback = resolved.host.lower() == "localhost" or ipaddress.ip_address(resolved.host).is_loopback
|
|
96
|
+
except ValueError:
|
|
97
|
+
host_is_loopback = False
|
|
98
|
+
if not host_is_loopback:
|
|
99
|
+
raise ValueError("The dashboard API must bind to a loopback host; remote access requires authentication.")
|
|
100
|
+
for origin in resolved.cors_origins:
|
|
101
|
+
parsed_origin = urlsplit(origin)
|
|
102
|
+
try:
|
|
103
|
+
origin_is_loopback = parsed_origin.hostname == "localhost" or ipaddress.ip_address(
|
|
104
|
+
parsed_origin.hostname or ""
|
|
105
|
+
).is_loopback
|
|
106
|
+
except ValueError:
|
|
107
|
+
origin_is_loopback = False
|
|
108
|
+
if parsed_origin.scheme not in ("http", "https") or not origin_is_loopback:
|
|
109
|
+
raise ValueError("Dashboard CORS origins must be localhost origins.")
|
|
110
|
+
services = build_services(resolved)
|
|
111
|
+
|
|
112
|
+
@asynccontextmanager
|
|
113
|
+
async def lifespan(_: FastAPI):
|
|
114
|
+
# A scan that was running when the previous process died can never
|
|
115
|
+
# finish; mark it failed so the UI never shows a phantom "running" job.
|
|
116
|
+
services.scans.recover_interrupted()
|
|
117
|
+
try:
|
|
118
|
+
yield
|
|
119
|
+
finally:
|
|
120
|
+
services.scans.shutdown()
|
|
121
|
+
services.database.close()
|
|
122
|
+
|
|
123
|
+
app = FastAPI(
|
|
124
|
+
title="Vibe Coding QA Dashboard API",
|
|
125
|
+
version=__version__,
|
|
126
|
+
docs_url="/api/docs",
|
|
127
|
+
redoc_url=None,
|
|
128
|
+
openapi_url="/api/openapi.json",
|
|
129
|
+
lifespan=lifespan,
|
|
130
|
+
)
|
|
131
|
+
app.state.services = services
|
|
132
|
+
|
|
133
|
+
app.add_middleware(
|
|
134
|
+
CORSMiddleware,
|
|
135
|
+
allow_origins=resolved.cors_origins,
|
|
136
|
+
allow_credentials=False,
|
|
137
|
+
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
138
|
+
allow_headers=["Content-Type", "Accept"],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Added last on purpose: Starlette applies the most recently added middleware
|
|
142
|
+
# outermost, so the auth gate protects every route (and CORS preflight).
|
|
143
|
+
basic_auth = os.environ.get("VCQA_BASIC_AUTH", "").strip()
|
|
144
|
+
if basic_auth:
|
|
145
|
+
app.add_middleware(BasicAuthMiddleware, credential=basic_auth)
|
|
146
|
+
|
|
147
|
+
@app.exception_handler(ValidationError)
|
|
148
|
+
async def _validation_error(_: Request, exc: ValidationError) -> JSONResponse:
|
|
149
|
+
return JSONResponse(status_code=400, content={"detail": str(exc), "code": "validation_error"})
|
|
150
|
+
|
|
151
|
+
@app.exception_handler(AIError)
|
|
152
|
+
async def _ai_error(_: Request, exc: AIError) -> JSONResponse:
|
|
153
|
+
return JSONResponse(status_code=400, content={"detail": str(exc), "code": "ai_error"})
|
|
154
|
+
|
|
155
|
+
@app.exception_handler(KeyError)
|
|
156
|
+
async def _not_found(_: Request, exc: KeyError) -> JSONResponse:
|
|
157
|
+
return JSONResponse(
|
|
158
|
+
status_code=404,
|
|
159
|
+
content={"detail": safe_error(exc, "The requested item was not found."), "code": "not_found"},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
for module in (system, dashboard, repositories, scans, findings, ai, intel):
|
|
163
|
+
app.include_router(module.router)
|
|
164
|
+
|
|
165
|
+
# Serve the built single-page dashboard when present, so one process (and one
|
|
166
|
+
# tunnelled port) exposes both the UI and the API. Mounted after the routers so
|
|
167
|
+
# /api still wins; unknown paths fall back to index.html for client routing.
|
|
168
|
+
dist = Path(__file__).resolve().parents[1] / "frontend" / "dist"
|
|
169
|
+
if dist.is_dir():
|
|
170
|
+
app.mount("/", _SpaStaticFiles(directory=dist, html=True), name="ui")
|
|
171
|
+
|
|
172
|
+
return app
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main() -> None:
|
|
176
|
+
import uvicorn
|
|
177
|
+
|
|
178
|
+
settings = get_settings()
|
|
179
|
+
uvicorn.run(create_app(settings), host=settings.host, port=settings.port, log_level="info")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
main()
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""SQLite persistence for the dashboard.
|
|
2
|
+
|
|
3
|
+
A tiny, dependency-free data layer: no ORM, just ``sqlite3`` with explicit
|
|
4
|
+
migrations. Scan history must survive restarts and interrupted scans must be
|
|
5
|
+
identifiable, so everything the engine produces is persisted here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sqlite3
|
|
9
|
+
import threading
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def utcnow() -> str:
|
|
15
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Each entry is (version, [statements]). Migrations are append-only; never edit a
|
|
19
|
+
# shipped migration, add a new one.
|
|
20
|
+
MIGRATIONS: list[tuple[int, list[str]]] = [
|
|
21
|
+
(
|
|
22
|
+
1,
|
|
23
|
+
[
|
|
24
|
+
"""
|
|
25
|
+
CREATE TABLE repositories (
|
|
26
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
27
|
+
name TEXT NOT NULL,
|
|
28
|
+
source TEXT NOT NULL UNIQUE,
|
|
29
|
+
source_type TEXT NOT NULL DEFAULT 'local',
|
|
30
|
+
created_at TEXT NOT NULL,
|
|
31
|
+
updated_at TEXT NOT NULL,
|
|
32
|
+
main_language TEXT,
|
|
33
|
+
frameworks TEXT,
|
|
34
|
+
package_manager TEXT,
|
|
35
|
+
database_tech TEXT,
|
|
36
|
+
auth_tech TEXT,
|
|
37
|
+
deployment TEXT,
|
|
38
|
+
default_branch TEXT,
|
|
39
|
+
last_commit_sha TEXT,
|
|
40
|
+
last_scan_id INTEGER
|
|
41
|
+
)
|
|
42
|
+
""",
|
|
43
|
+
"""
|
|
44
|
+
CREATE TABLE scans (
|
|
45
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
+
repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
|
47
|
+
status TEXT NOT NULL,
|
|
48
|
+
profile TEXT NOT NULL,
|
|
49
|
+
created_at TEXT NOT NULL,
|
|
50
|
+
started_at TEXT,
|
|
51
|
+
completed_at TEXT,
|
|
52
|
+
commit_sha TEXT,
|
|
53
|
+
overall_score REAL,
|
|
54
|
+
gate TEXT,
|
|
55
|
+
critical_count INTEGER DEFAULT 0,
|
|
56
|
+
high_count INTEGER DEFAULT 0,
|
|
57
|
+
total_findings INTEGER DEFAULT 0,
|
|
58
|
+
test_status TEXT,
|
|
59
|
+
tests_executed INTEGER,
|
|
60
|
+
tests_passed INTEGER,
|
|
61
|
+
tests_failed INTEGER,
|
|
62
|
+
tests_skipped INTEGER,
|
|
63
|
+
duration_ms INTEGER,
|
|
64
|
+
scanner_versions TEXT,
|
|
65
|
+
coverage_gaps TEXT,
|
|
66
|
+
ai_review_status TEXT,
|
|
67
|
+
error TEXT,
|
|
68
|
+
options TEXT,
|
|
69
|
+
trigger TEXT
|
|
70
|
+
)
|
|
71
|
+
""",
|
|
72
|
+
"CREATE INDEX idx_scans_repo ON scans(repository_id, id DESC)",
|
|
73
|
+
"""
|
|
74
|
+
CREATE TABLE findings (
|
|
75
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
76
|
+
scan_id INTEGER NOT NULL REFERENCES scans(id) ON DELETE CASCADE,
|
|
77
|
+
repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
|
78
|
+
fingerprint TEXT NOT NULL,
|
|
79
|
+
rule TEXT NOT NULL,
|
|
80
|
+
module TEXT NOT NULL,
|
|
81
|
+
severity TEXT NOT NULL,
|
|
82
|
+
file TEXT NOT NULL,
|
|
83
|
+
line INTEGER NOT NULL,
|
|
84
|
+
column INTEGER DEFAULT 0,
|
|
85
|
+
message TEXT,
|
|
86
|
+
suggestion TEXT,
|
|
87
|
+
confidence TEXT,
|
|
88
|
+
fixable INTEGER DEFAULT 0,
|
|
89
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
90
|
+
first_seen_scan_id INTEGER,
|
|
91
|
+
created_at TEXT NOT NULL
|
|
92
|
+
)
|
|
93
|
+
""",
|
|
94
|
+
"CREATE INDEX idx_findings_scan ON findings(scan_id)",
|
|
95
|
+
"CREATE INDEX idx_findings_repo ON findings(repository_id, status)",
|
|
96
|
+
"CREATE INDEX idx_findings_fingerprint ON findings(fingerprint)",
|
|
97
|
+
"""
|
|
98
|
+
CREATE TABLE scan_events (
|
|
99
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
100
|
+
scan_id INTEGER NOT NULL REFERENCES scans(id) ON DELETE CASCADE,
|
|
101
|
+
ts TEXT NOT NULL,
|
|
102
|
+
stage TEXT NOT NULL,
|
|
103
|
+
status TEXT,
|
|
104
|
+
message TEXT,
|
|
105
|
+
data TEXT
|
|
106
|
+
)
|
|
107
|
+
""",
|
|
108
|
+
"CREATE INDEX idx_events_scan ON scan_events(scan_id, id)",
|
|
109
|
+
"""
|
|
110
|
+
CREATE TABLE ai_reviews (
|
|
111
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
112
|
+
finding_id INTEGER REFERENCES findings(id) ON DELETE CASCADE,
|
|
113
|
+
scan_id INTEGER,
|
|
114
|
+
provider TEXT,
|
|
115
|
+
model TEXT,
|
|
116
|
+
kind TEXT,
|
|
117
|
+
created_at TEXT NOT NULL,
|
|
118
|
+
content TEXT,
|
|
119
|
+
error TEXT
|
|
120
|
+
)
|
|
121
|
+
""",
|
|
122
|
+
"""
|
|
123
|
+
CREATE TABLE ai_conversations (
|
|
124
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
125
|
+
repository_id INTEGER REFERENCES repositories(id) ON DELETE SET NULL,
|
|
126
|
+
title TEXT,
|
|
127
|
+
created_at TEXT NOT NULL,
|
|
128
|
+
updated_at TEXT NOT NULL
|
|
129
|
+
)
|
|
130
|
+
""",
|
|
131
|
+
"""
|
|
132
|
+
CREATE TABLE ai_messages (
|
|
133
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
134
|
+
conversation_id INTEGER NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
|
|
135
|
+
role TEXT NOT NULL,
|
|
136
|
+
content TEXT NOT NULL,
|
|
137
|
+
created_at TEXT NOT NULL,
|
|
138
|
+
metadata TEXT
|
|
139
|
+
)
|
|
140
|
+
""",
|
|
141
|
+
"CREATE INDEX idx_messages_conversation ON ai_messages(conversation_id, id)",
|
|
142
|
+
"""
|
|
143
|
+
CREATE TABLE remediation_proposals (
|
|
144
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
145
|
+
finding_id INTEGER REFERENCES findings(id) ON DELETE CASCADE,
|
|
146
|
+
repository_id INTEGER REFERENCES repositories(id) ON DELETE CASCADE,
|
|
147
|
+
status TEXT NOT NULL DEFAULT 'proposed',
|
|
148
|
+
created_at TEXT NOT NULL,
|
|
149
|
+
updated_at TEXT NOT NULL,
|
|
150
|
+
provider TEXT,
|
|
151
|
+
model TEXT,
|
|
152
|
+
file TEXT,
|
|
153
|
+
original TEXT,
|
|
154
|
+
proposed TEXT,
|
|
155
|
+
diff TEXT,
|
|
156
|
+
rationale TEXT,
|
|
157
|
+
error TEXT
|
|
158
|
+
)
|
|
159
|
+
""",
|
|
160
|
+
"""
|
|
161
|
+
CREATE TABLE remediation_history (
|
|
162
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
163
|
+
proposal_id INTEGER NOT NULL REFERENCES remediation_proposals(id) ON DELETE CASCADE,
|
|
164
|
+
action TEXT NOT NULL,
|
|
165
|
+
ts TEXT NOT NULL,
|
|
166
|
+
actor TEXT,
|
|
167
|
+
detail TEXT
|
|
168
|
+
)
|
|
169
|
+
""",
|
|
170
|
+
"""
|
|
171
|
+
CREATE TABLE app_settings (
|
|
172
|
+
key TEXT PRIMARY KEY,
|
|
173
|
+
value TEXT NOT NULL,
|
|
174
|
+
updated_at TEXT NOT NULL
|
|
175
|
+
)
|
|
176
|
+
""",
|
|
177
|
+
],
|
|
178
|
+
),
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class Database:
|
|
183
|
+
"""Thread-safe SQLite wrapper with WAL and per-thread connections."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, path: Path | str):
|
|
186
|
+
self.path = Path(path)
|
|
187
|
+
if str(self.path) != ":memory:":
|
|
188
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
self._local = threading.local()
|
|
190
|
+
self._write_lock = threading.Lock()
|
|
191
|
+
self._conn_lock = threading.Lock()
|
|
192
|
+
# Every connection is tracked so shutdown can release the file handles
|
|
193
|
+
# (worker threads open their own connection and Windows keeps the file
|
|
194
|
+
# locked until all of them are closed).
|
|
195
|
+
self._open_connections: list[sqlite3.Connection] = []
|
|
196
|
+
self._memory_conn: sqlite3.Connection | None = None
|
|
197
|
+
if str(self.path) == ":memory:":
|
|
198
|
+
# A shared in-memory database must keep one connection alive, otherwise
|
|
199
|
+
# each connect() would see an empty schema.
|
|
200
|
+
self._memory_conn = self._connect_raw()
|
|
201
|
+
self.migrate()
|
|
202
|
+
|
|
203
|
+
def _connect_raw(self) -> sqlite3.Connection:
|
|
204
|
+
conn = sqlite3.connect(str(self.path), check_same_thread=False)
|
|
205
|
+
conn.row_factory = sqlite3.Row
|
|
206
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
207
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
208
|
+
conn.execute("PRAGMA synchronous = NORMAL")
|
|
209
|
+
with self._conn_lock:
|
|
210
|
+
self._open_connections.append(conn)
|
|
211
|
+
return conn
|
|
212
|
+
|
|
213
|
+
def connection(self) -> sqlite3.Connection:
|
|
214
|
+
if self._memory_conn is not None:
|
|
215
|
+
return self._memory_conn
|
|
216
|
+
conn = getattr(self._local, "conn", None)
|
|
217
|
+
if conn is None:
|
|
218
|
+
conn = self._connect_raw()
|
|
219
|
+
self._local.conn = conn
|
|
220
|
+
return conn
|
|
221
|
+
|
|
222
|
+
def close(self) -> None:
|
|
223
|
+
"""Close every connection opened by this database."""
|
|
224
|
+
with self._conn_lock:
|
|
225
|
+
connections = list(self._open_connections)
|
|
226
|
+
self._open_connections.clear()
|
|
227
|
+
for conn in connections:
|
|
228
|
+
try:
|
|
229
|
+
conn.close()
|
|
230
|
+
except sqlite3.Error:
|
|
231
|
+
pass
|
|
232
|
+
self._local = threading.local()
|
|
233
|
+
self._memory_conn = None
|
|
234
|
+
|
|
235
|
+
def applied_versions(self) -> set[int]:
|
|
236
|
+
conn = self.connection()
|
|
237
|
+
conn.execute(
|
|
238
|
+
"CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)"
|
|
239
|
+
)
|
|
240
|
+
rows = conn.execute("SELECT version FROM schema_migrations").fetchall()
|
|
241
|
+
return {row["version"] for row in rows}
|
|
242
|
+
|
|
243
|
+
def migrate(self) -> list[int]:
|
|
244
|
+
applied = self.applied_versions()
|
|
245
|
+
newly: list[int] = []
|
|
246
|
+
conn = self.connection()
|
|
247
|
+
with self._write_lock:
|
|
248
|
+
for version, statements in MIGRATIONS:
|
|
249
|
+
if version in applied:
|
|
250
|
+
continue
|
|
251
|
+
for statement in statements:
|
|
252
|
+
conn.execute(statement)
|
|
253
|
+
conn.execute(
|
|
254
|
+
"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
|
|
255
|
+
(version, utcnow()),
|
|
256
|
+
)
|
|
257
|
+
conn.commit()
|
|
258
|
+
newly.append(version)
|
|
259
|
+
return newly
|
|
260
|
+
|
|
261
|
+
def execute(self, sql: str, params: tuple = ()) -> sqlite3.Cursor:
|
|
262
|
+
conn = self.connection()
|
|
263
|
+
with self._write_lock:
|
|
264
|
+
cursor = conn.execute(sql, params)
|
|
265
|
+
conn.commit()
|
|
266
|
+
return cursor
|
|
267
|
+
|
|
268
|
+
def executemany(self, sql: str, seq_params) -> None:
|
|
269
|
+
conn = self.connection()
|
|
270
|
+
with self._write_lock:
|
|
271
|
+
conn.executemany(sql, seq_params)
|
|
272
|
+
conn.commit()
|
|
273
|
+
|
|
274
|
+
def query(self, sql: str, params: tuple = ()) -> list[sqlite3.Row]:
|
|
275
|
+
return self.connection().execute(sql, params).fetchall()
|
|
276
|
+
|
|
277
|
+
def query_one(self, sql: str, params: tuple = ()) -> sqlite3.Row | None:
|
|
278
|
+
return self.connection().execute(sql, params).fetchone()
|