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 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,2 @@
1
+ __version__ = "1.0.0"
2
+ __author__ = "Flux8Labs"
@@ -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()