zenveil 1.0.0__tar.gz
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.
- zenveil-1.0.0/PKG-INFO +8 -0
- zenveil-1.0.0/pyproject.toml +22 -0
- zenveil-1.0.0/setup.cfg +4 -0
- zenveil-1.0.0/zenguard/__init__.py +1 -0
- zenveil-1.0.0/zenguard/api_client.py +94 -0
- zenveil-1.0.0/zenguard/cli.py +310 -0
- zenveil-1.0.0/zenguard/config.py +35 -0
- zenveil-1.0.0/zenguard/render.py +97 -0
- zenveil-1.0.0/zenveil.egg-info/PKG-INFO +8 -0
- zenveil-1.0.0/zenveil.egg-info/SOURCES.txt +11 -0
- zenveil-1.0.0/zenveil.egg-info/dependency_links.txt +1 -0
- zenveil-1.0.0/zenveil.egg-info/entry_points.txt +2 -0
- zenveil-1.0.0/zenveil.egg-info/top_level.txt +1 -0
zenveil-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: zenveil
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: AI-powered security scanner for repositories and APIs.
|
|
5
|
+
Project-URL: Homepage, https://zenveil.dev
|
|
6
|
+
Project-URL: Documentation, https://zenveil.dev/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/oyugirachel/zen-guard
|
|
8
|
+
Requires-Python: >=3.8
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zenveil"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "AI-powered security scanner for repositories and APIs."
|
|
9
|
+
requires-python = ">=3.8"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Homepage = "https://zenveil.dev"
|
|
14
|
+
Documentation = "https://zenveil.dev/docs"
|
|
15
|
+
Repository = "https://github.com/oyugirachel/zen-guard"
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
zenguard = "zenguard.cli:main"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["."]
|
|
22
|
+
include = ["zenguard*"]
|
zenveil-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ZenGuard client — AI-powered security scanner."""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""HTTP client that talks to the ZenGuard API server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Iterator
|
|
9
|
+
|
|
10
|
+
from zenguard import config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _headers(api_key: str) -> dict[str, str]:
|
|
14
|
+
return {
|
|
15
|
+
"X-API-Key": api_key,
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
"Accept": "application/json",
|
|
18
|
+
"User-Agent": "zenguard-client/1.0.0",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _post(path: str, payload: dict, api_key: str, base_url: str) -> dict:
|
|
23
|
+
url = f"{base_url.rstrip('/')}{path}"
|
|
24
|
+
data = json.dumps(payload).encode("utf-8")
|
|
25
|
+
req = urllib.request.Request(url, data=data, headers=_headers(api_key), method="POST")
|
|
26
|
+
try:
|
|
27
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
28
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
29
|
+
except urllib.error.HTTPError as exc:
|
|
30
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
31
|
+
try:
|
|
32
|
+
detail = json.loads(body).get("detail", body)
|
|
33
|
+
except json.JSONDecodeError:
|
|
34
|
+
detail = body
|
|
35
|
+
raise RuntimeError(f"API error {exc.code}: {detail}") from exc
|
|
36
|
+
except urllib.error.URLError as exc:
|
|
37
|
+
raise RuntimeError(f"Could not reach ZenGuard API: {exc.reason}") from exc
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _stream(path: str, payload: dict, api_key: str, base_url: str) -> Iterator[str]:
|
|
41
|
+
url = f"{base_url.rstrip('/')}{path}"
|
|
42
|
+
data = json.dumps(payload).encode("utf-8")
|
|
43
|
+
req = urllib.request.Request(url, data=data, headers={
|
|
44
|
+
**_headers(api_key),
|
|
45
|
+
"Accept": "text/plain",
|
|
46
|
+
}, method="POST")
|
|
47
|
+
try:
|
|
48
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
49
|
+
while True:
|
|
50
|
+
chunk = resp.read(256)
|
|
51
|
+
if not chunk:
|
|
52
|
+
break
|
|
53
|
+
yield chunk.decode("utf-8", errors="replace")
|
|
54
|
+
except urllib.error.HTTPError as exc:
|
|
55
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
56
|
+
raise RuntimeError(f"API error {exc.code}: {body}") from exc
|
|
57
|
+
except urllib.error.URLError as exc:
|
|
58
|
+
raise RuntimeError(f"Could not reach ZenGuard API: {exc.reason}") from exc
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ZenGuardClient:
|
|
62
|
+
def __init__(self, api_key: str | None = None, api_url: str | None = None) -> None:
|
|
63
|
+
self._api_key = api_key or config.get_api_key()
|
|
64
|
+
self._api_url = api_url or config.get_api_url()
|
|
65
|
+
if not self._api_key:
|
|
66
|
+
raise RuntimeError(
|
|
67
|
+
"No API key found. Run `zenguard login` or set ZENGUARD_API_KEY."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def scan_github(
|
|
71
|
+
self,
|
|
72
|
+
repository: str,
|
|
73
|
+
token: str | None = None,
|
|
74
|
+
ref: str | None = None,
|
|
75
|
+
check_cves: bool = False,
|
|
76
|
+
) -> dict:
|
|
77
|
+
return _post("/v1/scan/github", {
|
|
78
|
+
"repository": repository,
|
|
79
|
+
"token": token,
|
|
80
|
+
"ref": ref,
|
|
81
|
+
"check_cves": check_cves,
|
|
82
|
+
}, self._api_key, self._api_url)
|
|
83
|
+
|
|
84
|
+
def scan_api(self, url: str) -> dict:
|
|
85
|
+
return _post("/v1/scan/api", {"url": url}, self._api_key, self._api_url)
|
|
86
|
+
|
|
87
|
+
def explain_stream(self, finding: dict) -> Iterator[str]:
|
|
88
|
+
yield from _stream("/v1/explain", {"finding": finding}, self._api_key, self._api_url)
|
|
89
|
+
|
|
90
|
+
def fix_stream(self, finding: dict) -> Iterator[str]:
|
|
91
|
+
yield from _stream("/v1/fix", {"finding": finding}, self._api_key, self._api_url)
|
|
92
|
+
|
|
93
|
+
def triage_stream(self, scan: dict) -> Iterator[str]:
|
|
94
|
+
yield from _stream("/v1/triage", {"scan": scan}, self._api_key, self._api_url)
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""ZenGuard thin CLI client — talks to the ZenGuard API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
from zenguard import config
|
|
12
|
+
from zenguard.api_client import ZenGuardClient
|
|
13
|
+
from zenguard.render import render_list, render_scan, render_stats
|
|
14
|
+
|
|
15
|
+
_LAST_SCAN_FILE = Path(".zenguard-last-scan.json")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _save_scan(result: dict) -> None:
|
|
19
|
+
_LAST_SCAN_FILE.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_scan() -> dict:
|
|
23
|
+
if not _LAST_SCAN_FILE.exists():
|
|
24
|
+
raise ValueError("No cached scan found. Run `zenguard scan` first.")
|
|
25
|
+
return json.loads(_LAST_SCAN_FILE.read_text(encoding="utf-8"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _client() -> ZenGuardClient:
|
|
29
|
+
return ZenGuardClient()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
33
|
+
parser = argparse.ArgumentParser(prog="zenguard", description="AI-powered security scanner.")
|
|
34
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
35
|
+
|
|
36
|
+
# login
|
|
37
|
+
login_p = subparsers.add_parser("login", help="Save your ZenGuard API key.")
|
|
38
|
+
login_p.add_argument("api_key", help="Your ZenGuard API key.")
|
|
39
|
+
login_p.add_argument("--url", dest="api_url", help="Custom API URL (optional).")
|
|
40
|
+
|
|
41
|
+
# scan
|
|
42
|
+
scan_p = subparsers.add_parser("scan", help="Run a security scan.")
|
|
43
|
+
scan_sub = scan_p.add_subparsers(dest="target_type", required=True)
|
|
44
|
+
|
|
45
|
+
github_p = scan_sub.add_parser("github", help="Scan a GitHub repository.")
|
|
46
|
+
github_p.add_argument("repository", help="owner/repo or GitHub URL.")
|
|
47
|
+
github_p.add_argument("--token", dest="token", help="GitHub token for private repos.")
|
|
48
|
+
github_p.add_argument("--ref", dest="ref", help="Branch, tag, or commit.")
|
|
49
|
+
github_p.add_argument("--check-cves", action="store_true", dest="check_cves")
|
|
50
|
+
|
|
51
|
+
api_p = scan_sub.add_parser("api", help="Scan an API URL.")
|
|
52
|
+
api_p.add_argument("url", help="API base URL.")
|
|
53
|
+
|
|
54
|
+
# list
|
|
55
|
+
list_p = subparsers.add_parser("list", help="List findings from the last scan.")
|
|
56
|
+
list_p.add_argument("--severity", dest="severity_filter", help="e.g. high,critical")
|
|
57
|
+
list_p.add_argument("--scanner", dest="scanner_filter", help="e.g. secrets")
|
|
58
|
+
|
|
59
|
+
# stats
|
|
60
|
+
subparsers.add_parser("stats", help="Show statistics for the last scan.")
|
|
61
|
+
|
|
62
|
+
# explain
|
|
63
|
+
explain_p = subparsers.add_parser("explain", help="AI explanation of a finding.")
|
|
64
|
+
explain_p.add_argument("finding_id", help="Finding ID (e.g. ZG-ABC123).")
|
|
65
|
+
|
|
66
|
+
# fix
|
|
67
|
+
fix_p = subparsers.add_parser("fix", help="AI-generated fix for a finding.")
|
|
68
|
+
fix_p.add_argument("finding_id", help="Finding ID (e.g. ZG-ABC123).")
|
|
69
|
+
|
|
70
|
+
# triage
|
|
71
|
+
subparsers.add_parser("triage", help="AI-prioritized remediation plan for all findings.")
|
|
72
|
+
|
|
73
|
+
# ignore
|
|
74
|
+
ignore_p = subparsers.add_parser("ignore", help="Suppress a finding.")
|
|
75
|
+
ignore_p.add_argument("finding_id", help="Finding ID to ignore.")
|
|
76
|
+
ignore_p.add_argument("--reason", dest="reason", default="")
|
|
77
|
+
|
|
78
|
+
# report
|
|
79
|
+
report_p = subparsers.add_parser("report", help="Export the last scan.")
|
|
80
|
+
report_sub = report_p.add_subparsers(dest="report_type", required=True)
|
|
81
|
+
json_p = report_sub.add_parser("json", help="Write last scan to JSON.")
|
|
82
|
+
json_p.add_argument("output_file")
|
|
83
|
+
html_p = report_sub.add_parser("html", help="Write last scan to HTML.")
|
|
84
|
+
html_p.add_argument("output_file")
|
|
85
|
+
|
|
86
|
+
# help
|
|
87
|
+
subparsers.add_parser("help", help="List all commands.")
|
|
88
|
+
|
|
89
|
+
return parser
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
93
|
+
parser = build_parser()
|
|
94
|
+
args = parser.parse_args(argv)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
if args.command == "login":
|
|
98
|
+
data = config.load()
|
|
99
|
+
data["api_key"] = args.api_key
|
|
100
|
+
if getattr(args, "api_url", None):
|
|
101
|
+
data["api_url"] = args.api_url
|
|
102
|
+
config.save(data)
|
|
103
|
+
print(f"API key saved to {config._CONFIG_FILE}")
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
if args.command == "scan":
|
|
107
|
+
c = _client()
|
|
108
|
+
if args.target_type == "github":
|
|
109
|
+
print(f"Scanning {args.repository}…")
|
|
110
|
+
result = c.scan_github(
|
|
111
|
+
args.repository,
|
|
112
|
+
token=getattr(args, "token", None),
|
|
113
|
+
ref=getattr(args, "ref", None),
|
|
114
|
+
check_cves=getattr(args, "check_cves", False),
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
print(f"Scanning {args.url}…")
|
|
118
|
+
result = c.scan_api(args.url)
|
|
119
|
+
print(render_scan(result))
|
|
120
|
+
_save_scan(result)
|
|
121
|
+
findings = result.get("findings", [])
|
|
122
|
+
return 1 if any(f["severity"] in {"HIGH", "CRITICAL"} for f in findings) else 0
|
|
123
|
+
|
|
124
|
+
if args.command == "list":
|
|
125
|
+
result = _load_scan()
|
|
126
|
+
print(render_list(result.get("findings", []), {
|
|
127
|
+
"severity": getattr(args, "severity_filter", None),
|
|
128
|
+
"scanner": getattr(args, "scanner_filter", None),
|
|
129
|
+
}))
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
if args.command == "stats":
|
|
133
|
+
print(render_stats(_load_scan()))
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
if args.command == "explain":
|
|
137
|
+
result = _load_scan()
|
|
138
|
+
index = {f["id"]: f for f in result.get("findings", [])}
|
|
139
|
+
finding = index.get(args.finding_id.upper())
|
|
140
|
+
if not finding:
|
|
141
|
+
print(f"Finding {args.finding_id.upper()} not found.", file=sys.stderr)
|
|
142
|
+
return 2
|
|
143
|
+
print(f"\nExplaining {finding['id']}: {finding['title']}\n")
|
|
144
|
+
for chunk in _client().explain_stream(finding):
|
|
145
|
+
print(chunk, end="", flush=True)
|
|
146
|
+
print()
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
if args.command == "fix":
|
|
150
|
+
result = _load_scan()
|
|
151
|
+
index = {f["id"]: f for f in result.get("findings", [])}
|
|
152
|
+
finding = index.get(args.finding_id.upper())
|
|
153
|
+
if not finding:
|
|
154
|
+
print(f"Finding {args.finding_id.upper()} not found.", file=sys.stderr)
|
|
155
|
+
return 2
|
|
156
|
+
print(f"\nGenerating fix for {finding['id']}: {finding['title']}\n")
|
|
157
|
+
for chunk in _client().fix_stream(finding):
|
|
158
|
+
print(chunk, end="", flush=True)
|
|
159
|
+
print()
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
if args.command == "triage":
|
|
163
|
+
result = _load_scan()
|
|
164
|
+
if not result.get("findings"):
|
|
165
|
+
print("No findings to triage.")
|
|
166
|
+
return 0
|
|
167
|
+
print(f"\nTriaging {result['finding_count']} finding(s)…\n")
|
|
168
|
+
for chunk in _client().triage_stream(result):
|
|
169
|
+
print(chunk, end="", flush=True)
|
|
170
|
+
print()
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
if args.command == "ignore":
|
|
174
|
+
result = _load_scan()
|
|
175
|
+
index = {f["id"]: f for f in result.get("findings", [])}
|
|
176
|
+
finding = index.get(args.finding_id.upper())
|
|
177
|
+
if not finding:
|
|
178
|
+
print(f"Finding {args.finding_id.upper()} not found.", file=sys.stderr)
|
|
179
|
+
return 2
|
|
180
|
+
ignore_file = Path(".zenguard-ignore.json")
|
|
181
|
+
ignored = {}
|
|
182
|
+
if ignore_file.exists():
|
|
183
|
+
ignored = json.loads(ignore_file.read_text(encoding="utf-8"))
|
|
184
|
+
ignored[args.finding_id.upper()] = {
|
|
185
|
+
"title": finding["title"],
|
|
186
|
+
"scanner": finding["scanner_name"],
|
|
187
|
+
"severity": finding["severity"],
|
|
188
|
+
"reason": args.reason,
|
|
189
|
+
}
|
|
190
|
+
ignore_file.write_text(json.dumps(ignored, indent=2) + "\n", encoding="utf-8")
|
|
191
|
+
print(f"Suppressed {args.finding_id.upper()} ({finding['title']}).")
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
if args.command == "report":
|
|
195
|
+
result = _load_scan()
|
|
196
|
+
if args.report_type == "json":
|
|
197
|
+
Path(args.output_file).write_text(
|
|
198
|
+
json.dumps(result, indent=2) + "\n", encoding="utf-8"
|
|
199
|
+
)
|
|
200
|
+
print(f"Wrote JSON report to {args.output_file}")
|
|
201
|
+
elif args.report_type == "html":
|
|
202
|
+
_write_html_report(result, args.output_file)
|
|
203
|
+
print(f"Wrote HTML report to {args.output_file}")
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
if args.command == "help":
|
|
207
|
+
_print_help()
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
except (RuntimeError, ValueError) as exc:
|
|
211
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
212
|
+
return 2
|
|
213
|
+
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _write_html_report(result: dict, output_file: str) -> None:
|
|
218
|
+
import html as h
|
|
219
|
+
findings = result.get("findings", [])
|
|
220
|
+
_SEV_COLOR = {"CRITICAL": "#c0392b", "HIGH": "#e67e22", "MEDIUM": "#f1c40f", "LOW": "#2ecc71"}
|
|
221
|
+
_SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
|
222
|
+
sorted_findings = sorted(findings, key=lambda f: _SEV_ORDER.get(f["severity"].upper(), 99))
|
|
223
|
+
findings_html = ""
|
|
224
|
+
for f in sorted_findings:
|
|
225
|
+
color = _SEV_COLOR.get(f["severity"].upper(), "#888")
|
|
226
|
+
loc = f["location"]
|
|
227
|
+
loc_str = loc.get("target", "")
|
|
228
|
+
if loc.get("path"):
|
|
229
|
+
loc_str += f" → {loc['path']}"
|
|
230
|
+
if loc.get("line"):
|
|
231
|
+
loc_str += f" : line {loc['line']}"
|
|
232
|
+
findings_html += f"""
|
|
233
|
+
<div class="finding">
|
|
234
|
+
<div class="finding-header" style="border-left:6px solid {color}">
|
|
235
|
+
<span class="badge" style="background:{color}">{h.escape(f['severity'])}</span>
|
|
236
|
+
<span class="fid">{h.escape(f['id'])}</span>
|
|
237
|
+
<span class="ftitle">{h.escape(f['title'])}</span>
|
|
238
|
+
</div>
|
|
239
|
+
<div class="finding-body">
|
|
240
|
+
<table>
|
|
241
|
+
<tr><th>Scanner</th><td>{h.escape(f['scanner_name'])}</td></tr>
|
|
242
|
+
<tr><th>Location</th><td>{h.escape(loc_str)}</td></tr>
|
|
243
|
+
<tr><th>Evidence</th><td><code>{h.escape(f['evidence'])}</code></td></tr>
|
|
244
|
+
<tr><th>Description</th><td>{h.escape(f['description'])}</td></tr>
|
|
245
|
+
<tr><th>Remediation</th><td>{h.escape(f['remediation'])}</td></tr>
|
|
246
|
+
</table>
|
|
247
|
+
</div>
|
|
248
|
+
</div>"""
|
|
249
|
+
page = f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"/>
|
|
250
|
+
<title>ZenGuard Report — {h.escape(result.get('target',''))}</title>
|
|
251
|
+
<style>body{{font-family:sans-serif;background:#f4f6f9;color:#222;padding:24px}}
|
|
252
|
+
.finding{{background:#fff;border-radius:6px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,.1);overflow:hidden}}
|
|
253
|
+
.finding-header{{display:flex;align-items:center;gap:10px;padding:12px 16px;background:#fafafa}}
|
|
254
|
+
.badge{{color:#fff;border-radius:4px;padding:2px 8px;font-weight:700;font-size:.78rem;text-transform:uppercase}}
|
|
255
|
+
.fid{{font-family:monospace;font-size:.85rem;color:#555}}.ftitle{{font-weight:600}}
|
|
256
|
+
.finding-body{{padding:12px 16px}}table{{border-collapse:collapse;width:100%;font-size:.88rem}}
|
|
257
|
+
th{{text-align:left;color:#555;font-weight:600;width:110px;padding:4px 8px 4px 0;vertical-align:top}}
|
|
258
|
+
code{{background:#f0f0f0;padding:1px 4px;border-radius:3px;font-size:.82rem;word-break:break-all}}
|
|
259
|
+
</style></head><body>
|
|
260
|
+
<h1>ZenGuard Security Report</h1>
|
|
261
|
+
<p>Target: <strong>{h.escape(result.get('target',''))}</strong> | Findings: {result.get('finding_count',0)}</p>
|
|
262
|
+
{findings_html if findings_html else '<p>No findings.</p>'}
|
|
263
|
+
</body></html>"""
|
|
264
|
+
Path(output_file).write_text(page, encoding="utf-8")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _print_help() -> None:
|
|
268
|
+
print("""
|
|
269
|
+
ZenGuard — AI-powered security scanner
|
|
270
|
+
=======================================
|
|
271
|
+
|
|
272
|
+
ACCOUNT
|
|
273
|
+
zenguard login <api-key> Save your API key
|
|
274
|
+
|
|
275
|
+
SCANNING
|
|
276
|
+
zenguard scan github <owner/repo> Scan a GitHub repository
|
|
277
|
+
zenguard scan github <url> --token Use a GitHub token for private repos
|
|
278
|
+
zenguard scan api <url> Scan an API endpoint
|
|
279
|
+
|
|
280
|
+
FINDINGS
|
|
281
|
+
zenguard list List findings from the last scan
|
|
282
|
+
zenguard list --severity high,critical
|
|
283
|
+
zenguard list --scanner secrets
|
|
284
|
+
zenguard stats Scan statistics
|
|
285
|
+
|
|
286
|
+
AI ANALYSIS
|
|
287
|
+
zenguard explain <id> Explain a finding
|
|
288
|
+
zenguard fix <id> Generate a fix
|
|
289
|
+
zenguard triage Prioritized remediation plan
|
|
290
|
+
|
|
291
|
+
REPORTING
|
|
292
|
+
zenguard report json <file> Export to JSON
|
|
293
|
+
zenguard report html <file> Export to HTML
|
|
294
|
+
|
|
295
|
+
MANAGEMENT
|
|
296
|
+
zenguard ignore <id> Suppress a finding
|
|
297
|
+
zenguard ignore <id> --reason "text"
|
|
298
|
+
zenguard help Show this help
|
|
299
|
+
|
|
300
|
+
ENVIRONMENT VARIABLES
|
|
301
|
+
ZENGUARD_API_KEY Your ZenGuard API key
|
|
302
|
+
ZENGUARD_API_URL Custom API URL (default: https://api.zenveil.dev)
|
|
303
|
+
GITHUB_TOKEN GitHub token for private repo scans
|
|
304
|
+
|
|
305
|
+
Get your API key at https://zenveil.dev
|
|
306
|
+
""")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
if __name__ == "__main__":
|
|
310
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Stores ZenGuard API key and server URL locally."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_CONFIG_DIR = Path.home() / ".zenguard"
|
|
10
|
+
_CONFIG_FILE = _CONFIG_DIR / "config.json"
|
|
11
|
+
|
|
12
|
+
DEFAULT_API_URL = "https://api.zenveil.dev"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load() -> dict:
|
|
16
|
+
if _CONFIG_FILE.exists():
|
|
17
|
+
try:
|
|
18
|
+
return json.loads(_CONFIG_FILE.read_text(encoding="utf-8"))
|
|
19
|
+
except (json.JSONDecodeError, OSError):
|
|
20
|
+
pass
|
|
21
|
+
return {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def save(data: dict) -> None:
|
|
25
|
+
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
_CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
27
|
+
_CONFIG_FILE.chmod(0o600)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_api_key() -> str | None:
|
|
31
|
+
return os.environ.get("ZENGUARD_API_KEY") or load().get("api_key")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_api_url() -> str:
|
|
35
|
+
return os.environ.get("ZENGUARD_API_URL") or load().get("api_url") or DEFAULT_API_URL
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Console rendering for scan results from the API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
|
6
|
+
_SEV_PREFIX = {"CRITICAL": "[CRITICAL]", "HIGH": "[HIGH]", "MEDIUM": "[MEDIUM]", "LOW": "[LOW]"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def render_scan(result: dict) -> str:
|
|
10
|
+
findings = result.get("findings", [])
|
|
11
|
+
counts: dict[str, int] = {}
|
|
12
|
+
for f in findings:
|
|
13
|
+
sev = f["severity"].upper()
|
|
14
|
+
counts[sev] = counts.get(sev, 0) + 1
|
|
15
|
+
|
|
16
|
+
sev_str = ", ".join(
|
|
17
|
+
f"{s}={counts.get(s, 0)}"
|
|
18
|
+
for s in ["LOW", "MEDIUM", "HIGH", "CRITICAL"]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
lines = [
|
|
22
|
+
"",
|
|
23
|
+
"ZenGuard Scan Report",
|
|
24
|
+
f"Target: {result.get('target_type', '')} {result.get('target', '')}",
|
|
25
|
+
f"Findings: {result.get('finding_count', 0)}",
|
|
26
|
+
f"Severity: {sev_str}",
|
|
27
|
+
"",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
sorted_findings = sorted(findings, key=lambda f: _SEV_ORDER.get(f["severity"].upper(), 99))
|
|
31
|
+
for f in sorted_findings:
|
|
32
|
+
sev = f["severity"].upper()
|
|
33
|
+
prefix = _SEV_PREFIX.get(sev, f"[{sev}]")
|
|
34
|
+
loc = f["location"]
|
|
35
|
+
location_str = loc.get("path") or loc.get("url") or loc.get("target", "")
|
|
36
|
+
if loc.get("line"):
|
|
37
|
+
location_str = f"{location_str}:{loc['line']}"
|
|
38
|
+
|
|
39
|
+
lines += [
|
|
40
|
+
f"{prefix} {f['title']}",
|
|
41
|
+
f"ID: {f['id']}",
|
|
42
|
+
f"Category: {f['category']}",
|
|
43
|
+
f"Scanner: {f['scanner_name']}",
|
|
44
|
+
f"Location: {location_str}",
|
|
45
|
+
f"Evidence: {f['evidence']}",
|
|
46
|
+
f"Description: {f['description']}",
|
|
47
|
+
f"Remediation: {f['remediation']}",
|
|
48
|
+
"",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
return "\n".join(lines)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def render_list(findings: list[dict], filters: dict | None = None) -> str:
|
|
55
|
+
if filters:
|
|
56
|
+
sev = {s.strip().lower() for s in (filters.get("severity") or "").split(",") if s.strip()}
|
|
57
|
+
scanner = (filters.get("scanner") or "").lower()
|
|
58
|
+
if sev:
|
|
59
|
+
findings = [f for f in findings if f["severity"].lower() in sev]
|
|
60
|
+
if scanner:
|
|
61
|
+
findings = [f for f in findings if f["scanner_name"].lower() == scanner]
|
|
62
|
+
|
|
63
|
+
if not findings:
|
|
64
|
+
return "No findings match the given filters."
|
|
65
|
+
|
|
66
|
+
sorted_findings = sorted(findings, key=lambda f: (_SEV_ORDER.get(f["severity"].upper(), 99), f["title"]))
|
|
67
|
+
lines = [f"\n{'ID':<18} {'SEV':<9} {'SCANNER':<15} {'TITLE'}", "-" * 80]
|
|
68
|
+
for f in sorted_findings:
|
|
69
|
+
lines.append(f"{f['id']:<18} {f['severity']:<9} {f['scanner_name']:<15} {f['title']}")
|
|
70
|
+
lines.append(f"\n{len(sorted_findings)} finding(s) shown.\n")
|
|
71
|
+
return "\n".join(lines)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render_stats(result: dict) -> str:
|
|
75
|
+
findings = result.get("findings", [])
|
|
76
|
+
sev_counts: dict[str, int] = {}
|
|
77
|
+
scanner_counts: dict[str, int] = {}
|
|
78
|
+
for f in findings:
|
|
79
|
+
sev_counts[f["severity"]] = sev_counts.get(f["severity"], 0) + 1
|
|
80
|
+
scanner_counts[f["scanner_name"]] = scanner_counts.get(f["scanner_name"], 0) + 1
|
|
81
|
+
|
|
82
|
+
lines = [
|
|
83
|
+
f"\nScan target : {result.get('target')} ({result.get('target_type')})",
|
|
84
|
+
f"Started : {result.get('started_at')}",
|
|
85
|
+
f"Completed : {result.get('completed_at')}",
|
|
86
|
+
f"Total findings: {result.get('finding_count', 0)}",
|
|
87
|
+
]
|
|
88
|
+
if sev_counts:
|
|
89
|
+
lines.append("\nBy severity:")
|
|
90
|
+
for sev in sorted(sev_counts, key=lambda s: _SEV_ORDER.get(s.upper(), 99)):
|
|
91
|
+
lines.append(f" {sev:<10} {sev_counts[sev]}")
|
|
92
|
+
if scanner_counts:
|
|
93
|
+
lines.append("\nBy scanner:")
|
|
94
|
+
for sc, cnt in sorted(scanner_counts.items(), key=lambda x: -x[1]):
|
|
95
|
+
lines.append(f" {sc:<20} {cnt}")
|
|
96
|
+
lines.append("")
|
|
97
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: zenveil
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: AI-powered security scanner for repositories and APIs.
|
|
5
|
+
Project-URL: Homepage, https://zenveil.dev
|
|
6
|
+
Project-URL: Documentation, https://zenveil.dev/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/oyugirachel/zen-guard
|
|
8
|
+
Requires-Python: >=3.8
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
zenguard/__init__.py
|
|
3
|
+
zenguard/api_client.py
|
|
4
|
+
zenguard/cli.py
|
|
5
|
+
zenguard/config.py
|
|
6
|
+
zenguard/render.py
|
|
7
|
+
zenveil.egg-info/PKG-INFO
|
|
8
|
+
zenveil.egg-info/SOURCES.txt
|
|
9
|
+
zenveil.egg-info/dependency_links.txt
|
|
10
|
+
zenveil.egg-info/entry_points.txt
|
|
11
|
+
zenveil.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zenguard
|