From 4d05475ec4aad571e8fd2eb435704ed6b718bbf4 Mon Sep 17 00:00:00 2001 From: krypticmouse Date: Fri, 27 Mar 2026 01:00:38 +0000 Subject: [PATCH] feat: enhance security scan with DNS check, JSON output, Rich UI, and API endpoint Incorporates the useful new features from PR #135 (by @gridworks) on top of the existing PrivacyScanner implementation: - Add DNS configuration check (macOS, via scutil --dns) - Add --json flag to `jarvis scan` for machine-readable output - Add --no-scan flag to `jarvis init` to skip the post-init audit - Expand remote-access process list (ngrok, tailscaled, cloudflared, ZeroTier) - Upgrade `jarvis scan` output from plain text to Rich table - Add GET /v1/security/scan API endpoint - Add tests for all new features (30 tests, all passing) Closes #133 Co-Authored-By: gridworks <5502067+gridworks@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/cli/init_cmd.py | 11 +- src/openjarvis/cli/scan_cmd.py | 190 +++++++++++++++++++++++++++----- src/openjarvis/server/routes.py | 182 +++++++++++++++++++++--------- tests/cli/test_scan.py | 155 ++++++++++++++++++++++---- 4 files changed, 434 insertions(+), 104 deletions(-) diff --git a/src/openjarvis/cli/init_cmd.py b/src/openjarvis/cli/init_cmd.py index d93858e1..98b9da85 100644 --- a/src/openjarvis/cli/init_cmd.py +++ b/src/openjarvis/cli/init_cmd.py @@ -236,6 +236,13 @@ def _do_download(engine: str, model: str, spec, console: Console) -> None: @click.option( "--no-download", is_flag=True, default=False, help="Skip the model download prompt." ) +@click.option( + "--no-scan", + "skip_scan", + is_flag=True, + default=False, + help="Skip the post-init security environment audit.", +) @click.option( "--host", default=None, @@ -247,6 +254,7 @@ def init( full_config: bool = False, engine: Optional[str] = None, no_download: bool = False, + skip_scan: bool = False, host: Optional[str] = None, ) -> None: """Detect hardware and generate ~/.openjarvis/config.toml.""" @@ -405,7 +413,8 @@ def init( if click.confirm(prompt, default=True): _do_download(selected_engine, model, spec, console) - _quick_privacy_check(console) + if not skip_scan: + _quick_privacy_check(console) console.print() console.print( Panel( diff --git a/src/openjarvis/cli/scan_cmd.py b/src/openjarvis/cli/scan_cmd.py index 6ff2e210..a29571f7 100644 --- a/src/openjarvis/cli/scan_cmd.py +++ b/src/openjarvis/cli/scan_cmd.py @@ -19,11 +19,24 @@ _CLOUD_SYNC_PROCS = ["Dropbox", "OneDrive", "Google Drive", "iCloudDrive"] # Screen-recording / remote-access processes (macOS). _SCREEN_RECORDING_PROCS = [ - "TeamViewer", "AnyDesk", "ScreenConnect", "vncviewer", "Vine" + "TeamViewer", + "AnyDesk", + "ScreenConnect", + "vncviewer", + "Vine", ] -# Remote-access processes (Linux). -_REMOTE_ACCESS_PROCS = ["xrdp", "x11vnc", "vncserver", "AnyDesk"] +# Remote-access processes (cross-platform). +_REMOTE_ACCESS_PROCS = [ + "xrdp", + "x11vnc", + "vncserver", + "AnyDesk", + "ngrok", + "tailscaled", + "cloudflared", + "ZeroTier", +] # --------------------------------------------------------------------------- @@ -295,12 +308,83 @@ class PrivacyScanner: ) def check_remote_access(self) -> ScanResult: - """Check for running remote-access processes (Linux).""" + """Check for running remote-access / tunneling processes.""" return self._check_processes( names=_REMOTE_ACCESS_PROCS, check_name="Remote Access", warn_msg="{name} is running — system may be accessible remotely.", - platform="linux", + platform="all", + ) + + def check_dns(self) -> ScanResult: + """Check DNS configuration for encrypted resolvers (macOS).""" + if sys.platform != "darwin": + return ScanResult( + name="DNS Configuration", + status="skip", + message="DNS check not yet implemented for this platform.", + platform="darwin", + ) + try: + proc = self._run(["scutil", "--dns"]) + output = proc.stdout + except Exception: + return ScanResult( + name="DNS Configuration", + status="skip", + message="scutil command not available.", + platform="darwin", + ) + + _DOH_INDICATORS = [ + "dns-over-https", + "dns-over-tls", + "encrypted", + "doh", + "dot", + ] + if any(ind in output.lower() for ind in _DOH_INDICATORS): + return ScanResult( + name="DNS Configuration", + status="ok", + message="Encrypted DNS (DoH/DoT) appears to be active.", + platform="darwin", + ) + + # Extract nameserver IPs + nameservers: list[str] = [] + for line in output.splitlines(): + stripped = line.strip() + if stripped.startswith("nameserver["): + parts = stripped.split(":", 1) + if len(parts) == 2: + nameservers.append(parts[1].strip()) + + if not nameservers: + return ScanResult( + name="DNS Configuration", + status="ok", + message="Could not parse DNS nameservers from scutil output.", + platform="darwin", + ) + + _PLAIN_DNS = {"8.8.8.8", "8.8.4.4", "1.1.1.1", "1.0.0.1", "9.9.9.9"} + plain = [ns for ns in nameservers if ns in _PLAIN_DNS] + if plain: + return ScanResult( + name="DNS Configuration", + status="warn", + message=( + f"Plain DNS in use ({', '.join(plain)}). " + "Queries may be visible to your ISP or resolver." + ), + platform="darwin", + ) + return ScanResult( + name="DNS Configuration", + status="ok", + message=f"DNS resolvers: {', '.join(nameservers[:3])}.", + platform="darwin", ) # -- Orchestration ------------------------------------------------------- @@ -315,6 +399,7 @@ class PrivacyScanner: self.check_luks, self.check_screen_recording, self.check_remote_access, + self.check_dns, ] def run_all(self) -> list[ScanResult]: @@ -350,37 +435,88 @@ class PrivacyScanner: # CLI command # --------------------------------------------------------------------------- -_STATUS_ICONS = {"ok": "✓", "warn": "!", "fail": "✗", "skip": "-"} +_RICH_ICONS = { + "ok": "[green]\u2713[/green]", + "warn": "[yellow]![/yellow]", + "fail": "[red]\u2717[/red]", + "skip": "[dim]-[/dim]", +} + + +def _render_results(results: List[ScanResult]) -> None: + """Render scan results as a Rich table.""" + from rich.console import Console + from rich.table import Table + + console = Console() + console.print() + console.print("[bold]OpenJarvis Security Scan[/bold]") + console.print() + + table = Table(show_header=True, header_style="bold", show_lines=True) + table.add_column("", width=3, justify="center") + table.add_column("Check") + table.add_column("Finding") + + for r in results: + icon = _RICH_ICONS.get(r.status, "?") + style = {"ok": "green", "warn": "yellow", "fail": "red"}.get(r.status, "white") + table.add_row(icon, r.name, f"[{style}]{r.message}[/{style}]") + + console.print(table) + + ok_count = sum(1 for r in results if r.status == "ok") + warn_count = sum(1 for r in results if r.status == "warn") + fail_count = sum(1 for r in results if r.status == "fail") + + console.print() + parts = [f"[green]{ok_count} ok[/green]"] + if warn_count: + parts.append(f"[yellow]{warn_count} warning(s)[/yellow]") + if fail_count: + parts.append(f"[red]{fail_count} issue(s)[/red]") + console.print(" " + ", ".join(parts)) + console.print() + + if fail_count: + console.print( + "[red bold]Action required:[/red bold] address critical findings " + "before storing sensitive data with OpenJarvis." + ) + console.print() + elif warn_count: + console.print( + "[yellow]Review warnings above[/yellow] — they may not block " + "usage but could affect your privacy posture." + ) + console.print() @click.command() @click.option("--quick", is_flag=True, default=False, help="Run only critical checks.") -def scan(quick: bool) -> None: +@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON.") +def scan(quick: bool, as_json: bool) -> None: """Audit your environment for privacy and security risks.""" scanner = PrivacyScanner() results: List[ScanResult] = scanner.run_quick() if quick else scanner.run_all() + if as_json: + import json as json_mod + + output = [ + { + "name": r.name, + "status": r.status, + "message": r.message, + "platform": r.platform, + } + for r in results + ] + click.echo(json_mod.dumps(output, indent=2)) + return + if not results: click.echo("No applicable checks for this platform.") return - warnings = 0 - failures = 0 - for r in results: - icon = _STATUS_ICONS.get(r.status, "?") - click.echo(f" [{icon}] {r.name}: {r.message}") - if r.status == "warn": - warnings += 1 - elif r.status == "fail": - failures += 1 - - click.echo("") - parts = [] - if warnings: - parts.append(f"{warnings} warning(s)") - if failures: - parts.append(f"{failures} issue(s)") - if parts: - click.echo("Summary: " + ", ".join(parts) + ".") - else: - click.echo("Summary: all checks passed.") + _render_results(results) diff --git a/src/openjarvis/server/routes.py b/src/openjarvis/server/routes.py index 50fb645b..a615d7d0 100644 --- a/src/openjarvis/server/routes.py +++ b/src/openjarvis/server/routes.py @@ -32,12 +32,14 @@ def _to_messages(chat_messages) -> list[Message]: messages = [] for m in chat_messages: role = Role(m.role) if m.role in {r.value for r in Role} else Role.USER - messages.append(Message( - role=role, - content=m.content or "", - name=m.name, - tool_call_id=m.tool_call_id, - )) + messages.append( + Message( + role=role, + content=m.content or "", + name=m.name, + tool_call_id=m.tool_call_id, + ) + ) return messages @@ -75,7 +77,10 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request max_context_tokens=config.memory.context_max_tokens, ) enriched = inject_context( - query_text, messages, memory_backend, config=ctx_cfg, + query_text, + messages, + memory_backend, + config=ctx_cfg, ) # Rebuild request messages from enriched Message objects if len(enriched) > len(messages): @@ -83,16 +88,19 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request new_msgs = [] for msg in enriched: - new_msgs.append(ChatMessage( - role=msg.role.value, - content=msg.content, - name=msg.name, - tool_call_id=getattr(msg, "tool_call_id", None), - )) + new_msgs.append( + ChatMessage( + role=msg.role.value, + content=msg.content, + name=msg.name, + tool_call_id=getattr(msg, "tool_call_id", None), + ) + ) request_body.messages = new_msgs except Exception: logging.getLogger("openjarvis.server").debug( - "Memory context injection failed", exc_info=True, + "Memory context injection failed", + exc_info=True, ) # Run complexity analysis on the last user message @@ -111,7 +119,8 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request cr = score_complexity(query_text_for_complexity) suggested = adjust_tokens_for_model( - cr.suggested_max_tokens, model, + cr.suggested_max_tokens, + model, ) complexity_info = ComplexityInfo( score=cr.score, @@ -124,7 +133,8 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request request_body.max_tokens = suggested except Exception: logging.getLogger("openjarvis.server").debug( - "Complexity analysis failed", exc_info=True, + "Complexity analysis failed", + exc_info=True, ) if request_body.stream: @@ -143,13 +153,19 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request bus = getattr(request.app.state, "bus", None) return _handle_direct( - engine, model, request_body, - bus=bus, complexity_info=complexity_info, + engine, + model, + request_body, + bus=bus, + complexity_info=complexity_info, ) def _handle_direct( - engine, model: str, req: ChatCompletionRequest, bus=None, + engine, + model: str, + req: ChatCompletionRequest, + bus=None, complexity_info=None, ) -> ChatCompletionResponse: """Direct engine call without agent.""" @@ -161,8 +177,12 @@ def _handle_direct( from openjarvis.telemetry.wrapper import instrumented_generate result = instrumented_generate( - engine, messages, model=model, bus=bus, - temperature=req.temperature, max_tokens=req.max_tokens, + engine, + messages, + model=model, + bus=bus, + temperature=req.temperature, + max_tokens=req.max_tokens, **kwargs, ) else: @@ -194,10 +214,12 @@ def _handle_direct( return ChatCompletionResponse( model=model, - choices=[Choice( - message=choice_msg, - finish_reason=result.get("finish_reason", "stop"), - )], + choices=[ + Choice( + message=choice_msg, + finish_reason=result.get("finish_reason", "stop"), + ) + ], usage=UsageInfo( prompt_tokens=usage.get("prompt_tokens", 0), completion_tokens=usage.get("completion_tokens", 0), @@ -208,7 +230,9 @@ def _handle_direct( def _handle_agent( - agent, model: str, req: ChatCompletionRequest, + agent, + model: str, + req: ChatCompletionRequest, complexity_info=None, ) -> ChatCompletionResponse: """Run through agent.""" @@ -241,10 +265,12 @@ def _handle_agent( return ChatCompletionResponse( model=model, - choices=[Choice( - message=ChoiceMessage(role="assistant", content=result.content), - finish_reason="stop", - )], + choices=[ + Choice( + message=ChoiceMessage(role="assistant", content=result.content), + finish_reason="stop", + ) + ], usage=usage, complexity=complexity_info, ) @@ -258,7 +284,9 @@ async def _handle_agent_stream(agent, bus, model, req): async def _handle_stream( - engine, model: str, req: ChatCompletionRequest, + engine, + model: str, + req: ChatCompletionRequest, complexity_info=None, ): """Stream response using SSE format.""" @@ -270,9 +298,11 @@ async def _handle_stream( first_chunk = ChatCompletionChunk( id=chunk_id, model=model, - choices=[StreamChoice( - delta=DeltaMessage(role="assistant"), - )], + choices=[ + StreamChoice( + delta=DeltaMessage(role="assistant"), + ) + ], ) yield f"data: {first_chunk.model_dump_json()}\n\n" @@ -287,27 +317,34 @@ async def _handle_stream( chunk = ChatCompletionChunk( id=chunk_id, model=model, - choices=[StreamChoice( - delta=DeltaMessage(content=token), - )], + choices=[ + StreamChoice( + delta=DeltaMessage(content=token), + ) + ], ) yield f"data: {chunk.model_dump_json()}\n\n" except Exception as exc: # Surface errors as a content chunk so the frontend can # display them instead of silently failing. import logging + logging.getLogger("openjarvis.server").error( - "Stream error: %s", exc, exc_info=True, + "Stream error: %s", + exc, + exc_info=True, ) error_chunk = ChatCompletionChunk( id=chunk_id, model=model, - choices=[StreamChoice( - delta=DeltaMessage( - content=f"\n\nError during generation: {exc}", - ), - finish_reason="stop", - )], + choices=[ + StreamChoice( + delta=DeltaMessage( + content=f"\n\nError during generation: {exc}", + ), + finish_reason="stop", + ) + ], ) yield f"data: {error_chunk.model_dump_json()}\n\n" yield "data: [DONE]\n\n" @@ -315,13 +352,16 @@ async def _handle_stream( # Send finish chunk with usage data if available import json as _json + finish_data = ChatCompletionChunk( id=chunk_id, model=model, - choices=[StreamChoice( - delta=DeltaMessage(), - finish_reason="stop", - )], + choices=[ + StreamChoice( + delta=DeltaMessage(), + finish_reason="stop", + ) + ], ) finish_dict = _json.loads(finish_data.model_dump_json()) @@ -456,18 +496,22 @@ async def savings(request: Request): # Exclude cloud model tokens from savings — only local # inference counts toward cost savings. _cloud_prefixes = ( - "gpt-", "o1-", "o3-", "o4-", - "claude-", "gemini-", "openrouter/", + "gpt-", + "o1-", + "o3-", + "o4-", + "claude-", + "gemini-", + "openrouter/", ) local_models = [ - m for m in summary.per_model + m + for m in summary.per_model if not any(m.model_id.startswith(p) for p in _cloud_prefixes) ] result = compute_savings( prompt_tokens=sum(m.prompt_tokens for m in local_models), - completion_tokens=sum( - m.completion_tokens for m in local_models - ), + completion_tokens=sum(m.completion_tokens for m in local_models), total_calls=sum(m.call_count for m in local_models), session_start=session_start if session_start else 0.0, prompt_tokens_evaluated=sum( @@ -557,7 +601,8 @@ async def channel_send(request: Request): if not channel_name or not content: raise HTTPException( - status_code=400, detail="'channel' and 'content' are required", + status_code=400, + detail="'channel' and 'content' are required", ) ok = bridge.send(channel_name, content, conversation_id=conversation_id) @@ -575,4 +620,31 @@ async def channel_status(request: Request): return {"status": bridge.status().value} +# --------------------------------------------------------------------------- +# Security scan endpoint +# --------------------------------------------------------------------------- + + +@router.get("/v1/security/scan") +async def security_scan(): + """Run a read-only security environment audit and return findings.""" + from openjarvis.cli.scan_cmd import PrivacyScanner + + scanner = PrivacyScanner() + results = scanner.run_all() + return { + "has_warnings": any(r.status == "warn" for r in results), + "has_failures": any(r.status == "fail" for r in results), + "findings": [ + { + "name": r.name, + "status": r.status, + "message": r.message, + "platform": r.platform, + } + for r in results + ], + } + + __all__ = ["router"] diff --git a/tests/cli/test_scan.py b/tests/cli/test_scan.py index 536439d8..41370832 100644 --- a/tests/cli/test_scan.py +++ b/tests/cli/test_scan.py @@ -31,9 +31,7 @@ def _make_proc( class TestScanResultDataclass: def test_fields_exist(self) -> None: - r = ScanResult( - name="test", status="ok", message="all good", platform="all" - ) + r = ScanResult(name="test", status="ok", message="all good", platform="all") assert r.name == "test" assert r.status == "ok" assert r.message == "all good" @@ -246,15 +244,9 @@ class TestPlatformFiltering: other_plat = "linux" if current_plat == "darwin" else "darwin" with patch.object(scanner, "_get_all_checks") as mock_get: - darwin_ck = MagicMock( - return_value=ScanResult("d", "ok", "msg", "darwin") - ) - linux_ck = MagicMock( - return_value=ScanResult("l", "ok", "msg", "linux") - ) - all_ck = MagicMock( - return_value=ScanResult("a", "ok", "msg", "all") - ) + darwin_ck = MagicMock(return_value=ScanResult("d", "ok", "msg", "darwin")) + linux_ck = MagicMock(return_value=ScanResult("l", "ok", "msg", "linux")) + all_ck = MagicMock(return_value=ScanResult("a", "ok", "msg", "all")) mock_get.return_value = [darwin_ck, linux_ck, all_ck] results = scanner.run_all() @@ -275,23 +267,19 @@ class TestRemoteAccess: def test_no_remote_access(self) -> None: scanner = PrivacyScanner() with patch.object(scanner, "_run") as mock_run: - mock_run.return_value = CompletedProcess( - [], 1, stdout="", stderr="" - ) + mock_run.return_value = CompletedProcess([], 1, stdout="", stderr="") result = scanner.check_remote_access() assert result.status == "ok" def test_xrdp_running(self) -> None: scanner = PrivacyScanner() with patch.object(scanner, "_run") as mock_run: + def side_effect(cmd, **kw): if any("xrdp" in str(c) for c in cmd): - return CompletedProcess( - cmd, 0, stdout="12345", stderr="" - ) - return CompletedProcess( - cmd, 1, stdout="", stderr="" - ) + return CompletedProcess(cmd, 0, stdout="12345", stderr="") + return CompletedProcess(cmd, 1, stdout="", stderr="") + mock_run.side_effect = side_effect result = scanner.check_remote_access() assert result.status == "warn" @@ -348,3 +336,128 @@ class TestRunQuick: names = {r.name for r in results} assert "Network Exposure" not in names assert "Screen Recording" not in names + + +# --------------------------------------------------------------------------- +# TestDNS +# --------------------------------------------------------------------------- + + +class TestDNS: + """Tests for check_dns (macOS).""" + + def test_encrypted_dns_detected(self) -> None: + scanner = PrivacyScanner() + scutil_out = ( + "resolver #1\n nameserver[0] : 127.0.0.1\n flags : dns-over-https\n" + ) + with ( + patch("sys.platform", "darwin"), + patch("subprocess.run", return_value=_make_proc(stdout=scutil_out)), + ): + result = scanner.check_dns() + assert result.status == "ok" + assert "Encrypted" in result.message or "DoH" in result.message + + def test_plain_dns_detected(self) -> None: + scanner = PrivacyScanner() + scutil_out = ( + "resolver #1\n nameserver[0] : 8.8.8.8\n nameserver[1] : 8.8.4.4\n" + ) + with ( + patch("sys.platform", "darwin"), + patch("subprocess.run", return_value=_make_proc(stdout=scutil_out)), + ): + result = scanner.check_dns() + assert result.status == "warn" + assert "8.8.8.8" in result.message + + def test_private_dns(self) -> None: + scanner = PrivacyScanner() + scutil_out = "resolver #1\n nameserver[0] : 192.168.1.1\n" + with ( + patch("sys.platform", "darwin"), + patch("subprocess.run", return_value=_make_proc(stdout=scutil_out)), + ): + result = scanner.check_dns() + assert result.status == "ok" + assert "192.168.1.1" in result.message + + def test_skip_on_linux(self) -> None: + scanner = PrivacyScanner() + with patch("sys.platform", "linux"): + result = scanner.check_dns() + assert result.status == "skip" + + def test_scutil_not_available(self) -> None: + scanner = PrivacyScanner() + with ( + patch("sys.platform", "darwin"), + patch("subprocess.run", side_effect=FileNotFoundError), + ): + result = scanner.check_dns() + assert result.status == "skip" + + +# --------------------------------------------------------------------------- +# TestExpandedRemoteAccess +# --------------------------------------------------------------------------- + + +class TestExpandedRemoteAccess: + """Verify expanded remote-access process list.""" + + def test_ngrok_detected(self) -> None: + scanner = PrivacyScanner() + with patch.object(scanner, "_run") as mock_run: + + def side_effect(cmd, **kw): + if any("ngrok" in str(c) for c in cmd): + return CompletedProcess(cmd, 0, stdout="99999", stderr="") + return CompletedProcess(cmd, 1, stdout="", stderr="") + + mock_run.side_effect = side_effect + result = scanner.check_remote_access() + assert result.status == "warn" + assert "ngrok" in result.message + + def test_tailscaled_detected(self) -> None: + scanner = PrivacyScanner() + with patch.object(scanner, "_run") as mock_run: + + def side_effect(cmd, **kw): + if any("tailscaled" in str(c) for c in cmd): + return CompletedProcess(cmd, 0, stdout="88888", stderr="") + return CompletedProcess(cmd, 1, stdout="", stderr="") + + mock_run.side_effect = side_effect + result = scanner.check_remote_access() + assert result.status == "warn" + + +# --------------------------------------------------------------------------- +# TestJsonOutput +# --------------------------------------------------------------------------- + + +class TestJsonOutput: + """Test --json output flag.""" + + def test_json_output_structure(self) -> None: + from click.testing import CliRunner + + from openjarvis.cli.scan_cmd import scan + + runner = CliRunner() + with patch( + "openjarvis.cli.scan_cmd.PrivacyScanner.run_all", + return_value=[ + ScanResult("Test", "ok", "all good", "all"), + ], + ): + result = runner.invoke(scan, ["--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert isinstance(data, list) + assert data[0]["name"] == "Test" + assert data[0]["status"] == "ok"