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,376 @@
|
|
|
1
|
+
"""Command-line interface for Universal Dev Security."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
from universal_dev_security import __version__
|
|
14
|
+
from universal_dev_security.config import FAIL_ON_LEVELS, ConfigError, ProjectConfig, load_config
|
|
15
|
+
from universal_dev_security.detector import detect_project
|
|
16
|
+
from universal_dev_security.initializer import initialize_project
|
|
17
|
+
|
|
18
|
+
EXIT_OK, EXIT_FINDINGS, EXIT_ERROR = 0, 1, 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _out(data: Any) -> None:
|
|
22
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _err(message: str) -> None:
|
|
26
|
+
print(f"uds: {message}", file=sys.stderr)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _project(path: str) -> Path:
|
|
30
|
+
target = Path(path).expanduser().resolve()
|
|
31
|
+
if not target.is_dir():
|
|
32
|
+
raise ValueError(f"Not an existing directory: {target}")
|
|
33
|
+
return target
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _config(root: Path) -> ProjectConfig:
|
|
37
|
+
return load_config(root)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Commands
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
45
|
+
from universal_dev_security.scanners import available_scanners, get_scanner
|
|
46
|
+
|
|
47
|
+
root = _project(args.path)
|
|
48
|
+
scanners = [get_scanner(name).info(root) for name in available_scanners()]
|
|
49
|
+
try:
|
|
50
|
+
config = load_config(root)
|
|
51
|
+
config_state = {"exists": config.exists, "warnings": config.warnings}
|
|
52
|
+
except ConfigError as exc:
|
|
53
|
+
config_state = {"exists": True, "error": str(exc)}
|
|
54
|
+
data = {
|
|
55
|
+
"uds_version": __version__,
|
|
56
|
+
"python": sys.version.split()[0],
|
|
57
|
+
"platform": f"{platform.system()} {platform.release()}",
|
|
58
|
+
"git": shutil.which("git") is not None,
|
|
59
|
+
"config": config_state,
|
|
60
|
+
"scanners": scanners,
|
|
61
|
+
"network_used": False,
|
|
62
|
+
"global_settings_modified": False,
|
|
63
|
+
}
|
|
64
|
+
if args.json:
|
|
65
|
+
_out(data)
|
|
66
|
+
else:
|
|
67
|
+
print(f"UDS {__version__} · Python {data['python']} · {data['platform']}")
|
|
68
|
+
print(f"Git available: {'yes' if data['git'] else 'no'}")
|
|
69
|
+
print(f"Project config: {'found' if config_state.get('exists') else 'not initialized'}")
|
|
70
|
+
for warning in config_state.get("warnings", []):
|
|
71
|
+
print(f" ! {warning}")
|
|
72
|
+
if config_state.get("error"):
|
|
73
|
+
print(f" ! {config_state['error']}")
|
|
74
|
+
print("Scanners:")
|
|
75
|
+
for s in scanners:
|
|
76
|
+
if not s["installed"]:
|
|
77
|
+
state = "not installed" + (f" ({s['note']})" if s["note"] else "")
|
|
78
|
+
elif not s["compatible"]:
|
|
79
|
+
state = f"installed {s['version'] or 'unknown version'} — UNSUPPORTED (need {s['minimum_version']}+)"
|
|
80
|
+
else:
|
|
81
|
+
state = f"OK {s['version']}"
|
|
82
|
+
print(f" {s['display_name']:<12} {state}")
|
|
83
|
+
if not s["installed"]:
|
|
84
|
+
hint = s["install_hints"].get({"Windows": "windows", "Darwin": "macos"}.get(platform.system(), "linux"))
|
|
85
|
+
print(f" {'':<12} install: {hint} ({s['docs_url']})")
|
|
86
|
+
print("UDS runtime: OK (no network access used, no global settings modified)")
|
|
87
|
+
return EXIT_OK
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_detect(args: argparse.Namespace) -> int:
|
|
91
|
+
_out(detect_project(_project(args.path)))
|
|
92
|
+
return EXIT_OK
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
96
|
+
result = initialize_project(Path(args.path))
|
|
97
|
+
_out(result)
|
|
98
|
+
return EXIT_OK if result["status"] in {"initialized", "already_initialized"} else EXIT_ERROR
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
102
|
+
from universal_dev_security.history import list_scans, load_scan
|
|
103
|
+
|
|
104
|
+
target = _project(args.path)
|
|
105
|
+
config = target / ".uds" / "config.toml"
|
|
106
|
+
if not config.is_file():
|
|
107
|
+
print(f"UDS not initialized: {target}")
|
|
108
|
+
print("Run `uds init` to create the local project configuration.")
|
|
109
|
+
return EXIT_FINDINGS
|
|
110
|
+
print(f"UDS initialized: {target}")
|
|
111
|
+
print(f"Configuration: {config}")
|
|
112
|
+
scans = list_scans(target)
|
|
113
|
+
if scans:
|
|
114
|
+
latest = load_scan(target, "latest")
|
|
115
|
+
print(f"Latest scan: {latest['scan_id']} · status {latest['overall_status']} · "
|
|
116
|
+
f"{latest['summary']['finding_count']} finding(s) · scanners run: "
|
|
117
|
+
f"{', '.join(latest['coverage']['executed']) or 'none'}")
|
|
118
|
+
else:
|
|
119
|
+
print("Latest scan: none (run `uds scan`)")
|
|
120
|
+
return EXIT_OK
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _print_scan_summary(scan: dict[str, Any], paths: dict[str, str], comparison: dict[str, Any] | None) -> None:
|
|
124
|
+
summary = scan["summary"]
|
|
125
|
+
print(f"UDS scan {scan['scan_id']} · {scan['target']}")
|
|
126
|
+
print(f"Overall status: {scan['overall_status'].upper()}")
|
|
127
|
+
for s in scan["scanners"]:
|
|
128
|
+
print(f" {s['tool']:<12} {s['status']:<15} {s['finding_count'] if s['executed'] else '-':>5} {s['message']}")
|
|
129
|
+
sev = summary["by_severity"]
|
|
130
|
+
print("Findings: " + ", ".join(f"{k} {v}" for k, v in sev.items()) + f" (total {summary['finding_count']})")
|
|
131
|
+
if comparison:
|
|
132
|
+
print(f"Since previous scan: {len(comparison['new'])} new, {len(comparison['resolved'])} resolved, "
|
|
133
|
+
f"{len(comparison['not_rescanned'])} not re-scanned")
|
|
134
|
+
for warning in comparison.get("warnings", []):
|
|
135
|
+
print(f" ! {warning}")
|
|
136
|
+
for kind, path in paths.items():
|
|
137
|
+
print(f"Report ({kind}): {path}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
141
|
+
from universal_dev_security.engine import exit_code_for, run_scan
|
|
142
|
+
from universal_dev_security.history import compare_scans, history_summaries, list_scans, load_scan, save_scan
|
|
143
|
+
from universal_dev_security.reports import build_report, write_reports
|
|
144
|
+
|
|
145
|
+
root = _project(args.path)
|
|
146
|
+
config = _config(root)
|
|
147
|
+
for warning in config.warnings:
|
|
148
|
+
_err(f"config warning: {warning}")
|
|
149
|
+
progress: Callable[[str], None] | None = None if (args.json or args.quiet) else (lambda m: print(m, file=sys.stderr))
|
|
150
|
+
scan = run_scan(root, config, tools=args.tool or None,
|
|
151
|
+
allow_network=True if args.allow_network else None, progress=progress)
|
|
152
|
+
|
|
153
|
+
comparison = None
|
|
154
|
+
if not args.no_history:
|
|
155
|
+
previous = list_scans(root)
|
|
156
|
+
if previous:
|
|
157
|
+
try:
|
|
158
|
+
comparison = compare_scans(load_scan(root, previous[-1]), scan)
|
|
159
|
+
except (OSError, ValueError):
|
|
160
|
+
comparison = None
|
|
161
|
+
save_scan(root, scan, config.history_limit)
|
|
162
|
+
history = [] if args.no_history else history_summaries(root)
|
|
163
|
+
report = build_report(scan, comparison, history)
|
|
164
|
+
formats = tuple(f.strip() for f in args.format.split(",") if f.strip())
|
|
165
|
+
paths = write_reports(report, root, Path(args.report_dir) if args.report_dir else None, formats)
|
|
166
|
+
fail_on = args.fail_on or config.fail_on
|
|
167
|
+
code = exit_code_for(scan, fail_on, args.require_complete)
|
|
168
|
+
if args.json:
|
|
169
|
+
_out({"scan_id": scan["scan_id"], "overall_status": scan["overall_status"], "exit_code": code,
|
|
170
|
+
"summary": scan["summary"], "scanners": scan["scanners"], "reports": paths,
|
|
171
|
+
"comparison": {k: (len(v) if isinstance(v, list) else v) for k, v in comparison.items()} if comparison else None})
|
|
172
|
+
elif not args.quiet:
|
|
173
|
+
_print_scan_summary(scan, paths, comparison)
|
|
174
|
+
if code == EXIT_FINDINGS:
|
|
175
|
+
print(f"Result: findings at or above fail-on threshold '{fail_on}'.")
|
|
176
|
+
elif code == EXIT_ERROR:
|
|
177
|
+
print("Result: scan incomplete or failed — results must not be treated as a pass.")
|
|
178
|
+
return code
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
182
|
+
from universal_dev_security.history import compare_scans, history_summaries, list_scans, load_scan, resolve_scan_id
|
|
183
|
+
from universal_dev_security.reports import build_report, write_reports
|
|
184
|
+
|
|
185
|
+
root = _project(args.path)
|
|
186
|
+
scan_id = resolve_scan_id(root, args.scan)
|
|
187
|
+
scan = load_scan(root, scan_id)
|
|
188
|
+
scans = list_scans(root)
|
|
189
|
+
index = scans.index(scan_id)
|
|
190
|
+
comparison = compare_scans(load_scan(root, scans[index - 1]), scan) if index > 0 else None
|
|
191
|
+
history = [h for h in history_summaries(root, 1000) if str(h["scan_id"]) <= scan_id][-20:]
|
|
192
|
+
formats = tuple(f.strip() for f in args.format.split(",") if f.strip())
|
|
193
|
+
paths = write_reports(build_report(scan, comparison, history), root,
|
|
194
|
+
Path(args.report_dir) if args.report_dir else None, formats)
|
|
195
|
+
_out(paths)
|
|
196
|
+
return EXIT_OK
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
200
|
+
root = _project(args.path)
|
|
201
|
+
html = (Path(args.report_dir).expanduser().resolve() if args.report_dir else root / ".uds" / "reports") / "security-report.html"
|
|
202
|
+
if not html.is_file():
|
|
203
|
+
_err("No dashboard found. Run `uds scan` first.")
|
|
204
|
+
return EXIT_ERROR
|
|
205
|
+
print(html)
|
|
206
|
+
if args.open:
|
|
207
|
+
import webbrowser
|
|
208
|
+
webbrowser.open(html.as_uri())
|
|
209
|
+
return EXIT_OK
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def cmd_history(args: argparse.Namespace) -> int:
|
|
213
|
+
from universal_dev_security.history import history_summaries
|
|
214
|
+
|
|
215
|
+
entries = history_summaries(_project(args.path), args.limit)
|
|
216
|
+
if args.json:
|
|
217
|
+
_out(entries)
|
|
218
|
+
return EXIT_OK
|
|
219
|
+
if not entries:
|
|
220
|
+
print("No scan history. Run `uds scan`.")
|
|
221
|
+
return EXIT_OK
|
|
222
|
+
for e in entries:
|
|
223
|
+
sev = e["by_severity"]
|
|
224
|
+
print(f"{e['scan_id']} {e['overall_status']:<12} total {e['finding_count']:>4} "
|
|
225
|
+
+ " ".join(f"{k[:4]}:{v}" for k, v in sev.items()) + f" [{', '.join(e['executed'])}]")
|
|
226
|
+
return EXIT_OK
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def cmd_compare(args: argparse.Namespace) -> int:
|
|
230
|
+
from universal_dev_security.history import compare_scans, load_scan
|
|
231
|
+
|
|
232
|
+
root = _project(args.path)
|
|
233
|
+
result = compare_scans(load_scan(root, args.base), load_scan(root, args.head))
|
|
234
|
+
if args.json:
|
|
235
|
+
_out(result)
|
|
236
|
+
return EXIT_OK
|
|
237
|
+
print(f"Base {result['base_scan_id']} → head {result['current_scan_id']} (tools: {', '.join(result['compared_tools']) or 'none'})")
|
|
238
|
+
print(f"New: {len(result['new'])} Resolved: {len(result['resolved'])} Unchanged: {result['unchanged_count']} "
|
|
239
|
+
f"Not re-scanned: {len(result['not_rescanned'])}")
|
|
240
|
+
for label in ("new", "resolved"):
|
|
241
|
+
for f in result[label][:50]:
|
|
242
|
+
print(f" [{label}] {f['severity']:<12} {f['tool']:<11} {f['rule_id']} {f.get('file') or ''}")
|
|
243
|
+
return EXIT_OK
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def cmd_policy(args: argparse.Namespace) -> int:
|
|
247
|
+
from universal_dev_security.policy import GLOBAL_COMMAND_RULES, Action, ActionKind, PolicyEngine
|
|
248
|
+
|
|
249
|
+
root = _project(args.path)
|
|
250
|
+
config = _config(root)
|
|
251
|
+
if args.policy_command == "show":
|
|
252
|
+
_out({
|
|
253
|
+
"precedence": ["mandatory global policy", "technology profile", "project configuration"],
|
|
254
|
+
"global_command_rules": [{"rule_id": r.rule_id, "decision": r.decision.value, "risk": r.risk.value,
|
|
255
|
+
"reason": r.reason} for r in GLOBAL_COMMAND_RULES],
|
|
256
|
+
"project": config.to_dict(),
|
|
257
|
+
})
|
|
258
|
+
return EXIT_OK
|
|
259
|
+
engine = PolicyEngine(config)
|
|
260
|
+
if args.command_text is not None:
|
|
261
|
+
action = Action(ActionKind.SHELL, command=args.command_text, project_root=str(root))
|
|
262
|
+
else:
|
|
263
|
+
content = Path(args.content_file).read_text(encoding="utf-8", errors="replace") if args.content_file else ""
|
|
264
|
+
action = Action(ActionKind.WRITE_FILE, path=args.write, content=content, project_root=str(root))
|
|
265
|
+
decision = engine.evaluate(action)
|
|
266
|
+
_out(decision.to_dict())
|
|
267
|
+
return {"allow": EXIT_OK, "ask": EXIT_FINDINGS, "deny": EXIT_ERROR}[decision.decision.value]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def cmd_scanners(args: argparse.Namespace) -> int:
|
|
271
|
+
from universal_dev_security.scanners import available_scanners, get_scanner
|
|
272
|
+
|
|
273
|
+
_out([get_scanner(name).info(Path.cwd()) for name in available_scanners()])
|
|
274
|
+
return EXIT_OK
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# Parser
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
282
|
+
parser = argparse.ArgumentParser(prog="uds", description="Universal Dev Security — One Security System. Every Project.")
|
|
283
|
+
parser.add_argument("--version", action="version", version=f"UDS {__version__}")
|
|
284
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
285
|
+
|
|
286
|
+
def with_path(p: argparse.ArgumentParser) -> argparse.ArgumentParser:
|
|
287
|
+
p.add_argument("path", nargs="?", default=".", help="Project directory (default: current directory)")
|
|
288
|
+
return p
|
|
289
|
+
|
|
290
|
+
p = with_path(sub.add_parser("doctor", help="Check the UDS runtime, configuration and scanners"))
|
|
291
|
+
p.add_argument("--json", action="store_true")
|
|
292
|
+
p.set_defaults(func=cmd_doctor)
|
|
293
|
+
with_path(sub.add_parser("detect", help="Detect project technologies and scanning profile")).set_defaults(func=cmd_detect)
|
|
294
|
+
with_path(sub.add_parser("init", help="Initialize UDS in a project safely (never overwrites)")).set_defaults(func=cmd_init)
|
|
295
|
+
with_path(sub.add_parser("status", help="Show UDS project status")).set_defaults(func=cmd_status)
|
|
296
|
+
|
|
297
|
+
p = with_path(sub.add_parser("scan", help="Run applicable security scanners and write reports"))
|
|
298
|
+
p.add_argument("--tool", action="append", choices=["gitleaks", "semgrep", "osv-scanner", "trivy"],
|
|
299
|
+
help="Run only this scanner (repeatable). Default: scanners enabled in config.")
|
|
300
|
+
p.add_argument("--report-dir", help="Directory for reports (default: PATH/.uds/reports)")
|
|
301
|
+
p.add_argument("--format", default="json,html", help="Comma-separated: json,html,sarif (default: json,html)")
|
|
302
|
+
p.add_argument("--allow-network", action="store_true",
|
|
303
|
+
help="Allow scanners that send package metadata to external services (OSV-Scanner)")
|
|
304
|
+
p.add_argument("--fail-on", choices=FAIL_ON_LEVELS, help="Exit 1 when findings reach this severity")
|
|
305
|
+
p.add_argument("--require-complete", action="store_true",
|
|
306
|
+
help="Exit 2 when an applicable scanner did not run (not installed or skipped)")
|
|
307
|
+
p.add_argument("--no-history", action="store_true", help="Do not record this scan in .uds/history")
|
|
308
|
+
p.add_argument("--json", action="store_true", help="Print a machine-readable summary")
|
|
309
|
+
p.add_argument("--quiet", action="store_true")
|
|
310
|
+
p.set_defaults(func=cmd_scan)
|
|
311
|
+
|
|
312
|
+
p = with_path(sub.add_parser("report", help="Regenerate reports from scan history"))
|
|
313
|
+
p.add_argument("--scan", default="latest", help="Scan id, 'latest' or 'previous'")
|
|
314
|
+
p.add_argument("--report-dir")
|
|
315
|
+
p.add_argument("--format", default="json,html")
|
|
316
|
+
p.set_defaults(func=cmd_report)
|
|
317
|
+
|
|
318
|
+
p = with_path(sub.add_parser("dashboard", help="Show (or open) the latest HTML dashboard"))
|
|
319
|
+
p.add_argument("--report-dir")
|
|
320
|
+
p.add_argument("--open", action="store_true", help="Open the dashboard in the default browser")
|
|
321
|
+
p.set_defaults(func=cmd_dashboard)
|
|
322
|
+
|
|
323
|
+
p = with_path(sub.add_parser("history", help="List previous scans"))
|
|
324
|
+
p.add_argument("--limit", type=int, default=20)
|
|
325
|
+
p.add_argument("--json", action="store_true")
|
|
326
|
+
p.set_defaults(func=cmd_history)
|
|
327
|
+
|
|
328
|
+
p = with_path(sub.add_parser("compare", help="Compare two scans from history"))
|
|
329
|
+
p.add_argument("--base", default="previous")
|
|
330
|
+
p.add_argument("--head", default="latest")
|
|
331
|
+
p.add_argument("--json", action="store_true")
|
|
332
|
+
p.set_defaults(func=cmd_compare)
|
|
333
|
+
|
|
334
|
+
p = sub.add_parser("policy", help="Show or test the effective security policy")
|
|
335
|
+
psub = p.add_subparsers(dest="policy_command", required=True)
|
|
336
|
+
with_path(psub.add_parser("show", help="Print global rules and project policy")).set_defaults(func=cmd_policy)
|
|
337
|
+
pc = with_path(psub.add_parser("check", help="Evaluate a command or file write against the policy"))
|
|
338
|
+
group = pc.add_mutually_exclusive_group(required=True)
|
|
339
|
+
group.add_argument("--command", dest="command_text", help="Shell command to evaluate")
|
|
340
|
+
group.add_argument("--write", help="Relative path of a file about to be written")
|
|
341
|
+
pc.add_argument("--content-file", help="File whose content would be written (with --write)")
|
|
342
|
+
pc.set_defaults(func=cmd_policy)
|
|
343
|
+
|
|
344
|
+
sub.add_parser("scanners", help="List supported scanners, versions and install sources").set_defaults(func=cmd_scanners)
|
|
345
|
+
|
|
346
|
+
from universal_dev_security import cli_extra
|
|
347
|
+
cli_extra.register(sub, with_path)
|
|
348
|
+
return parser
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def main(argv: list[str] | None = None) -> int:
|
|
352
|
+
if os.name == "nt":
|
|
353
|
+
for stream in (sys.stdout, sys.stderr):
|
|
354
|
+
try:
|
|
355
|
+
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
356
|
+
except (AttributeError, ValueError):
|
|
357
|
+
pass
|
|
358
|
+
args = build_parser().parse_args(argv)
|
|
359
|
+
try:
|
|
360
|
+
return int(args.func(args))
|
|
361
|
+
except ConfigError as exc:
|
|
362
|
+
_err(str(exc))
|
|
363
|
+
return EXIT_ERROR
|
|
364
|
+
except (ValueError, FileNotFoundError, NotADirectoryError) as exc:
|
|
365
|
+
_err(str(exc))
|
|
366
|
+
return EXIT_ERROR
|
|
367
|
+
except PermissionError as exc:
|
|
368
|
+
_err(f"Permission denied: {exc.filename or ''}")
|
|
369
|
+
return EXIT_ERROR
|
|
370
|
+
except KeyboardInterrupt:
|
|
371
|
+
_err("Interrupted.")
|
|
372
|
+
return 130
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
if __name__ == "__main__":
|
|
376
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""CLI commands for integrations, Git protection, recovery, fixes, testing and updates."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
EXIT_OK, EXIT_FINDINGS, EXIT_ERROR = 0, 1, 2
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _out(data: Any) -> None:
|
|
14
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _project(path: str) -> Path:
|
|
18
|
+
target = Path(path).expanduser().resolve()
|
|
19
|
+
if not target.is_dir():
|
|
20
|
+
raise ValueError(f"Not an existing directory: {target}")
|
|
21
|
+
return target
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _print_plan(result: dict[str, Any], apply: bool) -> None:
|
|
25
|
+
if not result["changes"]:
|
|
26
|
+
print("No changes needed.")
|
|
27
|
+
for change in result["changes"]:
|
|
28
|
+
print(f"{change['action'].upper()}: {change['path']}")
|
|
29
|
+
if not apply and change.get("diff"):
|
|
30
|
+
print(change["diff"])
|
|
31
|
+
if apply and result.get("checkpoint"):
|
|
32
|
+
print(f"Applied. Recovery checkpoint: {result['checkpoint']} (undo with `uds rollback {result['checkpoint']}`)")
|
|
33
|
+
elif not apply and result["changes"]:
|
|
34
|
+
print("Dry run only. Re-run with --apply to make these changes.")
|
|
35
|
+
for note in result.get("notes", []):
|
|
36
|
+
print(f"note: {note}")
|
|
37
|
+
for conflict in result.get("conflicts", []):
|
|
38
|
+
print(f"conflict: {conflict['path']}: {conflict['message']}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# -- hooks runtime -----------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def cmd_hook(args: argparse.Namespace) -> int:
|
|
44
|
+
if args.hook_name == "claude-code":
|
|
45
|
+
from universal_dev_security.integrations.claude_code import run_hook
|
|
46
|
+
|
|
47
|
+
code, output = run_hook(sys.stdin.read())
|
|
48
|
+
if output:
|
|
49
|
+
print(output)
|
|
50
|
+
return code
|
|
51
|
+
from universal_dev_security import gitguard
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
if args.hook_name == "git-pre-commit":
|
|
55
|
+
code, message = gitguard.pre_commit(Path.cwd())
|
|
56
|
+
else:
|
|
57
|
+
remote = args.hook_args[0] if args.hook_args else "origin"
|
|
58
|
+
code, message = gitguard.pre_push(Path.cwd(), remote, sys.stdin.read())
|
|
59
|
+
except Exception as exc: # noqa: BLE001 - fail closed
|
|
60
|
+
print(f"UDS: security check failed ({type(exc).__name__}); blocking to be safe.", file=sys.stderr)
|
|
61
|
+
return EXIT_FINDINGS
|
|
62
|
+
print(message, file=sys.stderr)
|
|
63
|
+
return code
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_guard(args: argparse.Namespace) -> int:
|
|
67
|
+
from universal_dev_security.integrations.agents import guard
|
|
68
|
+
|
|
69
|
+
code, payload = guard(sys.stdin.read(), str(Path(args.project).expanduser().resolve()))
|
|
70
|
+
_out(payload)
|
|
71
|
+
return code
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# -- installation commands ------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def cmd_hooks(args: argparse.Namespace) -> int:
|
|
77
|
+
from universal_dev_security import gitguard
|
|
78
|
+
|
|
79
|
+
root = _project(args.path)
|
|
80
|
+
try:
|
|
81
|
+
if args.hooks_command == "status":
|
|
82
|
+
_out(gitguard.hooks_status(root))
|
|
83
|
+
return EXIT_OK
|
|
84
|
+
python = sys.executable if args.python_path else None
|
|
85
|
+
result = gitguard.install_hooks(root, apply=args.apply, uninstall=args.hooks_command == "uninstall",
|
|
86
|
+
python=python)
|
|
87
|
+
except gitguard.GitError as exc:
|
|
88
|
+
print(f"uds: {exc}", file=sys.stderr)
|
|
89
|
+
return EXIT_ERROR
|
|
90
|
+
_print_plan(result, args.apply)
|
|
91
|
+
return EXIT_FINDINGS if result.get("conflicts") else EXIT_OK
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_integrate(args: argparse.Namespace) -> int:
|
|
95
|
+
root = _project(args.path)
|
|
96
|
+
if args.target == "claude-code":
|
|
97
|
+
from universal_dev_security.integrations import claude_code
|
|
98
|
+
|
|
99
|
+
result = claude_code.install(root, apply=args.apply, remove=args.remove, local=args.local,
|
|
100
|
+
use_python_path=args.python_path)
|
|
101
|
+
elif args.target == "github":
|
|
102
|
+
from universal_dev_security.integrations import github
|
|
103
|
+
|
|
104
|
+
result = github.install(root, apply=args.apply, remove=args.remove, branch=args.branch,
|
|
105
|
+
fail_on=args.fail_on, uds_spec=args.uds_spec)
|
|
106
|
+
else:
|
|
107
|
+
from universal_dev_security.integrations import vscode
|
|
108
|
+
|
|
109
|
+
result = vscode.install(root, apply=args.apply, remove=args.remove)
|
|
110
|
+
if args.json:
|
|
111
|
+
_out(result)
|
|
112
|
+
else:
|
|
113
|
+
_print_plan(result, args.apply)
|
|
114
|
+
return EXIT_OK
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# -- recovery ---------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def cmd_checkpoint(args: argparse.Namespace) -> int:
|
|
120
|
+
from universal_dev_security.recovery import RecoveryManager
|
|
121
|
+
|
|
122
|
+
manager = RecoveryManager(_project(args.path))
|
|
123
|
+
if args.checkpoint_command == "list":
|
|
124
|
+
_out(manager.list())
|
|
125
|
+
return EXIT_OK
|
|
126
|
+
checkpoint = manager.create(args.label or "manual checkpoint", args.files)
|
|
127
|
+
manager.seal(checkpoint)
|
|
128
|
+
_out({"checkpoint": checkpoint, "files": args.files})
|
|
129
|
+
return EXIT_OK
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def cmd_rollback(args: argparse.Namespace) -> int:
|
|
133
|
+
from universal_dev_security.recovery import RecoveryManager
|
|
134
|
+
|
|
135
|
+
result = RecoveryManager(_project(args.path)).restore(args.checkpoint, force=args.force)
|
|
136
|
+
_out(result)
|
|
137
|
+
return EXIT_OK if result["status"] == "restored" else EXIT_FINDINGS
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# -- fixes and tests ------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def cmd_fix(args: argparse.Namespace) -> int:
|
|
143
|
+
from universal_dev_security.config import load_config
|
|
144
|
+
from universal_dev_security.fixes import apply_fixes, manual_recommendations, plan_fixes
|
|
145
|
+
from universal_dev_security.history import list_scans, load_scan
|
|
146
|
+
|
|
147
|
+
root = _project(args.path)
|
|
148
|
+
config = load_config(root)
|
|
149
|
+
latest = load_scan(root, "latest") if list_scans(root) else None
|
|
150
|
+
if not args.apply:
|
|
151
|
+
_out({"automatic_fixes": plan_fixes(root), "manual_recommendations": manual_recommendations(latest),
|
|
152
|
+
"note": "Dry run. Use --apply for low-risk fixes; add --include-medium for medium-risk fixes."})
|
|
153
|
+
return EXIT_OK
|
|
154
|
+
result = apply_fixes(root, config, include_medium=args.include_medium, run_test_suite=not args.skip_tests,
|
|
155
|
+
approved=args.approve)
|
|
156
|
+
_out(result)
|
|
157
|
+
return EXIT_OK if result["status"] in ("applied", "nothing_to_do") else EXIT_FINDINGS
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_test(args: argparse.Namespace) -> int:
|
|
161
|
+
from universal_dev_security.config import load_config
|
|
162
|
+
from universal_dev_security.testing import run_tests
|
|
163
|
+
|
|
164
|
+
root = _project(args.path)
|
|
165
|
+
result = run_tests(root, load_config(root))
|
|
166
|
+
_out(result)
|
|
167
|
+
return {"passed": EXIT_OK, "not_configured": EXIT_FINDINGS}.get(result["status"], EXIT_ERROR)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# -- updates ------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def cmd_update(args: argparse.Namespace) -> int:
|
|
173
|
+
from universal_dev_security.updates import check_uds_update, scanner_status
|
|
174
|
+
|
|
175
|
+
if args.update_command == "scanners":
|
|
176
|
+
_out(scanner_status())
|
|
177
|
+
return EXIT_OK
|
|
178
|
+
result = check_uds_update(args.source)
|
|
179
|
+
_out(result)
|
|
180
|
+
return EXIT_ERROR if result["status"] == "error" else EXIT_OK
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def register(sub: Any, with_path: Callable[[argparse.ArgumentParser], argparse.ArgumentParser]) -> None:
|
|
184
|
+
p = sub.add_parser("hook", help="Hook entry points (called by Git and Claude Code)")
|
|
185
|
+
p.add_argument("hook_name", choices=["claude-code", "git-pre-commit", "git-pre-push"])
|
|
186
|
+
p.add_argument("hook_args", nargs="*")
|
|
187
|
+
p.set_defaults(func=cmd_hook)
|
|
188
|
+
|
|
189
|
+
p = sub.add_parser("guard", help="Policy decision for AI agents: JSON action on stdin, decision on stdout")
|
|
190
|
+
p.add_argument("--project", default=".", help="Project root (default: current directory)")
|
|
191
|
+
p.set_defaults(func=cmd_guard)
|
|
192
|
+
|
|
193
|
+
p = sub.add_parser("hooks", help="Install, remove or inspect UDS Git hooks")
|
|
194
|
+
hsub = p.add_subparsers(dest="hooks_command", required=True)
|
|
195
|
+
for name in ("install", "uninstall"):
|
|
196
|
+
hp = with_path(hsub.add_parser(name))
|
|
197
|
+
hp.add_argument("--apply", action="store_true", help="Make the change (default is a dry run)")
|
|
198
|
+
hp.add_argument("--python-path", action="store_true",
|
|
199
|
+
help="Embed this Python interpreter in the hook instead of relying on `uds` on PATH")
|
|
200
|
+
hp.set_defaults(func=cmd_hooks)
|
|
201
|
+
with_path(hsub.add_parser("status")).set_defaults(func=cmd_hooks, apply=False, python_path=False)
|
|
202
|
+
|
|
203
|
+
p = sub.add_parser("integrate", help="Set up Claude Code, GitHub Actions or VS Code integration")
|
|
204
|
+
p.add_argument("target", choices=["claude-code", "github", "vscode"])
|
|
205
|
+
with_path(p)
|
|
206
|
+
p.add_argument("--apply", action="store_true", help="Make the change (default is a dry run with a diff)")
|
|
207
|
+
p.add_argument("--remove", action="store_true", help="Remove UDS-owned configuration only")
|
|
208
|
+
p.add_argument("--local", action="store_true", help="Claude Code: write .claude/settings.local.json")
|
|
209
|
+
p.add_argument("--python-path", action="store_true", help="Claude Code: embed this interpreter path in the hook")
|
|
210
|
+
p.add_argument("--branch", default="main", help="GitHub: default branch for push scans")
|
|
211
|
+
p.add_argument("--fail-on", default="high", help="GitHub: severity threshold that fails the check")
|
|
212
|
+
p.add_argument("--uds-spec", default="universal-dev-security", help="GitHub: pip install spec for UDS")
|
|
213
|
+
p.add_argument("--json", action="store_true")
|
|
214
|
+
p.set_defaults(func=cmd_integrate)
|
|
215
|
+
|
|
216
|
+
p = sub.add_parser("checkpoint", help="Create or list recovery checkpoints")
|
|
217
|
+
csub = p.add_subparsers(dest="checkpoint_command", required=True)
|
|
218
|
+
cp = with_path(csub.add_parser("create"))
|
|
219
|
+
cp.add_argument("--label")
|
|
220
|
+
cp.add_argument("--file", dest="files", action="append", required=True, help="Project-relative file (repeatable)")
|
|
221
|
+
cp.set_defaults(func=cmd_checkpoint)
|
|
222
|
+
with_path(csub.add_parser("list")).set_defaults(func=cmd_checkpoint)
|
|
223
|
+
|
|
224
|
+
p = sub.add_parser("rollback", help="Restore a recovery checkpoint (keeps later user edits unless --force)")
|
|
225
|
+
p.add_argument("checkpoint")
|
|
226
|
+
with_path(p)
|
|
227
|
+
p.add_argument("--force", action="store_true", help="Also overwrite files changed after the checkpoint")
|
|
228
|
+
p.set_defaults(func=cmd_rollback)
|
|
229
|
+
|
|
230
|
+
p = with_path(sub.add_parser("fix", help="Plan or apply safe, reversible automatic fixes"))
|
|
231
|
+
p.add_argument("--apply", action="store_true")
|
|
232
|
+
p.add_argument("--include-medium", action="store_true")
|
|
233
|
+
p.add_argument("--skip-tests", action="store_true", help="Do not run tests before/after fixing")
|
|
234
|
+
p.add_argument("--approve", action="store_true", help="Explicit approval when automatic fixes are disabled by config")
|
|
235
|
+
p.set_defaults(func=cmd_fix)
|
|
236
|
+
|
|
237
|
+
with_path(sub.add_parser("test", help="Detect and run the project's test suite")).set_defaults(func=cmd_test)
|
|
238
|
+
|
|
239
|
+
p = sub.add_parser("update", help="Check for UDS updates or show scanner versions (never installs)")
|
|
240
|
+
usub = p.add_subparsers(dest="update_command", required=True)
|
|
241
|
+
up = usub.add_parser("check")
|
|
242
|
+
up.add_argument("--source", help="'pypi' or 'github:OWNER/REPO' (network request only when given)")
|
|
243
|
+
up.set_defaults(func=cmd_update)
|
|
244
|
+
usub.add_parser("scanners").set_defaults(func=cmd_update)
|