ekmire 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.
- ekmire-1.0.0/PKG-INFO +12 -0
- ekmire-1.0.0/ekmire/__init__.py +2 -0
- ekmire-1.0.0/ekmire/__main__.py +532 -0
- ekmire-1.0.0/ekmire/bundle.py +154 -0
- ekmire-1.0.0/ekmire/config.py +43 -0
- ekmire-1.0.0/ekmire/docs_url.py +27 -0
- ekmire-1.0.0/ekmire/engines/__init__.py +1 -0
- ekmire-1.0.0/ekmire/engines/ast_engine.py +247 -0
- ekmire-1.0.0/ekmire/engines/mcp_audit.py +187 -0
- ekmire-1.0.0/ekmire/engines/regex_engine.py +80 -0
- ekmire-1.0.0/ekmire/hook.py +74 -0
- ekmire-1.0.0/ekmire/mcp_server.py +178 -0
- ekmire-1.0.0/ekmire/output.py +163 -0
- ekmire-1.0.0/ekmire/scanner.py +170 -0
- ekmire-1.0.0/ekmire/telemetry.py +40 -0
- ekmire-1.0.0/ekmire.egg-info/PKG-INFO +12 -0
- ekmire-1.0.0/ekmire.egg-info/SOURCES.txt +21 -0
- ekmire-1.0.0/ekmire.egg-info/dependency_links.txt +1 -0
- ekmire-1.0.0/ekmire.egg-info/entry_points.txt +2 -0
- ekmire-1.0.0/ekmire.egg-info/requires.txt +4 -0
- ekmire-1.0.0/ekmire.egg-info/top_level.txt +1 -0
- ekmire-1.0.0/pyproject.toml +25 -0
- ekmire-1.0.0/setup.cfg +4 -0
ekmire-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ekmire
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Developer security platform — write-time, commit-time, and runtime protection
|
|
5
|
+
Author-email: Flux8Labs <team@flux8labs.com>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: click>=8.1
|
|
10
|
+
Requires-Dist: requests>=2.31
|
|
11
|
+
Requires-Dist: rich>=13.0
|
|
12
|
+
Requires-Dist: pathspec>=0.12
|
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""ekmire CLI — main entry point.
|
|
2
|
+
|
|
3
|
+
Every command prints `ekmire v{version} by Flux8Labs`. No flag suppresses this.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from . import config as cfg
|
|
14
|
+
from . import bundle, hook, output, scanner
|
|
15
|
+
from .engines import mcp_audit
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _header() -> None:
|
|
21
|
+
console.print(f"[bold]ekmire v{__version__} by Flux8Labs[/bold]")
|
|
22
|
+
console.print()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group()
|
|
26
|
+
def cli() -> None:
|
|
27
|
+
"""ekmire developer security platform."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── auth ──────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
@cli.group()
|
|
34
|
+
def auth() -> None:
|
|
35
|
+
"""Authentication commands."""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@auth.command("login")
|
|
40
|
+
def auth_login() -> None:
|
|
41
|
+
"""Authenticate with ekmire Cloud (saves API key to ~/.ekmire/config)."""
|
|
42
|
+
_header()
|
|
43
|
+
import webbrowser
|
|
44
|
+
import secrets
|
|
45
|
+
|
|
46
|
+
token = secrets.token_urlsafe(16)
|
|
47
|
+
login_url = f"{cfg.get_cloud_url()}/cli-auth?token={token}"
|
|
48
|
+
|
|
49
|
+
console.print(f"Open this URL in your browser (auto-opening):\n [link={login_url}]{login_url}[/link]")
|
|
50
|
+
try:
|
|
51
|
+
webbrowser.open(login_url)
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
console.print("\n[dim]Waiting for authentication...[/dim]")
|
|
56
|
+
api_key = _poll_cli_auth(token)
|
|
57
|
+
if api_key:
|
|
58
|
+
cfg.save({"api_key": api_key, "cloud_url": cfg.get_cloud_url()})
|
|
59
|
+
console.print("\n[green]✓ Authenticated[/green]")
|
|
60
|
+
else:
|
|
61
|
+
console.print("[red]Authentication timed out. Try again.[/red]")
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@auth.command("status")
|
|
66
|
+
def auth_status() -> None:
|
|
67
|
+
"""Show current authentication state."""
|
|
68
|
+
_header()
|
|
69
|
+
data = cfg.load()
|
|
70
|
+
if data.get("api_key"):
|
|
71
|
+
masked = data["api_key"][:8] + "..." + data["api_key"][-4:]
|
|
72
|
+
console.print(f"[green]✓ Authenticated[/green] API key: {masked}")
|
|
73
|
+
console.print(f" Cloud: {cfg.get_cloud_url()}")
|
|
74
|
+
else:
|
|
75
|
+
console.print("[yellow]Not authenticated[/yellow] — run `ekmire auth login`")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── scan ──────────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
@cli.command()
|
|
81
|
+
@click.argument("path", default=".", required=False)
|
|
82
|
+
@click.option("--all", "scan_all", is_flag=True, help="Scan all files in working directory")
|
|
83
|
+
@click.option(
|
|
84
|
+
"--deep",
|
|
85
|
+
is_flag=True,
|
|
86
|
+
help="Recursive scan of any directory (no git context required)",
|
|
87
|
+
)
|
|
88
|
+
@click.option("--output", "output_format", type=click.Choice(["text", "json", "sarif"]), default="text")
|
|
89
|
+
@click.option(
|
|
90
|
+
"--fail-on",
|
|
91
|
+
type=click.Choice(["critical", "high", "medium", "low"]),
|
|
92
|
+
default=None,
|
|
93
|
+
help="Exit 1 if findings at or above this severity exist",
|
|
94
|
+
)
|
|
95
|
+
def scan(path: str, scan_all: bool, deep: bool, output_format: str, fail_on: str | None) -> None:
|
|
96
|
+
"""Run Build Guard on staged files, a path, or recursively (--deep)."""
|
|
97
|
+
_header()
|
|
98
|
+
|
|
99
|
+
api_key = cfg.get_api_key()
|
|
100
|
+
cloud_url = cfg.get_cloud_url()
|
|
101
|
+
rules_bundle = bundle.get_bundle(cloud_url, api_key)
|
|
102
|
+
rules = rules_bundle.get("rules", [])
|
|
103
|
+
|
|
104
|
+
root = Path(path).resolve()
|
|
105
|
+
mireignore = scanner.load_mireignore(root if root.is_dir() else root.parent)
|
|
106
|
+
|
|
107
|
+
if deep or scan_all or path != ".":
|
|
108
|
+
if output_format == "text":
|
|
109
|
+
console.print(f"Deep scan: {root} ", end="")
|
|
110
|
+
result = scanner.scan_path(root, rules, mireignore)
|
|
111
|
+
else:
|
|
112
|
+
# Default: staged files only (git pre-commit context)
|
|
113
|
+
if output_format == "text":
|
|
114
|
+
console.print("Scanning staged files...")
|
|
115
|
+
result = scanner.scan_staged(rules)
|
|
116
|
+
|
|
117
|
+
source = "device_scanner" if deep or scan_all else "cli"
|
|
118
|
+
_emit_findings(result, root, output_format, fail_on, source)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _emit_findings(
|
|
122
|
+
result: scanner.ScanResult,
|
|
123
|
+
root: Path,
|
|
124
|
+
fmt: str,
|
|
125
|
+
fail_on: str | None,
|
|
126
|
+
source: str = "cli",
|
|
127
|
+
) -> None:
|
|
128
|
+
findings = result.findings
|
|
129
|
+
|
|
130
|
+
if fmt == "json":
|
|
131
|
+
# JSON output: only active (non-suppressed) findings
|
|
132
|
+
print(output.to_json([f for f in findings if not getattr(f, "suppressed", False)]))
|
|
133
|
+
elif fmt == "sarif":
|
|
134
|
+
print(output.to_sarif([f for f in findings if not getattr(f, "suppressed", False)], root))
|
|
135
|
+
else:
|
|
136
|
+
severity_order = ["critical", "high", "medium", "low", "info"]
|
|
137
|
+
findings.sort(key=lambda f: severity_order.index(f.severity) if f.severity in severity_order else 99)
|
|
138
|
+
# Only non-suppressed findings block the build
|
|
139
|
+
blocked = any(not getattr(f, "suppressed", False) for f in findings)
|
|
140
|
+
output.print_findings(findings, result.files_scanned, result.elapsed_s, blocked)
|
|
141
|
+
|
|
142
|
+
# Post telemetry (all findings, including suppressed) if authenticated
|
|
143
|
+
if findings and cfg.is_authenticated():
|
|
144
|
+
_post_findings_telemetry(findings, source=source)
|
|
145
|
+
_post_suppressions(findings)
|
|
146
|
+
|
|
147
|
+
# Only active (non-suppressed) findings block the build
|
|
148
|
+
active_findings = [f for f in findings if not getattr(f, "suppressed", False)]
|
|
149
|
+
|
|
150
|
+
if fail_on:
|
|
151
|
+
order = ["critical", "high", "medium", "low"]
|
|
152
|
+
threshold = order.index(fail_on) if fail_on in order else 99
|
|
153
|
+
worst = min(
|
|
154
|
+
(order.index(f.severity) for f in active_findings if f.severity in order),
|
|
155
|
+
default=99,
|
|
156
|
+
)
|
|
157
|
+
if worst <= threshold:
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
elif active_findings:
|
|
160
|
+
sys.exit(1)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ── hook ──────────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
@cli.group()
|
|
166
|
+
def hook_cmd() -> None:
|
|
167
|
+
"""Git hook management."""
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# Rename group to avoid shadowing the hook module
|
|
172
|
+
cli.add_command(hook_cmd, name="hook")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@hook_cmd.command("install")
|
|
176
|
+
@click.option("--ai", is_flag=True, help="Also run AI dev-audit on staged diff")
|
|
177
|
+
def hook_install(ai: bool) -> None:
|
|
178
|
+
"""Install git pre-commit hook."""
|
|
179
|
+
_header()
|
|
180
|
+
try:
|
|
181
|
+
path = hook.install(ai=ai)
|
|
182
|
+
mode = " (with AI dev-audit)" if ai else ""
|
|
183
|
+
console.print(f"[green]✓ Hook installed{mode} at {path}[/green]")
|
|
184
|
+
except Exception as e:
|
|
185
|
+
console.print(f"[red]Hook install failed: {e}[/red]")
|
|
186
|
+
sys.exit(1)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@hook_cmd.command("uninstall")
|
|
190
|
+
def hook_uninstall() -> None:
|
|
191
|
+
"""Remove the ekmire git pre-commit hook."""
|
|
192
|
+
_header()
|
|
193
|
+
if hook.uninstall():
|
|
194
|
+
console.print("[green]✓ Hook removed[/green]")
|
|
195
|
+
else:
|
|
196
|
+
console.print("[yellow]No ekmire hook found[/yellow]")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── mcp ───────────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
@cli.group()
|
|
202
|
+
def mcp() -> None:
|
|
203
|
+
"""MCP server commands."""
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@mcp.command("audit")
|
|
208
|
+
def mcp_audit_cmd() -> None:
|
|
209
|
+
"""Full MCP ecosystem audit — scans all IDE MCP configs system-wide."""
|
|
210
|
+
_header()
|
|
211
|
+
console.print("MCP Ecosystem Audit — scanning all IDE configs...\n")
|
|
212
|
+
|
|
213
|
+
results, total_servers = mcp_audit.audit_all()
|
|
214
|
+
|
|
215
|
+
all_findings: list[mcp_audit.McpFinding] = []
|
|
216
|
+
for config_file, findings in results:
|
|
217
|
+
console.print(f" [dim]{config_file}[/dim]")
|
|
218
|
+
if not findings:
|
|
219
|
+
console.print(" [green]✓ No issues found[/green]")
|
|
220
|
+
else:
|
|
221
|
+
for f in findings:
|
|
222
|
+
sev_colour = output._SEVERITY_COLOURS.get(f.severity, "white")
|
|
223
|
+
console.print(
|
|
224
|
+
f" server: [bold]{f.server_name}[/bold]"
|
|
225
|
+
f" [{sev_colour}][{f.severity.upper()}][/{sev_colour}] {f.rule_id}"
|
|
226
|
+
)
|
|
227
|
+
console.print(f" {f.description}")
|
|
228
|
+
console.print(f" [dim]Fix:[/dim] {f.fix}")
|
|
229
|
+
all_findings.extend(findings)
|
|
230
|
+
console.print()
|
|
231
|
+
|
|
232
|
+
critical = sum(1 for f in all_findings if f.severity == "critical")
|
|
233
|
+
high = sum(1 for f in all_findings if f.severity == "high")
|
|
234
|
+
|
|
235
|
+
console.print("─" * 46)
|
|
236
|
+
if cfg.is_authenticated():
|
|
237
|
+
cloud_url = cfg.get_cloud_url()
|
|
238
|
+
report_url = cloud_url.replace("api.", "app.").rstrip("/") + "/events?source=mcp_audit"
|
|
239
|
+
console.print(
|
|
240
|
+
f" {total_servers} MCP servers scanned · {len(all_findings)} findings "
|
|
241
|
+
f"({critical} critical, {high} high)"
|
|
242
|
+
)
|
|
243
|
+
console.print(f" Full report: {report_url}")
|
|
244
|
+
else:
|
|
245
|
+
console.print(
|
|
246
|
+
f" {total_servers} MCP servers scanned · {len(all_findings)} findings "
|
|
247
|
+
f"({critical} critical, {high} high)"
|
|
248
|
+
)
|
|
249
|
+
console.print("─" * 46)
|
|
250
|
+
|
|
251
|
+
if all_findings:
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@mcp.command("start")
|
|
256
|
+
def mcp_start() -> None:
|
|
257
|
+
"""Start MCP server in stdio mode (for IDE integration)."""
|
|
258
|
+
from .mcp_server import run_stdio
|
|
259
|
+
run_stdio()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ── init ──────────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
@cli.command()
|
|
265
|
+
def init() -> None:
|
|
266
|
+
"""First-run wizard: auth → deep scan → hook install → summary."""
|
|
267
|
+
_header()
|
|
268
|
+
|
|
269
|
+
# Step 1 — Auth
|
|
270
|
+
if cfg.is_authenticated():
|
|
271
|
+
console.print("Step 1/3 Authenticating...")
|
|
272
|
+
console.print(" [green]✓ Already authenticated[/green]")
|
|
273
|
+
else:
|
|
274
|
+
console.print("Step 1/3 Authenticating...")
|
|
275
|
+
import webbrowser
|
|
276
|
+
import secrets
|
|
277
|
+
|
|
278
|
+
token = secrets.token_urlsafe(16)
|
|
279
|
+
login_url = f"{cfg.get_cloud_url()}/cli-auth?token={token}"
|
|
280
|
+
console.print(f" Open [link={login_url}]{login_url}[/link] in your browser (auto-opening)")
|
|
281
|
+
try:
|
|
282
|
+
webbrowser.open(login_url)
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
api_key = _poll_cli_auth(token)
|
|
287
|
+
if api_key:
|
|
288
|
+
cfg.save({"api_key": api_key, "cloud_url": cfg.get_cloud_url()})
|
|
289
|
+
console.print(" [green]✓ Authenticated[/green]")
|
|
290
|
+
else:
|
|
291
|
+
console.print(" [red]Auth timed out — skipping auth step[/red]")
|
|
292
|
+
|
|
293
|
+
console.print()
|
|
294
|
+
|
|
295
|
+
# Step 2 — Deep scan current directory
|
|
296
|
+
console.print("Step 2/3 Scanning current directory for existing issues...")
|
|
297
|
+
api_key = cfg.get_api_key()
|
|
298
|
+
rules_bundle = bundle.get_bundle(cfg.get_cloud_url(), api_key)
|
|
299
|
+
rules = rules_bundle.get("rules", [])
|
|
300
|
+
root = Path(".")
|
|
301
|
+
mireignore = scanner.load_mireignore(root)
|
|
302
|
+
result = scanner.scan_path(root, rules, mireignore)
|
|
303
|
+
|
|
304
|
+
if not result.findings:
|
|
305
|
+
console.print(" [green]✓ No issues found[/green]")
|
|
306
|
+
else:
|
|
307
|
+
sev_order = ["critical", "high", "medium", "low"]
|
|
308
|
+
for f in sorted(result.findings, key=lambda x: sev_order.index(x.severity) if x.severity in sev_order else 99):
|
|
309
|
+
sev_colour = output._SEVERITY_COLOURS.get(f.severity, "white")
|
|
310
|
+
console.print(
|
|
311
|
+
f" [dim]{f.file}:{getattr(f, 'line', '?')}[/dim] "
|
|
312
|
+
f"[{sev_colour}][{f.severity.upper()}][/{sev_colour}] {f.rule_id}"
|
|
313
|
+
)
|
|
314
|
+
if api_key:
|
|
315
|
+
console.print(
|
|
316
|
+
f"\n Full report: {cfg.get_cloud_url().replace('api.', 'app.')}/events?source=device_scanner"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
console.print()
|
|
320
|
+
|
|
321
|
+
# Step 3 — Install hook (if in a git repo)
|
|
322
|
+
console.print("Step 3/3 Installing git pre-commit hook...")
|
|
323
|
+
try:
|
|
324
|
+
hook_path = hook.install()
|
|
325
|
+
console.print(f" [green]✓ Hook installed at {hook_path}[/green]")
|
|
326
|
+
except Exception:
|
|
327
|
+
console.print(" [yellow]Skipped (not in a git repository)[/yellow]")
|
|
328
|
+
|
|
329
|
+
console.print()
|
|
330
|
+
console.print("─" * 46)
|
|
331
|
+
summary_parts = []
|
|
332
|
+
if result.findings:
|
|
333
|
+
summary_parts.append(f"{len(result.findings)} issues to review in your dashboard")
|
|
334
|
+
console.print(" ekmire is active." + (" " + ", ".join(summary_parts) + "." if summary_parts else ""))
|
|
335
|
+
console.print(" Docs: https://ekmire.com/docs/quick-start")
|
|
336
|
+
console.print("─" * 46)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ── dev-audit ─────────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
@cli.command("dev-audit")
|
|
342
|
+
@click.argument("target", default=".")
|
|
343
|
+
@click.option("--diff", is_flag=True, help="Audit current git staged diff")
|
|
344
|
+
def dev_audit(target: str, diff: bool) -> None:
|
|
345
|
+
"""AI security review of a file, directory, or staged diff."""
|
|
346
|
+
_header()
|
|
347
|
+
|
|
348
|
+
if not cfg.is_authenticated():
|
|
349
|
+
console.print("[red]Not authenticated — run `ekmire auth login` first[/red]")
|
|
350
|
+
sys.exit(1)
|
|
351
|
+
|
|
352
|
+
console.print("[dim]AI security analysis in progress...[/dim]")
|
|
353
|
+
import requests
|
|
354
|
+
|
|
355
|
+
api_key = cfg.get_api_key()
|
|
356
|
+
cloud_url = cfg.get_cloud_url()
|
|
357
|
+
|
|
358
|
+
if diff:
|
|
359
|
+
import subprocess
|
|
360
|
+
try:
|
|
361
|
+
content = subprocess.check_output(
|
|
362
|
+
["git", "diff", "--cached"], text=True
|
|
363
|
+
)
|
|
364
|
+
filename = "<staged diff>"
|
|
365
|
+
except Exception as e:
|
|
366
|
+
console.print(f"[red]Could not get staged diff: {e}[/red]")
|
|
367
|
+
sys.exit(1)
|
|
368
|
+
else:
|
|
369
|
+
p = Path(target)
|
|
370
|
+
if p.is_file():
|
|
371
|
+
content = p.read_text(errors="ignore")
|
|
372
|
+
filename = str(p)
|
|
373
|
+
elif p.is_dir():
|
|
374
|
+
# Concatenate all text files up to a reasonable limit
|
|
375
|
+
parts = []
|
|
376
|
+
for f in p.rglob("*"):
|
|
377
|
+
if f.is_file() and f.suffix in (".py", ".js", ".ts", ".go", ".rb", ".java"):
|
|
378
|
+
try:
|
|
379
|
+
parts.append(f"# {f}\n" + f.read_text(errors="ignore"))
|
|
380
|
+
except OSError:
|
|
381
|
+
pass
|
|
382
|
+
if len(parts) >= 20:
|
|
383
|
+
break
|
|
384
|
+
content = "\n\n".join(parts)
|
|
385
|
+
filename = str(p)
|
|
386
|
+
else:
|
|
387
|
+
console.print(f"[red]Path not found: {target}[/red]")
|
|
388
|
+
sys.exit(1)
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
resp = requests.post(
|
|
392
|
+
f"{cloud_url}/v1/llm/route",
|
|
393
|
+
json={"task": "dev_audit", "content": content, "filename": filename},
|
|
394
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
395
|
+
timeout=30,
|
|
396
|
+
)
|
|
397
|
+
resp.raise_for_status()
|
|
398
|
+
findings = resp.json()
|
|
399
|
+
except Exception as e:
|
|
400
|
+
console.print(f"[red]AI audit failed: {e}[/red]")
|
|
401
|
+
sys.exit(1)
|
|
402
|
+
|
|
403
|
+
if not findings:
|
|
404
|
+
console.print("[green]✓ No issues found[/green]")
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
for f in findings:
|
|
408
|
+
sev = f.get("severity", "info")
|
|
409
|
+
sev_colour = output._SEVERITY_COLOURS.get(sev, "white")
|
|
410
|
+
console.print(
|
|
411
|
+
f" Line {f.get('line', '?')} [{sev_colour}][{sev.upper()}][/{sev_colour}] {f.get('category', '?')}"
|
|
412
|
+
)
|
|
413
|
+
console.print(f" {f.get('description', '')}")
|
|
414
|
+
if f.get("remediation_prompt"):
|
|
415
|
+
console.print(f" [dim]Fix:[/dim] {f['remediation_prompt']}")
|
|
416
|
+
console.print()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ── device-scan ───────────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
@cli.command("device-scan")
|
|
422
|
+
@click.argument("path", default=".", required=False)
|
|
423
|
+
@click.option("--output", "output_format", type=click.Choice(["text", "json", "sarif"]), default="text")
|
|
424
|
+
@click.option(
|
|
425
|
+
"--fail-on",
|
|
426
|
+
type=click.Choice(["critical", "high", "medium", "low"]),
|
|
427
|
+
default=None,
|
|
428
|
+
help="Exit 1 if findings at or above this severity exist",
|
|
429
|
+
)
|
|
430
|
+
def device_scan(path: str, output_format: str, fail_on: str | None) -> None:
|
|
431
|
+
"""Full device scan — recursively scans a path and reports as device_scanner source.
|
|
432
|
+
|
|
433
|
+
Findings appear in the dashboard under Events → Source: device_scanner.
|
|
434
|
+
Requires authentication (`mire auth login`).
|
|
435
|
+
"""
|
|
436
|
+
_header()
|
|
437
|
+
|
|
438
|
+
if not cfg.is_authenticated():
|
|
439
|
+
console.print("[yellow]Not authenticated — findings won't appear in dashboard.[/yellow]")
|
|
440
|
+
console.print("[dim]Run `ekmire auth login` to connect to ekmire Cloud.[/dim]\n")
|
|
441
|
+
|
|
442
|
+
api_key = cfg.get_api_key()
|
|
443
|
+
cloud_url = cfg.get_cloud_url()
|
|
444
|
+
rules_bundle = bundle.get_bundle(cloud_url, api_key)
|
|
445
|
+
rules = rules_bundle.get("rules", [])
|
|
446
|
+
|
|
447
|
+
root = Path(path).resolve()
|
|
448
|
+
mireignore = scanner.load_mireignore(root if root.is_dir() else root.parent)
|
|
449
|
+
|
|
450
|
+
if output_format == "text":
|
|
451
|
+
console.print(f"Device scan: {root} ", end="")
|
|
452
|
+
|
|
453
|
+
result = scanner.scan_path(root, rules, mireignore)
|
|
454
|
+
_emit_findings(result, root, output_format, fail_on, source="device_scanner")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# ── version ───────────────────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
@cli.command()
|
|
460
|
+
def version() -> None:
|
|
461
|
+
"""Print version and Flux8Labs attribution."""
|
|
462
|
+
print(f"ekmire v{__version__} by Flux8Labs")
|
|
463
|
+
print("Copyright © Flux8Labs. All rights reserved.")
|
|
464
|
+
print("https://ekmire.com")
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
def _poll_cli_auth(token: str, timeout_s: int = 120) -> str | None:
|
|
470
|
+
import time
|
|
471
|
+
import requests
|
|
472
|
+
|
|
473
|
+
cloud_url = cfg.get_cloud_url()
|
|
474
|
+
deadline = time.time() + timeout_s
|
|
475
|
+
while time.time() < deadline:
|
|
476
|
+
try:
|
|
477
|
+
resp = requests.get(
|
|
478
|
+
f"{cloud_url}/v1/auth/cli-poll",
|
|
479
|
+
params={"token": token},
|
|
480
|
+
timeout=5,
|
|
481
|
+
)
|
|
482
|
+
if resp.status_code == 200:
|
|
483
|
+
return resp.json().get("api_key")
|
|
484
|
+
except Exception:
|
|
485
|
+
pass
|
|
486
|
+
time.sleep(2)
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _post_findings_telemetry(findings: list, source: str = "cli") -> None:
|
|
491
|
+
import requests
|
|
492
|
+
from .telemetry import build_telemetry_events
|
|
493
|
+
|
|
494
|
+
api_key = cfg.get_api_key()
|
|
495
|
+
cloud_url = cfg.get_cloud_url()
|
|
496
|
+
events = build_telemetry_events(findings, source=source)
|
|
497
|
+
try:
|
|
498
|
+
requests.post(
|
|
499
|
+
f"{cloud_url}/v1/events",
|
|
500
|
+
json={"events": events},
|
|
501
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
502
|
+
timeout=5,
|
|
503
|
+
)
|
|
504
|
+
except Exception:
|
|
505
|
+
pass # telemetry is best-effort
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _post_suppressions(findings: list) -> None:
|
|
509
|
+
"""Post suppressed findings to rule_suppressions endpoint (best-effort)."""
|
|
510
|
+
from .telemetry import build_suppression_records
|
|
511
|
+
|
|
512
|
+
records = build_suppression_records(findings)
|
|
513
|
+
if not records:
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
import requests
|
|
517
|
+
|
|
518
|
+
api_key = cfg.get_api_key()
|
|
519
|
+
cloud_url = cfg.get_cloud_url()
|
|
520
|
+
try:
|
|
521
|
+
requests.post(
|
|
522
|
+
f"{cloud_url}/v1/events/suppressions",
|
|
523
|
+
json={"suppressions": records},
|
|
524
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
525
|
+
timeout=5,
|
|
526
|
+
)
|
|
527
|
+
except Exception:
|
|
528
|
+
pass # best-effort
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
if __name__ == "__main__":
|
|
532
|
+
cli()
|