valicode 2.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.
- aov/__init__.py +1 -0
- aov/cli.py +1705 -0
- valicode-2.0.0.dist-info/METADATA +55 -0
- valicode-2.0.0.dist-info/RECORD +7 -0
- valicode-2.0.0.dist-info/WHEEL +5 -0
- valicode-2.0.0.dist-info/entry_points.txt +3 -0
- valicode-2.0.0.dist-info/top_level.txt +1 -0
aov/cli.py
ADDED
|
@@ -0,0 +1,1705 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import fnmatch
|
|
5
|
+
import html
|
|
6
|
+
import hashlib
|
|
7
|
+
import shutil
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
import httpx
|
|
17
|
+
import toml
|
|
18
|
+
|
|
19
|
+
CONFIG_PATH = Path.home() / ".valicode" / "config.toml"
|
|
20
|
+
LEGACY_CONFIG_PATH = Path.home() / ".aov" / "config.toml"
|
|
21
|
+
DEFAULT_API_BASE = "https://api.valicode.sbs/v1"
|
|
22
|
+
DEFAULT_IGNORE_DIRS = {
|
|
23
|
+
"node_modules", ".git", ".next", "dist", "build", "coverage",
|
|
24
|
+
".venv", "venv", "__pycache__", ".turbo", ".cache",
|
|
25
|
+
".pytest_cache", ".mypy_cache", ".ruff_cache",
|
|
26
|
+
}
|
|
27
|
+
DEFAULT_IGNORE_EXTENSIONS = {
|
|
28
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico",
|
|
29
|
+
".mp4", ".mov", ".avi", ".mkv", ".mp3", ".wav",
|
|
30
|
+
".pdf", ".zip", ".tar", ".gz", ".tgz", ".rar", ".7z",
|
|
31
|
+
".bin", ".exe", ".dll", ".so", ".dylib", ".pyc",
|
|
32
|
+
}
|
|
33
|
+
SOURCE_EXTENSIONS = {
|
|
34
|
+
".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java",
|
|
35
|
+
".cs", ".cpp", ".c", ".h", ".rb", ".php", ".swift", ".kt",
|
|
36
|
+
".scala", ".sh", ".yaml", ".yml", ".json", ".toml", ".md",
|
|
37
|
+
".sql", ".graphql", ".prisma", ".Dockerfile",
|
|
38
|
+
}
|
|
39
|
+
LOCKFILE_NAMES = {
|
|
40
|
+
"package-lock.json", "pnpm-lock.yaml", "yarn.lock", "poetry.lock",
|
|
41
|
+
"Cargo.lock", "Gemfile.lock", "composer.lock",
|
|
42
|
+
}
|
|
43
|
+
DEFAULT_MAX_UPLOAD_BYTES = 500 * 1024 * 1024
|
|
44
|
+
DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024
|
|
45
|
+
OUTPUT_FORMATS = ["table", "json", "sarif", "markdown", "html", "junit", "summary", "mermaid", "chat"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config() -> dict:
|
|
49
|
+
if CONFIG_PATH.exists():
|
|
50
|
+
return toml.loads(CONFIG_PATH.read_text())
|
|
51
|
+
if LEGACY_CONFIG_PATH.exists():
|
|
52
|
+
return toml.loads(LEGACY_CONFIG_PATH.read_text())
|
|
53
|
+
return {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def save_config(cfg: dict) -> None:
|
|
57
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
CONFIG_PATH.write_text(toml.dumps(cfg))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_api_base() -> str:
|
|
62
|
+
cfg = load_config()
|
|
63
|
+
return (
|
|
64
|
+
os.environ.get("VALICODE_API_BASE")
|
|
65
|
+
or os.environ.get("AOV_API_BASE")
|
|
66
|
+
or cfg.get("api_base")
|
|
67
|
+
or DEFAULT_API_BASE
|
|
68
|
+
).rstrip("/")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@click.group()
|
|
72
|
+
@click.version_option("2.0.0", prog_name="valicode")
|
|
73
|
+
def cli():
|
|
74
|
+
"""Valicode - audit AI-generated code before it ships."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@cli.command()
|
|
78
|
+
@click.option("--check-api", is_flag=True, help="Also call the Valicode API health/docs endpoint")
|
|
79
|
+
def doctor(check_api):
|
|
80
|
+
"""Validate the local CLI security-analysis environment."""
|
|
81
|
+
cfg = load_config()
|
|
82
|
+
capabilities = sandbox_capabilities()
|
|
83
|
+
checks = [
|
|
84
|
+
("api_key", bool(cfg.get("api_key")), "API key configured in ~/.valicode/config.toml", True),
|
|
85
|
+
("git", shutil.which("git") is not None, "git executable available", True),
|
|
86
|
+
("semgrep", shutil.which("semgrep") is not None, "semgrep executable available for local parity checks", False),
|
|
87
|
+
("docker", capabilities["docker_available"], "Docker available for isolated validation", False),
|
|
88
|
+
("microvm", capabilities["microvm_available"], capabilities["microvm_detail"], False),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
in_git_repo = subprocess.run(
|
|
92
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
93
|
+
capture_output=True,
|
|
94
|
+
text=True,
|
|
95
|
+
check=False,
|
|
96
|
+
).stdout.strip() == "true"
|
|
97
|
+
checks.append(("git_repo", in_git_repo, "current directory is inside a git repository", True))
|
|
98
|
+
|
|
99
|
+
if check_api:
|
|
100
|
+
try:
|
|
101
|
+
response = httpx.get(get_api_base().rsplit("/v1", 1)[0] + "/health", timeout=10)
|
|
102
|
+
checks.append(("api_reachable", response.status_code < 500, "Valicode API reachable", True))
|
|
103
|
+
except httpx.HTTPError:
|
|
104
|
+
checks.append(("api_reachable", False, "Valicode API reachable", True))
|
|
105
|
+
|
|
106
|
+
click.echo(f"{'Check':18} {'Status':8} Detail")
|
|
107
|
+
for check_id, ok, detail, required in checks:
|
|
108
|
+
click.echo(f"{check_id:18} {'PASS' if ok else 'FAIL':8} {detail}")
|
|
109
|
+
|
|
110
|
+
if not all(ok for _, ok, _, required in checks if required):
|
|
111
|
+
sys.exit(1)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@cli.command()
|
|
115
|
+
@click.argument("path", type=click.Path(exists=True), required=False)
|
|
116
|
+
@click.option("--diff", "diff_file", type=click.Path(exists=True), help="Path to a .diff file")
|
|
117
|
+
@click.option("--staged", is_flag=True, help="Analyze staged git changes")
|
|
118
|
+
@click.option("--pr", type=int, help="GitHub PR number")
|
|
119
|
+
@click.option("--repo", help="GitHub repo (owner/repo)")
|
|
120
|
+
@click.option("--output", type=click.Choice(OUTPUT_FORMATS), default="table")
|
|
121
|
+
@click.option("--save-report", type=click.Path(file_okay=False), help="Write a full local report bundle to this directory")
|
|
122
|
+
@click.option("--fail-under", type=int, default=0, help="Exit 1 if score below threshold")
|
|
123
|
+
@click.option("--context/--no-context", default=True, help="Include safe repository context for cross-file analysis")
|
|
124
|
+
@click.option("--run-tests", is_flag=True, help="Run detected local tests and include the redacted result")
|
|
125
|
+
@click.option("--sandbox", type=click.Choice(["auto", "microvm", "docker", "local", "none"]), default="auto")
|
|
126
|
+
@click.option("--validate-fixes", is_flag=True, help="Apply suggested patches in a temporary workspace and validate them")
|
|
127
|
+
def analyze(path, diff_file, staged, pr, repo, output, save_report, fail_under, context, run_tests, sandbox, validate_fixes):
|
|
128
|
+
"""Analyze a file, diff, staged changes, or GitHub PR."""
|
|
129
|
+
cfg = load_config()
|
|
130
|
+
api_key = cfg.get("api_key")
|
|
131
|
+
if not api_key:
|
|
132
|
+
click.echo("Error: No API key configured. Run valicode login first.", err=True)
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
|
|
135
|
+
diff_content = _get_diff_content(path, diff_file, staged, pr, repo, cfg)
|
|
136
|
+
if not diff_content:
|
|
137
|
+
click.echo("No changes to analyze.")
|
|
138
|
+
sys.exit(0)
|
|
139
|
+
|
|
140
|
+
repo_context = build_repository_context(Path.cwd(), include_files=context)
|
|
141
|
+
if run_tests:
|
|
142
|
+
repo_context["test_runs"] = run_local_validation(Path.cwd(), sandbox=sandbox)
|
|
143
|
+
click.echo(f"Analyzing code with {len(repo_context.get('files', []))} context file(s)...")
|
|
144
|
+
try:
|
|
145
|
+
response = httpx.post(
|
|
146
|
+
f"{get_api_base()}/analyses",
|
|
147
|
+
headers={"X-API-Key": api_key, "Content-Type": "application/json"},
|
|
148
|
+
json={"diff": diff_content, "repo": repo, "repo_context": repo_context, "force_reanalysis": True},
|
|
149
|
+
timeout=120,
|
|
150
|
+
)
|
|
151
|
+
response.raise_for_status()
|
|
152
|
+
result = response.json()
|
|
153
|
+
except httpx.HTTPStatusError as exc:
|
|
154
|
+
if exc.response.status_code == 401:
|
|
155
|
+
click.echo("Authentication failed. Check your API key.", err=True)
|
|
156
|
+
elif exc.response.status_code == 429:
|
|
157
|
+
click.echo("Rate limit exceeded. Wait before retrying.", err=True)
|
|
158
|
+
else:
|
|
159
|
+
click.echo(f"API error {exc.response.status_code}", err=True)
|
|
160
|
+
sys.exit(1)
|
|
161
|
+
except httpx.TimeoutException:
|
|
162
|
+
click.echo("Request timed out.", err=True)
|
|
163
|
+
sys.exit(1)
|
|
164
|
+
|
|
165
|
+
if validate_fixes:
|
|
166
|
+
result["fix_validations"] = validate_suggested_fixes(Path.cwd(), result, api_key, sandbox=sandbox)
|
|
167
|
+
|
|
168
|
+
if save_report:
|
|
169
|
+
written = save_report_bundle(Path(save_report), result)
|
|
170
|
+
click.echo(f"Report bundle written to {Path(save_report).resolve()} ({len(written)} files)")
|
|
171
|
+
print_result(result, output)
|
|
172
|
+
if output in {"table", "summary", "chat"}:
|
|
173
|
+
print_analysis_links(result)
|
|
174
|
+
|
|
175
|
+
score = result.get("score", 100)
|
|
176
|
+
if result.get("merge_gate", {}).get("status") == "fail":
|
|
177
|
+
click.echo("\nMerge gate failed because validated blockers were found.", err=True)
|
|
178
|
+
sys.exit(1)
|
|
179
|
+
if fail_under and score < fail_under:
|
|
180
|
+
click.echo(f"\nScore {score} is below threshold {fail_under}. Failing.", err=True)
|
|
181
|
+
sys.exit(1)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@cli.command()
|
|
185
|
+
@click.argument("path", type=click.Path(exists=True, file_okay=False), default=".")
|
|
186
|
+
@click.option("--production", is_flag=True, help="Run full production-readiness workspace scan")
|
|
187
|
+
@click.option("--repo", help="GitHub repo (owner/repo)")
|
|
188
|
+
@click.option("--output", type=click.Choice(OUTPUT_FORMATS), default="table")
|
|
189
|
+
@click.option("--save-report", type=click.Path(file_okay=False), help="Write a full local report bundle to this directory")
|
|
190
|
+
@click.option("--fail-under", type=int, default=0, help="Exit 1 if score below threshold")
|
|
191
|
+
@click.option("--max-upload-mb", type=int, default=500, help="Maximum workspace payload size in MB")
|
|
192
|
+
@click.option("--max-file-kb", type=int, default=2048, help="Maximum individual file size in KB")
|
|
193
|
+
@click.option("--include-lockfiles", is_flag=True, help="Include lockfiles in the workspace payload")
|
|
194
|
+
@click.option("--dry-run", is_flag=True, help="Show files that would be sent without calling the API")
|
|
195
|
+
@click.option("--sandbox", type=click.Choice(["auto", "microvm", "docker", "local", "none"]), default="auto", help="Isolation policy for build and test execution")
|
|
196
|
+
@click.option("--fuzz-command", help="Explicit fuzz command to run under the selected sandbox")
|
|
197
|
+
@click.option("--validate-fixes", is_flag=True, help="Validate suggested patches in an isolated temporary workspace")
|
|
198
|
+
def scan(path, production, repo, output, save_report, fail_under, max_upload_mb, max_file_kb, include_lockfiles, dry_run, sandbox, fuzz_command, validate_fixes):
|
|
199
|
+
"""Scan a whole workspace, ignoring dependencies, build output, and binaries."""
|
|
200
|
+
cfg = load_config()
|
|
201
|
+
api_key = cfg.get("api_key")
|
|
202
|
+
if not api_key and not dry_run:
|
|
203
|
+
click.echo("Error: No API key configured. Run valicode login first.", err=True)
|
|
204
|
+
sys.exit(1)
|
|
205
|
+
|
|
206
|
+
root = Path(path).resolve()
|
|
207
|
+
files, ignored = collect_workspace_files(
|
|
208
|
+
root,
|
|
209
|
+
max_total_bytes=max_upload_mb * 1024 * 1024,
|
|
210
|
+
max_file_bytes=max_file_kb * 1024,
|
|
211
|
+
include_lockfiles=include_lockfiles,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if not files:
|
|
215
|
+
click.echo("No source files found to scan.")
|
|
216
|
+
sys.exit(0)
|
|
217
|
+
|
|
218
|
+
manifest = {
|
|
219
|
+
"mode": "production" if production else "workspace",
|
|
220
|
+
"root": str(root),
|
|
221
|
+
"files_scanned": len(files),
|
|
222
|
+
"bytes_scanned": sum(item["size"] for item in files),
|
|
223
|
+
"ignored_count": len(ignored),
|
|
224
|
+
"eligible_files": len(files),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if dry_run:
|
|
228
|
+
_print_scan_manifest(files, ignored, manifest)
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
diff_content = workspace_files_to_diff(root, files)
|
|
232
|
+
context = build_repository_context(root, include_files=False)
|
|
233
|
+
context["scan"] = manifest
|
|
234
|
+
if production:
|
|
235
|
+
click.echo("Running local validation commands...")
|
|
236
|
+
context["test_runs"] = run_local_validation(root, sandbox=sandbox)
|
|
237
|
+
if fuzz_command:
|
|
238
|
+
context["test_runs"].extend(run_explicit_command(root, fuzz_command, sandbox=sandbox, kind="fuzz"))
|
|
239
|
+
context["sandbox"] = {"requested": sandbox, **sandbox_capabilities()}
|
|
240
|
+
click.echo("Scanning workspace...")
|
|
241
|
+
try:
|
|
242
|
+
response = httpx.post(
|
|
243
|
+
f"{get_api_base()}/analyses",
|
|
244
|
+
headers={"X-API-Key": api_key, "Content-Type": "application/json"},
|
|
245
|
+
json={"diff": diff_content, "repo": repo, "repo_context": context, "force_reanalysis": True},
|
|
246
|
+
timeout=300,
|
|
247
|
+
)
|
|
248
|
+
response.raise_for_status()
|
|
249
|
+
result = response.json()
|
|
250
|
+
except httpx.HTTPStatusError as exc:
|
|
251
|
+
click.echo(f"API error {exc.response.status_code}", err=True)
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
except httpx.TimeoutException:
|
|
254
|
+
click.echo("Workspace scan timed out.", err=True)
|
|
255
|
+
sys.exit(1)
|
|
256
|
+
|
|
257
|
+
if validate_fixes:
|
|
258
|
+
result["fix_validations"] = validate_suggested_fixes(root, result, api_key, sandbox=sandbox)
|
|
259
|
+
|
|
260
|
+
result["workspace_manifest"] = manifest
|
|
261
|
+
if save_report:
|
|
262
|
+
written = save_report_bundle(Path(save_report), result)
|
|
263
|
+
click.echo(f"Report bundle written to {Path(save_report).resolve()} ({len(written)} files)")
|
|
264
|
+
print_result(result, output)
|
|
265
|
+
if output == "table":
|
|
266
|
+
click.echo(f"Workspace files scanned: {manifest['files_scanned']} / bytes: {manifest['bytes_scanned']} / ignored: {manifest['ignored_count']}")
|
|
267
|
+
print_analysis_links(result)
|
|
268
|
+
elif output in {"summary", "chat"}:
|
|
269
|
+
print_analysis_links(result)
|
|
270
|
+
|
|
271
|
+
score = result.get("score", 100)
|
|
272
|
+
if result.get("merge_gate", {}).get("status") == "fail":
|
|
273
|
+
click.echo("\nMerge gate failed because validated blockers or failed checks were found.", err=True)
|
|
274
|
+
sys.exit(1)
|
|
275
|
+
if fail_under and score < fail_under:
|
|
276
|
+
click.echo(f"\nScore {score} is below threshold {fail_under}. Failing.", err=True)
|
|
277
|
+
sys.exit(1)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def build_repository_context(root: Path, *, include_files: bool = True) -> dict:
|
|
281
|
+
root = root.resolve()
|
|
282
|
+
if (root / ".git").exists() is False and shutil.which("git"):
|
|
283
|
+
git_root = subprocess.run(
|
|
284
|
+
["git", "rev-parse", "--show-toplevel"], cwd=root, capture_output=True, text=True, check=False,
|
|
285
|
+
).stdout.strip()
|
|
286
|
+
if git_root:
|
|
287
|
+
root = Path(git_root).resolve()
|
|
288
|
+
files, ignored = collect_workspace_files(
|
|
289
|
+
root,
|
|
290
|
+
max_total_bytes=25 * 1024 * 1024 if include_files else DEFAULT_MAX_UPLOAD_BYTES,
|
|
291
|
+
max_file_bytes=512 * 1024,
|
|
292
|
+
include_lockfiles=True,
|
|
293
|
+
)
|
|
294
|
+
suffix_counts: dict[str, int] = {}
|
|
295
|
+
for item in files:
|
|
296
|
+
suffix = Path(item["path"]).suffix.lower() or Path(item["path"]).name
|
|
297
|
+
suffix_counts[suffix] = suffix_counts.get(suffix, 0) + 1
|
|
298
|
+
language_map = {
|
|
299
|
+
".py": "Python", ".ts": "TypeScript", ".tsx": "TypeScript/React",
|
|
300
|
+
".js": "JavaScript", ".jsx": "JavaScript/React", ".go": "Go",
|
|
301
|
+
".rs": "Rust", ".java": "Java", ".cs": "C#", ".php": "PHP",
|
|
302
|
+
}
|
|
303
|
+
dominant = max(suffix_counts, key=suffix_counts.get) if suffix_counts else ""
|
|
304
|
+
context = {
|
|
305
|
+
"primary_language": language_map.get(dominant, dominant or "unknown"),
|
|
306
|
+
"frameworks": detect_frameworks(root),
|
|
307
|
+
"base_sha": git_value(root, ["rev-parse", "HEAD~1"]),
|
|
308
|
+
"head_sha": git_value(root, ["rev-parse", "HEAD"]),
|
|
309
|
+
"scan": {
|
|
310
|
+
"mode": "contextual-diff" if include_files else "workspace",
|
|
311
|
+
"eligible_files": len(files),
|
|
312
|
+
"ignored_count": len(ignored),
|
|
313
|
+
"bytes_scanned": sum(item["size"] for item in files),
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
if include_files:
|
|
317
|
+
context["files"] = [
|
|
318
|
+
{
|
|
319
|
+
"path": item["path"],
|
|
320
|
+
"content": (root / item["path"]).read_text(encoding="utf-8", errors="replace"),
|
|
321
|
+
"size": item["size"],
|
|
322
|
+
}
|
|
323
|
+
for item in files
|
|
324
|
+
]
|
|
325
|
+
return context
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def git_value(root: Path, args: list[str]) -> str | None:
|
|
329
|
+
if not shutil.which("git"):
|
|
330
|
+
return None
|
|
331
|
+
result = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=False)
|
|
332
|
+
return result.stdout.strip() or None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def detect_frameworks(root: Path) -> list[str]:
|
|
336
|
+
frameworks = []
|
|
337
|
+
package_json = root / "package.json"
|
|
338
|
+
if package_json.exists():
|
|
339
|
+
try:
|
|
340
|
+
package = json.loads(package_json.read_text(encoding="utf-8"))
|
|
341
|
+
dependencies = {**package.get("dependencies", {}), **package.get("devDependencies", {})}
|
|
342
|
+
for package_name, label in {"next": "Next.js", "react": "React", "vue": "Vue", "express": "Express", "nestjs": "NestJS"}.items():
|
|
343
|
+
if package_name in dependencies:
|
|
344
|
+
frameworks.append(label)
|
|
345
|
+
except (OSError, json.JSONDecodeError):
|
|
346
|
+
pass
|
|
347
|
+
pyproject = (root / "pyproject.toml")
|
|
348
|
+
if pyproject.exists():
|
|
349
|
+
content = pyproject.read_text(encoding="utf-8", errors="ignore").lower()
|
|
350
|
+
for marker, label in {"fastapi": "FastAPI", "django": "Django", "flask": "Flask"}.items():
|
|
351
|
+
if marker in content:
|
|
352
|
+
frameworks.append(label)
|
|
353
|
+
return sorted(set(frameworks))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def run_local_validation(root: Path, *, sandbox: str = "local") -> list[dict]:
|
|
357
|
+
commands: list[list[str]] = []
|
|
358
|
+
if shutil.which("gitleaks"):
|
|
359
|
+
commands.append(["gitleaks", "detect", "--source", ".", "--no-git", "--redact", "--exit-code", "1"])
|
|
360
|
+
if shutil.which("ruff") and any(root.rglob("*.py")):
|
|
361
|
+
commands.append(["ruff", "check", "."])
|
|
362
|
+
if (root / "pyproject.toml").exists() and shutil.which("python"):
|
|
363
|
+
commands.append([sys.executable, "-m", "compileall", "-q", "."])
|
|
364
|
+
commands.append([sys.executable, "-m", "pytest", "-q"])
|
|
365
|
+
if (root / "package.json").exists():
|
|
366
|
+
try:
|
|
367
|
+
package = json.loads((root / "package.json").read_text(encoding="utf-8"))
|
|
368
|
+
scripts = package.get("scripts", {})
|
|
369
|
+
manager = "pnpm" if (root / "pnpm-lock.yaml").exists() else "npm"
|
|
370
|
+
if "test" in scripts and shutil.which(manager):
|
|
371
|
+
commands.append([manager, "test"] if manager == "pnpm" else [manager, "test", "--", "--runInBand"])
|
|
372
|
+
if "lint" in scripts and shutil.which(manager):
|
|
373
|
+
commands.append([manager, "lint"])
|
|
374
|
+
except (OSError, json.JSONDecodeError):
|
|
375
|
+
pass
|
|
376
|
+
if (root / "go.mod").exists() and shutil.which("go"):
|
|
377
|
+
commands.append(["go", "test", "./..."])
|
|
378
|
+
if (root / "Cargo.toml").exists() and shutil.which("cargo"):
|
|
379
|
+
commands.append(["cargo", "test", "--quiet"])
|
|
380
|
+
|
|
381
|
+
runs = []
|
|
382
|
+
for command in commands[:8]:
|
|
383
|
+
runs.extend(_execute_validation_command(root, command, sandbox=sandbox, kind="validation"))
|
|
384
|
+
return runs
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def run_explicit_command(root: Path, command: str, *, sandbox: str, kind: str) -> list[dict]:
|
|
388
|
+
try:
|
|
389
|
+
args = shlex.split(command, posix=os.name != "nt")
|
|
390
|
+
except ValueError as exc:
|
|
391
|
+
return [{"command": command, "status": "failed", "exit_code": None, "duration_ms": 0, "output_summary": f"Invalid command: {exc}", "kind": kind}]
|
|
392
|
+
return _execute_validation_command(root, args, sandbox=sandbox, kind=kind)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def sandbox_capabilities() -> dict:
|
|
396
|
+
microvm_runner = load_config().get("microvm_runner")
|
|
397
|
+
firecracker = shutil.which("firecracker")
|
|
398
|
+
has_kvm = Path("/dev/kvm").exists()
|
|
399
|
+
if os.name == "nt":
|
|
400
|
+
microvm_detail = "Firecracker microVM requires a Linux host with KVM; this Windows host cannot run it directly."
|
|
401
|
+
elif microvm_runner:
|
|
402
|
+
microvm_detail = f"microvm_runner configured: {microvm_runner}"
|
|
403
|
+
elif firecracker and has_kvm:
|
|
404
|
+
microvm_detail = "firecracker and /dev/kvm detected; configure microvm_runner to execute validation commands."
|
|
405
|
+
else:
|
|
406
|
+
microvm_detail = "Firecracker microVM unavailable; install Firecracker on Linux/KVM or configure microvm_runner."
|
|
407
|
+
return {
|
|
408
|
+
"docker_available": shutil.which("docker") is not None,
|
|
409
|
+
"microvm_available": bool(microvm_runner and os.name != "nt"),
|
|
410
|
+
"microvm_runner": microvm_runner,
|
|
411
|
+
"microvm_detail": microvm_detail,
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _execute_validation_command(root: Path, command: list[str], *, sandbox: str, kind: str) -> list[dict]:
|
|
416
|
+
command_text = " ".join(command)
|
|
417
|
+
selected = sandbox
|
|
418
|
+
cfg = load_config()
|
|
419
|
+
if sandbox == "auto":
|
|
420
|
+
selected = "docker" if shutil.which("docker") and cfg.get("audit_image") else "none"
|
|
421
|
+
if selected == "none":
|
|
422
|
+
return [{
|
|
423
|
+
"command": command_text, "status": "skipped", "exit_code": None, "duration_ms": 0,
|
|
424
|
+
"output_summary": "Execution skipped: configure audit_image for Docker or explicitly use --sandbox local.",
|
|
425
|
+
"kind": kind, "sandbox": "none",
|
|
426
|
+
}]
|
|
427
|
+
|
|
428
|
+
actual_command = command
|
|
429
|
+
cwd = root
|
|
430
|
+
temp_workspace = None
|
|
431
|
+
if selected == "microvm":
|
|
432
|
+
microvm_runner = cfg.get("microvm_runner")
|
|
433
|
+
if os.name == "nt" or not microvm_runner:
|
|
434
|
+
return [{
|
|
435
|
+
"command": command_text, "status": "skipped", "exit_code": None, "duration_ms": 0,
|
|
436
|
+
"output_summary": sandbox_capabilities()["microvm_detail"],
|
|
437
|
+
"kind": kind, "sandbox": "microvm",
|
|
438
|
+
}]
|
|
439
|
+
temp_workspace = tempfile.TemporaryDirectory(prefix="valicode-microvm-")
|
|
440
|
+
workspace = Path(temp_workspace.name) / "workspace"
|
|
441
|
+
shutil.copytree(root, workspace, ignore=shutil.ignore_patterns(*DEFAULT_IGNORE_DIRS))
|
|
442
|
+
actual_command = [microvm_runner, str(workspace), *command]
|
|
443
|
+
cwd = workspace
|
|
444
|
+
if selected == "docker":
|
|
445
|
+
image = cfg.get("audit_image")
|
|
446
|
+
if not image or not shutil.which("docker"):
|
|
447
|
+
return [{
|
|
448
|
+
"command": command_text, "status": "skipped", "exit_code": None, "duration_ms": 0,
|
|
449
|
+
"output_summary": "Docker sandbox unavailable or audit_image is not configured.",
|
|
450
|
+
"kind": kind, "sandbox": "docker",
|
|
451
|
+
}]
|
|
452
|
+
temp_workspace = tempfile.TemporaryDirectory(prefix="valicode-audit-")
|
|
453
|
+
workspace = Path(temp_workspace.name) / "workspace"
|
|
454
|
+
shutil.copytree(root, workspace, ignore=shutil.ignore_patterns(*DEFAULT_IGNORE_DIRS))
|
|
455
|
+
actual_command = [
|
|
456
|
+
"docker", "run", "--rm", "--network", "none", "--cpus", "2", "--memory", "2g",
|
|
457
|
+
"--pids-limit", "256", "--security-opt", "no-new-privileges", "--cap-drop", "ALL",
|
|
458
|
+
"-v", f"{workspace}:/workspace", "-w", "/workspace", image, *command,
|
|
459
|
+
]
|
|
460
|
+
cwd = workspace
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
started = time.perf_counter()
|
|
464
|
+
try:
|
|
465
|
+
result = subprocess.run(actual_command, cwd=cwd, capture_output=True, text=True, timeout=180, check=False)
|
|
466
|
+
output = redact_local_output((result.stdout or "") + "\n" + (result.stderr or ""))
|
|
467
|
+
return [{
|
|
468
|
+
"command": command_text,
|
|
469
|
+
"status": "passed" if result.returncode == 0 else "failed",
|
|
470
|
+
"exit_code": result.returncode,
|
|
471
|
+
"duration_ms": int((time.perf_counter() - started) * 1000),
|
|
472
|
+
"output_summary": output[-4000:],
|
|
473
|
+
"kind": kind, "sandbox": selected,
|
|
474
|
+
}]
|
|
475
|
+
except subprocess.TimeoutExpired:
|
|
476
|
+
return [{
|
|
477
|
+
"command": command_text, "status": "failed", "exit_code": None,
|
|
478
|
+
"duration_ms": int((time.perf_counter() - started) * 1000),
|
|
479
|
+
"output_summary": "Command timed out after 180 seconds.",
|
|
480
|
+
"kind": kind, "sandbox": selected,
|
|
481
|
+
}]
|
|
482
|
+
finally:
|
|
483
|
+
if temp_workspace is not None:
|
|
484
|
+
temp_workspace.cleanup()
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def redact_local_output(output: str) -> str:
|
|
488
|
+
import re
|
|
489
|
+
output = re.sub(r"(?i)(api[_-]?key|secret|token|password)(\s*[:=]\s*)\S+", r"\1\2[REDACTED]", output)
|
|
490
|
+
output = re.sub(r"(?i)(sk-|ghp_|github_pat_)[A-Za-z0-9_-]{12,}", "[REDACTED]", output)
|
|
491
|
+
return output
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def validate_suggested_fixes(root: Path, result: dict, api_key: str, *, sandbox: str) -> list[dict]:
|
|
495
|
+
validations = []
|
|
496
|
+
analysis_id = result.get("id")
|
|
497
|
+
if not analysis_id or not shutil.which("git"):
|
|
498
|
+
return [{"status": "skipped", "reason": "Analysis id or git executable is unavailable."}]
|
|
499
|
+
for issue in [item for item in result.get("issues", []) if item.get("fix_patch") and item.get("id")][:20]:
|
|
500
|
+
patch = issue["fix_patch"]
|
|
501
|
+
patch_hash = hashlib.sha256(patch.encode()).hexdigest()
|
|
502
|
+
with tempfile.TemporaryDirectory(prefix="valicode-fix-") as temp_dir:
|
|
503
|
+
workspace = Path(temp_dir) / "workspace"
|
|
504
|
+
shutil.copytree(root, workspace, ignore=shutil.ignore_patterns(*DEFAULT_IGNORE_DIRS))
|
|
505
|
+
patch_file = Path(temp_dir) / "fix.patch"
|
|
506
|
+
patch_file.write_text(patch, encoding="utf-8")
|
|
507
|
+
check = subprocess.run(["git", "apply", "--check", str(patch_file)], cwd=workspace, capture_output=True, text=True, check=False)
|
|
508
|
+
syntax_valid = check.returncode == 0
|
|
509
|
+
test_runs = []
|
|
510
|
+
if syntax_valid:
|
|
511
|
+
applied = subprocess.run(["git", "apply", str(patch_file)], cwd=workspace, capture_output=True, text=True, check=False)
|
|
512
|
+
syntax_valid = applied.returncode == 0
|
|
513
|
+
if syntax_valid:
|
|
514
|
+
test_runs = run_local_validation(workspace, sandbox=sandbox)
|
|
515
|
+
executed = [run for run in test_runs if run.get("status") in {"passed", "failed"}]
|
|
516
|
+
tests_passed = bool(executed) and all(run.get("status") == "passed" for run in executed)
|
|
517
|
+
status_value = "validated" if syntax_valid and tests_passed else "syntax_valid" if syntax_valid else "rejected"
|
|
518
|
+
summary = "Patch applies cleanly. " if syntax_valid else redact_local_output(check.stderr or "Patch does not apply cleanly.")
|
|
519
|
+
if test_runs:
|
|
520
|
+
summary += "; ".join(f"{run.get('command')}={run.get('status')}" for run in test_runs)
|
|
521
|
+
payload = {
|
|
522
|
+
"issue_id": issue["id"], "patch_hash": patch_hash, "status": status_value,
|
|
523
|
+
"syntax_valid": syntax_valid, "tests_passed": tests_passed if executed else None,
|
|
524
|
+
"validation_summary": summary[:2000],
|
|
525
|
+
}
|
|
526
|
+
try:
|
|
527
|
+
response = httpx.post(
|
|
528
|
+
f"{get_api_base()}/analyses/{analysis_id}/fix-validation",
|
|
529
|
+
headers={"X-API-Key": api_key, "Content-Type": "application/json"},
|
|
530
|
+
json=payload,
|
|
531
|
+
timeout=30,
|
|
532
|
+
)
|
|
533
|
+
response.raise_for_status()
|
|
534
|
+
payload["recorded"] = True
|
|
535
|
+
except httpx.HTTPError:
|
|
536
|
+
payload["recorded"] = False
|
|
537
|
+
validations.append(payload)
|
|
538
|
+
return validations
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _get_diff_content(path, diff_file, staged, pr, repo, cfg) -> str | None:
|
|
542
|
+
if staged:
|
|
543
|
+
result = subprocess.run(["git", "diff", "--cached"], capture_output=True, text=True, check=False)
|
|
544
|
+
return result.stdout or None
|
|
545
|
+
if diff_file:
|
|
546
|
+
return Path(diff_file).read_text()
|
|
547
|
+
if path:
|
|
548
|
+
path_obj = Path(path)
|
|
549
|
+
if path_obj.is_dir():
|
|
550
|
+
click.echo("Error: Directories must be scanned with valicode scan.", err=True)
|
|
551
|
+
sys.exit(1)
|
|
552
|
+
content = path_obj.read_text()
|
|
553
|
+
return f"+++ b/{path}\n" + "\n".join(f"+{line}" for line in content.splitlines())
|
|
554
|
+
if pr and repo:
|
|
555
|
+
token = cfg.get("github_token")
|
|
556
|
+
headers = {"Accept": "application/vnd.github.diff"}
|
|
557
|
+
if token:
|
|
558
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
559
|
+
resp = httpx.get(f"https://api.github.com/repos/{repo}/pulls/{pr}", headers=headers, timeout=30)
|
|
560
|
+
if resp.status_code == 200:
|
|
561
|
+
return resp.text
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def collect_workspace_files(
|
|
566
|
+
root: Path,
|
|
567
|
+
*,
|
|
568
|
+
max_total_bytes: int = DEFAULT_MAX_UPLOAD_BYTES,
|
|
569
|
+
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
|
|
570
|
+
include_lockfiles: bool = False,
|
|
571
|
+
) -> tuple[list[dict], list[dict]]:
|
|
572
|
+
ignore_patterns = load_valicodeignore(root)
|
|
573
|
+
files: list[dict] = []
|
|
574
|
+
ignored: list[dict] = []
|
|
575
|
+
total = 0
|
|
576
|
+
|
|
577
|
+
for current_root, dirnames, filenames in os.walk(root):
|
|
578
|
+
current_path = Path(current_root)
|
|
579
|
+
rel_dir = current_path.relative_to(root).as_posix()
|
|
580
|
+
|
|
581
|
+
kept_dirs = []
|
|
582
|
+
for dirname in dirnames:
|
|
583
|
+
rel_path = dirname if rel_dir == "." else f"{rel_dir}/{dirname}"
|
|
584
|
+
if dirname in DEFAULT_IGNORE_DIRS or matches_ignore(rel_path, ignore_patterns):
|
|
585
|
+
ignored.append({"path": rel_path, "reason": "ignored_dir"})
|
|
586
|
+
else:
|
|
587
|
+
kept_dirs.append(dirname)
|
|
588
|
+
dirnames[:] = kept_dirs
|
|
589
|
+
|
|
590
|
+
for filename in filenames:
|
|
591
|
+
file_path = current_path / filename
|
|
592
|
+
rel_path = file_path.relative_to(root).as_posix()
|
|
593
|
+
reason = should_ignore_file(file_path, rel_path, ignore_patterns, include_lockfiles, max_file_bytes)
|
|
594
|
+
if reason:
|
|
595
|
+
ignored.append({"path": rel_path, "reason": reason})
|
|
596
|
+
continue
|
|
597
|
+
size = file_path.stat().st_size
|
|
598
|
+
if total + size > max_total_bytes:
|
|
599
|
+
ignored.append({"path": rel_path, "reason": "max_upload_exceeded"})
|
|
600
|
+
continue
|
|
601
|
+
total += size
|
|
602
|
+
files.append({"path": rel_path, "size": size})
|
|
603
|
+
|
|
604
|
+
return sorted(files, key=lambda item: item["path"]), ignored
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def load_valicodeignore(root: Path) -> list[str]:
|
|
608
|
+
ignore_file = root / ".valicodeignore"
|
|
609
|
+
if not ignore_file.exists():
|
|
610
|
+
return []
|
|
611
|
+
patterns = []
|
|
612
|
+
for line in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
613
|
+
stripped = line.strip()
|
|
614
|
+
if stripped and not stripped.startswith("#"):
|
|
615
|
+
patterns.append(stripped)
|
|
616
|
+
return patterns
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def matches_ignore(rel_path: str, patterns: list[str]) -> bool:
|
|
620
|
+
normalized = rel_path.strip("/")
|
|
621
|
+
for pattern in patterns:
|
|
622
|
+
normalized_pattern = pattern.strip("/")
|
|
623
|
+
if fnmatch.fnmatch(normalized, normalized_pattern) or fnmatch.fnmatch(normalized, f"{normalized_pattern}/*"):
|
|
624
|
+
return True
|
|
625
|
+
return False
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def should_ignore_file(
|
|
629
|
+
file_path: Path,
|
|
630
|
+
rel_path: str,
|
|
631
|
+
ignore_patterns: list[str],
|
|
632
|
+
include_lockfiles: bool,
|
|
633
|
+
max_file_bytes: int,
|
|
634
|
+
) -> str | None:
|
|
635
|
+
name = file_path.name
|
|
636
|
+
suffix = file_path.suffix.lower()
|
|
637
|
+
if matches_ignore(rel_path, ignore_patterns):
|
|
638
|
+
return "valicodeignore"
|
|
639
|
+
if name == ".valicodeignore":
|
|
640
|
+
return "valicodeignore"
|
|
641
|
+
if name.startswith(".env") and name not in {".env.example", ".env.sample"}:
|
|
642
|
+
return "env_file"
|
|
643
|
+
if suffix in DEFAULT_IGNORE_EXTENSIONS:
|
|
644
|
+
return "binary_or_asset"
|
|
645
|
+
if name in LOCKFILE_NAMES and not include_lockfiles:
|
|
646
|
+
return "lockfile"
|
|
647
|
+
if file_path.stat().st_size > max_file_bytes:
|
|
648
|
+
return "max_file_size"
|
|
649
|
+
if is_binary_file(file_path):
|
|
650
|
+
return "binary"
|
|
651
|
+
if suffix and suffix not in SOURCE_EXTENSIONS and name not in {"Dockerfile", "Makefile", ".env.example", ".env.sample"}:
|
|
652
|
+
return "unsupported_extension"
|
|
653
|
+
return None
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def is_binary_file(path: Path) -> bool:
|
|
657
|
+
try:
|
|
658
|
+
chunk = path.read_bytes()[:4096]
|
|
659
|
+
except OSError:
|
|
660
|
+
return True
|
|
661
|
+
return b"\x00" in chunk
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def workspace_files_to_diff(root: Path, files: list[dict]) -> str:
|
|
665
|
+
parts = []
|
|
666
|
+
for item in files:
|
|
667
|
+
rel_path = item["path"]
|
|
668
|
+
file_path = root / rel_path
|
|
669
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
670
|
+
parts.append(f"diff --git a/{rel_path} b/{rel_path}")
|
|
671
|
+
parts.append("--- /dev/null")
|
|
672
|
+
parts.append(f"+++ b/{rel_path}")
|
|
673
|
+
parts.append("@@ -0,0 +1,0 @@")
|
|
674
|
+
parts.extend(f"+{line}" for line in content.splitlines())
|
|
675
|
+
return "\n".join(parts) + "\n"
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _print_scan_manifest(files: list[dict], ignored: list[dict], manifest: dict) -> None:
|
|
679
|
+
click.echo("Valicode workspace scan")
|
|
680
|
+
click.echo(f"{manifest['files_scanned']} file(s) / {manifest['bytes_scanned']} bytes / {manifest['ignored_count']} ignored")
|
|
681
|
+
click.echo(f"{'Path':60} Size")
|
|
682
|
+
for item in files[:100]:
|
|
683
|
+
click.echo(f"{item['path'][:60]:60} {item['size']}")
|
|
684
|
+
if len(files) > 100:
|
|
685
|
+
click.echo(f"... {len(files) - 100} more file(s)")
|
|
686
|
+
ignored_reasons: dict[str, int] = {}
|
|
687
|
+
for item in ignored:
|
|
688
|
+
ignored_reasons[item["reason"]] = ignored_reasons.get(item["reason"], 0) + 1
|
|
689
|
+
if ignored_reasons:
|
|
690
|
+
click.echo("Ignored: " + ", ".join(f"{reason}={count}" for reason, count in sorted(ignored_reasons.items())))
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _print_table(result: dict) -> None:
|
|
694
|
+
score = result.get("score", 0)
|
|
695
|
+
issues = result.get("issues", [])
|
|
696
|
+
click.echo("Valicode")
|
|
697
|
+
click.echo(f"Score: {score}/100 / {len(issues)} issue(s) found / {result.get('files_analyzed', 0)} file(s) analyzed")
|
|
698
|
+
coverage = result.get("coverage") or {}
|
|
699
|
+
gate = result.get("merge_gate") or {}
|
|
700
|
+
if coverage:
|
|
701
|
+
click.echo(
|
|
702
|
+
f"Coverage: {coverage.get('coverage_percent', 0)}% "
|
|
703
|
+
f"({coverage.get('analyzed_files', 0)}/{coverage.get('eligible_files', 0)} files)"
|
|
704
|
+
)
|
|
705
|
+
click.echo(f"Merge gate: {str(gate.get('status', 'unknown')).upper()} / blockers: {gate.get('blocker_count', 0)}")
|
|
706
|
+
runs = result.get("analysis_runs") or []
|
|
707
|
+
if runs:
|
|
708
|
+
click.echo("Checks: " + ", ".join(
|
|
709
|
+
f"{run.get('tool')}={run.get('status')} ({run.get('findings_count', 0)} findings, {run.get('duration_ms', 0)}ms)"
|
|
710
|
+
for run in runs
|
|
711
|
+
))
|
|
712
|
+
test_runs = result.get("test_runs") or []
|
|
713
|
+
if test_runs:
|
|
714
|
+
click.echo("Local validation: " + ", ".join(f"{run.get('command')}={run.get('status')}" for run in test_runs))
|
|
715
|
+
if result.get("summary"):
|
|
716
|
+
click.echo(result.get("summary", ""))
|
|
717
|
+
fingerprint = result.get("ai_fingerprint") or {}
|
|
718
|
+
if fingerprint.get("any_ai_detected"):
|
|
719
|
+
confidence = round(float(fingerprint.get("dominant_confidence") or 0) * 100)
|
|
720
|
+
click.echo(f"AI: {fingerprint.get('dominant_tool', 'unknown')} ({confidence}% confidence)")
|
|
721
|
+
if not issues:
|
|
722
|
+
click.echo("No issues detected.")
|
|
723
|
+
return
|
|
724
|
+
click.echo(f"{'Severity':10} {'Category':14} {'File':30} {'Line':6} Issue")
|
|
725
|
+
order = ["critical", "high", "medium", "low", "info"]
|
|
726
|
+
for issue in sorted(issues, key=lambda item: order.index(item.get("severity", "info"))):
|
|
727
|
+
sev = issue.get("severity", "info")
|
|
728
|
+
click.echo(
|
|
729
|
+
f"{sev.upper():10} "
|
|
730
|
+
f"{issue.get('category', '')[:14]:14} "
|
|
731
|
+
f"{issue.get('file_path', '')[-30:]:30} "
|
|
732
|
+
f"{str(issue.get('line_start', '')):6} "
|
|
733
|
+
f"{issue.get('title', '')}"
|
|
734
|
+
)
|
|
735
|
+
confidence = round(float(issue.get("confidence") or 0) * 100)
|
|
736
|
+
flags = []
|
|
737
|
+
if issue.get("is_new"):
|
|
738
|
+
flags.append("new")
|
|
739
|
+
if issue.get("blocks_merge"):
|
|
740
|
+
flags.append("blocks merge")
|
|
741
|
+
click.echo(f" Confidence: {confidence}% / validation: {issue.get('validation_status', 'unknown')} / {', '.join(flags) or 'existing risk'}")
|
|
742
|
+
if issue.get("impact"):
|
|
743
|
+
click.echo(f" Impact: {issue['impact']}")
|
|
744
|
+
if issue.get("code_snippet"):
|
|
745
|
+
click.echo(" Evidence: " + str(issue["code_snippet"]).replace("\n", " | ")[:300])
|
|
746
|
+
if issue.get("suggestion"):
|
|
747
|
+
click.echo(f" Fix: {issue['suggestion']}")
|
|
748
|
+
if issue.get("remediation_test"):
|
|
749
|
+
click.echo(f" Regression test: {issue['remediation_test']}")
|
|
750
|
+
|
|
751
|
+
breakdown = result.get("score_breakdown") or []
|
|
752
|
+
if breakdown:
|
|
753
|
+
click.echo("\nScore breakdown:")
|
|
754
|
+
for item in breakdown[:20]:
|
|
755
|
+
click.echo(f" -{item.get('penalty', 0):>5}: {item.get('reason', '')}")
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _to_sarif(result: dict) -> str:
|
|
759
|
+
sarif = {
|
|
760
|
+
"version": "2.1.0",
|
|
761
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
762
|
+
"runs": [
|
|
763
|
+
{
|
|
764
|
+
"tool": {"driver": {"name": "Valicode", "rules": []}},
|
|
765
|
+
"results": [],
|
|
766
|
+
}
|
|
767
|
+
],
|
|
768
|
+
}
|
|
769
|
+
for issue in result.get("issues", []):
|
|
770
|
+
sarif["runs"][0]["results"].append(
|
|
771
|
+
{
|
|
772
|
+
"ruleId": issue.get("rule_id") or issue.get("category", "aov"),
|
|
773
|
+
"level": "error" if issue.get("severity") in ("critical", "high") else "warning",
|
|
774
|
+
"message": {"text": issue.get("title", "")},
|
|
775
|
+
"locations": [
|
|
776
|
+
{
|
|
777
|
+
"physicalLocation": {
|
|
778
|
+
"artifactLocation": {"uri": issue.get("file_path", "")},
|
|
779
|
+
"region": {"startLine": issue.get("line_start") or 1},
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
],
|
|
783
|
+
}
|
|
784
|
+
)
|
|
785
|
+
return json.dumps(sarif, indent=2)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def print_result(result: dict, output: str) -> None:
|
|
789
|
+
if output == "json":
|
|
790
|
+
click.echo(json.dumps(result, indent=2, default=str))
|
|
791
|
+
elif output == "sarif":
|
|
792
|
+
click.echo(_to_sarif(result))
|
|
793
|
+
elif output == "markdown":
|
|
794
|
+
click.echo(_to_markdown(result))
|
|
795
|
+
elif output == "html":
|
|
796
|
+
click.echo(_to_html(result))
|
|
797
|
+
elif output == "junit":
|
|
798
|
+
click.echo(_to_junit(result))
|
|
799
|
+
elif output == "summary":
|
|
800
|
+
click.echo(_to_summary(result))
|
|
801
|
+
elif output == "mermaid":
|
|
802
|
+
click.echo(_to_mermaid(result))
|
|
803
|
+
elif output == "chat":
|
|
804
|
+
click.echo(_to_chat_alert(result))
|
|
805
|
+
else:
|
|
806
|
+
_print_table(result)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def print_analysis_links(result: dict) -> None:
|
|
810
|
+
links = result.get("links") or {}
|
|
811
|
+
if not links:
|
|
812
|
+
return
|
|
813
|
+
click.echo("")
|
|
814
|
+
if links.get("dashboard"):
|
|
815
|
+
click.echo(f"Dashboard: {links['dashboard']}")
|
|
816
|
+
if links.get("pdf"):
|
|
817
|
+
click.echo(f"PDF: {links['pdf']}")
|
|
818
|
+
if links.get("sarif"):
|
|
819
|
+
click.echo(f"SARIF: {links['sarif']}")
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def save_report_bundle(output_dir: Path, result: dict) -> list[Path]:
|
|
823
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
824
|
+
files = {
|
|
825
|
+
"findings.json": json.dumps(result, indent=2, default=str),
|
|
826
|
+
"findings.sarif": _to_sarif(result),
|
|
827
|
+
"summary.md": _to_markdown(result),
|
|
828
|
+
"report.html": _to_html(result),
|
|
829
|
+
"report.pdf": _to_pdf_bytes(result),
|
|
830
|
+
"chat-alert.txt": _to_chat_alert(result),
|
|
831
|
+
"junit.xml": _to_junit(result),
|
|
832
|
+
"summary.txt": _to_summary(result),
|
|
833
|
+
"architecture.mmd": _to_mermaid(result, graph="architecture"),
|
|
834
|
+
"dataflow.mmd": _to_mermaid(result, graph="dataflow"),
|
|
835
|
+
"score-breakdown.json": json.dumps(result.get("score_breakdown") or [], indent=2, default=str),
|
|
836
|
+
}
|
|
837
|
+
written = []
|
|
838
|
+
for name, content in files.items():
|
|
839
|
+
path = output_dir / name
|
|
840
|
+
if isinstance(content, bytes):
|
|
841
|
+
path.write_bytes(content)
|
|
842
|
+
else:
|
|
843
|
+
path.write_text(content, encoding="utf-8")
|
|
844
|
+
written.append(path)
|
|
845
|
+
return written
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _to_summary(result: dict) -> str:
|
|
849
|
+
issues = result.get("issues", [])
|
|
850
|
+
lines = [
|
|
851
|
+
f"Valicode score: {result.get('score', 0)}/100",
|
|
852
|
+
f"Issues: {len(issues)}",
|
|
853
|
+
f"Files analyzed: {result.get('files_analyzed', 0)}",
|
|
854
|
+
f"Merge gate: {(result.get('merge_gate') or {}).get('status', 'unknown')}",
|
|
855
|
+
]
|
|
856
|
+
if result.get("summary"):
|
|
857
|
+
lines.append("")
|
|
858
|
+
lines.append(str(result["summary"]))
|
|
859
|
+
by_severity: dict[str, int] = {}
|
|
860
|
+
for issue in issues:
|
|
861
|
+
severity = issue.get("severity", "info")
|
|
862
|
+
by_severity[severity] = by_severity.get(severity, 0) + 1
|
|
863
|
+
if by_severity:
|
|
864
|
+
lines.append("")
|
|
865
|
+
lines.append("By severity: " + ", ".join(f"{key}={value}" for key, value in sorted(by_severity.items())))
|
|
866
|
+
return "\n".join(lines)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _to_chat_alert(result: dict) -> str:
|
|
870
|
+
issues = result.get("issues", [])
|
|
871
|
+
blockers = [
|
|
872
|
+
issue for issue in issues
|
|
873
|
+
if issue.get("severity") in {"critical", "high"} or issue.get("blocks_merge")
|
|
874
|
+
]
|
|
875
|
+
shown = blockers[:10] if blockers else issues[:10]
|
|
876
|
+
score = result.get("score", 0)
|
|
877
|
+
gate = (result.get("merge_gate") or {}).get("status", "unknown")
|
|
878
|
+
severity_counts: dict[str, int] = {}
|
|
879
|
+
for issue in issues:
|
|
880
|
+
severity = issue.get("severity", "info")
|
|
881
|
+
severity_counts[severity] = severity_counts.get(severity, 0) + 1
|
|
882
|
+
|
|
883
|
+
if not issues:
|
|
884
|
+
return "\n".join([
|
|
885
|
+
"Valicode alert",
|
|
886
|
+
f"Score: {score}/100",
|
|
887
|
+
"No issues detected.",
|
|
888
|
+
])
|
|
889
|
+
|
|
890
|
+
lines = [
|
|
891
|
+
"Valicode alert",
|
|
892
|
+
f"Score: {score}/100 | Merge gate: {str(gate).upper()} | Issues: {len(issues)}",
|
|
893
|
+
"Severity: " + ", ".join(f"{key}={severity_counts[key]}" for key in ["critical", "high", "medium", "low", "info"] if key in severity_counts),
|
|
894
|
+
"",
|
|
895
|
+
"Errors to fix:",
|
|
896
|
+
]
|
|
897
|
+
for index, issue in enumerate(shown, start=1):
|
|
898
|
+
location = _issue_location(issue)
|
|
899
|
+
confidence = round(float(issue.get("confidence") or 0) * 100)
|
|
900
|
+
lines.append(f"{index}. [{str(issue.get('severity', 'info')).upper()}] {issue.get('title', 'Issue')}")
|
|
901
|
+
lines.append(f" Where: {location}")
|
|
902
|
+
if issue.get("category"):
|
|
903
|
+
lines.append(f" Type: {issue.get('category')}")
|
|
904
|
+
if confidence:
|
|
905
|
+
lines.append(f" Confidence: {confidence}%")
|
|
906
|
+
if issue.get("impact"):
|
|
907
|
+
lines.extend(f" Impact: {line}" for line in _wrap_pdf_text(str(issue["impact"]), width=88))
|
|
908
|
+
if issue.get("suggestion"):
|
|
909
|
+
lines.extend(f" Fix: {line}" for line in _wrap_pdf_text(str(issue["suggestion"]), width=88))
|
|
910
|
+
elif issue.get("remediation_test"):
|
|
911
|
+
lines.extend(f" Test: {line}" for line in _wrap_pdf_text(str(issue["remediation_test"]), width=88))
|
|
912
|
+
lines.append("")
|
|
913
|
+
if len(issues) > len(shown):
|
|
914
|
+
lines.append(f"... {len(issues) - len(shown)} more issue(s). Open report.pdf or findings.json for the full list.")
|
|
915
|
+
if result.get("id"):
|
|
916
|
+
lines.append(f"Analysis ID: {result['id']}")
|
|
917
|
+
return "\n".join(lines).strip()
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _issue_location(issue: dict) -> str:
|
|
921
|
+
path = issue.get("file_path") or issue.get("path") or "unknown file"
|
|
922
|
+
line = issue.get("line_start") or issue.get("line") or issue.get("start_line")
|
|
923
|
+
if line:
|
|
924
|
+
return f"{path}:{line}"
|
|
925
|
+
return str(path)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _to_markdown(result: dict) -> str:
|
|
929
|
+
issues = result.get("issues", [])
|
|
930
|
+
lines = [
|
|
931
|
+
"# Valicode Audit Report",
|
|
932
|
+
"",
|
|
933
|
+
f"- Score: `{result.get('score', 0)}/100`",
|
|
934
|
+
f"- Issues: `{len(issues)}`",
|
|
935
|
+
f"- Files analyzed: `{result.get('files_analyzed', 0)}`",
|
|
936
|
+
f"- Merge gate: `{(result.get('merge_gate') or {}).get('status', 'unknown')}`",
|
|
937
|
+
]
|
|
938
|
+
if result.get("summary"):
|
|
939
|
+
lines.extend(["", "## Summary", "", str(result["summary"])])
|
|
940
|
+
if result.get("score_breakdown"):
|
|
941
|
+
lines.extend(["", "## Score Breakdown", ""])
|
|
942
|
+
for item in result["score_breakdown"][:30]:
|
|
943
|
+
lines.append(f"- `-{item.get('penalty', 0)}` {item.get('reason', '')}")
|
|
944
|
+
if issues:
|
|
945
|
+
lines.extend(["", "## Findings", ""])
|
|
946
|
+
for issue in issues:
|
|
947
|
+
location = f"{issue.get('file_path', '')}:{issue.get('line_start', '')}".rstrip(":")
|
|
948
|
+
lines.extend([
|
|
949
|
+
f"### {issue.get('title', 'Issue')}",
|
|
950
|
+
"",
|
|
951
|
+
f"- Severity: `{issue.get('severity', 'info')}`",
|
|
952
|
+
f"- Category: `{issue.get('category', 'logic')}`",
|
|
953
|
+
f"- Location: `{location}`",
|
|
954
|
+
f"- Confidence: `{round(float(issue.get('confidence') or 0) * 100)}%`",
|
|
955
|
+
])
|
|
956
|
+
if issue.get("description"):
|
|
957
|
+
lines.append(f"- Description: {issue['description']}")
|
|
958
|
+
if issue.get("suggestion"):
|
|
959
|
+
lines.append(f"- Fix: {issue['suggestion']}")
|
|
960
|
+
if issue.get("remediation_test"):
|
|
961
|
+
lines.append(f"- Regression test: {issue['remediation_test']}")
|
|
962
|
+
if issue.get("code_snippet"):
|
|
963
|
+
lines.extend(["", "```", str(issue["code_snippet"])[:1200], "```", ""])
|
|
964
|
+
return "\n".join(lines)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _to_html(result: dict) -> str:
|
|
968
|
+
issue_rows = []
|
|
969
|
+
for issue in result.get("issues", []):
|
|
970
|
+
issue_rows.append(
|
|
971
|
+
"<tr>"
|
|
972
|
+
f"<td>{html.escape(str(issue.get('severity', 'info')))}</td>"
|
|
973
|
+
f"<td>{html.escape(str(issue.get('category', 'logic')))}</td>"
|
|
974
|
+
f"<td>{html.escape(str(issue.get('file_path', '')))}:{html.escape(str(issue.get('line_start', '')))}</td>"
|
|
975
|
+
f"<td>{html.escape(str(issue.get('title', '')))}</td>"
|
|
976
|
+
f"<td>{html.escape(str(issue.get('suggestion', '')))}</td>"
|
|
977
|
+
"</tr>"
|
|
978
|
+
)
|
|
979
|
+
return """<!doctype html>
|
|
980
|
+
<html lang="en">
|
|
981
|
+
<head>
|
|
982
|
+
<meta charset="utf-8">
|
|
983
|
+
<title>Valicode Audit Report</title>
|
|
984
|
+
<style>
|
|
985
|
+
body { font-family: Inter, Arial, sans-serif; margin: 32px; color: #18181b; }
|
|
986
|
+
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
987
|
+
th, td { border-bottom: 1px solid #e4e4e7; padding: 8px; text-align: left; vertical-align: top; }
|
|
988
|
+
.score { font-size: 40px; font-weight: 700; }
|
|
989
|
+
.muted { color: #71717a; }
|
|
990
|
+
</style>
|
|
991
|
+
</head>
|
|
992
|
+
<body>
|
|
993
|
+
<h1>Valicode Audit Report</h1>
|
|
994
|
+
<div class="score">""" + html.escape(str(result.get("score", 0))) + """/100</div>
|
|
995
|
+
<p class="muted">""" + html.escape(str(len(result.get("issues", [])))) + """ issue(s), """ + html.escape(str(result.get("files_analyzed", 0))) + """ file(s) analyzed.</p>
|
|
996
|
+
<p>""" + html.escape(str(result.get("summary", ""))) + """</p>
|
|
997
|
+
<table>
|
|
998
|
+
<thead><tr><th>Severity</th><th>Category</th><th>Location</th><th>Issue</th><th>Fix</th></tr></thead>
|
|
999
|
+
<tbody>""" + "\n".join(issue_rows) + """</tbody>
|
|
1000
|
+
</table>
|
|
1001
|
+
</body>
|
|
1002
|
+
</html>
|
|
1003
|
+
"""
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _to_junit(result: dict) -> str:
|
|
1007
|
+
issues = result.get("issues", [])
|
|
1008
|
+
cases = []
|
|
1009
|
+
for issue in issues:
|
|
1010
|
+
name = html.escape(str(issue.get("rule_id") or issue.get("title") or "valicode-issue"))
|
|
1011
|
+
message = html.escape(str(issue.get("title", "")))
|
|
1012
|
+
details = html.escape(f"{issue.get('file_path', '')}:{issue.get('line_start', '')} {issue.get('description', '')}")
|
|
1013
|
+
cases.append(f'<testcase classname="Valicode" name="{name}"><failure message="{message}">{details}</failure></testcase>')
|
|
1014
|
+
return f'<?xml version="1.0" encoding="UTF-8"?><testsuite name="Valicode" tests="{len(issues)}" failures="{len(issues)}">' + "".join(cases) + "</testsuite>"
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _to_pdf_bytes(result: dict) -> bytes:
|
|
1018
|
+
lines = _pdf_report_lines(result)
|
|
1019
|
+
pages = [lines[index:index + 46] for index in range(0, len(lines), 46)] or [[]]
|
|
1020
|
+
objects: list[bytes] = []
|
|
1021
|
+
|
|
1022
|
+
def add_object(payload: bytes) -> int:
|
|
1023
|
+
objects.append(payload)
|
|
1024
|
+
return len(objects)
|
|
1025
|
+
|
|
1026
|
+
font_obj = add_object(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
|
|
1027
|
+
page_refs: list[int] = []
|
|
1028
|
+
page_payloads: list[tuple[int, bytes]] = []
|
|
1029
|
+
pages_obj = 0
|
|
1030
|
+
for page_number, page_lines in enumerate(pages, start=1):
|
|
1031
|
+
content = _pdf_page_stream(page_lines, page_number, len(pages))
|
|
1032
|
+
content_obj = add_object(b"<< /Length " + str(len(content)).encode("ascii") + b" >>\nstream\n" + content + b"\nendstream")
|
|
1033
|
+
page_ref = add_object(b"")
|
|
1034
|
+
page_refs.append(page_ref)
|
|
1035
|
+
page_payloads.append((page_ref, b"<< /Type /Page /Parent {PAGES} 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 " + str(font_obj).encode("ascii") + b" 0 R >> >> /Contents " + str(content_obj).encode("ascii") + b" 0 R >>"))
|
|
1036
|
+
pages_obj = add_object(b"<< /Type /Pages /Kids [" + b" ".join(f"{ref} 0 R".encode("ascii") for ref in page_refs) + b"] /Count " + str(len(page_refs)).encode("ascii") + b" >>")
|
|
1037
|
+
for page_ref, payload in page_payloads:
|
|
1038
|
+
objects[page_ref - 1] = payload.replace(b"{PAGES}", str(pages_obj).encode("ascii"))
|
|
1039
|
+
catalog_obj = add_object(b"<< /Type /Catalog /Pages " + str(pages_obj).encode("ascii") + b" 0 R >>")
|
|
1040
|
+
|
|
1041
|
+
output = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
|
1042
|
+
offsets = [0]
|
|
1043
|
+
for index, payload in enumerate(objects, start=1):
|
|
1044
|
+
offsets.append(len(output))
|
|
1045
|
+
output.extend(f"{index} 0 obj\n".encode("ascii"))
|
|
1046
|
+
output.extend(payload)
|
|
1047
|
+
output.extend(b"\nendobj\n")
|
|
1048
|
+
xref_offset = len(output)
|
|
1049
|
+
output.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii"))
|
|
1050
|
+
output.extend(b"0000000000 65535 f \n")
|
|
1051
|
+
for offset in offsets[1:]:
|
|
1052
|
+
output.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
|
|
1053
|
+
output.extend(
|
|
1054
|
+
f"trailer\n<< /Size {len(objects) + 1} /Root {catalog_obj} 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode("ascii")
|
|
1055
|
+
)
|
|
1056
|
+
return bytes(output)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _pdf_report_lines(result: dict) -> list[str]:
|
|
1060
|
+
issues = result.get("issues", [])
|
|
1061
|
+
lines = [
|
|
1062
|
+
"Valicode Audit Report",
|
|
1063
|
+
"",
|
|
1064
|
+
f"Score: {result.get('score', 0)}/100",
|
|
1065
|
+
f"Issues: {len(issues)}",
|
|
1066
|
+
f"Files analyzed: {result.get('files_analyzed', 0)}",
|
|
1067
|
+
f"Merge gate: {(result.get('merge_gate') or {}).get('status', 'unknown')}",
|
|
1068
|
+
"",
|
|
1069
|
+
]
|
|
1070
|
+
if result.get("summary"):
|
|
1071
|
+
lines.extend(_wrap_pdf_text(f"Summary: {result['summary']}"))
|
|
1072
|
+
lines.append("")
|
|
1073
|
+
if result.get("workspace_manifest"):
|
|
1074
|
+
manifest = result["workspace_manifest"]
|
|
1075
|
+
lines.extend([
|
|
1076
|
+
"Workspace",
|
|
1077
|
+
f"Files scanned: {manifest.get('files_scanned', 0)}",
|
|
1078
|
+
f"Bytes scanned: {manifest.get('bytes_scanned', 0)}",
|
|
1079
|
+
f"Ignored: {manifest.get('ignored_count', 0)}",
|
|
1080
|
+
"",
|
|
1081
|
+
])
|
|
1082
|
+
if result.get("score_breakdown"):
|
|
1083
|
+
lines.append("Score Breakdown")
|
|
1084
|
+
for item in result["score_breakdown"][:20]:
|
|
1085
|
+
lines.extend(_wrap_pdf_text(f"-{item.get('penalty', 0)}: {item.get('reason', '')}"))
|
|
1086
|
+
lines.append("")
|
|
1087
|
+
if issues:
|
|
1088
|
+
lines.append("Findings")
|
|
1089
|
+
for index, issue in enumerate(issues[:80], start=1):
|
|
1090
|
+
location = f"{issue.get('file_path', '')}:{issue.get('line_start', '')}".rstrip(":")
|
|
1091
|
+
header = f"{index}. [{str(issue.get('severity', 'info')).upper()}] {issue.get('title', 'Issue')}"
|
|
1092
|
+
lines.extend(_wrap_pdf_text(header))
|
|
1093
|
+
lines.extend(_wrap_pdf_text(f"Location: {location}"))
|
|
1094
|
+
if issue.get("suggestion"):
|
|
1095
|
+
lines.extend(_wrap_pdf_text(f"Fix: {issue['suggestion']}"))
|
|
1096
|
+
lines.append("")
|
|
1097
|
+
if len(issues) > 80:
|
|
1098
|
+
lines.append(f"... {len(issues) - 80} additional finding(s) omitted from PDF. See findings.json for full data.")
|
|
1099
|
+
return lines
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _pdf_page_stream(lines: list[str], page_number: int, page_count: int) -> bytes:
|
|
1103
|
+
commands = ["BT", "/F1 11 Tf", "50 742 Td", "14 TL"]
|
|
1104
|
+
for line in lines:
|
|
1105
|
+
commands.append(f"({_pdf_escape(line)}) Tj")
|
|
1106
|
+
commands.append("T*")
|
|
1107
|
+
commands.extend(["/F1 9 Tf", f"0 -18 Td", f"(Page {page_number} of {page_count}) Tj", "ET"])
|
|
1108
|
+
return "\n".join(commands).encode("latin-1", errors="replace")
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _wrap_pdf_text(value: str, width: int = 96) -> list[str]:
|
|
1112
|
+
words = str(value).replace("\n", " ").split()
|
|
1113
|
+
lines: list[str] = []
|
|
1114
|
+
current = ""
|
|
1115
|
+
for word in words:
|
|
1116
|
+
candidate = f"{current} {word}".strip()
|
|
1117
|
+
if len(candidate) > width and current:
|
|
1118
|
+
lines.append(current)
|
|
1119
|
+
current = word
|
|
1120
|
+
else:
|
|
1121
|
+
current = candidate
|
|
1122
|
+
if current:
|
|
1123
|
+
lines.append(current)
|
|
1124
|
+
return lines or [""]
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _pdf_escape(value: str) -> str:
|
|
1128
|
+
text = str(value).encode("latin-1", errors="replace").decode("latin-1")
|
|
1129
|
+
return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def _to_mermaid(result: dict, graph: str = "architecture") -> str:
|
|
1133
|
+
if graph == "dataflow":
|
|
1134
|
+
dataflow = result.get("dataflow") or result.get("data_flow") or {}
|
|
1135
|
+
edges = dataflow.get("edges") if isinstance(dataflow, dict) else None
|
|
1136
|
+
if edges:
|
|
1137
|
+
lines = ["flowchart LR"]
|
|
1138
|
+
for edge in edges[:80]:
|
|
1139
|
+
source = _mermaid_node(edge.get("source") or edge.get("from") or "input")
|
|
1140
|
+
target = _mermaid_node(edge.get("target") or edge.get("to") or "output")
|
|
1141
|
+
label = str(edge.get("label") or edge.get("type") or "")
|
|
1142
|
+
lines.append(f" {source} -->|{_mermaid_label(label)}| {target}")
|
|
1143
|
+
return "\n".join(lines)
|
|
1144
|
+
graph_data = result.get("architecture_graph") or result.get("architecture") or {}
|
|
1145
|
+
if isinstance(graph_data, dict) and graph_data.get("edges"):
|
|
1146
|
+
lines = ["flowchart TD"]
|
|
1147
|
+
for edge in graph_data["edges"][:80]:
|
|
1148
|
+
source = _mermaid_node(edge.get("source") or edge.get("from") or "component")
|
|
1149
|
+
target = _mermaid_node(edge.get("target") or edge.get("to") or "dependency")
|
|
1150
|
+
lines.append(f" {source} --> {target}")
|
|
1151
|
+
return "\n".join(lines)
|
|
1152
|
+
files = sorted({issue.get("file_path") for issue in result.get("issues", []) if issue.get("file_path")})
|
|
1153
|
+
lines = ["flowchart TD", " audit[Valicode audit]"]
|
|
1154
|
+
for path in files[:40]:
|
|
1155
|
+
lines.append(f" audit --> {_mermaid_node(path)}")
|
|
1156
|
+
return "\n".join(lines)
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _mermaid_node(value: str) -> str:
|
|
1160
|
+
safe = "".join(ch if ch.isalnum() else "_" for ch in str(value))[:60].strip("_") or "node"
|
|
1161
|
+
label = _mermaid_label(str(value)[:80])
|
|
1162
|
+
return f'{safe}["{label}"]'
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _mermaid_label(value: str) -> str:
|
|
1166
|
+
return str(value).replace('"', "'").replace("\n", " ")
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def api_request(method: str, path: str, *, json_body: dict | None = None, params: dict | None = None, raw: bool = False):
|
|
1170
|
+
cfg = load_config()
|
|
1171
|
+
api_key = cfg.get("api_key")
|
|
1172
|
+
if not api_key:
|
|
1173
|
+
click.echo("Error: No API key configured. Run valicode login first.", err=True)
|
|
1174
|
+
sys.exit(1)
|
|
1175
|
+
url = f"{get_api_base()}/{path.lstrip('/')}"
|
|
1176
|
+
headers = {"X-API-Key": api_key, "Content-Type": "application/json"}
|
|
1177
|
+
try:
|
|
1178
|
+
response = httpx.request(method, url, headers=headers, json=json_body, params=_clean_params(params), timeout=60)
|
|
1179
|
+
response.raise_for_status()
|
|
1180
|
+
except httpx.HTTPStatusError as exc:
|
|
1181
|
+
message = _response_error_message(exc.response)
|
|
1182
|
+
click.echo(f"API error {exc.response.status_code}: {message}", err=True)
|
|
1183
|
+
sys.exit(1)
|
|
1184
|
+
except httpx.HTTPError as exc:
|
|
1185
|
+
click.echo(f"API request failed: {exc}", err=True)
|
|
1186
|
+
sys.exit(1)
|
|
1187
|
+
if raw:
|
|
1188
|
+
return response
|
|
1189
|
+
if not response.content:
|
|
1190
|
+
return {}
|
|
1191
|
+
return response.json()
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _clean_params(params: dict | None) -> dict | None:
|
|
1195
|
+
if not params:
|
|
1196
|
+
return None
|
|
1197
|
+
return {key: value for key, value in params.items() if value is not None}
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _response_error_message(response: httpx.Response) -> str:
|
|
1201
|
+
try:
|
|
1202
|
+
data = response.json()
|
|
1203
|
+
return str(data.get("message") or data.get("detail") or data.get("error") or data)
|
|
1204
|
+
except ValueError:
|
|
1205
|
+
return response.text[:500]
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def print_payload(data, *, json_output: bool = False) -> None:
|
|
1209
|
+
if json_output:
|
|
1210
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
1211
|
+
return
|
|
1212
|
+
if isinstance(data, dict):
|
|
1213
|
+
_print_dict_payload(data)
|
|
1214
|
+
elif isinstance(data, list):
|
|
1215
|
+
_print_rows(data)
|
|
1216
|
+
else:
|
|
1217
|
+
click.echo(str(data))
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def _print_dict_payload(data: dict) -> None:
|
|
1221
|
+
for key, value in data.items():
|
|
1222
|
+
if isinstance(value, list):
|
|
1223
|
+
click.echo(f"{key}:")
|
|
1224
|
+
_print_rows(value)
|
|
1225
|
+
elif isinstance(value, dict):
|
|
1226
|
+
click.echo(f"{key}:")
|
|
1227
|
+
for child_key, child_value in value.items():
|
|
1228
|
+
click.echo(f" {child_key}: {_format_cell(child_value)}")
|
|
1229
|
+
else:
|
|
1230
|
+
click.echo(f"{key}: {_format_cell(value)}")
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _print_rows(rows: list[dict]) -> None:
|
|
1234
|
+
if not rows:
|
|
1235
|
+
click.echo("No records.")
|
|
1236
|
+
return
|
|
1237
|
+
if not all(isinstance(row, dict) for row in rows):
|
|
1238
|
+
for row in rows:
|
|
1239
|
+
click.echo(_format_cell(row))
|
|
1240
|
+
return
|
|
1241
|
+
columns = _select_columns(rows)
|
|
1242
|
+
widths = {column: min(36, max(len(column), *(len(_format_cell(row.get(column))) for row in rows[:50]))) for column in columns}
|
|
1243
|
+
click.echo(" ".join(column[:widths[column]].ljust(widths[column]) for column in columns))
|
|
1244
|
+
for row in rows:
|
|
1245
|
+
click.echo(" ".join(_format_cell(row.get(column))[:widths[column]].ljust(widths[column]) for column in columns))
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _select_columns(rows: list[dict]) -> list[str]:
|
|
1249
|
+
preferred = [
|
|
1250
|
+
"id", "email", "name", "full_name", "repository", "plan", "role", "status", "score",
|
|
1251
|
+
"severity", "category", "title", "created_at", "last_used_at", "requests", "errors",
|
|
1252
|
+
]
|
|
1253
|
+
present = []
|
|
1254
|
+
keys = set().union(*(row.keys() for row in rows))
|
|
1255
|
+
for key in preferred:
|
|
1256
|
+
if key in keys:
|
|
1257
|
+
present.append(key)
|
|
1258
|
+
for key in sorted(keys):
|
|
1259
|
+
if key not in present and len(present) < 8:
|
|
1260
|
+
present.append(key)
|
|
1261
|
+
return present[:8]
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _format_cell(value) -> str:
|
|
1265
|
+
if value is None:
|
|
1266
|
+
return ""
|
|
1267
|
+
if isinstance(value, (dict, list)):
|
|
1268
|
+
return json.dumps(value, default=str)[:120]
|
|
1269
|
+
return str(value)
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
@cli.command()
|
|
1273
|
+
def login():
|
|
1274
|
+
"""Authenticate with your Valicode account."""
|
|
1275
|
+
api_key = click.prompt("Paste your API key", hide_input=True)
|
|
1276
|
+
cfg = load_config()
|
|
1277
|
+
cfg["api_key"] = api_key.strip()
|
|
1278
|
+
save_config(cfg)
|
|
1279
|
+
click.echo("API key saved.")
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
@cli.group()
|
|
1283
|
+
def config():
|
|
1284
|
+
"""Manage local CLI configuration."""
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
@config.command("set")
|
|
1288
|
+
@click.argument("key")
|
|
1289
|
+
@click.argument("value")
|
|
1290
|
+
def config_set(key: str, value: str):
|
|
1291
|
+
cfg = load_config()
|
|
1292
|
+
cfg[key] = value
|
|
1293
|
+
save_config(cfg)
|
|
1294
|
+
click.echo(f"Saved {key}.")
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
@cli.command()
|
|
1298
|
+
@click.option("--json", "json_output", is_flag=True, help="Print raw JSON")
|
|
1299
|
+
def dashboard(json_output: bool):
|
|
1300
|
+
"""Show the same operational overview as the web dashboard."""
|
|
1301
|
+
data = api_request("GET", "dashboard/overview")
|
|
1302
|
+
if json_output:
|
|
1303
|
+
print_payload(data, json_output=True)
|
|
1304
|
+
return
|
|
1305
|
+
latest = data.get("latest_analysis") or {}
|
|
1306
|
+
quota = data.get("quota") or {}
|
|
1307
|
+
click.echo("Valicode dashboard")
|
|
1308
|
+
click.echo(f"Current score: {latest.get('score', 'none')}")
|
|
1309
|
+
click.echo(f"Clean streak: {data.get('streak_days', 0)} day(s)")
|
|
1310
|
+
click.echo(f"This week: {data.get('week_avg', 'n/a')} / previous: {data.get('prev_week_avg', 'n/a')}")
|
|
1311
|
+
click.echo(f"Usage: {quota.get('used', 0)} / {quota.get('limit', 'unlimited')} ({quota.get('plan', 'unknown')})")
|
|
1312
|
+
if data.get("action_required"):
|
|
1313
|
+
click.echo(f"Action required: {data['action_required'].get('message')}")
|
|
1314
|
+
if data.get("repos_needing_attention"):
|
|
1315
|
+
click.echo("\nRepos needing attention:")
|
|
1316
|
+
_print_rows(data["repos_needing_attention"])
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
@cli.group("analyses")
|
|
1320
|
+
def analyses_group():
|
|
1321
|
+
"""Browse analysis history and reports."""
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
@analyses_group.command("list")
|
|
1325
|
+
@click.option("--limit", default=20, show_default=True)
|
|
1326
|
+
@click.option("--offset", default=0, show_default=True)
|
|
1327
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1328
|
+
def analyses_list(limit: int, offset: int, json_output: bool):
|
|
1329
|
+
data = api_request("GET", "analyses", params={"limit": limit, "offset": offset})
|
|
1330
|
+
print_payload(data if json_output else data.get("analyses", data), json_output=json_output)
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
@analyses_group.command("overview")
|
|
1334
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1335
|
+
def analyses_overview(json_output: bool):
|
|
1336
|
+
print_payload(api_request("GET", "analyses/overview"), json_output=json_output)
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
@analyses_group.command("show")
|
|
1340
|
+
@click.argument("analysis_id")
|
|
1341
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1342
|
+
def analyses_show(analysis_id: str, json_output: bool):
|
|
1343
|
+
print_payload(api_request("GET", f"analyses/{analysis_id}"), json_output=json_output)
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
@analyses_group.command("report")
|
|
1347
|
+
@click.argument("analysis_id")
|
|
1348
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1349
|
+
def analyses_report(analysis_id: str, json_output: bool):
|
|
1350
|
+
print_payload(api_request("GET", f"analyses/{analysis_id}/report"), json_output=json_output)
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
@analyses_group.command("compliance")
|
|
1354
|
+
@click.argument("analysis_id")
|
|
1355
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1356
|
+
def analyses_compliance(analysis_id: str, json_output: bool):
|
|
1357
|
+
print_payload(api_request("GET", f"analyses/{analysis_id}/compliance"), json_output=json_output)
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
@analyses_group.command("autofix")
|
|
1361
|
+
@click.argument("analysis_id")
|
|
1362
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1363
|
+
def analyses_autofix(analysis_id: str, json_output: bool):
|
|
1364
|
+
print_payload(api_request("POST", f"analyses/{analysis_id}/autofix"), json_output=json_output)
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
@cli.group("repos")
|
|
1368
|
+
def repos_group():
|
|
1369
|
+
"""Manage repositories and repository-level rule settings."""
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
@repos_group.command("list")
|
|
1373
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1374
|
+
def repos_list(json_output: bool):
|
|
1375
|
+
data = api_request("GET", "repositories")
|
|
1376
|
+
print_payload(data if json_output else data.get("repositories", data), json_output=json_output)
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
@repos_group.command("show")
|
|
1380
|
+
@click.argument("repository_id")
|
|
1381
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1382
|
+
def repos_show(repository_id: str, json_output: bool):
|
|
1383
|
+
print_payload(api_request("GET", f"repositories/{repository_id}"), json_output=json_output)
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
@repos_group.command("add")
|
|
1387
|
+
@click.argument("full_name")
|
|
1388
|
+
@click.option("--github-repo-id", required=True, help="GitHub numeric repository id")
|
|
1389
|
+
@click.option("--default-branch", default="main", show_default=True)
|
|
1390
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1391
|
+
def repos_add(full_name: str, github_repo_id: str, default_branch: str, json_output: bool):
|
|
1392
|
+
payload = {"full_name": full_name, "github_repo_id": int(github_repo_id), "default_branch": default_branch}
|
|
1393
|
+
print_payload(api_request("POST", "repositories", json_body=payload), json_output=json_output)
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
@repos_group.command("rules")
|
|
1397
|
+
@click.argument("repository_id", required=False)
|
|
1398
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1399
|
+
def repos_rules(repository_id: str | None, json_output: bool):
|
|
1400
|
+
data = api_request("GET", "rules", params={"repository_id": repository_id})
|
|
1401
|
+
print_payload(data if json_output else data.get("rules", data), json_output=json_output)
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
@repos_group.command("set-rules")
|
|
1405
|
+
@click.argument("repository_id")
|
|
1406
|
+
@click.option("--disable", "disabled_rules", multiple=True, help="Rule id to disable. Repeat for multiple rules.")
|
|
1407
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1408
|
+
def repos_set_rules(repository_id: str, disabled_rules: tuple[str, ...], json_output: bool):
|
|
1409
|
+
payload = {"disabled_rules": list(disabled_rules)}
|
|
1410
|
+
print_payload(api_request("PATCH", f"repositories/{repository_id}/rules", json_body=payload), json_output=json_output)
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
@cli.group("issues")
|
|
1414
|
+
def issues_group():
|
|
1415
|
+
"""List and triage open findings."""
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
@issues_group.command("list")
|
|
1419
|
+
@click.option("--severity", type=click.Choice(["critical", "high", "medium", "low", "info"]))
|
|
1420
|
+
@click.option("--limit", default=100, show_default=True)
|
|
1421
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1422
|
+
def issues_list(severity: str | None, limit: int, json_output: bool):
|
|
1423
|
+
data = api_request("GET", "account/issues", params={"severity": severity, "limit": limit})
|
|
1424
|
+
print_payload(data if json_output else data.get("issues", data), json_output=json_output)
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
@issues_group.command("feedback")
|
|
1428
|
+
@click.argument("analysis_id")
|
|
1429
|
+
@click.argument("issue_id")
|
|
1430
|
+
@click.option("--value", required=True, type=click.Choice(["confirmed", "false_positive", "not_sure"]))
|
|
1431
|
+
@click.option("--comment", default="")
|
|
1432
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1433
|
+
def issues_feedback(analysis_id: str, issue_id: str, value: str, comment: str, json_output: bool):
|
|
1434
|
+
payload = {"feedback": value, "comment": comment}
|
|
1435
|
+
print_payload(api_request("POST", f"analyses/{analysis_id}/issues/{issue_id}/feedback", json_body=payload), json_output=json_output)
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
@issues_group.command("status")
|
|
1439
|
+
@click.argument("analysis_id")
|
|
1440
|
+
@click.argument("issue_id")
|
|
1441
|
+
@click.argument("status", type=click.Choice(["open", "confirmed", "false_positive", "fixed", "ignored", "reopened"]))
|
|
1442
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1443
|
+
def issues_status(analysis_id: str, issue_id: str, status: str, json_output: bool):
|
|
1444
|
+
payload = {"status": status}
|
|
1445
|
+
print_payload(api_request("PATCH", f"analyses/{analysis_id}/issues/{issue_id}/status", json_body=payload), json_output=json_output)
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
@cli.group("keys")
|
|
1449
|
+
def keys_group():
|
|
1450
|
+
"""Manage API keys."""
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
@keys_group.command("list")
|
|
1454
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1455
|
+
def keys_list(json_output: bool):
|
|
1456
|
+
data = api_request("GET", "api-keys")
|
|
1457
|
+
print_payload(data if json_output else data.get("keys", data), json_output=json_output)
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
@keys_group.command("create")
|
|
1461
|
+
@click.argument("label")
|
|
1462
|
+
@click.option("--expires-in-days", type=int)
|
|
1463
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1464
|
+
def keys_create(label: str, expires_in_days: int | None, json_output: bool):
|
|
1465
|
+
payload = {"label": label, "expires_in_days": expires_in_days}
|
|
1466
|
+
data = api_request("POST", "api-keys", json_body=payload)
|
|
1467
|
+
if json_output:
|
|
1468
|
+
print_payload(data, json_output=True)
|
|
1469
|
+
return
|
|
1470
|
+
click.echo("API key created. Copy it now; it will not be shown again.")
|
|
1471
|
+
click.echo(data.get("raw_key") or data.get("api_key", ""))
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
@keys_group.command("revoke")
|
|
1475
|
+
@click.argument("key_id")
|
|
1476
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1477
|
+
def keys_revoke(key_id: str, json_output: bool):
|
|
1478
|
+
print_payload(api_request("DELETE", f"api-keys/{key_id}"), json_output=json_output)
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
@cli.command("usage")
|
|
1482
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1483
|
+
def usage_command(json_output: bool):
|
|
1484
|
+
"""Show quota and recent API usage."""
|
|
1485
|
+
print_payload(api_request("GET", "account/usage"), json_output=json_output)
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
@cli.group("billing")
|
|
1489
|
+
def billing_group():
|
|
1490
|
+
"""Manage plan and billing links."""
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
@billing_group.command("summary")
|
|
1494
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1495
|
+
def billing_summary_command(json_output: bool):
|
|
1496
|
+
print_payload(api_request("GET", "billing"), json_output=json_output)
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
@billing_group.command("checkout")
|
|
1500
|
+
@click.argument("plan", type=click.Choice(["pro", "team", "enterprise"]))
|
|
1501
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1502
|
+
def billing_checkout(plan: str, json_output: bool):
|
|
1503
|
+
print_payload(api_request("POST", f"billing/checkout/{plan}"), json_output=json_output)
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
@billing_group.command("portal")
|
|
1507
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1508
|
+
def billing_portal(json_output: bool):
|
|
1509
|
+
print_payload(api_request("POST", "billing/portal"), json_output=json_output)
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
@cli.group("integrations")
|
|
1513
|
+
def integrations_group():
|
|
1514
|
+
"""Manage GitHub, Slack, email digest, and CI integrations."""
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
@integrations_group.command("status")
|
|
1518
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1519
|
+
def integrations_status(json_output: bool):
|
|
1520
|
+
print_payload(api_request("GET", "dashboard/integrations"), json_output=json_output)
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
@integrations_group.command("slack-set")
|
|
1524
|
+
@click.argument("webhook_url")
|
|
1525
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1526
|
+
def integrations_slack_set(webhook_url: str, json_output: bool):
|
|
1527
|
+
print_payload(api_request("PATCH", "dashboard/integrations", json_body={"slack_webhook_url": webhook_url}), json_output=json_output)
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
@integrations_group.command("slack-clear")
|
|
1531
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1532
|
+
def integrations_slack_clear(json_output: bool):
|
|
1533
|
+
print_payload(api_request("PATCH", "dashboard/integrations", json_body={"slack_webhook_url": ""}), json_output=json_output)
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
@integrations_group.command("slack-test")
|
|
1537
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1538
|
+
def integrations_slack_test(json_output: bool):
|
|
1539
|
+
print_payload(api_request("POST", "dashboard/integrations/test-slack"), json_output=json_output)
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
@integrations_group.command("digest")
|
|
1543
|
+
@click.argument("state", type=click.Choice(["on", "off"]))
|
|
1544
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1545
|
+
def integrations_digest(state: str, json_output: bool):
|
|
1546
|
+
print_payload(api_request("PATCH", "dashboard/integrations", json_body={"weekly_digest_enabled": state == "on"}), json_output=json_output)
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
@cli.group("account")
|
|
1550
|
+
def account_group():
|
|
1551
|
+
"""Manage the current account."""
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
@account_group.command("me")
|
|
1555
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1556
|
+
def account_me(json_output: bool):
|
|
1557
|
+
print_payload(api_request("GET", "account/me"), json_output=json_output)
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
@account_group.command("preferences")
|
|
1561
|
+
@click.option("--weekly-digest/--no-weekly-digest", default=True)
|
|
1562
|
+
@click.option("--critical-alerts/--no-critical-alerts", default=True)
|
|
1563
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1564
|
+
def account_preferences(weekly_digest: bool, critical_alerts: bool, json_output: bool):
|
|
1565
|
+
payload = {"weekly_digest": weekly_digest, "critical_alerts": critical_alerts}
|
|
1566
|
+
print_payload(api_request("PATCH", "account/preferences", json_body=payload), json_output=json_output)
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
@cli.group("admin")
|
|
1570
|
+
def admin_group():
|
|
1571
|
+
"""Admin controls for users, beta access, billing, rules, and system health."""
|
|
1572
|
+
|
|
1573
|
+
|
|
1574
|
+
@admin_group.command("overview")
|
|
1575
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1576
|
+
def admin_overview(json_output: bool):
|
|
1577
|
+
print_payload(api_request("GET", "admin/overview"), json_output=json_output)
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
@admin_group.command("users")
|
|
1581
|
+
@click.option("--search")
|
|
1582
|
+
@click.option("--plan")
|
|
1583
|
+
@click.option("--status")
|
|
1584
|
+
@click.option("--page", default=1, show_default=True)
|
|
1585
|
+
@click.option("--per-page", default=20, show_default=True)
|
|
1586
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1587
|
+
def admin_users(search: str | None, plan: str | None, status: str | None, page: int, per_page: int, json_output: bool):
|
|
1588
|
+
data = api_request("GET", "admin/users", params={"search": search, "plan": plan, "status": status, "page": page, "per_page": per_page})
|
|
1589
|
+
print_payload(data if json_output else data.get("users", data), json_output=json_output)
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
@admin_group.command("user-update")
|
|
1593
|
+
@click.argument("user_id")
|
|
1594
|
+
@click.option("--plan", type=click.Choice(["free", "pro", "team", "enterprise"]))
|
|
1595
|
+
@click.option("--role", type=click.Choice(["user", "admin"]))
|
|
1596
|
+
@click.option("--status", type=click.Choice(["active", "suspended"]))
|
|
1597
|
+
@click.option("--suspend/--unsuspend", "suspended", default=None, help="Suspend or reactivate the user.")
|
|
1598
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1599
|
+
def admin_user_update(user_id: str, plan: str | None, role: str | None, status: str | None, suspended: bool | None, json_output: bool):
|
|
1600
|
+
payload = _clean_params({"plan": plan, "role": role, "status": status, "suspended": suspended}) or {}
|
|
1601
|
+
print_payload(api_request("PATCH", f"admin/users/{user_id}", json_body=payload), json_output=json_output)
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
@admin_group.command("impersonate")
|
|
1605
|
+
@click.argument("user_id")
|
|
1606
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1607
|
+
def admin_impersonate(user_id: str, json_output: bool):
|
|
1608
|
+
data = api_request("POST", f"admin/users/{user_id}/impersonate")
|
|
1609
|
+
if json_output:
|
|
1610
|
+
print_payload(data, json_output=True)
|
|
1611
|
+
return
|
|
1612
|
+
click.echo(f"Temporary read-only token for {data.get('target_user', {}).get('email')}:")
|
|
1613
|
+
click.echo(data.get("token", ""))
|
|
1614
|
+
click.echo(f"Expires in {data.get('expires_in', 900)} seconds.")
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
@admin_group.command("revoke-user-keys")
|
|
1618
|
+
@click.argument("user_id")
|
|
1619
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1620
|
+
def admin_revoke_user_keys(user_id: str, json_output: bool):
|
|
1621
|
+
print_payload(api_request("DELETE", f"admin/users/{user_id}/api-keys"), json_output=json_output)
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
@admin_group.command("revoke-access")
|
|
1625
|
+
@click.argument("user_id")
|
|
1626
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1627
|
+
def admin_revoke_access(user_id: str, json_output: bool):
|
|
1628
|
+
print_payload(api_request("POST", f"admin/users/{user_id}/revoke-access"), json_output=json_output)
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
@admin_group.command("export-users")
|
|
1632
|
+
@click.option("--output", type=click.Path(dir_okay=False), default="valicode-users.csv", show_default=True)
|
|
1633
|
+
def admin_export_users(output: str):
|
|
1634
|
+
response = api_request("GET", "admin/users/export", raw=True)
|
|
1635
|
+
Path(output).write_bytes(response.content)
|
|
1636
|
+
click.echo(f"Exported users to {Path(output).resolve()}")
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
@admin_group.command("beta")
|
|
1640
|
+
@click.option("--status")
|
|
1641
|
+
@click.option("--search")
|
|
1642
|
+
@click.option("--page", default=1, show_default=True)
|
|
1643
|
+
@click.option("--per-page", default=20, show_default=True)
|
|
1644
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1645
|
+
def admin_beta(status: str | None, search: str | None, page: int, per_page: int, json_output: bool):
|
|
1646
|
+
data = api_request("GET", "admin/beta-applications", params={"status": status, "q": search, "page": page, "per_page": per_page})
|
|
1647
|
+
print_payload(data if json_output else data.get("applications", data), json_output=json_output)
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
@admin_group.command("beta-set")
|
|
1651
|
+
@click.argument("application_id")
|
|
1652
|
+
@click.argument("status", type=click.Choice(["pending", "approved", "rejected"]))
|
|
1653
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1654
|
+
def admin_beta_set(application_id: str, status: str, json_output: bool):
|
|
1655
|
+
print_payload(api_request("PATCH", f"admin/beta-applications/{application_id}", json_body={"status": status}), json_output=json_output)
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
@admin_group.command("rules")
|
|
1659
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1660
|
+
def admin_rules_command(json_output: bool):
|
|
1661
|
+
data = api_request("GET", "admin/rules")
|
|
1662
|
+
print_payload(data if json_output else data.get("rules", data), json_output=json_output)
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
@admin_group.command("rule-set")
|
|
1666
|
+
@click.argument("rule_id")
|
|
1667
|
+
@click.argument("state", type=click.Choice(["on", "off"]))
|
|
1668
|
+
@click.option("--severity", type=click.Choice(["critical", "high", "medium", "low", "info"]))
|
|
1669
|
+
@click.option("--fail-merge/--no-fail-merge", default=None)
|
|
1670
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1671
|
+
def admin_rule_set(rule_id: str, state: str, severity: str | None, fail_merge: bool | None, json_output: bool):
|
|
1672
|
+
payload = _clean_params({"enabled": state == "on", "severity_override": severity, "fail_merge": fail_merge}) or {}
|
|
1673
|
+
print_payload(api_request("PATCH", f"admin/rules/{rule_id}", json_body=payload), json_output=json_output)
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
@admin_group.command("subscriptions")
|
|
1677
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1678
|
+
def admin_subscriptions(json_output: bool):
|
|
1679
|
+
data = api_request("GET", "admin/subscriptions")
|
|
1680
|
+
print_payload(data if json_output else data.get("subscriptions", data), json_output=json_output)
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
@admin_group.command("usage")
|
|
1684
|
+
@click.option("--days", default=30, show_default=True)
|
|
1685
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1686
|
+
def admin_usage(days: int, json_output: bool):
|
|
1687
|
+
print_payload(api_request("GET", "admin/usage", params={"days": days}), json_output=json_output)
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
@admin_group.command("audit")
|
|
1691
|
+
@click.option("--limit", default=100, show_default=True)
|
|
1692
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1693
|
+
def admin_audit(limit: int, json_output: bool):
|
|
1694
|
+
data = api_request("GET", "admin/audit-logs", params={"limit": limit})
|
|
1695
|
+
print_payload(data if json_output else data.get("logs", data), json_output=json_output)
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
@admin_group.command("system")
|
|
1699
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
1700
|
+
def admin_system(json_output: bool):
|
|
1701
|
+
print_payload(api_request("GET", "admin/system"), json_output=json_output)
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
if __name__ == "__main__":
|
|
1705
|
+
cli()
|