universal-dev-security 1.0.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.
- universal_dev_security/__init__.py +3 -0
- universal_dev_security/__main__.py +4 -0
- universal_dev_security/cli.py +376 -0
- universal_dev_security/cli_extra.py +244 -0
- universal_dev_security/config.py +171 -0
- universal_dev_security/detector.py +201 -0
- universal_dev_security/engine.py +203 -0
- universal_dev_security/findings.py +199 -0
- universal_dev_security/fixes.py +138 -0
- universal_dev_security/gitguard.py +269 -0
- universal_dev_security/history.py +149 -0
- universal_dev_security/initializer.py +112 -0
- universal_dev_security/integrations/__init__.py +46 -0
- universal_dev_security/integrations/agents.py +45 -0
- universal_dev_security/integrations/claude_code.py +192 -0
- universal_dev_security/integrations/github.py +149 -0
- universal_dev_security/integrations/vscode.py +44 -0
- universal_dev_security/policy.py +384 -0
- universal_dev_security/projectfiles.py +80 -0
- universal_dev_security/recovery.py +168 -0
- universal_dev_security/redaction.py +132 -0
- universal_dev_security/reports.py +466 -0
- universal_dev_security/scanner.py +36 -0
- universal_dev_security/scanners/__init__.py +36 -0
- universal_dev_security/scanners/base.py +269 -0
- universal_dev_security/scanners/gitleaks.py +132 -0
- universal_dev_security/scanners/osv.py +156 -0
- universal_dev_security/scanners/semgrep.py +130 -0
- universal_dev_security/scanners/trivy.py +139 -0
- universal_dev_security/testing.py +100 -0
- universal_dev_security/updates.py +84 -0
- universal_dev_security-1.0.0.dist-info/METADATA +128 -0
- universal_dev_security-1.0.0.dist-info/RECORD +37 -0
- universal_dev_security-1.0.0.dist-info/WHEEL +5 -0
- universal_dev_security-1.0.0.dist-info/entry_points.txt +2 -0
- universal_dev_security-1.0.0.dist-info/licenses/LICENSE +163 -0
- universal_dev_security-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""Sanitized JSON, SARIF and dark HTML dashboard reports.
|
|
2
|
+
|
|
3
|
+
Security properties:
|
|
4
|
+
* Every untrusted string is HTML-escaped; links are restricted to http(s).
|
|
5
|
+
* Embedded JSON escapes ``<``, ``>`` and ``&`` so it cannot terminate the script block.
|
|
6
|
+
* A Content-Security-Policy allows only the report's own inline script (by hash);
|
|
7
|
+
no external resources, fonts, or CDNs are loaded.
|
|
8
|
+
* Reports contain finding metadata only — never secret values or raw scanner output.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from html import escape
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from universal_dev_security import __version__
|
|
21
|
+
from universal_dev_security.findings import SEVERITY_ORDER
|
|
22
|
+
|
|
23
|
+
MAX_HTML_FINDINGS = 2000
|
|
24
|
+
|
|
25
|
+
INSTALL_HINTS = {
|
|
26
|
+
"gitleaks": "https://github.com/gitleaks/gitleaks#installing",
|
|
27
|
+
"semgrep": "https://semgrep.dev/docs/getting-started/quickstart",
|
|
28
|
+
"osv-scanner": "https://google.github.io/osv-scanner/installation/",
|
|
29
|
+
"trivy": "https://trivy.dev/latest/getting-started/installation/",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Report model
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def recommendations(scan: dict[str, Any], comparison: dict[str, Any] | None = None) -> list[dict[str, str]]:
|
|
38
|
+
"""Deterministic recommendations derived only from scan data."""
|
|
39
|
+
recs: list[dict[str, str]] = []
|
|
40
|
+
findings = scan.get("findings", [])
|
|
41
|
+
secrets = [f for f in findings if f.get("category") == "secret"]
|
|
42
|
+
committed = [f for f in secrets if f.get("tracked_in_git") is True]
|
|
43
|
+
local_only = [f for f in secrets if f.get("tracked_in_git") is False]
|
|
44
|
+
unknown = [f for f in secrets if f.get("tracked_in_git") is None]
|
|
45
|
+
if committed:
|
|
46
|
+
recs.append({"priority": "urgent", "title": f"Rotate {len(committed)} credential(s) found in Git-tracked files",
|
|
47
|
+
"detail": "These secrets are committed and shared with everyone who can access the repository. "
|
|
48
|
+
"Revoke or rotate each with its issuer first, then remove it from the code and plan "
|
|
49
|
+
"an approved Git history clean-up."})
|
|
50
|
+
if unknown:
|
|
51
|
+
recs.append({"priority": "urgent", "title": f"Rotate {len(unknown)} potentially exposed credential(s)",
|
|
52
|
+
"detail": "Revoke or rotate each credential with its issuer before removing it from code. "
|
|
53
|
+
"If it was committed, plan an approved Git history clean-up."})
|
|
54
|
+
if local_only:
|
|
55
|
+
files = sorted({str(f.get("file")) for f in local_only})
|
|
56
|
+
shown = ", ".join(files[:5]) + (f" and {len(files) - 5} more file(s)" if len(files) > 5 else "")
|
|
57
|
+
recs.append({"priority": "medium",
|
|
58
|
+
"title": f"{len(local_only)} potential secret(s) in local files not tracked by Git",
|
|
59
|
+
"detail": f"Found in {shown}. These have not been shared through Git. Keep the files "
|
|
60
|
+
"git-ignored and out of shared folders, and rotate any credential that was ever "
|
|
61
|
+
"committed, shared or published elsewhere."})
|
|
62
|
+
deps = [f for f in findings if f.get("category") == "dependency"]
|
|
63
|
+
fixable = sorted({f"{f.get('package')} → {f.get('fixed_version')}" for f in deps if f.get("fixed_version")})
|
|
64
|
+
if fixable:
|
|
65
|
+
shown = ", ".join(fixable[:8]) + (" …" if len(fixable) > 8 else "")
|
|
66
|
+
recs.append({"priority": "high", "title": f"Upgrade {len(fixable)} vulnerable dependency version(s) with known fixes",
|
|
67
|
+
"detail": f"{shown}. Upgrade, then run the test suite (`uds test`)."})
|
|
68
|
+
unfixed = [f for f in deps if not f.get("fixed_version")]
|
|
69
|
+
if unfixed:
|
|
70
|
+
recs.append({"priority": "medium", "title": f"Assess {len(unfixed)} dependency finding(s) without a published fix",
|
|
71
|
+
"detail": "Check exploitability in your usage, consider alternatives, and monitor advisories."})
|
|
72
|
+
code = [f for f in findings if f.get("category") == "code" and f.get("severity") in ("critical", "high")]
|
|
73
|
+
if code:
|
|
74
|
+
recs.append({"priority": "high", "title": f"Review {len(code)} high/critical code finding(s)",
|
|
75
|
+
"detail": "Validate each finding and fix confirmed issues; add regression tests."})
|
|
76
|
+
config = [f for f in findings if f.get("category") == "configuration"]
|
|
77
|
+
if config:
|
|
78
|
+
recs.append({"priority": "medium", "title": f"Harden {len(config)} configuration issue(s)",
|
|
79
|
+
"detail": "Apply the resolution listed on each configuration finding."})
|
|
80
|
+
for scanner in scan.get("scanners", []):
|
|
81
|
+
tool, status, reason = scanner.get("tool"), scanner.get("status"), scanner.get("reason")
|
|
82
|
+
if status == "not_installed":
|
|
83
|
+
recs.append({"priority": "coverage", "title": f"Install {tool} to extend coverage",
|
|
84
|
+
"detail": f"Official installation instructions: {INSTALL_HINTS.get(tool, '')}"})
|
|
85
|
+
elif status == "skipped" and reason == "network_not_approved":
|
|
86
|
+
recs.append({"priority": "coverage", "title": f"Approve network use for {tool} if acceptable",
|
|
87
|
+
"detail": str(scanner.get("message", ""))})
|
|
88
|
+
elif status == "skipped":
|
|
89
|
+
recs.append({"priority": "coverage", "title": f"{tool} was skipped", "detail": str(scanner.get("message", ""))})
|
|
90
|
+
elif status == "error":
|
|
91
|
+
recs.append({"priority": "coverage", "title": f"{tool} failed — results are incomplete",
|
|
92
|
+
"detail": str(scanner.get("message", ""))})
|
|
93
|
+
if comparison and comparison.get("new"):
|
|
94
|
+
recs.insert(0, {"priority": "urgent" if any(f.get("category") == "secret" for f in comparison["new"]) else "high",
|
|
95
|
+
"title": f"{len(comparison['new'])} new finding(s) since the previous scan",
|
|
96
|
+
"detail": "Prioritize newly introduced findings — they are usually cheapest to fix now."})
|
|
97
|
+
if not recs and scan.get("overall_status") == "clean":
|
|
98
|
+
recs.append({"priority": "info", "title": "No findings from the executed scanners",
|
|
99
|
+
"detail": "Keep scanning on every change; a clean result is not proof of security."})
|
|
100
|
+
return recs
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_report(scan: dict[str, Any], comparison: dict[str, Any] | None = None,
|
|
104
|
+
history: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
105
|
+
report = dict(scan)
|
|
106
|
+
report["report_generated_at"] = datetime.now(timezone.utc).isoformat()
|
|
107
|
+
report["recommendations"] = recommendations(scan, comparison)
|
|
108
|
+
report["comparison"] = comparison
|
|
109
|
+
report["history"] = history or []
|
|
110
|
+
return report
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# SARIF
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
_SARIF_LEVEL = {"critical": "error", "high": "error", "medium": "warning", "low": "note", "unclassified": "warning"}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_sarif(scan: dict[str, Any]) -> dict[str, Any]:
|
|
121
|
+
runs = []
|
|
122
|
+
for scanner in scan.get("scanners", []):
|
|
123
|
+
if not scanner.get("executed"):
|
|
124
|
+
continue
|
|
125
|
+
tool = scanner["tool"]
|
|
126
|
+
findings = [f for f in scan.get("findings", []) if f.get("tool") == tool]
|
|
127
|
+
rules: dict[str, dict[str, Any]] = {}
|
|
128
|
+
results = []
|
|
129
|
+
for f in findings:
|
|
130
|
+
rules.setdefault(f["rule_id"], {
|
|
131
|
+
"id": f["rule_id"],
|
|
132
|
+
"shortDescription": {"text": str(f.get("title") or f["rule_id"])[:300]},
|
|
133
|
+
**({"helpUri": f["references"][0]} if f.get("references") else {}),
|
|
134
|
+
})
|
|
135
|
+
level = "error" if f.get("category") == "secret" else _SARIF_LEVEL.get(f.get("severity", ""), "warning")
|
|
136
|
+
text = str(f.get("message") or f.get("title") or f["rule_id"])
|
|
137
|
+
if f.get("package"):
|
|
138
|
+
text += f" [{f['package']} {f.get('installed_version') or ''}]"
|
|
139
|
+
result: dict[str, Any] = {
|
|
140
|
+
"ruleId": f["rule_id"],
|
|
141
|
+
"level": level,
|
|
142
|
+
"message": {"text": text[:1000]},
|
|
143
|
+
"partialFingerprints": {"udsFingerprint/v1": f["fingerprint"]},
|
|
144
|
+
"properties": {"uds-severity": f.get("severity"), "uds-severity-source": f.get("severity_source"),
|
|
145
|
+
"category": f.get("category")},
|
|
146
|
+
}
|
|
147
|
+
if f.get("file"):
|
|
148
|
+
location: dict[str, Any] = {"artifactLocation": {"uri": f["file"]}}
|
|
149
|
+
if f.get("start_line"):
|
|
150
|
+
location["region"] = {"startLine": max(1, int(f["start_line"]))}
|
|
151
|
+
result["locations"] = [{"physicalLocation": location}]
|
|
152
|
+
results.append(result)
|
|
153
|
+
runs.append({
|
|
154
|
+
"tool": {"driver": {"name": f"UDS / {tool}", "version": scanner.get("version") or "unknown",
|
|
155
|
+
"informationUri": INSTALL_HINTS.get(tool, "https://github.com"),
|
|
156
|
+
"rules": list(rules.values())}},
|
|
157
|
+
"results": results,
|
|
158
|
+
})
|
|
159
|
+
return {"version": "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json", "runs": runs}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# HTML dashboard
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def _e(value: Any) -> str:
|
|
167
|
+
return escape("" if value is None else str(value), quote=True)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _link(url: str) -> str:
|
|
171
|
+
if not isinstance(url, str) or not url.lower().startswith(("https://", "http://")):
|
|
172
|
+
return ""
|
|
173
|
+
return f'<a href="{_e(url)}" rel="noopener noreferrer" target="_blank">{_e(url)}</a>'
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
STATUS_LABEL = {
|
|
177
|
+
"clean": ("Clean", "ok"), "findings": ("Findings", "bad"), "incomplete": ("Incomplete coverage", "warn"),
|
|
178
|
+
"error": ("Scan error", "bad"), "not_scanned": ("Not scanned", "warn"),
|
|
179
|
+
"not_installed": ("Not installed", "warn"), "not_applicable": ("Not applicable", "muted"),
|
|
180
|
+
"skipped": ("Skipped", "warn"),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
CSS = """
|
|
184
|
+
:root{--bg:#080d18;--surface:#101a2b;--surface-2:#142238;--surface-3:#0c1524;--ink:#e8eef8;--muted:#9fb0c7;--line:#263750;
|
|
185
|
+
--accent:#55d6be;--accent-soft:#123a37;--sev-critical:#ff6b81;--sev-high:#ff9f68;--sev-medium:#f5c66a;--sev-low:#86b7ff;
|
|
186
|
+
--sev-unclassified:#b4bfce;--ok:#55d6be;--bad:#ff7b86;--warn:#f5c66a;--focus:#7fe9d6}
|
|
187
|
+
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
|
|
188
|
+
body{margin:0;background:radial-gradient(ellipse at 12% 0%,#13283f 0,transparent 36%),var(--bg);color:var(--ink);
|
|
189
|
+
font:14px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
|
190
|
+
a{color:#8fd8ff;overflow-wrap:anywhere}a:focus-visible,button:focus-visible,summary:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
|
|
191
|
+
.skip{position:absolute;left:-999px}.skip:focus{left:16px;top:16px;background:var(--surface);padding:8px;z-index:9}
|
|
192
|
+
main{max-width:1200px;margin:0 auto;padding:32px 20px 56px}
|
|
193
|
+
.topbar{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:22px;flex-wrap:wrap}
|
|
194
|
+
.brand{display:flex;align-items:center;gap:12px;font-weight:750;letter-spacing:.02em}
|
|
195
|
+
.mark{width:38px;height:38px;border-radius:12px;display:grid;place-items:center;background:linear-gradient(145deg,#1b766d,#174b65);color:#d9fff8;font-size:19px}
|
|
196
|
+
.eyebrow{color:var(--accent);font-size:11px;font-weight:750;letter-spacing:.14em;text-transform:uppercase}
|
|
197
|
+
h1{font-size:clamp(24px,4vw,32px);line-height:1.2;margin:6px 0;letter-spacing:-.03em}h2{font-size:17px;margin:0}h3{font-size:13px;margin:0 0 6px}
|
|
198
|
+
.muted{color:var(--muted);overflow-wrap:anywhere}
|
|
199
|
+
.card{background:linear-gradient(160deg,rgba(18,30,49,.97),rgba(13,23,38,.97));border:1px solid var(--line);border-radius:16px;padding:22px;margin-bottom:16px}
|
|
200
|
+
.head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap}
|
|
201
|
+
.meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:6px 18px;margin-top:14px;font-size:13px}
|
|
202
|
+
.meta b{color:var(--muted);font-weight:600}
|
|
203
|
+
.pill{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);border-radius:999px;padding:5px 11px;font-size:12px;font-weight:700;letter-spacing:.03em;white-space:nowrap}
|
|
204
|
+
.pill:before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
|
|
205
|
+
.ok{color:var(--ok);background:var(--accent-soft);border-color:#28645b}.bad{color:var(--bad);background:#3b202b80;border-color:#713642}
|
|
206
|
+
.warn{color:var(--warn);background:#3a302080;border-color:#66532c}.muted-pill{color:var(--muted);background:#1a2637}
|
|
207
|
+
.sev-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px}
|
|
208
|
+
.stat{background:var(--surface-3);border:1px solid var(--line);border-radius:12px;padding:14px;border-top:3px solid var(--line)}
|
|
209
|
+
.stat .label{font-size:12px;color:var(--muted)}.stat .value{font-size:26px;font-weight:760;line-height:1.2;margin-top:6px}
|
|
210
|
+
.stat.critical{border-top-color:var(--sev-critical)}.stat.high{border-top-color:var(--sev-high)}.stat.medium{border-top-color:var(--sev-medium)}
|
|
211
|
+
.stat.low{border-top-color:var(--sev-low)}.stat.unclassified{border-top-color:var(--sev-unclassified)}.stat.total{border-top-color:var(--accent)}
|
|
212
|
+
.sev{display:inline-block;border-radius:6px;padding:2px 8px;font-size:11px;font-weight:750;text-transform:uppercase;letter-spacing:.04em;color:#0b1220}
|
|
213
|
+
.sev-critical{background:var(--sev-critical)}.sev-high{background:var(--sev-high)}.sev-medium{background:var(--sev-medium)}
|
|
214
|
+
.sev-low{background:var(--sev-low)}.sev-unclassified{background:var(--sev-unclassified)}
|
|
215
|
+
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
|
216
|
+
.table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:12px}
|
|
217
|
+
table{width:100%;border-collapse:collapse}th,td{padding:10px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}
|
|
218
|
+
th{background:var(--surface-2);color:#b3c3d8;font-size:11px;text-transform:uppercase;letter-spacing:.08em}tbody tr:last-child td{border-bottom:0}
|
|
219
|
+
code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:#9bd9ff;font-size:12px;overflow-wrap:anywhere}
|
|
220
|
+
.controls{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
|
|
221
|
+
input,select,button{background:#0b1524;color:var(--ink);border:1px solid var(--line);border-radius:10px;padding:9px 12px;font:inherit}
|
|
222
|
+
input{flex:1;min-width:200px}button{cursor:pointer;font-weight:650}button:hover{border-color:var(--accent)}
|
|
223
|
+
.finding{border:1px solid var(--line);border-radius:12px;margin-bottom:8px;background:var(--surface-3)}
|
|
224
|
+
.finding summary{cursor:pointer;padding:12px 14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;list-style:none}
|
|
225
|
+
.finding summary::-webkit-details-marker{display:none}.finding summary:before{content:"▸";color:var(--muted)}.finding[open] summary:before{content:"▾"}
|
|
226
|
+
.finding .title{font-weight:650;flex:1;min-width:200px}.finding .loc{font-size:12px;color:var(--muted)}
|
|
227
|
+
.finding .body{padding:0 16px 14px 34px;display:grid;gap:6px}.finding dl{display:grid;grid-template-columns:max-content 1fr;gap:4px 14px;margin:0}
|
|
228
|
+
.finding dt{color:var(--muted);font-size:12px}.finding dd{margin:0;overflow-wrap:anywhere}
|
|
229
|
+
.tag{display:inline-flex;border:1px solid var(--line);border-radius:7px;padding:2px 7px;color:#c2d0e2;background:#132238;font-size:11px;font-weight:700}
|
|
230
|
+
.tag-bad{color:var(--bad);background:#3b202b80;border-color:#713642}
|
|
231
|
+
.rec{border-left:3px solid var(--accent);background:#0f2a2f;padding:10px 14px;border-radius:0 10px 10px 0;margin-bottom:8px}
|
|
232
|
+
.rec.urgent{border-left-color:var(--sev-critical);background:#2c1620}.rec.high{border-left-color:var(--sev-high);background:#2b1d16}
|
|
233
|
+
.rec.coverage{border-left-color:var(--warn);background:#2a2416}.rec .p{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
|
|
234
|
+
ul{padding-left:19px;margin:6px 0 0;color:var(--muted)}footer{color:#7d8ea6;font-size:12px;text-align:center;padding:16px}
|
|
235
|
+
.empty{color:var(--muted);text-align:center;padding:20px}
|
|
236
|
+
@media(max-width:900px){.sev-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.grid2{grid-template-columns:1fr}}
|
|
237
|
+
@media(max-width:520px){main{padding:18px 14px 36px}.card{padding:16px}.sev-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.finding .body{padding-left:16px}}
|
|
238
|
+
@media print{body{background:#fff;color:#000}.controls,.skip{display:none}.card{border-color:#999;background:#fff}}
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
SCRIPT = """(function(){
|
|
242
|
+
var q=document.getElementById('search'),sv=document.getElementById('severity-filter'),tl=document.getElementById('tool-filter'),
|
|
243
|
+
ct=document.getElementById('category-filter'),cnt=document.getElementById('visible-count');
|
|
244
|
+
var items=[].slice.call(document.querySelectorAll('#findings-list .finding'));
|
|
245
|
+
function filter(){var t=(q.value||'').toLowerCase(),s=sv.value,o=tl.value,c=ct.value,n=0;
|
|
246
|
+
items.forEach(function(el){var ok=(!t||el.textContent.toLowerCase().indexOf(t)>-1)&&(!s||el.dataset.severity===s)&&(!o||el.dataset.tool===o)&&(!c||el.dataset.category===c);
|
|
247
|
+
el.hidden=!ok;if(ok)n++;});cnt.textContent=n+' shown';}
|
|
248
|
+
[q,sv,tl,ct].forEach(function(el){el.addEventListener('input',filter);el.addEventListener('change',filter);});
|
|
249
|
+
document.getElementById('expand-all').addEventListener('click',function(){var open=this.getAttribute('aria-pressed')!=='true';
|
|
250
|
+
items.forEach(function(el){if(!el.hidden)el.open=open;});this.setAttribute('aria-pressed',String(open));this.textContent=open?'Collapse all':'Expand all';});
|
|
251
|
+
function save(name,type,text){var b=new Blob([text],{type:type}),a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=name;
|
|
252
|
+
document.body.appendChild(a);a.click();setTimeout(function(){URL.revokeObjectURL(a.href);a.remove();},0);}
|
|
253
|
+
document.getElementById('export-json').addEventListener('click',function(){save('uds-security-report.json','application/json',document.getElementById('uds-data').textContent);});
|
|
254
|
+
document.getElementById('export-html').addEventListener('click',function(){save('uds-security-report.html','text/html','<!doctype html>\\n'+document.documentElement.outerHTML);});
|
|
255
|
+
})();"""
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _status_pill(status: str) -> str:
|
|
259
|
+
label, cls = STATUS_LABEL.get(status, (status.replace("_", " ").title(), "warn"))
|
|
260
|
+
cls = "muted-pill" if cls == "muted" else cls
|
|
261
|
+
return f'<span class="pill {cls}">{_e(label)}</span>'
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _exposure(f: dict[str, Any]) -> str | None:
|
|
265
|
+
if f.get("category") != "secret" or f.get("tracked_in_git") is None:
|
|
266
|
+
return None
|
|
267
|
+
return "in-git" if f["tracked_in_git"] else "local"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _finding_html(f: dict[str, Any]) -> str:
|
|
271
|
+
sev = f.get("severity", "unclassified")
|
|
272
|
+
loc = f.get("file") or "—"
|
|
273
|
+
if f.get("start_line"):
|
|
274
|
+
loc += f":{f['start_line']}"
|
|
275
|
+
rows = [("Rule", f"<code>{_e(f.get('rule_id'))}</code>"), ("Scanner", _e(f.get("tool"))),
|
|
276
|
+
("Category", _e(f.get("category"))), ("Location", f"<code>{_e(loc)}</code>")]
|
|
277
|
+
if f.get("package"):
|
|
278
|
+
rows.append(("Package", _e(f"{f['package']} {f.get('installed_version') or ''}")))
|
|
279
|
+
rows.append(("Fixed in", _e(f.get("fixed_version") or "No fix listed")))
|
|
280
|
+
if f.get("commit"):
|
|
281
|
+
rows.append(("Commit", f"<code>{_e(f['commit'])}</code>"))
|
|
282
|
+
exposure = _exposure(f)
|
|
283
|
+
if exposure:
|
|
284
|
+
rows.append(("Git exposure", _e({"in-git": "Tracked by Git — committed and shared",
|
|
285
|
+
"local": "Not tracked by Git — local file only"}[exposure])))
|
|
286
|
+
source = f.get("severity_source", "none")
|
|
287
|
+
sev_note = {"scanner": "reported by scanner", "cvss-qualitative-scale": "CVSS qualitative scale",
|
|
288
|
+
"advisory-database": "advisory database", "none": "no validated severity available"}.get(source, source)
|
|
289
|
+
rows.append(("Severity", f"{_e(sev)} <span class='muted'>({_e(sev_note)}"
|
|
290
|
+
+ (f"; scanner value: {_e(f['scanner_severity'])}" if f.get("scanner_severity") else "")
|
|
291
|
+
+ ")</span>"))
|
|
292
|
+
if f.get("message"):
|
|
293
|
+
rows.append(("Details", _e(f["message"])))
|
|
294
|
+
if f.get("remediation"):
|
|
295
|
+
rows.append(("Remediation", _e(f["remediation"])))
|
|
296
|
+
links = [link for link in (_link(r) for r in f.get("references", [])[:5]) if link]
|
|
297
|
+
if links:
|
|
298
|
+
rows.append(("References", "<br>".join(links)))
|
|
299
|
+
rows.append(("Fingerprint", f"<code>{_e(f.get('fingerprint'))}</code>"))
|
|
300
|
+
dl = "".join(f"<dt>{k}</dt><dd>{v}</dd>" for k, v in rows)
|
|
301
|
+
return (f'<details class="finding" data-severity="{_e(sev)}" data-tool="{_e(f.get("tool"))}" '
|
|
302
|
+
f'data-category="{_e(f.get("category"))}"><summary><span class="sev sev-{_e(sev)}">{_e(sev)}</span>'
|
|
303
|
+
f'<span class="title">{_e(f.get("title") or f.get("rule_id"))}</span>'
|
|
304
|
+
+ ({"in-git": '<span class="tag tag-bad">in Git</span>',
|
|
305
|
+
"local": '<span class="tag">local only</span>'}.get(exposure or "", ""))
|
|
306
|
+
+ f'<span class="tag">{_e(f.get("tool"))}</span><span class="loc">{_e(loc)}</span></summary>'
|
|
307
|
+
f'<div class="body"><dl>{dl}</dl></div></details>')
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _comparison_html(comparison: dict[str, Any] | None) -> str:
|
|
311
|
+
if not comparison:
|
|
312
|
+
return ('<p class="muted">No previous scan in history to compare with. Comparison appears after the next scan.</p>')
|
|
313
|
+
new, resolved = comparison.get("new", []), comparison.get("resolved", [])
|
|
314
|
+
stats = (f'<div class="sev-grid" style="grid-template-columns:repeat(4,minmax(0,1fr))">'
|
|
315
|
+
f'<div class="stat high"><div class="label">New</div><div class="value">{len(new)}</div></div>'
|
|
316
|
+
f'<div class="stat total"><div class="label">Resolved</div><div class="value">{len(resolved)}</div></div>'
|
|
317
|
+
f'<div class="stat unclassified"><div class="label">Unchanged</div><div class="value">{comparison.get("unchanged_count", 0)}</div></div>'
|
|
318
|
+
f'<div class="stat medium"><div class="label">Not re-scanned</div><div class="value">{len(comparison.get("not_rescanned", []))}</div></div></div>')
|
|
319
|
+
items = "".join(
|
|
320
|
+
f'<tr><td><span class="sev sev-{_e(f.get("severity"))}">{_e(f.get("severity"))}</span></td><td>{_e(f.get("title"))}</td>'
|
|
321
|
+
f'<td><code>{_e(f.get("file"))}</code></td><td>{_e(label)}</td></tr>'
|
|
322
|
+
for label, group in (("new", new[:50]), ("resolved", resolved[:50])) for f in group
|
|
323
|
+
)
|
|
324
|
+
table = (f'<div class="table-wrap" style="margin-top:12px"><table><thead><tr><th>Severity</th><th>Finding</th><th>File</th>'
|
|
325
|
+
f'<th>Change</th></tr></thead><tbody>{items}</tbody></table></div>') if items else ""
|
|
326
|
+
warnings = "".join(f'<div class="rec coverage"><div class="p">scope changed</div>{_e(w)}</div>'
|
|
327
|
+
for w in comparison.get("warnings", []))
|
|
328
|
+
return (f'<p class="muted">Compared with scan <code>{_e(comparison.get("base_scan_id"))}</code> for '
|
|
329
|
+
f'{_e(", ".join(comparison.get("compared_tools", [])) or "no common scanners")}.</p>'
|
|
330
|
+
f'{warnings}{stats}{table}'
|
|
331
|
+
f'<p class="muted" style="font-size:12px">{_e(comparison.get("note"))}</p>')
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _history_html(history: list[dict[str, Any]]) -> str:
|
|
335
|
+
if not history:
|
|
336
|
+
return '<p class="muted">No scan history yet.</p>'
|
|
337
|
+
rows = []
|
|
338
|
+
for entry in reversed(history[-20:]):
|
|
339
|
+
sev = entry.get("by_severity", {})
|
|
340
|
+
cells = "".join(f"<td>{_e(sev.get(s.value, 0))}</td>" for s in SEVERITY_ORDER)
|
|
341
|
+
rows.append(f'<tr><td><code>{_e(entry.get("scan_id"))}</code></td><td>{_status_pill(str(entry.get("overall_status")))}</td>'
|
|
342
|
+
f'<td>{_e(entry.get("finding_count"))}</td>{cells}<td>{_e(", ".join(entry.get("executed", [])))}</td></tr>')
|
|
343
|
+
head = "".join(f"<th>{_e(s.value.title())}</th>" for s in SEVERITY_ORDER)
|
|
344
|
+
return (f'<div class="table-wrap"><table><thead><tr><th>Scan</th><th>Status</th><th>Total</th>{head}<th>Scanners run</th>'
|
|
345
|
+
f'</tr></thead><tbody>{"".join(rows)}</tbody></table></div>')
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def render_html(report: dict[str, Any]) -> str:
|
|
349
|
+
summary = report.get("summary", {})
|
|
350
|
+
by_sev = summary.get("by_severity", {})
|
|
351
|
+
findings = report.get("findings", [])
|
|
352
|
+
shown = findings[:MAX_HTML_FINDINGS]
|
|
353
|
+
status = str(report.get("overall_status", "unknown"))
|
|
354
|
+
|
|
355
|
+
sev_cards = (f'<div class="stat total"><div class="label">Total findings</div><div class="value">{_e(summary.get("finding_count", 0))}</div></div>'
|
|
356
|
+
+ "".join(f'<div class="stat {s.value}"><div class="label">{_e(s.value.title())}</div>'
|
|
357
|
+
f'<div class="value">{_e(by_sev.get(s.value, 0))}</div></div>' for s in SEVERITY_ORDER))
|
|
358
|
+
|
|
359
|
+
scanner_rows = "".join(
|
|
360
|
+
f'<tr><td><b>{_e(s.get("tool"))}</b></td><td>{_status_pill(str(s.get("status")))}</td><td>{_e(s.get("version") or "—")}</td>'
|
|
361
|
+
f'<td>{_e(s.get("finding_count")) if s.get("executed") else "—"}</td><td class="muted">{_e(s.get("message"))}</td></tr>'
|
|
362
|
+
for s in report.get("scanners", [])
|
|
363
|
+
)
|
|
364
|
+
privacy_rows = "".join(
|
|
365
|
+
f'<li><b>{_e(p.get("tool"))}</b>: {_e(p.get("note"))}</li>' for p in report.get("privacy", []) if p.get("note")
|
|
366
|
+
)
|
|
367
|
+
recs = "".join(
|
|
368
|
+
f'<div class="rec {_e(r.get("priority"))}"><div class="p">{_e(r.get("priority"))}</div><b>{_e(r.get("title"))}</b>'
|
|
369
|
+
f'<div class="muted">{_e(r.get("detail"))}</div></div>' for r in report.get("recommendations", [])
|
|
370
|
+
) or '<p class="muted">No recommendations.</p>'
|
|
371
|
+
|
|
372
|
+
tools = sorted({str(f.get("tool")) for f in findings})
|
|
373
|
+
categories = sorted({str(f.get("category")) for f in findings})
|
|
374
|
+
tool_opts = "".join(f'<option value="{_e(t)}">{_e(t)}</option>' for t in tools)
|
|
375
|
+
cat_opts = "".join(f'<option value="{_e(c)}">{_e(c.title())}</option>' for c in categories)
|
|
376
|
+
sev_opts = "".join(f'<option value="{s.value}">{s.value.title()}</option>' for s in SEVERITY_ORDER)
|
|
377
|
+
finding_items = "".join(_finding_html(f) for f in shown) or \
|
|
378
|
+
'<p class="empty">No findings were returned by the scanners that executed.</p>'
|
|
379
|
+
truncated = (f'<p class="muted">Showing the first {MAX_HTML_FINDINGS} of {len(findings)} findings. '
|
|
380
|
+
'The JSON report contains all findings.</p>') if len(findings) > len(shown) else ""
|
|
381
|
+
limitations = "".join(f"<li>{_e(item)}</li>" for item in report.get("limitations", []))
|
|
382
|
+
project = report.get("project", {})
|
|
383
|
+
|
|
384
|
+
data = json.dumps(report, ensure_ascii=False).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
|
|
385
|
+
script_hash = base64.b64encode(hashlib.sha256(SCRIPT.encode("utf-8")).digest()).decode("ascii")
|
|
386
|
+
csp = (f"default-src 'none'; style-src 'unsafe-inline'; script-src 'sha256-{script_hash}'; "
|
|
387
|
+
"img-src data:; base-uri 'none'; form-action 'none'")
|
|
388
|
+
|
|
389
|
+
return f"""<!doctype html>
|
|
390
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
391
|
+
<meta http-equiv="Content-Security-Policy" content="{_e(csp)}">
|
|
392
|
+
<meta name="referrer" content="no-referrer"><meta name="color-scheme" content="dark">
|
|
393
|
+
<title>UDS Security Dashboard</title><style>{CSS}</style></head><body>
|
|
394
|
+
<a class="skip" href="#findings">Skip to findings</a>
|
|
395
|
+
<main>
|
|
396
|
+
<div class="topbar"><div class="brand"><div class="mark" aria-hidden="true">⬡</div><div>UNIVERSAL DEV SECURITY
|
|
397
|
+
<div class="muted" style="font-size:11px;font-weight:500">One Security System. Every Project.</div></div></div>
|
|
398
|
+
<div class="controls" style="margin:0"><button type="button" id="export-json">Export JSON</button>
|
|
399
|
+
<button type="button" id="export-html">Export HTML</button></div></div>
|
|
400
|
+
|
|
401
|
+
<header class="card"><div class="head"><div><div class="eyebrow">Security overview</div><h1>Security dashboard</h1>
|
|
402
|
+
<div class="muted">Results from locally executed scanners. Severity is never inferred.</div></div>{_status_pill(status)}</div>
|
|
403
|
+
<div class="meta"><div><b>Target</b> · <span class="muted">{_e(report.get("target"))}</span></div>
|
|
404
|
+
<div><b>Scan</b> · <code>{_e(report.get("scan_id"))}</code></div>
|
|
405
|
+
<div><b>Started</b> · {_e(report.get("started_at"))}</div>
|
|
406
|
+
<div><b>Technologies</b> · {_e(", ".join(project.get("detected", [])))}</div>
|
|
407
|
+
<div><b>UDS</b> · {_e(report.get("uds_version"))}</div>
|
|
408
|
+
<div><b>Security score</b> · Not calculated <span class="muted">(no validated methodology)</span></div></div></header>
|
|
409
|
+
|
|
410
|
+
<section class="card" aria-labelledby="sev-h"><div class="head"><h2 id="sev-h">Findings by severity</h2></div>
|
|
411
|
+
<div class="sev-grid">{sev_cards}</div></section>
|
|
412
|
+
|
|
413
|
+
<div class="grid2">
|
|
414
|
+
<section class="card" aria-labelledby="rec-h"><div class="head"><h2 id="rec-h">Recommendations</h2></div>{recs}</section>
|
|
415
|
+
<section class="card" aria-labelledby="cmp-h"><div class="head"><h2 id="cmp-h">Change since previous scan</h2></div>{_comparison_html(report.get("comparison"))}</section>
|
|
416
|
+
</div>
|
|
417
|
+
|
|
418
|
+
<section class="card" aria-labelledby="cov-h"><div class="head"><h2 id="cov-h">Scanner coverage</h2></div>
|
|
419
|
+
<div class="table-wrap"><table><thead><tr><th>Scanner</th><th>Status</th><th>Version</th><th>Findings</th><th>Details</th></tr></thead>
|
|
420
|
+
<tbody>{scanner_rows}</tbody></table></div>
|
|
421
|
+
<ul>{privacy_rows}</ul></section>
|
|
422
|
+
|
|
423
|
+
<section class="card" id="findings" aria-labelledby="find-h"><div class="head"><h2 id="find-h">Findings</h2>
|
|
424
|
+
<span class="pill muted-pill" id="visible-count" aria-live="polite">{len(shown)} shown</span></div>
|
|
425
|
+
<div class="controls"><input id="search" type="search" placeholder="Search title, rule, file, package…" aria-label="Search findings">
|
|
426
|
+
<select id="severity-filter" aria-label="Filter by severity"><option value="">All severities</option>{sev_opts}</select>
|
|
427
|
+
<select id="tool-filter" aria-label="Filter by scanner"><option value="">All scanners</option>{tool_opts}</select>
|
|
428
|
+
<select id="category-filter" aria-label="Filter by category"><option value="">All categories</option>{cat_opts}</select>
|
|
429
|
+
<button type="button" id="expand-all" aria-pressed="false">Expand all</button></div>
|
|
430
|
+
{truncated}<div id="findings-list">{finding_items}</div></section>
|
|
431
|
+
|
|
432
|
+
<section class="card" aria-labelledby="hist-h"><div class="head"><h2 id="hist-h">Scan history</h2></div>{_history_html(report.get("history", []))}</section>
|
|
433
|
+
|
|
434
|
+
<section class="card" aria-labelledby="lim-h"><h2 id="lim-h">Coverage & limitations</h2><ul>{limitations}</ul>
|
|
435
|
+
<p class="muted" style="font-size:12px">{_e(summary.get("score_note"))}</p></section>
|
|
436
|
+
<footer>Generated locally by UDS {_e(__version__)} · Secret values and raw scanner output are intentionally excluded.</footer>
|
|
437
|
+
</main>
|
|
438
|
+
<script type="application/json" id="uds-data">{data}</script>
|
|
439
|
+
<script>{SCRIPT}</script></body></html>"""
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def write_reports(report: dict[str, Any], target: Path, report_dir: Path | None = None,
|
|
443
|
+
formats: tuple[str, ...] = ("json", "html")) -> dict[str, str]:
|
|
444
|
+
"""Write the requested report formats; return their paths."""
|
|
445
|
+
root = target.expanduser().resolve()
|
|
446
|
+
if report_dir is None:
|
|
447
|
+
from universal_dev_security.history import ensure_uds_dir
|
|
448
|
+
ensure_uds_dir(root)
|
|
449
|
+
destination = root / ".uds" / "reports"
|
|
450
|
+
else:
|
|
451
|
+
destination = report_dir.expanduser().resolve()
|
|
452
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
453
|
+
paths: dict[str, str] = {}
|
|
454
|
+
if "json" in formats:
|
|
455
|
+
path = destination / "security-report.json"
|
|
456
|
+
path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n")
|
|
457
|
+
paths["json"] = str(path)
|
|
458
|
+
if "html" in formats:
|
|
459
|
+
path = destination / "security-report.html"
|
|
460
|
+
path.write_text(render_html(report), encoding="utf-8", newline="\n")
|
|
461
|
+
paths["html"] = str(path)
|
|
462
|
+
if "sarif" in formats:
|
|
463
|
+
path = destination / "security-report.sarif"
|
|
464
|
+
path.write_text(json.dumps(build_sarif(report), indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n")
|
|
465
|
+
paths["sarif"] = str(path)
|
|
466
|
+
return paths
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Backwards-compatible API from UDS 0.4.x.
|
|
2
|
+
|
|
3
|
+
New code should use :mod:`universal_dev_security.scanners` and
|
|
4
|
+
:func:`universal_dev_security.engine.run_scan`.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from universal_dev_security.projectfiles import build_inventory
|
|
12
|
+
from universal_dev_security.scanners.base import ScanOptions, find_executable
|
|
13
|
+
from universal_dev_security.scanners.gitleaks import GitleaksAdapter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find_gitleaks() -> str | None:
|
|
17
|
+
return find_executable("gitleaks", ("gitleaks",))[0]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def scan_with_gitleaks(target: Path) -> dict[str, Any]:
|
|
21
|
+
"""Run Gitleaks and return the 0.4.x result dictionary (sanitized)."""
|
|
22
|
+
resolved = target.expanduser().resolve()
|
|
23
|
+
if not resolved.is_dir():
|
|
24
|
+
raise ValueError(f"Scan target is not an existing directory: {resolved}")
|
|
25
|
+
result = GitleaksAdapter().scan(resolved, build_inventory(resolved), ScanOptions())
|
|
26
|
+
return {
|
|
27
|
+
"tool": "gitleaks",
|
|
28
|
+
"status": result.status.value,
|
|
29
|
+
"findings": [
|
|
30
|
+
{"rule_id": f.rule_id, "file": f.file, "start_line": f.start_line, "end_line": f.end_line,
|
|
31
|
+
"commit": f.commit or ""}
|
|
32
|
+
for f in result.findings
|
|
33
|
+
],
|
|
34
|
+
"finding_count": result.finding_count,
|
|
35
|
+
"message": result.message,
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Scanner adapter registry. Adapters are independent and replaceable."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from universal_dev_security.scanners.base import ScannerAdapter, ScanOptions
|
|
5
|
+
from universal_dev_security.scanners.gitleaks import GitleaksAdapter
|
|
6
|
+
from universal_dev_security.scanners.osv import OsvScannerAdapter
|
|
7
|
+
from universal_dev_security.scanners.semgrep import SemgrepAdapter
|
|
8
|
+
from universal_dev_security.scanners.trivy import TrivyAdapter
|
|
9
|
+
|
|
10
|
+
_REGISTRY: dict[str, type[ScannerAdapter]] = {
|
|
11
|
+
"gitleaks": GitleaksAdapter,
|
|
12
|
+
"semgrep": SemgrepAdapter,
|
|
13
|
+
"osv-scanner": OsvScannerAdapter,
|
|
14
|
+
"trivy": TrivyAdapter,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def available_scanners() -> list[str]:
|
|
19
|
+
return list(_REGISTRY)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_scanner(name: str) -> ScannerAdapter:
|
|
23
|
+
try:
|
|
24
|
+
return _REGISTRY[name]()
|
|
25
|
+
except KeyError as exc:
|
|
26
|
+
raise ValueError(f"Unknown scanner: {name}") from exc
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register_scanner(name: str, adapter: type[ScannerAdapter]) -> None:
|
|
30
|
+
"""Register a third-party adapter (used by plugins and tests)."""
|
|
31
|
+
if not issubclass(adapter, ScannerAdapter):
|
|
32
|
+
raise TypeError("Adapter must subclass ScannerAdapter")
|
|
33
|
+
_REGISTRY[name] = adapter
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = ["ScannerAdapter", "ScanOptions", "available_scanners", "get_scanner", "register_scanner"]
|