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,158 @@
|
|
|
1
|
+
"""Input validation, path safety, rate limiting and error hygiene.
|
|
2
|
+
|
|
3
|
+
The dashboard is bound to localhost by default, but it still must not become a
|
|
4
|
+
generic filesystem or network proxy. Everything a request can influence is
|
|
5
|
+
validated here before it reaches the engine:
|
|
6
|
+
|
|
7
|
+
* local repository paths must resolve to a directory inside an allow-listed root
|
|
8
|
+
(no path traversal, no symlink escape);
|
|
9
|
+
* git sources must be well-formed and must never carry embedded credentials;
|
|
10
|
+
* AI calls are size-bounded and rate-limited;
|
|
11
|
+
* errors returned to the client are safe: no stack traces, no credentials, no
|
|
12
|
+
local absolute paths from internals.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from vibe_coding_qa.repo import is_git_url
|
|
21
|
+
from vibe_coding_qa.scanners import redact_secrets
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ValidationError(ValueError):
|
|
25
|
+
"""Raised when a client-supplied value is rejected."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_GITHUB_REF = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
29
|
+
_SSH_GITHUB_URL = re.compile(r"^git@github\.com:[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_repo_path(source: str, allowed_roots: list[Path]) -> Path:
|
|
33
|
+
"""Validate a local repository path against the allow-listed roots.
|
|
34
|
+
|
|
35
|
+
Rejects empty values, non-existent paths, non-directories, and any path that
|
|
36
|
+
(after resolving symlinks) is not inside one of ``allowed_roots``.
|
|
37
|
+
"""
|
|
38
|
+
candidate = (source or "").strip()
|
|
39
|
+
if not candidate:
|
|
40
|
+
raise ValidationError("Repository path is required.")
|
|
41
|
+
if "\0" in candidate:
|
|
42
|
+
raise ValidationError("Repository path contains an invalid character.")
|
|
43
|
+
try:
|
|
44
|
+
resolved = Path(candidate).expanduser().resolve(strict=True)
|
|
45
|
+
except (OSError, RuntimeError):
|
|
46
|
+
raise ValidationError(f"Repository path does not exist: {candidate}")
|
|
47
|
+
if not resolved.is_dir():
|
|
48
|
+
raise ValidationError(f"Repository path is not a directory: {candidate}")
|
|
49
|
+
for root in allowed_roots:
|
|
50
|
+
try:
|
|
51
|
+
root_resolved = Path(root).expanduser().resolve()
|
|
52
|
+
except (OSError, RuntimeError):
|
|
53
|
+
continue
|
|
54
|
+
if resolved == root_resolved or root_resolved in resolved.parents:
|
|
55
|
+
return resolved
|
|
56
|
+
raise ValidationError(
|
|
57
|
+
"Repository path is outside the allowed roots. Set VCQA_ALLOWED_REPO_ROOTS "
|
|
58
|
+
"to permit additional directories."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_repo_file(root: Path, relative_path: str) -> Path:
|
|
63
|
+
"""Resolve a repository-relative file and reject traversal or symlink escape."""
|
|
64
|
+
candidate = Path(relative_path)
|
|
65
|
+
if candidate.is_absolute():
|
|
66
|
+
raise ValidationError("The requested file path must be relative to the repository.")
|
|
67
|
+
try:
|
|
68
|
+
resolved_root = root.resolve(strict=True)
|
|
69
|
+
target = (resolved_root / candidate).resolve(strict=True)
|
|
70
|
+
except (OSError, RuntimeError):
|
|
71
|
+
raise ValidationError("The requested file is not available in the repository.") from None
|
|
72
|
+
if target == resolved_root or not target.is_relative_to(resolved_root) or not target.is_file():
|
|
73
|
+
raise ValidationError("The requested file must be inside the repository.")
|
|
74
|
+
return target
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_git_source(source: str) -> str:
|
|
78
|
+
"""Validate a remote git source and reject embedded credentials.
|
|
79
|
+
|
|
80
|
+
Accepts ``owner/repo`` shorthand, an ``https://`` URL, or an SSH URL. A
|
|
81
|
+
``user:password@`` userinfo segment is rejected outright: credentials must be
|
|
82
|
+
supplied out-of-band (``VCQA_GITHUB_TOKEN``) and never travel through the API.
|
|
83
|
+
"""
|
|
84
|
+
candidate = (source or "").strip()
|
|
85
|
+
if not candidate:
|
|
86
|
+
raise ValidationError("Repository source is required.")
|
|
87
|
+
if "@" in candidate.split("://", 1)[-1].split("/", 1)[0] and candidate.startswith("http"):
|
|
88
|
+
raise ValidationError("Repository URLs must not contain embedded credentials.")
|
|
89
|
+
if _GITHUB_REF.match(candidate):
|
|
90
|
+
return candidate
|
|
91
|
+
if not is_git_url(candidate):
|
|
92
|
+
raise ValidationError("Source must be a local path, owner/repo, or a git URL.")
|
|
93
|
+
if candidate.startswith(("http://", "https://")):
|
|
94
|
+
from urllib.parse import urlsplit
|
|
95
|
+
|
|
96
|
+
parsed = urlsplit(candidate)
|
|
97
|
+
if parsed.username is not None or parsed.password is not None:
|
|
98
|
+
raise ValidationError("Repository URLs must not contain embedded credentials.")
|
|
99
|
+
if parsed.scheme != "https" or parsed.hostname != "github.com":
|
|
100
|
+
raise ValidationError("Remote repositories must use HTTPS on github.com.")
|
|
101
|
+
try:
|
|
102
|
+
port = parsed.port
|
|
103
|
+
except ValueError:
|
|
104
|
+
raise ValidationError("GitHub repository URL has an invalid port.") from None
|
|
105
|
+
if port not in (None, 443):
|
|
106
|
+
raise ValidationError("GitHub repository URLs must use the standard HTTPS port.")
|
|
107
|
+
if parsed.query or parsed.fragment or not re.fullmatch(
|
|
108
|
+
r"/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?/?", parsed.path
|
|
109
|
+
):
|
|
110
|
+
raise ValidationError("GitHub URLs must point directly to an owner/repository.")
|
|
111
|
+
elif candidate.startswith("git@") and not _SSH_GITHUB_URL.fullmatch(candidate):
|
|
112
|
+
raise ValidationError("SSH repository URLs must use the git@github.com:owner/repository format.")
|
|
113
|
+
return candidate
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class RateLimiter:
|
|
117
|
+
"""A simple thread-safe fixed-window rate limiter."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, max_requests: int, window_seconds: float = 60.0):
|
|
120
|
+
self.max_requests = max(1, int(max_requests))
|
|
121
|
+
self.window_seconds = float(window_seconds)
|
|
122
|
+
self._lock = threading.Lock()
|
|
123
|
+
self._hits: dict[str, list[float]] = {}
|
|
124
|
+
|
|
125
|
+
def allow(self, key: str = "default") -> bool:
|
|
126
|
+
now = time.monotonic()
|
|
127
|
+
with self._lock:
|
|
128
|
+
stamps = [t for t in self._hits.get(key, []) if now - t < self.window_seconds]
|
|
129
|
+
if len(stamps) >= self.max_requests:
|
|
130
|
+
self._hits[key] = stamps
|
|
131
|
+
return False
|
|
132
|
+
stamps.append(now)
|
|
133
|
+
self._hits[key] = stamps
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def bound_text(text: str | None, max_chars: int) -> str:
|
|
138
|
+
"""Redact secrets and enforce a hard character budget before egress."""
|
|
139
|
+
value = redact_secrets(text or "")
|
|
140
|
+
if len(value) > max_chars:
|
|
141
|
+
value = value[:max_chars] + "\n...[truncated]"
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def safe_error(exc: BaseException, fallback: str = "The operation failed.") -> str:
|
|
146
|
+
"""Return a client-safe error string.
|
|
147
|
+
|
|
148
|
+
Known, deliberately raised messages (``ValidationError``/``KeyError``/
|
|
149
|
+
``FileNotFoundError`` created by this app) are surfaced; anything else is
|
|
150
|
+
collapsed to a generic message so internal details never leak.
|
|
151
|
+
"""
|
|
152
|
+
if isinstance(exc, KeyError):
|
|
153
|
+
return str(exc.args[0]) if exc.args else "The requested item was not found."
|
|
154
|
+
if isinstance(exc, ValidationError):
|
|
155
|
+
return str(exc)
|
|
156
|
+
if isinstance(exc, OSError) and getattr(exc, "strerror", None):
|
|
157
|
+
return f"I/O error: {exc.strerror}"
|
|
158
|
+
return fallback
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Overview and activity aggregation.
|
|
2
|
+
|
|
3
|
+
Everything here is derived from persisted scans and findings — there are no
|
|
4
|
+
placeholder values. When a repository has never been scanned the API reports
|
|
5
|
+
``has_data = false`` and empty series instead of inventing numbers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ..config import Settings
|
|
9
|
+
from ..persistence.store import Store
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DashboardService:
|
|
13
|
+
def __init__(self, store: Store, settings: Settings):
|
|
14
|
+
self.store = store
|
|
15
|
+
self.settings = settings
|
|
16
|
+
|
|
17
|
+
# -------------------------------------------------------------- overview
|
|
18
|
+
def overview(self, repository_id: int | None = None) -> dict:
|
|
19
|
+
scans = self.store.latest_completed_scans()
|
|
20
|
+
if repository_id is not None:
|
|
21
|
+
scans = [s for s in scans if s["repository_id"] == repository_id]
|
|
22
|
+
|
|
23
|
+
latest = scans[0] if scans else None
|
|
24
|
+
|
|
25
|
+
severity = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
26
|
+
categories: dict[str, int] = {}
|
|
27
|
+
for scan in scans:
|
|
28
|
+
for key, value in self.store.severity_counts(scan_id=scan["id"]).items():
|
|
29
|
+
severity[key] = severity.get(key, 0) + value
|
|
30
|
+
for module, value in self.store.module_counts(scan_id=scan["id"]).items():
|
|
31
|
+
categories[module] = categories.get(module, 0) + value
|
|
32
|
+
|
|
33
|
+
history_scans = self.store.recent_scans(limit=20)
|
|
34
|
+
if repository_id is not None:
|
|
35
|
+
history_scans = [s for s in history_scans if s["repository_id"] == repository_id]
|
|
36
|
+
history_scans = list(reversed(history_scans))
|
|
37
|
+
|
|
38
|
+
score_history = []
|
|
39
|
+
findings_trend = []
|
|
40
|
+
test_history = []
|
|
41
|
+
for scan in history_scans:
|
|
42
|
+
counts = self.store.severity_counts(scan_id=scan["id"])
|
|
43
|
+
at = scan.get("completed_at") or scan.get("created_at")
|
|
44
|
+
label = scan.get("repository_name") or f"repository {scan['repository_id']}"
|
|
45
|
+
score_history.append({
|
|
46
|
+
"scan_id": scan["id"],
|
|
47
|
+
"repository_id": scan["repository_id"],
|
|
48
|
+
"repository": label,
|
|
49
|
+
"at": at,
|
|
50
|
+
"score": scan.get("overall_score"),
|
|
51
|
+
"gate": scan.get("gate"),
|
|
52
|
+
"critical": scan.get("critical_count") or 0,
|
|
53
|
+
"high": scan.get("high_count") or 0,
|
|
54
|
+
})
|
|
55
|
+
findings_trend.append({
|
|
56
|
+
"scan_id": scan["id"],
|
|
57
|
+
"repository_id": scan["repository_id"],
|
|
58
|
+
"at": at,
|
|
59
|
+
"critical": counts.get("critical", 0),
|
|
60
|
+
"high": counts.get("high", 0),
|
|
61
|
+
"medium": counts.get("medium", 0),
|
|
62
|
+
"low": counts.get("low", 0),
|
|
63
|
+
})
|
|
64
|
+
test_history.append({
|
|
65
|
+
"scan_id": scan["id"],
|
|
66
|
+
"repository_id": scan["repository_id"],
|
|
67
|
+
"at": at,
|
|
68
|
+
"executed": scan.get("tests_executed"),
|
|
69
|
+
"passed": scan.get("tests_passed"),
|
|
70
|
+
"failed": scan.get("tests_failed"),
|
|
71
|
+
"skipped": scan.get("tests_skipped"),
|
|
72
|
+
"status": scan.get("test_status"),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
repositories = self.store.list_repositories()
|
|
76
|
+
if repository_id is not None:
|
|
77
|
+
repositories = [r for r in repositories if r["id"] == repository_id]
|
|
78
|
+
repository_health = []
|
|
79
|
+
for repo in repositories:
|
|
80
|
+
latest_scan = repo.get("latest_scan") or {}
|
|
81
|
+
repository_health.append({
|
|
82
|
+
"repository_id": repo["id"],
|
|
83
|
+
"name": repo["name"],
|
|
84
|
+
"source_type": repo["source_type"],
|
|
85
|
+
"main_language": repo.get("main_language"),
|
|
86
|
+
"framework_count": len(repo.get("frameworks") or []),
|
|
87
|
+
"score": latest_scan.get("overall_score"),
|
|
88
|
+
"gate": latest_scan.get("gate"),
|
|
89
|
+
"critical": latest_scan.get("critical_count") or 0,
|
|
90
|
+
"high": latest_scan.get("high_count") or 0,
|
|
91
|
+
"open_findings": self.store.open_finding_count(repo["id"]),
|
|
92
|
+
"last_scan_at": latest_scan.get("completed_at"),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
summary = {
|
|
96
|
+
"overall_score": latest.get("overall_score") if latest else None,
|
|
97
|
+
"critical": (latest.get("critical_count") or 0) if latest else 0,
|
|
98
|
+
"high": (latest.get("high_count") or 0) if latest else 0,
|
|
99
|
+
"total_open": self.store.open_finding_count(repository_id),
|
|
100
|
+
"tests_executed": latest.get("tests_executed") if latest else None,
|
|
101
|
+
"tests_passed": latest.get("tests_passed") if latest else None,
|
|
102
|
+
"gate": latest.get("gate") if latest else None,
|
|
103
|
+
"repository_count": len(repositories),
|
|
104
|
+
"latest_scan_duration_ms": latest.get("duration_ms") if latest else None,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"summary": summary,
|
|
109
|
+
"severity_breakdown": severity,
|
|
110
|
+
"category_breakdown": categories,
|
|
111
|
+
"score_history": score_history,
|
|
112
|
+
"findings_trend": findings_trend,
|
|
113
|
+
"test_history": test_history,
|
|
114
|
+
"repository_health": repository_health,
|
|
115
|
+
"has_data": bool(scans),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# -------------------------------------------------------------- activity
|
|
119
|
+
def activity(self, limit: int = 30) -> dict:
|
|
120
|
+
items: list[dict] = []
|
|
121
|
+
|
|
122
|
+
for scan in self.store.recent_scans(limit=limit):
|
|
123
|
+
items.append({
|
|
124
|
+
"kind": "scan",
|
|
125
|
+
"title": f"Scan {scan['status']} · {scan.get('repository_name', '')}".strip(),
|
|
126
|
+
"detail": _scan_detail(scan),
|
|
127
|
+
"at": scan.get("completed_at") or scan.get("created_at"),
|
|
128
|
+
"repository_id": scan["repository_id"],
|
|
129
|
+
"scan_id": scan["id"],
|
|
130
|
+
"severity": None,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
findings, _ = self.store.list_findings(limit=limit)
|
|
134
|
+
for finding in findings:
|
|
135
|
+
items.append({
|
|
136
|
+
"kind": "finding",
|
|
137
|
+
"title": f"{finding['severity'].upper()} · {finding['rule']}",
|
|
138
|
+
"detail": f"{finding['file']}:{finding['line']} — {finding.get('message') or ''}".strip(),
|
|
139
|
+
"at": finding.get("created_at"),
|
|
140
|
+
"repository_id": finding["repository_id"],
|
|
141
|
+
"scan_id": finding["scan_id"],
|
|
142
|
+
"finding_id": finding["id"],
|
|
143
|
+
"severity": finding["severity"],
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
for proposal in self.store.list_proposals()[:limit]:
|
|
147
|
+
items.append({
|
|
148
|
+
"kind": "remediation",
|
|
149
|
+
"title": f"Remediation {proposal['status']}",
|
|
150
|
+
"detail": f"{proposal.get('file') or ''} — {proposal.get('rationale') or ''}".strip(),
|
|
151
|
+
"at": proposal.get("updated_at") or proposal.get("created_at"),
|
|
152
|
+
"repository_id": proposal.get("repository_id"),
|
|
153
|
+
"scan_id": None,
|
|
154
|
+
"finding_id": proposal.get("finding_id"),
|
|
155
|
+
"severity": None,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
items.sort(key=lambda item: item.get("at") or "", reverse=True)
|
|
159
|
+
return {"items": items[:limit]}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _scan_detail(scan: dict) -> str:
|
|
163
|
+
parts = []
|
|
164
|
+
if scan.get("overall_score") is not None:
|
|
165
|
+
parts.append(f"score {scan['overall_score']}")
|
|
166
|
+
if scan.get("gate"):
|
|
167
|
+
parts.append(f"gate {scan['gate']}")
|
|
168
|
+
parts.append(f"critical {scan.get('critical_count') or 0}")
|
|
169
|
+
parts.append(f"high {scan.get('high_count') or 0}")
|
|
170
|
+
if scan.get("total_findings") is not None:
|
|
171
|
+
parts.append(f"findings {scan['total_findings']}")
|
|
172
|
+
return " · ".join(parts)
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Architecture graph and dependency intelligence.
|
|
2
|
+
|
|
3
|
+
Both are derived from static evidence: the repository profile, the resolved
|
|
4
|
+
lockfiles, and the persisted findings of the latest scan. Nothing here is
|
|
5
|
+
invented — when the data is missing the response says so.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from vibe_coding_qa.profile import analyze_repo
|
|
16
|
+
from vibe_coding_qa.scanners.dependencies import osv_lookup, resolved_dependencies
|
|
17
|
+
|
|
18
|
+
from ..persistence.store import Store
|
|
19
|
+
from .paths import repository_dir
|
|
20
|
+
|
|
21
|
+
_OSV_MAX_LOOKUPS = 40
|
|
22
|
+
|
|
23
|
+
# Profiling a repository (``analyze_repo``) walks the working tree and is by far
|
|
24
|
+
# the most expensive step behind the Architecture and Dependencies pages. The
|
|
25
|
+
# source only changes when the repo is re-scanned or edited, so memoise the
|
|
26
|
+
# result per working-tree root for a short window. Without this, every page load
|
|
27
|
+
# re-profiled the whole repo from scratch, which left those pages hanging on
|
|
28
|
+
# large repositories.
|
|
29
|
+
_PROFILE_TTL_SECONDS = 600.0
|
|
30
|
+
_PROFILE_CACHE_MAX = 32
|
|
31
|
+
_profile_cache: dict[str, tuple[float, object]] = {}
|
|
32
|
+
_profile_lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _profile_cached(root: Path):
|
|
36
|
+
"""``analyze_repo(root)`` with a bounded, time-based cache."""
|
|
37
|
+
key = str(root.resolve())
|
|
38
|
+
now = time.monotonic()
|
|
39
|
+
with _profile_lock:
|
|
40
|
+
cached = _profile_cache.get(key)
|
|
41
|
+
if cached is not None and now - cached[0] < _PROFILE_TTL_SECONDS:
|
|
42
|
+
return cached[1]
|
|
43
|
+
# Compute outside the lock so different repositories can profile concurrently.
|
|
44
|
+
profile = analyze_repo(root)
|
|
45
|
+
with _profile_lock:
|
|
46
|
+
_profile_cache[key] = (now, profile)
|
|
47
|
+
if len(_profile_cache) > _PROFILE_CACHE_MAX:
|
|
48
|
+
oldest = min(_profile_cache, key=lambda item: _profile_cache[item][0])
|
|
49
|
+
_profile_cache.pop(oldest, None)
|
|
50
|
+
return profile
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _profile_dict(store: Store, repository: dict, root: Path | None) -> dict:
|
|
54
|
+
"""The repository profile as a plain dict (AppProfile.to_dict shape).
|
|
55
|
+
|
|
56
|
+
Prefers the profile persisted when the repo was last scanned, so the page
|
|
57
|
+
renders instantly. Otherwise it profiles the working tree once (cached) and
|
|
58
|
+
persists the result, so every later load - including after a restart - is
|
|
59
|
+
instant too. Returns ``{}`` when nothing is available.
|
|
60
|
+
"""
|
|
61
|
+
persisted = store.get_setting(f"profile:{repository['id']}")
|
|
62
|
+
if isinstance(persisted, dict) and persisted:
|
|
63
|
+
return persisted
|
|
64
|
+
if root is None:
|
|
65
|
+
return {}
|
|
66
|
+
try:
|
|
67
|
+
computed = _profile_cached(root).to_dict()
|
|
68
|
+
except Exception: # noqa: BLE001
|
|
69
|
+
return {}
|
|
70
|
+
if computed:
|
|
71
|
+
try:
|
|
72
|
+
store.set_setting(f"profile:{repository['id']}", computed)
|
|
73
|
+
except Exception: # noqa: BLE001
|
|
74
|
+
pass
|
|
75
|
+
return computed
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _source_node_for(file: str, module: str) -> str:
|
|
79
|
+
"""Map a finding's file to a node in the architecture graph."""
|
|
80
|
+
lowered = file.lower()
|
|
81
|
+
if lowered.endswith((".sql", ".prisma", ".graphql")):
|
|
82
|
+
return "database"
|
|
83
|
+
if "/migrations/" in f"/{lowered}" or lowered.startswith("migrations/"):
|
|
84
|
+
return "database"
|
|
85
|
+
if ".github/workflows" in lowered or lowered.endswith((".tf", "vercel.json", "netlify.toml", "dockerfile")):
|
|
86
|
+
return "deployment"
|
|
87
|
+
if lowered.endswith((".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx")) or lowered.startswith("tests/"):
|
|
88
|
+
return "tests"
|
|
89
|
+
if module == "database":
|
|
90
|
+
return "database"
|
|
91
|
+
if module == "infrastructure" or module == "dependencies":
|
|
92
|
+
return "deployment"
|
|
93
|
+
return "app"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_architecture(store: Store, repository: dict) -> dict:
|
|
97
|
+
root = repository_dir(repository)
|
|
98
|
+
notes: list[str] = [
|
|
99
|
+
"The graph is derived from static file inspection and the latest scan; it is not a runtime "
|
|
100
|
+
"call graph.",
|
|
101
|
+
]
|
|
102
|
+
nodes: list[dict] = []
|
|
103
|
+
edges: list[dict] = []
|
|
104
|
+
|
|
105
|
+
def add_node(node_id: str, kind: str, label: str, **extra) -> None:
|
|
106
|
+
if any(node["id"] == node_id for node in nodes):
|
|
107
|
+
return
|
|
108
|
+
nodes.append({"id": node_id, "kind": kind, "label": label,
|
|
109
|
+
"files": extra.pop("files", []), "confidence": "static", "details": extra})
|
|
110
|
+
|
|
111
|
+
add_node("app", "application", repository["name"],
|
|
112
|
+
source=repository["source"], source_type=repository["source_type"])
|
|
113
|
+
|
|
114
|
+
architecture = _profile_dict(store, repository, root)
|
|
115
|
+
if not architecture and root is not None:
|
|
116
|
+
notes.append("The repository could not be profiled; only persisted data is shown.")
|
|
117
|
+
|
|
118
|
+
if root is None and repository["source_type"] != "local":
|
|
119
|
+
notes.append("This remote repository has not been scanned yet, so no working tree is available.")
|
|
120
|
+
|
|
121
|
+
for framework in repository.get("frameworks") or architecture.get("frameworks") or []:
|
|
122
|
+
node_id = f"framework:{framework}"
|
|
123
|
+
add_node(node_id, "framework", framework)
|
|
124
|
+
edges.append({"source": "app", "target": node_id, "kind": "uses"})
|
|
125
|
+
|
|
126
|
+
for database in repository.get("database_tech") or architecture.get("databases") or []:
|
|
127
|
+
node_id = f"database:{database}"
|
|
128
|
+
add_node(node_id, "database", database)
|
|
129
|
+
edges.append({"source": "app", "target": node_id, "kind": "stores-data"})
|
|
130
|
+
|
|
131
|
+
for provider in repository.get("auth_tech") or architecture.get("auth_providers") or []:
|
|
132
|
+
node_id = f"auth:{provider}"
|
|
133
|
+
add_node(node_id, "auth", provider)
|
|
134
|
+
edges.append({"source": "app", "target": node_id, "kind": "authenticates"})
|
|
135
|
+
|
|
136
|
+
for target in repository.get("deployment") or architecture.get("deployment") or []:
|
|
137
|
+
node_id = f"deployment:{target}"
|
|
138
|
+
add_node(node_id, "deployment", target)
|
|
139
|
+
edges.append({"source": "app", "target": node_id, "kind": "deployed-via"})
|
|
140
|
+
|
|
141
|
+
for runner in architecture.get("test_runners") or []:
|
|
142
|
+
node_id = "tests"
|
|
143
|
+
add_node(node_id, "tests", "Automated tests", runners=architecture.get("test_runners") or [])
|
|
144
|
+
edges.append({"source": "app", "target": node_id, "kind": "verified-by"})
|
|
145
|
+
|
|
146
|
+
for endpoint in (architecture.get("endpoints") or [])[:25]:
|
|
147
|
+
node_id = f"endpoint:{endpoint.get('file')}:{endpoint.get('line')}"
|
|
148
|
+
add_node(node_id, "endpoint", str(endpoint.get("path"))[:80], files=[endpoint.get("file")])
|
|
149
|
+
edges.append({"source": "app", "target": node_id, "kind": "exposes"})
|
|
150
|
+
|
|
151
|
+
# Attach the latest scan's findings to graph nodes by file.
|
|
152
|
+
findings_by_node: dict[str, list[dict]] = {}
|
|
153
|
+
latest = store.latest_completed_scan(repository["id"])
|
|
154
|
+
if latest:
|
|
155
|
+
findings = []
|
|
156
|
+
offset = 0
|
|
157
|
+
while True:
|
|
158
|
+
page, total = store.list_findings(scan_id=latest["id"], limit=200, offset=offset)
|
|
159
|
+
findings.extend(page)
|
|
160
|
+
if not page or len(findings) >= total:
|
|
161
|
+
break
|
|
162
|
+
offset += len(page)
|
|
163
|
+
for finding in findings:
|
|
164
|
+
node_id = _source_node_for(finding["file"], finding["module"])
|
|
165
|
+
if node_id != "app" and not any(node["id"] == node_id for node in nodes):
|
|
166
|
+
node_id = "app"
|
|
167
|
+
findings_by_node.setdefault(node_id, []).append({
|
|
168
|
+
"id": finding["id"],
|
|
169
|
+
"rule": finding["rule"],
|
|
170
|
+
"module": finding["module"],
|
|
171
|
+
"severity": finding["severity"],
|
|
172
|
+
"file": finding["file"],
|
|
173
|
+
"line": finding["line"],
|
|
174
|
+
"message": finding.get("message"),
|
|
175
|
+
})
|
|
176
|
+
else:
|
|
177
|
+
notes.append("No completed scan exists yet, so findings are not mapped onto the graph.")
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
"repository_id": repository["id"],
|
|
181
|
+
"nodes": nodes,
|
|
182
|
+
"edges": edges,
|
|
183
|
+
"findings_by_node": findings_by_node,
|
|
184
|
+
"notes": notes,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _ecosystem_for(name: str, package_manager: str | None) -> str:
|
|
189
|
+
if package_manager in ("pip", "poetry", "pipenv"):
|
|
190
|
+
return "PyPI"
|
|
191
|
+
return "npm"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def build_dependencies(store: Store, repository: dict, *, osv: bool) -> dict:
|
|
195
|
+
root = repository_dir(repository)
|
|
196
|
+
notes: list[str] = []
|
|
197
|
+
if root is None:
|
|
198
|
+
return {
|
|
199
|
+
"repository_id": repository["id"],
|
|
200
|
+
"package_manager": repository.get("package_manager"),
|
|
201
|
+
"lockfiles": [],
|
|
202
|
+
"dependencies": [],
|
|
203
|
+
"notes": ["No working tree is available for this repository on this host; scan it first."],
|
|
204
|
+
"osv_checked": False,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
pdict = _profile_dict(store, repository, root)
|
|
208
|
+
|
|
209
|
+
direct: dict[str, str | None] = {}
|
|
210
|
+
for name, version in (pdict.get("dependencies") or {}).items():
|
|
211
|
+
direct[name] = None if version in ("*", "") else version
|
|
212
|
+
|
|
213
|
+
lockfiles = list(pdict.get("lockfiles") or [])
|
|
214
|
+
package_manager = pdict.get("package_manager")
|
|
215
|
+
|
|
216
|
+
resolved: dict[str, dict] = {}
|
|
217
|
+
try:
|
|
218
|
+
for ecosystem, name, version, lockfile in resolved_dependencies(root):
|
|
219
|
+
entry = resolved.setdefault(name, {"ecosystem": ecosystem, "version": version,
|
|
220
|
+
"lockfile": str(Path(lockfile).name)})
|
|
221
|
+
entry["version"] = version
|
|
222
|
+
except Exception: # noqa: BLE001
|
|
223
|
+
notes.append("Lockfiles could not be parsed for resolved versions.")
|
|
224
|
+
|
|
225
|
+
names = sorted(set(direct) | set(resolved))
|
|
226
|
+
if len(names) > 500:
|
|
227
|
+
notes.append(f"Dependency list truncated to the first 500 of {len(names)} entries.")
|
|
228
|
+
names = names[:500]
|
|
229
|
+
|
|
230
|
+
advisories: dict[str, list[dict]] = {}
|
|
231
|
+
osv_checked = False
|
|
232
|
+
if osv and names:
|
|
233
|
+
targets = []
|
|
234
|
+
for name in names:
|
|
235
|
+
entry = resolved.get(name)
|
|
236
|
+
version = (entry or {}).get("version") or direct.get(name)
|
|
237
|
+
if not version:
|
|
238
|
+
continue
|
|
239
|
+
ecosystem = (entry or {}).get("ecosystem") or _ecosystem_for(name, package_manager)
|
|
240
|
+
targets.append((name, ecosystem, version))
|
|
241
|
+
if len(targets) >= _OSV_MAX_LOOKUPS:
|
|
242
|
+
notes.append(f"OSV lookups limited to the first {_OSV_MAX_LOOKUPS} dependencies.")
|
|
243
|
+
break
|
|
244
|
+
if targets:
|
|
245
|
+
osv_checked = True
|
|
246
|
+
failures = 0
|
|
247
|
+
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
248
|
+
futures = {pool.submit(osv_lookup, ecosystem, name, version): name
|
|
249
|
+
for name, ecosystem, version in targets}
|
|
250
|
+
for future in as_completed(futures):
|
|
251
|
+
name = futures[future]
|
|
252
|
+
try:
|
|
253
|
+
advisories[name] = [_advisory(a) for a in future.result()]
|
|
254
|
+
except Exception: # noqa: BLE001
|
|
255
|
+
failures += 1
|
|
256
|
+
advisories[name] = []
|
|
257
|
+
if failures:
|
|
258
|
+
notes.append(f"{failures} OSV lookup(s) could not be completed; results are partial.")
|
|
259
|
+
else:
|
|
260
|
+
notes.append("No pinned or resolved dependency versions were found to check against OSV.")
|
|
261
|
+
|
|
262
|
+
dependencies = []
|
|
263
|
+
for name in names:
|
|
264
|
+
entry = resolved.get(name) or {}
|
|
265
|
+
version = entry.get("version") or direct.get(name)
|
|
266
|
+
found = advisories.get(name) or []
|
|
267
|
+
if found:
|
|
268
|
+
status = "advisory"
|
|
269
|
+
elif not version:
|
|
270
|
+
status = "unpinned"
|
|
271
|
+
else:
|
|
272
|
+
status = "ok"
|
|
273
|
+
dependencies.append({
|
|
274
|
+
"name": name,
|
|
275
|
+
"version": version,
|
|
276
|
+
"direct": name in direct,
|
|
277
|
+
"license": None,
|
|
278
|
+
"advisories": found,
|
|
279
|
+
"status": status,
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
"repository_id": repository["id"],
|
|
284
|
+
"package_manager": package_manager,
|
|
285
|
+
"lockfiles": lockfiles,
|
|
286
|
+
"dependencies": dependencies,
|
|
287
|
+
"notes": notes,
|
|
288
|
+
"osv_checked": osv_checked,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _advisory(raw: dict) -> dict:
|
|
293
|
+
aliases = raw.get("aliases") or []
|
|
294
|
+
severity = ((raw.get("database_specific") or {}).get("severity") or "").lower() or None
|
|
295
|
+
return {
|
|
296
|
+
"id": raw.get("id"),
|
|
297
|
+
"aliases": aliases[:5],
|
|
298
|
+
"severity": severity,
|
|
299
|
+
"summary": (raw.get("summary") or "")[:300],
|
|
300
|
+
"url": f"https://osv.dev/vulnerability/{raw.get('id', '')}",
|
|
301
|
+
"modified": raw.get("modified"),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def compare_scans(store: Store, base_id: int, head_id: int) -> dict:
|
|
306
|
+
"""Diff two scans of the same repository by finding fingerprint."""
|
|
307
|
+
base = store.get_scan(base_id)
|
|
308
|
+
head = store.get_scan(head_id)
|
|
309
|
+
if base is None or head is None:
|
|
310
|
+
raise KeyError("Both scans must exist to compare them.")
|
|
311
|
+
if base["repository_id"] != head["repository_id"]:
|
|
312
|
+
raise KeyError("Only scans of the same repository can be compared.")
|
|
313
|
+
|
|
314
|
+
base_findings, _ = store.list_findings(scan_id=base_id, limit=2000)
|
|
315
|
+
head_findings, _ = store.list_findings(scan_id=head_id, limit=2000)
|
|
316
|
+
base_map = {f["fingerprint"]: f for f in base_findings}
|
|
317
|
+
head_map = {f["fingerprint"]: f for f in head_findings}
|
|
318
|
+
|
|
319
|
+
added = [head_map[k] for k in head_map.keys() - base_map.keys()]
|
|
320
|
+
resolved = [base_map[k] for k in base_map.keys() - head_map.keys()]
|
|
321
|
+
|
|
322
|
+
def summarise(items: list[dict]) -> list[dict]:
|
|
323
|
+
return [
|
|
324
|
+
{"rule": i["rule"], "severity": i["severity"], "file": i["file"], "line": i["line"],
|
|
325
|
+
"message": i.get("message")}
|
|
326
|
+
for i in sorted(items, key=lambda x: (x["severity"], x["file"], x["line"]))
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
"base_scan_id": base_id,
|
|
331
|
+
"head_scan_id": head_id,
|
|
332
|
+
"base_score": base.get("overall_score"),
|
|
333
|
+
"head_score": head.get("overall_score"),
|
|
334
|
+
"score_delta": (
|
|
335
|
+
(head.get("overall_score") or 0) - (base.get("overall_score") or 0)
|
|
336
|
+
if base.get("overall_score") is not None and head.get("overall_score") is not None else None
|
|
337
|
+
),
|
|
338
|
+
"added": summarise(added),
|
|
339
|
+
"resolved": summarise(resolved),
|
|
340
|
+
"unchanged_count": len(base_map.keys() & head_map.keys()),
|
|
341
|
+
}
|