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
vibe_coding_qa/fixes.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Automated fix engine: apply safe, reviewable fixes."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import Finding, Severity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FixClassification:
|
|
14
|
+
SAFE = "safe-automatic"
|
|
15
|
+
APPROVAL = "approval-required"
|
|
16
|
+
MANUAL = "manual-remediation"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FixEngine:
|
|
20
|
+
def __init__(self, repo: Path, dry_run: bool = True):
|
|
21
|
+
self.repo = repo
|
|
22
|
+
self.dry_run = dry_run
|
|
23
|
+
self.applied: list[dict] = []
|
|
24
|
+
self.skipped: list[dict] = []
|
|
25
|
+
self.backups: list[Path] = []
|
|
26
|
+
self.backup_dir = repo / ".vcqa-backups" / datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
|
27
|
+
|
|
28
|
+
def apply(self, findings: list[Finding]) -> dict:
|
|
29
|
+
for finding in findings:
|
|
30
|
+
if not finding.fixable:
|
|
31
|
+
self.skipped.append({"finding": finding.to_dict(), "reason": "not marked fixable", "classification": FixClassification.MANUAL})
|
|
32
|
+
continue
|
|
33
|
+
classification = self.classify(finding)
|
|
34
|
+
if classification == FixClassification.MANUAL:
|
|
35
|
+
self.skipped.append({"finding": finding.to_dict(), "reason": "manual remediation required", "classification": classification})
|
|
36
|
+
continue
|
|
37
|
+
if classification == FixClassification.APPROVAL:
|
|
38
|
+
self.skipped.append({"finding": finding.to_dict(), "reason": "requires explicit approval", "classification": classification})
|
|
39
|
+
continue
|
|
40
|
+
method_name = f"fix_{finding.rule.replace(':', '_').replace('-', '_')}"
|
|
41
|
+
method = getattr(self, method_name, None)
|
|
42
|
+
if method is None:
|
|
43
|
+
self.skipped.append({"finding": finding.to_dict(), "reason": "no automated fix available", "classification": classification})
|
|
44
|
+
continue
|
|
45
|
+
try:
|
|
46
|
+
method(finding)
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
self.skipped.append({"finding": finding.to_dict(), "reason": str(exc), "classification": classification})
|
|
49
|
+
return {"applied": self.applied, "skipped": self.skipped, "dry_run": self.dry_run}
|
|
50
|
+
|
|
51
|
+
def classify(self, finding: Finding) -> str:
|
|
52
|
+
if finding.severity in (Severity.CRITICAL,):
|
|
53
|
+
return FixClassification.APPROVAL
|
|
54
|
+
if finding.rule in ("security:client-side-secret", "security:committed-env-file"):
|
|
55
|
+
return FixClassification.MANUAL
|
|
56
|
+
if finding.rule in ("quality:console-log-in-production", "quality:debugger-statement"):
|
|
57
|
+
return FixClassification.SAFE
|
|
58
|
+
return FixClassification.APPROVAL
|
|
59
|
+
|
|
60
|
+
def _resolve_path(self, file: Path) -> Path:
|
|
61
|
+
target = self.repo / file
|
|
62
|
+
resolved = target.resolve()
|
|
63
|
+
repo_resolved = self.repo.resolve()
|
|
64
|
+
# Prevent symlink/path traversal escape.
|
|
65
|
+
if not str(resolved).startswith(str(repo_resolved) + os.sep) and resolved != repo_resolved:
|
|
66
|
+
raise ValueError(f"Target path escapes repository: {file}")
|
|
67
|
+
return resolved
|
|
68
|
+
|
|
69
|
+
def _read(self, file: Path) -> list[str]:
|
|
70
|
+
path = self._resolve_path(file)
|
|
71
|
+
return path.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
72
|
+
|
|
73
|
+
def _backup(self, path: Path) -> Path:
|
|
74
|
+
if self.dry_run:
|
|
75
|
+
return path
|
|
76
|
+
rel = path.relative_to(self.repo.resolve())
|
|
77
|
+
backup = self.backup_dir / rel
|
|
78
|
+
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
shutil.copy2(path, backup)
|
|
80
|
+
self.backups.append(backup)
|
|
81
|
+
return backup
|
|
82
|
+
|
|
83
|
+
def _write(self, file: Path, lines: list[str]) -> None:
|
|
84
|
+
path = self._resolve_path(file)
|
|
85
|
+
self._backup(path)
|
|
86
|
+
if self.dry_run:
|
|
87
|
+
return
|
|
88
|
+
path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
|
|
89
|
+
|
|
90
|
+
def _make_diff(self, original_lines: list[str], new_lines: list[str], file: Path) -> str:
|
|
91
|
+
return "\n".join(difflib.unified_diff(
|
|
92
|
+
original_lines,
|
|
93
|
+
new_lines,
|
|
94
|
+
fromfile=str(file),
|
|
95
|
+
tofile=str(file),
|
|
96
|
+
lineterm="",
|
|
97
|
+
))
|
|
98
|
+
|
|
99
|
+
def fix_quality_console_log_in_production(self, finding: Finding) -> None:
|
|
100
|
+
lines = self._read(finding.file)
|
|
101
|
+
original = list(lines)
|
|
102
|
+
i = finding.line - 1
|
|
103
|
+
if i < 0 or i >= len(lines):
|
|
104
|
+
raise IndexError("line out of range")
|
|
105
|
+
line = lines[i]
|
|
106
|
+
cleaned = re.sub(r"\s*console\.(log|debug|warn)\s*\([^;]*\);?", "", line)
|
|
107
|
+
if cleaned == line or not cleaned.strip():
|
|
108
|
+
cleaned = re.sub(r"console\.(log|debug|warn)\s*\([^;]*\);?", "", line)
|
|
109
|
+
lines[i] = cleaned
|
|
110
|
+
diff = self._make_diff(original, lines, finding.file)
|
|
111
|
+
self._write(finding.file, lines)
|
|
112
|
+
self.applied.append({"finding": finding.to_dict(), "replacement": cleaned, "diff": diff, "classification": FixClassification.SAFE})
|
|
113
|
+
|
|
114
|
+
def fix_quality_debugger_statement(self, finding: Finding) -> None:
|
|
115
|
+
lines = self._read(finding.file)
|
|
116
|
+
original = list(lines)
|
|
117
|
+
i = finding.line - 1
|
|
118
|
+
if i < 0 or i >= len(lines):
|
|
119
|
+
raise IndexError("line out of range")
|
|
120
|
+
line = lines[i]
|
|
121
|
+
cleaned = re.sub(r"\s*debugger;?", "", line)
|
|
122
|
+
lines[i] = cleaned
|
|
123
|
+
diff = self._make_diff(original, lines, finding.file)
|
|
124
|
+
self._write(finding.file, lines)
|
|
125
|
+
self.applied.append({"finding": finding.to_dict(), "replacement": cleaned, "diff": diff, "classification": FixClassification.SAFE})
|
|
126
|
+
|
|
127
|
+
def fix_security_client_side_secret(self, finding: Finding) -> None:
|
|
128
|
+
raise RuntimeError("client-side secrets require moving the value to a server route; cannot auto-fix")
|
|
129
|
+
|
|
130
|
+
def fix_security_committed_env_file(self, finding: Finding) -> None:
|
|
131
|
+
raise RuntimeError("remove the file from git and rotate secrets; cannot auto-fix")
|
|
132
|
+
|
|
133
|
+
def rollback(self, backup: Path | None = None) -> list[Path]:
|
|
134
|
+
"""Restore the most recent backup, or a specific backup path."""
|
|
135
|
+
if backup is None:
|
|
136
|
+
if not self.backups:
|
|
137
|
+
return []
|
|
138
|
+
backup = self.backups[-1]
|
|
139
|
+
rel = backup.relative_to(self.backup_dir)
|
|
140
|
+
target = self.repo / rel
|
|
141
|
+
shutil.copy2(backup, target)
|
|
142
|
+
return [target]
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _finding_key(finding: "Finding | dict") -> tuple[str, str]:
|
|
146
|
+
if isinstance(finding, Finding):
|
|
147
|
+
return finding.rule, str(finding.file)
|
|
148
|
+
return str(finding["rule"]), str(finding["file"])
|
|
149
|
+
|
|
150
|
+
def verify(self, rescan_findings: list[Finding], tests: dict | None = None) -> dict:
|
|
151
|
+
"""Confirm which applied fixes actually removed the detected issue.
|
|
152
|
+
|
|
153
|
+
Verification is evidence-based: a fix is only marked ``resolved`` when a
|
|
154
|
+
fresh scan of the modified repository no longer reports the rule for that
|
|
155
|
+
file. It is never inferred from the fact that a file was edited or from a
|
|
156
|
+
shifted line number. When the same rule appears more than once in a file,
|
|
157
|
+
resolutions are counted by multiplicity so a partial fix is reported as
|
|
158
|
+
``partially_resolved`` rather than ``resolved``.
|
|
159
|
+
|
|
160
|
+
If tests were run and failed, no fix can be confirmed and every attempt is
|
|
161
|
+
reported as ``unverifiable`` so a regression is never masked.
|
|
162
|
+
"""
|
|
163
|
+
before: dict[tuple[str, str], int] = {}
|
|
164
|
+
changes_by_key: dict[tuple[str, str], list[dict]] = {}
|
|
165
|
+
for change in self.applied:
|
|
166
|
+
key = self._finding_key(change["finding"])
|
|
167
|
+
before[key] = before.get(key, 0) + 1
|
|
168
|
+
changes_by_key.setdefault(key, []).append(change)
|
|
169
|
+
|
|
170
|
+
after: dict[tuple[str, str], int] = {}
|
|
171
|
+
for finding in rescan_findings:
|
|
172
|
+
key = self._finding_key(finding)
|
|
173
|
+
after[key] = after.get(key, 0) + 1
|
|
174
|
+
|
|
175
|
+
tests_failed = bool(tests and tests.get("status") == "fail")
|
|
176
|
+
report: dict = {
|
|
177
|
+
"resolved": [],
|
|
178
|
+
"partially_resolved": [],
|
|
179
|
+
"unresolved": [],
|
|
180
|
+
"unverifiable": [],
|
|
181
|
+
"tests_failed": tests_failed,
|
|
182
|
+
"verified": False,
|
|
183
|
+
}
|
|
184
|
+
for key, attempted in before.items():
|
|
185
|
+
remaining = after.get(key, 0)
|
|
186
|
+
entry = {"rule": key[0], "file": key[1], "attempted": attempted, "remaining": remaining}
|
|
187
|
+
if tests_failed:
|
|
188
|
+
status = "unverifiable"
|
|
189
|
+
elif remaining == 0:
|
|
190
|
+
status = "resolved"
|
|
191
|
+
elif remaining < attempted:
|
|
192
|
+
status = "partially_resolved"
|
|
193
|
+
else:
|
|
194
|
+
status = "unresolved"
|
|
195
|
+
entry["status"] = status
|
|
196
|
+
report[status].append(entry)
|
|
197
|
+
for change in changes_by_key[key]:
|
|
198
|
+
change["resolution"] = status
|
|
199
|
+
|
|
200
|
+
report["verified"] = (
|
|
201
|
+
not tests_failed
|
|
202
|
+
and bool(report["resolved"])
|
|
203
|
+
and not report["partially_resolved"]
|
|
204
|
+
and not report["unresolved"]
|
|
205
|
+
)
|
|
206
|
+
return report
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def suggest_fixes(findings: list[Finding]) -> list[dict]:
|
|
210
|
+
"""Generate human-readable suggestions without modifying files."""
|
|
211
|
+
suggestions = []
|
|
212
|
+
for f in findings:
|
|
213
|
+
suggestions.append({
|
|
214
|
+
"file": str(f.file),
|
|
215
|
+
"line": f.line,
|
|
216
|
+
"rule": f.rule,
|
|
217
|
+
"severity": f.severity.value,
|
|
218
|
+
"message": f.message,
|
|
219
|
+
"suggestion": f.suggestion,
|
|
220
|
+
"auto_fixable": f.fixable,
|
|
221
|
+
})
|
|
222
|
+
return suggestions
|
vibe_coding_qa/gates.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Production-readiness scoring and release gate."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from . import Finding, ModuleResult, ScanResult, Severity, Status
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
MODULE_WEIGHTS = {
|
|
10
|
+
"security": 1.5,
|
|
11
|
+
"database": 1.4,
|
|
12
|
+
"dependencies": 1.2,
|
|
13
|
+
"infrastructure": 1.2,
|
|
14
|
+
"quality": 1.0,
|
|
15
|
+
"compliance": 1.1,
|
|
16
|
+
"performance": 0.8,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
SEVERITY_PENALTY = {
|
|
20
|
+
Severity.CRITICAL: 40,
|
|
21
|
+
Severity.HIGH: 20,
|
|
22
|
+
Severity.MEDIUM: 10,
|
|
23
|
+
Severity.LOW: 3,
|
|
24
|
+
Severity.INFO: 0,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
CONFIDENCE_MULTIPLIER = {
|
|
28
|
+
"high": 1.0,
|
|
29
|
+
"medium": 0.8,
|
|
30
|
+
"low": 0.5,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
EXPLOITABILITY_MULTIPLIER = {
|
|
34
|
+
"security": 1.5,
|
|
35
|
+
"database": 1.4,
|
|
36
|
+
"dependencies": 1.2,
|
|
37
|
+
"infrastructure": 1.2,
|
|
38
|
+
"compliance": 1.0,
|
|
39
|
+
"quality": 0.9,
|
|
40
|
+
"performance": 0.8,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
EXPLOITABLE_RULES = {
|
|
44
|
+
"security:client-side-secret",
|
|
45
|
+
"security:generic-api-key",
|
|
46
|
+
"security:sql-injection-risk",
|
|
47
|
+
"security:unsafe-shell-execution",
|
|
48
|
+
"security:path-traversal-risk",
|
|
49
|
+
"security:ssrf-risk",
|
|
50
|
+
"security:private-key",
|
|
51
|
+
"security:aws-access-key",
|
|
52
|
+
"security:openai-key",
|
|
53
|
+
"security:stripe-key",
|
|
54
|
+
"security:supabase-service-key",
|
|
55
|
+
"security:firebase-insecure-rules",
|
|
56
|
+
"database:table-without-rls",
|
|
57
|
+
"database:editable-role-field",
|
|
58
|
+
"database:client-role-update",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _finding_key(f: Finding) -> tuple:
|
|
63
|
+
return (f.rule, str(f.file), f.line)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def deduplicate_findings(findings: list[Finding]) -> list[Finding]:
|
|
67
|
+
seen: set[tuple] = set()
|
|
68
|
+
unique: list[Finding] = []
|
|
69
|
+
for f in findings:
|
|
70
|
+
key = _finding_key(f)
|
|
71
|
+
if key in seen:
|
|
72
|
+
continue
|
|
73
|
+
seen.add(key)
|
|
74
|
+
unique.append(f)
|
|
75
|
+
return unique
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _penalty_for(f: Finding) -> float:
|
|
79
|
+
base = float(SEVERITY_PENALTY.get(f.severity, 0))
|
|
80
|
+
confidence = CONFIDENCE_MULTIPLIER.get(f.confidence, 0.8)
|
|
81
|
+
exploitability = EXPLOITABILITY_MULTIPLIER.get(f.module, 1.0)
|
|
82
|
+
if f.rule in EXPLOITABLE_RULES:
|
|
83
|
+
exploitability *= 1.2
|
|
84
|
+
return round(base * confidence * exploitability, 2)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def score_module(findings: list[Finding], max_score: float = 100.0) -> tuple[float, Status, list[dict[str, Any]]]:
|
|
88
|
+
unique = deduplicate_findings(findings)
|
|
89
|
+
if not unique:
|
|
90
|
+
return max_score, Status.PASS, []
|
|
91
|
+
deductions: list[dict[str, Any]] = []
|
|
92
|
+
penalty = 0.0
|
|
93
|
+
for f in unique:
|
|
94
|
+
p = _penalty_for(f)
|
|
95
|
+
penalty += p
|
|
96
|
+
deductions.append({
|
|
97
|
+
"rule": f.rule,
|
|
98
|
+
"file": str(f.file),
|
|
99
|
+
"line": f.line,
|
|
100
|
+
"severity": f.severity.value,
|
|
101
|
+
"confidence": f.confidence,
|
|
102
|
+
"penalty": p,
|
|
103
|
+
"reason": f"{f.severity.value} {f.rule} in {f.module}",
|
|
104
|
+
})
|
|
105
|
+
score = max(0.0, max_score - penalty)
|
|
106
|
+
if any(f.severity == Severity.CRITICAL for f in unique):
|
|
107
|
+
return score, Status.BLOCK, deductions
|
|
108
|
+
if any(f.severity == Severity.HIGH for f in unique):
|
|
109
|
+
return score, Status.FAIL, deductions
|
|
110
|
+
return score, Status.PASS, deductions
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _determine_gate(
|
|
114
|
+
overall: float,
|
|
115
|
+
minimum_score: int,
|
|
116
|
+
critical_count: int,
|
|
117
|
+
high_count: int,
|
|
118
|
+
test_status: str | Status | None,
|
|
119
|
+
test_execution_mode: str | None,
|
|
120
|
+
) -> str:
|
|
121
|
+
"""
|
|
122
|
+
Determine the release gate decision.
|
|
123
|
+
|
|
124
|
+
Returns one of: "block", "fail", "pass", "not_verified"
|
|
125
|
+
"""
|
|
126
|
+
# Critical findings always block
|
|
127
|
+
if critical_count > 0:
|
|
128
|
+
return "block"
|
|
129
|
+
|
|
130
|
+
# High findings fail
|
|
131
|
+
if high_count > 0:
|
|
132
|
+
return "fail"
|
|
133
|
+
|
|
134
|
+
# Below minimum score fails
|
|
135
|
+
if overall < minimum_score:
|
|
136
|
+
return "fail"
|
|
137
|
+
|
|
138
|
+
# Normalize test_status to string
|
|
139
|
+
if isinstance(test_status, Status):
|
|
140
|
+
test_status_str = test_status.value
|
|
141
|
+
else:
|
|
142
|
+
test_status_str = test_status
|
|
143
|
+
|
|
144
|
+
if test_status_str == "fail":
|
|
145
|
+
return "fail"
|
|
146
|
+
if test_status_str == "pass":
|
|
147
|
+
return "pass"
|
|
148
|
+
return "not_verified"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def assess(result: ScanResult, minimum_score: int | None = None) -> dict:
|
|
152
|
+
if minimum_score is None:
|
|
153
|
+
minimum_score = int(os.environ.get("VCQA_MINIMUM_SCORE", "60"))
|
|
154
|
+
score_explanation: dict[str, Any] = {"deductions": [], "modules": []}
|
|
155
|
+
for module in result.modules:
|
|
156
|
+
module.findings = deduplicate_findings(module.findings)
|
|
157
|
+
module.score, module.status, deductions = score_module(module.findings)
|
|
158
|
+
score_explanation["modules"].append({
|
|
159
|
+
"module": module.module,
|
|
160
|
+
"score": module.score,
|
|
161
|
+
"status": module.status.value,
|
|
162
|
+
"deductions": deductions,
|
|
163
|
+
})
|
|
164
|
+
score_explanation["deductions"].extend(deductions)
|
|
165
|
+
|
|
166
|
+
weighted_sum = 0.0
|
|
167
|
+
total_weight = 0.0
|
|
168
|
+
for module in result.modules:
|
|
169
|
+
weight = MODULE_WEIGHTS.get(module.module, 1.0)
|
|
170
|
+
weighted_sum += module.score * weight
|
|
171
|
+
total_weight += weight
|
|
172
|
+
overall = round(weighted_sum / total_weight, 2) if total_weight else 0.0
|
|
173
|
+
|
|
174
|
+
# Get test information
|
|
175
|
+
test_status = result.tests.get("status")
|
|
176
|
+
test_execution_mode = result.tests.get("execution_mode")
|
|
177
|
+
|
|
178
|
+
gate = _determine_gate(
|
|
179
|
+
overall=overall,
|
|
180
|
+
minimum_score=minimum_score,
|
|
181
|
+
critical_count=result.critical_count,
|
|
182
|
+
high_count=result.high_count,
|
|
183
|
+
test_status=test_status,
|
|
184
|
+
test_execution_mode=test_execution_mode,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
score_explanation["overall_score"] = overall
|
|
188
|
+
score_explanation["minimum_score"] = minimum_score
|
|
189
|
+
score_explanation["weights"] = MODULE_WEIGHTS
|
|
190
|
+
score_explanation["gate"] = gate
|
|
191
|
+
score_explanation["test_status"] = test_status
|
|
192
|
+
score_explanation["test_execution_mode"] = test_execution_mode
|
|
193
|
+
|
|
194
|
+
assessment = {
|
|
195
|
+
"overall_score": overall,
|
|
196
|
+
"minimum_score": minimum_score,
|
|
197
|
+
"gate": gate,
|
|
198
|
+
"weights": MODULE_WEIGHTS,
|
|
199
|
+
"score_explanation": score_explanation,
|
|
200
|
+
"criteria": {
|
|
201
|
+
"critical_blocks_release": True,
|
|
202
|
+
"high_fails_release": True,
|
|
203
|
+
"minimum_score": minimum_score,
|
|
204
|
+
},
|
|
205
|
+
# Also store at top level for easy access in release_decision
|
|
206
|
+
"test_status": test_status,
|
|
207
|
+
"test_execution_mode": test_execution_mode,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
# Store authoritative assessment on the result for consistent access
|
|
211
|
+
result._assessment = assessment
|
|
212
|
+
|
|
213
|
+
return assessment
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def release_gate_label(result: ScanResult) -> str:
|
|
217
|
+
if not result._assessment:
|
|
218
|
+
assess(result)
|
|
219
|
+
return {
|
|
220
|
+
"pass": "PASS",
|
|
221
|
+
"fail": "FAIL",
|
|
222
|
+
"block": "BLOCKED",
|
|
223
|
+
"not_verified": "NOT VERIFIED",
|
|
224
|
+
}.get(result.gate, "NOT VERIFIED")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def release_decision(result: ScanResult) -> str:
|
|
228
|
+
# Ensure every consumer reads the same authoritative assessment.
|
|
229
|
+
if not result._assessment:
|
|
230
|
+
assess(result)
|
|
231
|
+
# Use authoritative assessment if available
|
|
232
|
+
if result._assessment:
|
|
233
|
+
gate = result._assessment.get("gate", "unknown")
|
|
234
|
+
overall = result._assessment.get("overall_score", 0)
|
|
235
|
+
minimum = result._assessment.get("minimum_score", 60)
|
|
236
|
+
test_status = result._assessment.get("test_status")
|
|
237
|
+
test_mode = result._assessment.get("test_execution_mode")
|
|
238
|
+
|
|
239
|
+
# Normalize test_status to string
|
|
240
|
+
if isinstance(test_status, Status):
|
|
241
|
+
test_status_str = test_status.value
|
|
242
|
+
else:
|
|
243
|
+
test_status_str = test_status
|
|
244
|
+
|
|
245
|
+
if gate == "block":
|
|
246
|
+
return "BLOCKED: critical issues or failing tests must be resolved before release."
|
|
247
|
+
if gate == "fail":
|
|
248
|
+
if result.critical_count > 0:
|
|
249
|
+
return "BLOCKED: critical issues or failing tests must be resolved before release."
|
|
250
|
+
if result.high_count > 0:
|
|
251
|
+
return f"FAIL: high-severity findings remain; fix before release."
|
|
252
|
+
if overall < minimum:
|
|
253
|
+
return f"FAIL: overall readiness score ({overall}) is below the minimum threshold ({minimum})."
|
|
254
|
+
if test_status_str == "fail":
|
|
255
|
+
return "FAIL: test suite failed."
|
|
256
|
+
if gate == "not_verified":
|
|
257
|
+
return f"NOT VERIFIED: overall score ({overall}) meets threshold but tests were not executed ({test_mode}). Run with --explicit-permission to verify."
|
|
258
|
+
if gate == "pass":
|
|
259
|
+
return f"PASS: no critical or high issues, score ({overall}) above threshold ({minimum}), tests passed."
|
|
260
|
+
# Fallback to legacy logic
|
|
261
|
+
if result.release_blocked:
|
|
262
|
+
return "BLOCKED: critical issues or failing tests must be resolved before release."
|
|
263
|
+
if result.high_count > 0:
|
|
264
|
+
return "FAIL: high-severity findings remain; fix before release."
|
|
265
|
+
if result.overall_score < 60:
|
|
266
|
+
return "FAIL: overall readiness score is below 60."
|
|
267
|
+
return "PASS: no critical or high issues and score is above the release threshold."
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def coverage_gaps(result: ScanResult) -> list[str]:
|
|
271
|
+
gaps: list[str] = []
|
|
272
|
+
architecture = result.tests.get("architecture", {})
|
|
273
|
+
if not architecture.get("has_tests"):
|
|
274
|
+
gaps.append("No automated test runner detected; runtime behavior is unverified.")
|
|
275
|
+
if not architecture.get("lockfiles"):
|
|
276
|
+
gaps.append("No dependency lockfile found; supply-chain reproducibility is unverified.")
|
|
277
|
+
if not architecture.get("auth_providers"):
|
|
278
|
+
gaps.append("No authentication provider detected; review access-control assumptions.")
|
|
279
|
+
if not architecture.get("ci_files"):
|
|
280
|
+
gaps.append("No CI workflow detected; build and deployment processes are unaudited.")
|
|
281
|
+
if not architecture.get("has_gitignore"):
|
|
282
|
+
gaps.append("No .gitignore detected; secret leaks are more likely.")
|
|
283
|
+
if not result.modules:
|
|
284
|
+
gaps.append("No scanner modules produced results.")
|
|
285
|
+
|
|
286
|
+
# Add test execution status to gaps
|
|
287
|
+
test_status = result.tests.get("status")
|
|
288
|
+
test_mode = result.tests.get("execution_mode")
|
|
289
|
+
test_reason = result.tests.get("test_reason")
|
|
290
|
+
if test_status == "skip":
|
|
291
|
+
if test_reason in ("all_tests_skipped", "zero_tests_discovered"):
|
|
292
|
+
gaps.append(f"Tests not verified: no test executed ({test_reason}).")
|
|
293
|
+
elif test_reason == "no_recognizable_results":
|
|
294
|
+
gaps.append("Tests not verified: the runner output could not be recognized.")
|
|
295
|
+
elif test_mode == "test_infrastructure_unavailable":
|
|
296
|
+
gaps.append("Tests not verified: required dependencies are unavailable in the sandbox image.")
|
|
297
|
+
else:
|
|
298
|
+
gaps.append(f"Tests not executed: {test_mode}. Use --explicit-permission to run tests.")
|
|
299
|
+
|
|
300
|
+
return gaps
|