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,142 @@
|
|
|
1
|
+
"""AI routes.
|
|
2
|
+
|
|
3
|
+
These endpoints are advisory only. They never influence the release gate, never
|
|
4
|
+
apply a patch without an explicit, confirmed approval call, and never return the
|
|
5
|
+
provider API key. When no provider is configured they answer with
|
|
6
|
+
``configured = false`` and an actionable message instead of inventing content.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Depends
|
|
14
|
+
|
|
15
|
+
from ..schemas import (
|
|
16
|
+
AIChatRequest,
|
|
17
|
+
AIChatResponse,
|
|
18
|
+
AIConversationOut,
|
|
19
|
+
AIReviewOut,
|
|
20
|
+
AIReviewRequest,
|
|
21
|
+
ApproveRequest,
|
|
22
|
+
ConversationOut,
|
|
23
|
+
GenericMessage,
|
|
24
|
+
RemediationOut,
|
|
25
|
+
RemediationProposeRequest,
|
|
26
|
+
SummaryOut,
|
|
27
|
+
SummaryRequest,
|
|
28
|
+
TestGenerateOut,
|
|
29
|
+
ThreatModelOut,
|
|
30
|
+
ThreatModelRequest,
|
|
31
|
+
)
|
|
32
|
+
from .deps import Services, get_services
|
|
33
|
+
|
|
34
|
+
router = APIRouter(tags=["ai"])
|
|
35
|
+
|
|
36
|
+
ServicesDep = Annotated[Services, Depends(get_services)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------------- assistant
|
|
40
|
+
@router.post("/api/ai/chat", response_model=AIChatResponse)
|
|
41
|
+
def chat(payload: AIChatRequest, services: ServicesDep) -> dict:
|
|
42
|
+
return services.ai.chat(
|
|
43
|
+
message=payload.message,
|
|
44
|
+
repository_id=payload.repository_id,
|
|
45
|
+
conversation_id=payload.conversation_id,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.get("/api/ai/conversations", response_model=list[ConversationOut])
|
|
50
|
+
def list_conversations(services: ServicesDep, repository_id: int | None = None) -> list[dict]:
|
|
51
|
+
conversations = services.store.list_conversations(repository_id)
|
|
52
|
+
return [
|
|
53
|
+
{**item, "message_count": len(services.store.list_messages(item["id"]))}
|
|
54
|
+
for item in conversations
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.get("/api/ai/conversations/{conversation_id}", response_model=AIConversationOut)
|
|
59
|
+
def get_conversation(conversation_id: int, services: ServicesDep) -> dict:
|
|
60
|
+
conversation = services.store.get_conversation(conversation_id)
|
|
61
|
+
if conversation is None:
|
|
62
|
+
raise KeyError(f"Conversation {conversation_id} was not found.")
|
|
63
|
+
return conversation
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@router.delete("/api/ai/conversations/{conversation_id}", response_model=GenericMessage)
|
|
67
|
+
def delete_conversation(conversation_id: int, services: ServicesDep) -> dict:
|
|
68
|
+
if services.store.get_conversation(conversation_id) is None:
|
|
69
|
+
raise KeyError(f"Conversation {conversation_id} was not found.")
|
|
70
|
+
services.store.delete_conversation(conversation_id)
|
|
71
|
+
return {"detail": "Conversation deleted."}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# -------------------------------------------------------------- security analyst
|
|
75
|
+
@router.post("/api/ai/findings/{finding_id}/review", response_model=AIReviewOut)
|
|
76
|
+
def review_finding(finding_id: int, payload: AIReviewRequest, services: ServicesDep) -> dict:
|
|
77
|
+
return services.ai.review_finding(finding_id, payload.kind)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.post("/api/ai/findings/{finding_id}/test", response_model=TestGenerateOut)
|
|
81
|
+
def generate_test(finding_id: int, services: ServicesDep) -> dict:
|
|
82
|
+
"""Draft a regression test. It is returned as text and never written to disk."""
|
|
83
|
+
return services.ai.generate_test(finding_id)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ----------------------------------------------------------------- remediation
|
|
87
|
+
@router.post("/api/ai/remediations", response_model=RemediationOut, status_code=201)
|
|
88
|
+
def propose_remediation(payload: RemediationProposeRequest, services: ServicesDep) -> dict:
|
|
89
|
+
"""Generate a patch proposal. Nothing is applied to the working tree."""
|
|
90
|
+
return services.ai.propose_remediation(payload.finding_id)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@router.get("/api/ai/remediations", response_model=list[RemediationOut])
|
|
94
|
+
def list_remediations(
|
|
95
|
+
services: ServicesDep,
|
|
96
|
+
repository_id: int | None = None,
|
|
97
|
+
finding_id: int | None = None,
|
|
98
|
+
) -> list[dict]:
|
|
99
|
+
return services.store.list_proposals(repository_id=repository_id, finding_id=finding_id)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@router.get("/api/ai/remediations/{proposal_id}", response_model=RemediationOut)
|
|
103
|
+
def get_remediation(proposal_id: int, services: ServicesDep) -> dict:
|
|
104
|
+
proposal = services.store.get_proposal(proposal_id)
|
|
105
|
+
if proposal is None:
|
|
106
|
+
raise KeyError(f"Proposal {proposal_id} was not found.")
|
|
107
|
+
return proposal
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.post("/api/ai/remediations/{proposal_id}/approve", response_model=RemediationOut)
|
|
111
|
+
def approve_remediation(
|
|
112
|
+
proposal_id: int, payload: ApproveRequest, services: ServicesDep
|
|
113
|
+
) -> dict:
|
|
114
|
+
"""Apply a proposal to the working tree.
|
|
115
|
+
|
|
116
|
+
Requires ``confirm = true``; a backup of the original file is written first
|
|
117
|
+
so the change can be rolled back.
|
|
118
|
+
"""
|
|
119
|
+
return services.ai.approve_remediation(
|
|
120
|
+
proposal_id,
|
|
121
|
+
confirm=payload.confirm,
|
|
122
|
+
run_tests=payload.run_tests,
|
|
123
|
+
explicit_permission=payload.explicit_permission,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@router.post("/api/ai/remediations/{proposal_id}/rollback", response_model=RemediationOut)
|
|
128
|
+
def rollback_remediation(proposal_id: int, services: ServicesDep) -> dict:
|
|
129
|
+
return services.ai.rollback_remediation(proposal_id)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------- reports
|
|
133
|
+
@router.post("/api/ai/summary", response_model=SummaryOut)
|
|
134
|
+
def executive_summary(payload: SummaryRequest, services: ServicesDep) -> dict:
|
|
135
|
+
return services.ai.executive_summary(
|
|
136
|
+
repository_id=payload.repository_id, scan_id=payload.scan_id
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@router.post("/api/ai/threat-model", response_model=ThreatModelOut)
|
|
141
|
+
def threat_model(payload: ThreatModelRequest, services: ServicesDep) -> dict:
|
|
142
|
+
return services.ai.threat_model(payload.repository_id)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Dashboard aggregation routes (overview cards, charts and the activity feed)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, Query
|
|
8
|
+
|
|
9
|
+
from ..schemas import ActivityOut, OverviewOut
|
|
10
|
+
from .deps import Services, get_services
|
|
11
|
+
|
|
12
|
+
router = APIRouter(tags=["dashboard"])
|
|
13
|
+
|
|
14
|
+
ServicesDep = Annotated[Services, Depends(get_services)]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/api/dashboard/overview", response_model=OverviewOut)
|
|
18
|
+
def overview(
|
|
19
|
+
services: ServicesDep,
|
|
20
|
+
repository_id: int | None = None,
|
|
21
|
+
) -> dict:
|
|
22
|
+
"""Overview metrics derived from persisted scans only.
|
|
23
|
+
|
|
24
|
+
``has_data`` is false and every series is empty until a scan has actually
|
|
25
|
+
completed — the dashboard never renders invented numbers.
|
|
26
|
+
"""
|
|
27
|
+
if repository_id is not None:
|
|
28
|
+
services.repositories.require(repository_id)
|
|
29
|
+
return services.dashboard.overview(repository_id)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("/api/dashboard/activity", response_model=ActivityOut)
|
|
33
|
+
def activity(services: ServicesDep, limit: int = Query(default=30, ge=1, le=100)) -> dict:
|
|
34
|
+
return services.dashboard.activity(limit)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Shared service container exposed to the API through a FastAPI dependency.
|
|
2
|
+
|
|
3
|
+
The container is created once per application instance and stored on
|
|
4
|
+
``app.state``, so every route sees the same SQLite store, scan runner and AI
|
|
5
|
+
service. This keeps the routes thin and makes the wiring testable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from fastapi import Request
|
|
13
|
+
|
|
14
|
+
from ..ai.service import AIService
|
|
15
|
+
from ..config import Settings, get_settings
|
|
16
|
+
from ..persistence.database import Database
|
|
17
|
+
from ..persistence.store import Store
|
|
18
|
+
from ..services.dashboard import DashboardService
|
|
19
|
+
from ..services.repositories import RepositoryService
|
|
20
|
+
from ..services.scans import ScanRunner
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Services:
|
|
25
|
+
settings: Settings
|
|
26
|
+
database: Database
|
|
27
|
+
store: Store
|
|
28
|
+
repositories: RepositoryService
|
|
29
|
+
scans: ScanRunner
|
|
30
|
+
dashboard: DashboardService
|
|
31
|
+
ai: AIService
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_services(settings: Settings | None = None) -> Services:
|
|
35
|
+
resolved = settings or get_settings()
|
|
36
|
+
database = Database(resolved.db_path)
|
|
37
|
+
store = Store(database)
|
|
38
|
+
repositories = RepositoryService(store, resolved)
|
|
39
|
+
return Services(
|
|
40
|
+
settings=resolved,
|
|
41
|
+
database=database,
|
|
42
|
+
store=store,
|
|
43
|
+
repositories=repositories,
|
|
44
|
+
scans=ScanRunner(store, resolved, repositories),
|
|
45
|
+
dashboard=DashboardService(store, resolved),
|
|
46
|
+
ai=AIService(store, resolved),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_services(request: Request) -> Services:
|
|
51
|
+
return request.app.state.services
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Finding routes: filter, inspect with real source evidence, and triage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, Query
|
|
8
|
+
|
|
9
|
+
from vibe_coding_qa.scanners import redact_secrets
|
|
10
|
+
|
|
11
|
+
from ..schemas import FindingDetailOut, FindingListOut, FindingOut, FindingStatusUpdate
|
|
12
|
+
from ..services.paths import repository_dir
|
|
13
|
+
from ..security import ValidationError, resolve_repo_file
|
|
14
|
+
from .deps import Services, get_services
|
|
15
|
+
|
|
16
|
+
router = APIRouter(tags=["findings"])
|
|
17
|
+
|
|
18
|
+
ServicesDep = Annotated[Services, Depends(get_services)]
|
|
19
|
+
|
|
20
|
+
_EVIDENCE_RADIUS = 12
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/api/findings", response_model=FindingListOut)
|
|
24
|
+
def list_findings(
|
|
25
|
+
services: ServicesDep,
|
|
26
|
+
repository_id: int | None = None,
|
|
27
|
+
scan_id: int | None = None,
|
|
28
|
+
severity: list[str] | None = Query(default=None),
|
|
29
|
+
module: list[str] | None = Query(default=None),
|
|
30
|
+
status: list[str] | None = Query(default=None),
|
|
31
|
+
search: str | None = None,
|
|
32
|
+
limit: int | None = Query(default=None, ge=1, le=200),
|
|
33
|
+
offset: int = Query(default=0, ge=0),
|
|
34
|
+
) -> dict:
|
|
35
|
+
"""List findings.
|
|
36
|
+
|
|
37
|
+
Without an explicit ``scan_id`` the store restricts results to the newest
|
|
38
|
+
completed scan per repository, so the UI shows the current state instead of
|
|
39
|
+
historical duplicates.
|
|
40
|
+
"""
|
|
41
|
+
page_size = limit or services.settings.finding_page_size
|
|
42
|
+
page_size = min(page_size, services.settings.max_page_size)
|
|
43
|
+
items, total = services.store.list_findings(
|
|
44
|
+
repository_id=repository_id,
|
|
45
|
+
scan_id=scan_id,
|
|
46
|
+
severity=severity,
|
|
47
|
+
module=module,
|
|
48
|
+
status=status,
|
|
49
|
+
search=search,
|
|
50
|
+
limit=page_size,
|
|
51
|
+
offset=offset,
|
|
52
|
+
)
|
|
53
|
+
return {"items": items, "total": total, "limit": page_size, "offset": offset}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.get("/api/findings/{finding_id}", response_model=FindingDetailOut)
|
|
57
|
+
def get_finding(finding_id: int, services: ServicesDep) -> dict:
|
|
58
|
+
finding = services.store.get_finding(finding_id)
|
|
59
|
+
if finding is None:
|
|
60
|
+
raise KeyError(f"Finding {finding_id} was not found.")
|
|
61
|
+
|
|
62
|
+
detail = dict(finding)
|
|
63
|
+
detail["evidence"] = _source_evidence(services, finding)
|
|
64
|
+
detail["history"] = services.store.finding_history(finding["fingerprint"], finding["repository_id"])
|
|
65
|
+
detail["related"] = services.store.findings_in_file(
|
|
66
|
+
finding["repository_id"], finding["file"], exclude_id=finding_id
|
|
67
|
+
)
|
|
68
|
+
review = services.store.latest_ai_review(finding_id)
|
|
69
|
+
detail["latest_ai_review"] = review
|
|
70
|
+
return detail
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.patch("/api/findings/{finding_id}", response_model=FindingOut)
|
|
74
|
+
def update_finding_status(
|
|
75
|
+
finding_id: int, payload: FindingStatusUpdate, services: ServicesDep
|
|
76
|
+
) -> dict:
|
|
77
|
+
"""Triage a single finding. The status is local to the dashboard."""
|
|
78
|
+
if services.store.get_finding(finding_id) is None:
|
|
79
|
+
raise KeyError(f"Finding {finding_id} was not found.")
|
|
80
|
+
return services.store.set_finding_status(finding_id, payload.status)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _source_evidence(services: Services, finding: dict, radius: int = _EVIDENCE_RADIUS) -> list[str]:
|
|
84
|
+
"""Numbered, redacted lines around the finding, read from the working tree."""
|
|
85
|
+
repository = services.store.get_repository(finding["repository_id"])
|
|
86
|
+
if repository is None:
|
|
87
|
+
return []
|
|
88
|
+
root = repository_dir(repository)
|
|
89
|
+
if root is None:
|
|
90
|
+
return []
|
|
91
|
+
try:
|
|
92
|
+
target = resolve_repo_file(root, finding["file"])
|
|
93
|
+
except (OSError, RuntimeError, ValueError, ValidationError):
|
|
94
|
+
return []
|
|
95
|
+
try:
|
|
96
|
+
lines = target.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
97
|
+
except OSError:
|
|
98
|
+
return []
|
|
99
|
+
index = max(0, int(finding["line"]) - 1)
|
|
100
|
+
start = max(0, index - radius)
|
|
101
|
+
end = min(len(lines), index + radius + 1)
|
|
102
|
+
return [redact_secrets(f"{number + 1:>5}: {lines[number]}") for number in range(start, end)]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Architecture and dependency intelligence routes.
|
|
2
|
+
|
|
3
|
+
Both read only static evidence (the repository profile, resolved lockfiles and
|
|
4
|
+
the persisted findings of the latest scan). When the evidence is missing the
|
|
5
|
+
response says so rather than filling the gap with guesswork.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, Depends, Query
|
|
13
|
+
|
|
14
|
+
from ..schemas import ArchitectureOut, DependenciesOut
|
|
15
|
+
from ..services.intel import build_architecture, build_dependencies
|
|
16
|
+
from .deps import Services, get_services
|
|
17
|
+
|
|
18
|
+
router = APIRouter(tags=["intelligence"])
|
|
19
|
+
|
|
20
|
+
ServicesDep = Annotated[Services, Depends(get_services)]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/api/architecture/{repository_id}", response_model=ArchitectureOut)
|
|
24
|
+
def architecture(repository_id: int, services: ServicesDep) -> dict:
|
|
25
|
+
repository = services.repositories.require(repository_id)
|
|
26
|
+
return build_architecture(services.store, repository)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.get("/api/dependencies/{repository_id}", response_model=DependenciesOut)
|
|
30
|
+
def dependencies(
|
|
31
|
+
repository_id: int,
|
|
32
|
+
services: ServicesDep,
|
|
33
|
+
osv: bool = Query(default=False, description="Query the OSV advisory database (network access)"),
|
|
34
|
+
) -> dict:
|
|
35
|
+
"""Dependencies with resolved versions.
|
|
36
|
+
|
|
37
|
+
OSV lookups are opt-in: a GET never performs network calls unless the caller
|
|
38
|
+
asks for them explicitly.
|
|
39
|
+
"""
|
|
40
|
+
repository = services.repositories.require(repository_id)
|
|
41
|
+
return build_dependencies(services.store, repository, osv=osv)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Repository routes: register, inspect, refresh and remove repositories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, Query
|
|
8
|
+
|
|
9
|
+
from ..schemas import GenericMessage, RepositoryCreate, RepositoryOut, ScanListOut
|
|
10
|
+
from .deps import Services, get_services
|
|
11
|
+
|
|
12
|
+
router = APIRouter(tags=["repositories"])
|
|
13
|
+
|
|
14
|
+
ServicesDep = Annotated[Services, Depends(get_services)]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/api/repositories", response_model=list[RepositoryOut])
|
|
18
|
+
def list_repositories(services: ServicesDep) -> list[dict]:
|
|
19
|
+
return services.repositories.list()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.post("/api/repositories", response_model=RepositoryOut, status_code=201)
|
|
23
|
+
def create_repository(payload: RepositoryCreate, services: ServicesDep) -> dict:
|
|
24
|
+
"""Register a local directory or a remote git source.
|
|
25
|
+
|
|
26
|
+
Nothing is cloned here: a remote repository is fetched the first time it is
|
|
27
|
+
scanned, so registration never performs a surprise network operation.
|
|
28
|
+
"""
|
|
29
|
+
return services.repositories.register(
|
|
30
|
+
source=payload.source, source_type=payload.source_type, name=payload.name
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.get("/api/repositories/{repository_id}", response_model=RepositoryOut)
|
|
35
|
+
def get_repository(repository_id: int, services: ServicesDep) -> dict:
|
|
36
|
+
return services.repositories.require(repository_id)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.post("/api/repositories/{repository_id}/refresh", response_model=RepositoryOut)
|
|
40
|
+
def refresh_repository(repository_id: int, services: ServicesDep) -> dict:
|
|
41
|
+
return services.repositories.refresh(repository_id)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.delete("/api/repositories/{repository_id}", response_model=GenericMessage)
|
|
45
|
+
def delete_repository(repository_id: int, services: ServicesDep) -> dict:
|
|
46
|
+
services.repositories.require(repository_id)
|
|
47
|
+
services.repositories.delete(repository_id)
|
|
48
|
+
return {"detail": "Repository and its scan history were deleted."}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.get("/api/repositories/{repository_id}/scans", response_model=ScanListOut)
|
|
52
|
+
def repository_scans(
|
|
53
|
+
repository_id: int,
|
|
54
|
+
services: ServicesDep,
|
|
55
|
+
limit: int = Query(default=50, ge=1, le=200),
|
|
56
|
+
offset: int = Query(default=0, ge=0),
|
|
57
|
+
) -> dict:
|
|
58
|
+
services.repositories.require(repository_id)
|
|
59
|
+
items, total = services.store.list_scans(
|
|
60
|
+
repository_id=repository_id, limit=limit, offset=offset
|
|
61
|
+
)
|
|
62
|
+
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Scan routes: create, list, inspect, cancel, diff, and stream live events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
10
|
+
from fastapi.responses import StreamingResponse
|
|
11
|
+
|
|
12
|
+
from ..profiles import get_profile
|
|
13
|
+
from ..schemas import ScanCreate, ScanEventOut, ScanListOut, ScanOut
|
|
14
|
+
from ..security import ValidationError
|
|
15
|
+
from ..services.intel import compare_scans
|
|
16
|
+
from ..services.settings import scan_settings
|
|
17
|
+
from .deps import Services, get_services
|
|
18
|
+
|
|
19
|
+
router = APIRouter(tags=["scans"])
|
|
20
|
+
|
|
21
|
+
ServicesDep = Annotated[Services, Depends(get_services)]
|
|
22
|
+
|
|
23
|
+
_TERMINAL = {"completed", "failed", "cancelled"}
|
|
24
|
+
_POLL_SECONDS = 0.5
|
|
25
|
+
_MAX_STREAM_SECONDS = 60 * 60
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# NOTE: this must be declared before ``/api/scans/{scan_id}`` so that "compare"
|
|
29
|
+
# is not parsed as a scan id.
|
|
30
|
+
@router.get("/api/scans/compare")
|
|
31
|
+
def diff_scans(
|
|
32
|
+
services: ServicesDep,
|
|
33
|
+
base: int = Query(description="Baseline scan id"),
|
|
34
|
+
head: int = Query(description="Scan id to compare against the baseline"),
|
|
35
|
+
) -> dict:
|
|
36
|
+
if base == head:
|
|
37
|
+
raise ValidationError("Pick two different scans to compare.")
|
|
38
|
+
return compare_scans(services.store, base, head)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.post("/api/scans", response_model=ScanOut, status_code=202)
|
|
42
|
+
def create_scan(payload: ScanCreate, services: ServicesDep) -> dict:
|
|
43
|
+
"""Queue a scan. It runs in the bounded background pool, not in this request."""
|
|
44
|
+
repository = services.repositories.require(payload.repository_id)
|
|
45
|
+
defaults = scan_settings(services.store, services.settings)
|
|
46
|
+
|
|
47
|
+
profile_key = (payload.profile or defaults["default_scan_profile"]).strip()
|
|
48
|
+
try:
|
|
49
|
+
get_profile(profile_key)
|
|
50
|
+
except KeyError as exc:
|
|
51
|
+
raise ValidationError(f"Unknown scan profile: {profile_key}") from exc
|
|
52
|
+
|
|
53
|
+
options = payload.options.model_dump(exclude_none=True)
|
|
54
|
+
try:
|
|
55
|
+
return services.scans.create(repository=repository, profile_key=profile_key, options=options)
|
|
56
|
+
except RuntimeError as exc: # the queue is bounded on purpose
|
|
57
|
+
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.get("/api/scans", response_model=ScanListOut)
|
|
61
|
+
def list_scans(
|
|
62
|
+
services: ServicesDep,
|
|
63
|
+
repository_id: int | None = None,
|
|
64
|
+
status: str | None = Query(default=None, pattern="^(queued|running|completed|failed|cancelled)$"),
|
|
65
|
+
limit: int = Query(default=50, ge=1, le=200),
|
|
66
|
+
offset: int = Query(default=0, ge=0),
|
|
67
|
+
) -> dict:
|
|
68
|
+
items, total = services.store.list_scans(
|
|
69
|
+
repository_id=repository_id, status=status, limit=limit, offset=offset
|
|
70
|
+
)
|
|
71
|
+
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@router.get("/api/scans/{scan_id}", response_model=ScanOut)
|
|
75
|
+
def get_scan(scan_id: int, services: ServicesDep) -> dict:
|
|
76
|
+
scan = services.store.get_scan(scan_id)
|
|
77
|
+
if scan is None:
|
|
78
|
+
raise KeyError(f"Scan {scan_id} was not found.")
|
|
79
|
+
return scan
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.post("/api/scans/{scan_id}/cancel", response_model=ScanOut)
|
|
83
|
+
def cancel_scan(scan_id: int, services: ServicesDep) -> dict:
|
|
84
|
+
"""Cancel a scan that has not started yet.
|
|
85
|
+
|
|
86
|
+
A scan already executing inside the sandbox is left to finish: interrupting
|
|
87
|
+
it mid-run would risk leaving the engine in an inconsistent state.
|
|
88
|
+
"""
|
|
89
|
+
scan = services.scans.cancel(scan_id)
|
|
90
|
+
if scan is None:
|
|
91
|
+
raise KeyError(f"Scan {scan_id} was not found.")
|
|
92
|
+
return scan
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.get("/api/scans/{scan_id}/events.json", response_model=list[ScanEventOut])
|
|
96
|
+
def scan_events_snapshot(
|
|
97
|
+
scan_id: int,
|
|
98
|
+
services: ServicesDep,
|
|
99
|
+
after: int = Query(default=0, ge=0),
|
|
100
|
+
) -> list[dict]:
|
|
101
|
+
"""Every persisted event for a scan (used for polling and by the tests)."""
|
|
102
|
+
if services.store.get_scan(scan_id) is None:
|
|
103
|
+
raise KeyError(f"Scan {scan_id} was not found.")
|
|
104
|
+
return services.store.list_events(scan_id, after_id=after)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.get("/api/scans/{scan_id}/events")
|
|
108
|
+
async def scan_events_stream(scan_id: int, request: Request, services: ServicesDep, after: int = 0):
|
|
109
|
+
"""Server-Sent Events for a scan.
|
|
110
|
+
|
|
111
|
+
Only events the engine actually emitted are sent: the dashboard never
|
|
112
|
+
fabricates progress. The stream ends once the scan reaches a terminal state
|
|
113
|
+
and every event has been delivered.
|
|
114
|
+
"""
|
|
115
|
+
if services.store.get_scan(scan_id) is None:
|
|
116
|
+
raise KeyError(f"Scan {scan_id} was not found.")
|
|
117
|
+
|
|
118
|
+
return StreamingResponse(
|
|
119
|
+
_event_stream(request, services, scan_id, after),
|
|
120
|
+
media_type="text/event-stream",
|
|
121
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _event_stream(request: Request, services: Services, scan_id: int, after: int):
|
|
126
|
+
last_id = max(0, after)
|
|
127
|
+
elapsed = 0.0
|
|
128
|
+
yield _sse("open", {"scan_id": scan_id})
|
|
129
|
+
while True:
|
|
130
|
+
if await request.is_disconnected():
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
events = services.store.list_events(scan_id, after_id=last_id)
|
|
134
|
+
if events:
|
|
135
|
+
for event in events:
|
|
136
|
+
last_id = event["id"]
|
|
137
|
+
yield _sse("scan-event", {
|
|
138
|
+
"id": event["id"],
|
|
139
|
+
"scan_id": event["scan_id"],
|
|
140
|
+
"ts": event["ts"],
|
|
141
|
+
"stage": event["stage"],
|
|
142
|
+
"status": event.get("status"),
|
|
143
|
+
"message": event.get("message"),
|
|
144
|
+
"data": event.get("data") or {},
|
|
145
|
+
})
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
scan = services.store.get_scan(scan_id)
|
|
149
|
+
status = (scan or {}).get("status")
|
|
150
|
+
if status in _TERMINAL:
|
|
151
|
+
yield _sse("end", {"scan_id": scan_id, "status": status})
|
|
152
|
+
return
|
|
153
|
+
if status is None:
|
|
154
|
+
yield _sse("end", {"scan_id": scan_id, "status": "missing"})
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
elapsed += _POLL_SECONDS
|
|
158
|
+
if elapsed >= _MAX_STREAM_SECONDS:
|
|
159
|
+
yield _sse("end", {"scan_id": scan_id, "status": status, "reason": "stream-timeout"})
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
# Comment frame keeps proxies and browsers from closing the connection.
|
|
163
|
+
yield ": keep-alive\n\n"
|
|
164
|
+
await asyncio.sleep(_POLL_SECONDS)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _sse(event: str, payload: dict) -> str:
|
|
168
|
+
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""System routes: health, scan profiles, and operator settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends
|
|
8
|
+
|
|
9
|
+
from .. import __version__
|
|
10
|
+
from ..profiles import list_profiles
|
|
11
|
+
from ..schemas import (
|
|
12
|
+
AIConnectionTestOut,
|
|
13
|
+
AISettingsIn,
|
|
14
|
+
AISettingsOut,
|
|
15
|
+
HealthOut,
|
|
16
|
+
ScanSettingsIn,
|
|
17
|
+
ScanSettingsOut,
|
|
18
|
+
)
|
|
19
|
+
from ..services.settings import scan_settings, update_scan_settings
|
|
20
|
+
from .deps import Services, get_services
|
|
21
|
+
|
|
22
|
+
router = APIRouter(tags=["system"])
|
|
23
|
+
|
|
24
|
+
ServicesDep = Annotated[Services, Depends(get_services)]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/api/health", response_model=HealthOut)
|
|
28
|
+
def health(services: ServicesDep) -> dict:
|
|
29
|
+
"""Liveness plus the facts the UI needs to render its header."""
|
|
30
|
+
return {
|
|
31
|
+
"status": "ok",
|
|
32
|
+
"version": __version__,
|
|
33
|
+
"database": services.settings.db_path.name,
|
|
34
|
+
"ai_configured": services.ai.config().enabled,
|
|
35
|
+
"repositories": services.store.count_repositories(),
|
|
36
|
+
"scans": services.store.count_scans(),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/api/profiles")
|
|
41
|
+
def profiles() -> list[dict]:
|
|
42
|
+
"""The available scan profiles and exactly what each one changes."""
|
|
43
|
+
return list_profiles()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.get("/api/settings/scan", response_model=ScanSettingsOut)
|
|
47
|
+
def read_scan_settings(services: ServicesDep) -> dict:
|
|
48
|
+
return scan_settings(services.store, services.settings)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.put("/api/settings/scan", response_model=ScanSettingsOut)
|
|
52
|
+
def write_scan_settings(payload: ScanSettingsIn, services: ServicesDep) -> dict:
|
|
53
|
+
return update_scan_settings(services.store, payload)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.get("/api/settings/ai", response_model=AISettingsOut)
|
|
57
|
+
def read_ai_settings(services: ServicesDep) -> dict:
|
|
58
|
+
"""The AI configuration. The API key itself is never returned."""
|
|
59
|
+
return services.ai.settings_view()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.put("/api/settings/ai", response_model=AISettingsOut)
|
|
63
|
+
def write_ai_settings(payload: AISettingsIn, services: ServicesDep) -> dict:
|
|
64
|
+
return services.ai.update_settings(payload)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.post("/api/settings/ai/test", response_model=AIConnectionTestOut)
|
|
68
|
+
def test_ai_connection(services: ServicesDep) -> dict:
|
|
69
|
+
return services.ai.test_connection()
|