diff --git a/docs/configuration.md b/docs/configuration.md index 89f38a1..c6fddf7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,6 +62,7 @@ These flags can be used with any command: - `--suppress-warnings`: Hides configuration override panels and security warnings from the console. - `--accept-telemetry`: Explicitly opts-in to anonymous telemetry (required in silent mode if no preference is set). - `--decline-telemetry`: Explicitly opts-out of anonymous telemetry (required in silent mode if no preference is set). +- `--colorblind`: Use colorblind-safe palette (Okabe-Ito) and save to config. ### CI/CD Usage diff --git a/src/edgewalker/cli/cli.py b/src/edgewalker/cli/cli.py index 11a2a35..030789c 100644 --- a/src/edgewalker/cli/cli.py +++ b/src/edgewalker/cli/cli.py @@ -6,6 +6,7 @@ # Standard Library import asyncio +import contextlib import importlib.metadata import platform import shutil @@ -22,7 +23,7 @@ from rich.table import Table # First Party -from edgewalker import __version__, theme +from edgewalker import __version__, theme, theme as _theme from edgewalker.cli.controller import ScanController from edgewalker.cli.guided import GuidedScanner from edgewalker.cli.menu import InteractiveMenu @@ -41,6 +42,21 @@ print_logo, ) + +def apply_colorblind_theme(persist: bool = True) -> None: + """Switch the active theme to the colorblind-safe skin. + + Args: + persist: If True, save the theme choice to config.yaml. + """ + if persist: + update_setting("theme", "colorblind") + else: + settings.theme = "colorblind" + + _theme.load_active_theme() + + # ============================================================================ # TYPER APP SETUP # ============================================================================ @@ -120,9 +136,7 @@ def config_show() -> None: f"[yellow]Note: Some settings are currently overridden by {sources}.[/yellow]" ) - # Add security warnings - security_warnings = settings.get_security_warnings() - if security_warnings: + if security_warnings := settings.get_security_warnings(): console.print( f"\n[bold {theme.RISK_CRITICAL}]SECURITY WARNINGS:[/bold {theme.RISK_CRITICAL}]" ) @@ -311,9 +325,8 @@ def version() -> None: # Gather dependency info dynamically deps = [] - try: - requires = importlib.metadata.requires("edgewalker") - if requires: + with contextlib.suppress(importlib.metadata.PackageNotFoundError): + if requires := importlib.metadata.requires("edgewalker"): for req in requires: # Extract package name (e.g., "rich>=14.3.3" -> "rich") dep_name = ( @@ -325,16 +338,11 @@ def version() -> None: .split("<")[0] .strip() ) - # Handle cases with extras or environment markers - dep_name = dep_name.split("[")[0].split(";")[0].strip() - if dep_name: + if dep_name := dep_name.split("[")[0].split(";")[0].strip(): deps.append(dep_name) - except importlib.metadata.PackageNotFoundError: - pass - # Fallback to pyproject.toml if metadata fails (e.g., running from source) if not deps: - try: + with contextlib.suppress(OSError, UnicodeDecodeError): pyproject_path = CONFIG_DIR.parent.parent / "hackathon-q2-2025" / "pyproject.toml" # Try relative path from this file too if not pyproject_path.exists(): @@ -354,12 +362,8 @@ def version() -> None: .split("<")[0] .strip() ) - dep_name = dep_name.split("[")[0].split(";")[0].strip() - if dep_name: + if dep_name := dep_name.split("[")[0].split(";")[0].strip(): deps.append(dep_name) - except (OSError, UnicodeDecodeError): - pass # nosec: B110 - fallback to hardcoded list is intentional if parsing fails - # Final fallback to hardcoded list if all else fails if not deps: deps = [ @@ -423,6 +427,11 @@ def main( "--decline-telemetry", help="Explicitly opt-out of telemetry (used in silent mode).", ), + colorblind: bool = typer.Option( + False, + "--colorblind", + help="Use colorblind-safe palette (Okabe-Ito) and save to config.", + ), ) -> None: """EdgeWalker - IoT Home Network Security Scanner.""" # Update settings with global flags @@ -434,14 +443,15 @@ def main( update_setting("accept_telemetry", True) if decline_telemetry: update_setting("decline_telemetry", True) + if colorblind: + apply_colorblind_theme(persist=True) + console.print( + f"[{theme.SUCCESS}]Colorblind-safe theme applied and saved to config.[/{theme.SUCCESS}]" + ) # Configure logging using the Typer options setup_logging(verbosity=verbose, log_file=log_file) - if ctx.invoked_subcommand is None: - # This is handled in main.py to allow TUI by default - pass - def interactive_mode() -> None: """Entry point for the interactive menu interface.""" diff --git a/src/edgewalker/core/telemetry.py b/src/edgewalker/core/telemetry.py index 8622d73..f7bebca 100644 --- a/src/edgewalker/core/telemetry.py +++ b/src/edgewalker/core/telemetry.py @@ -76,9 +76,7 @@ def has_seen_telemetry_prompt(self) -> bool: def anonymize_ip(ip: str) -> str: """Anonymize IP by replacing last 2 octets with 0.0.""" parts = ip.split(".") - if len(parts) == 4: - return f"{parts[0]}.{parts[1]}.0.0" - return ip + return f"{parts[0]}.{parts[1]}.0.0" if len(parts) == 4 else ip @staticmethod def anonymize_mac(mac: Optional[str]) -> Optional[str]: diff --git a/src/edgewalker/main.py b/src/edgewalker/main.py index c3fac09..755bcdf 100644 --- a/src/edgewalker/main.py +++ b/src/edgewalker/main.py @@ -8,6 +8,7 @@ """ # Standard Library +import contextlib import sys # First Party @@ -34,10 +35,8 @@ def main() -> None: # If no arguments, launch TUI if len(sys.argv) == 1: - try: + with contextlib.suppress(KeyboardInterrupt): EdgeWalkerApp().run() - except KeyboardInterrupt: - pass else: app() diff --git a/src/edgewalker/skins/colorblind.yaml b/src/edgewalker/skins/colorblind.yaml new file mode 100644 index 0000000..964af73 --- /dev/null +++ b/src/edgewalker/skins/colorblind.yaml @@ -0,0 +1,53 @@ +metadata: + name: "Colorblind Safe (Okabe-Ito)" + author: "Periphery" + +# Palette: Okabe & Ito (2008) — universal colorblind-safe scientific standard. +# Works for deuteranopia, protanopia, and tritanopia. +# +# Sky Blue #56B4E9 — primary accent, borders, headers +# Orange #E69F00 — warnings +# Vermillion #D55E00 — danger / critical (distinct from orange at all types) +# Blue #0072B2 — secondary / active elements +# Bluish Grn #009E73 — success (readable by red-green blind as distinct from orange) +# Yellow #F0E442 — highlight +# Foreground #E8E8E8 — high-contrast white-ish text on dark bg + +theme: + primary: "#0072B2" + secondary: "#56B4E9" + warning: "#E69F00" + error: "#D55E00" + success: "#009E73" + accent: "#56B4E9" + foreground: "#E8E8E8" + background: "#0f0f1a" + surface: "#1a1a2e" + panel: "#16213e" + boost: "#1e1e3a" + dark: true + lavender: "#56B4E9" + highlight: "#F0E442" + variables: + muted: "#888888" + text: "#E8E8E8" + danger: "#D55E00" + surface-alt: "#16213e" + +icons: + scan: "⌕" + check: "[OK]" + fail: "[X]" + bullet: "•" + arrow: "->" + warn: "[!]" + skull: "[!]" + vulnerable: "[X]" + circle: "o" + circle_filled: "*" + step: ">" + bar_full: "█" + bar_empty: "░" + plus: "[+]" + info: "[*]" + alert: "[!]" diff --git a/tests/test_cli.py b/tests/test_cli.py index 7545530..b4538ee 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -478,6 +478,25 @@ def test_prompt_next_scan_suggest_cve(mock_run, mock_input, mock_status): assert mock_run.called +@patch("edgewalker.cli.guided.GuidedScanner.automatic_mode", new_callable=AsyncMock) +@patch("edgewalker.utils.ensure_telemetry_choice") +def test_run_guided_scan_colorblind_flag(mock_telemetry, mock_auto): + """--colorblind flag is accepted and applies the colorblind theme.""" + # First Party + from edgewalker import theme as t + + original_accent = t.ACCENT + # Now a global flag, must come before 'scan' + result = runner.invoke(app, ["--colorblind", "scan", "--target", "1.1.1.1"]) + assert result.exit_code == 0 + # restore default theme so other tests aren't affected + # First Party + from edgewalker.core.config import settings + + settings.theme = "periphery" + t.load_active_theme() + + @patch("edgewalker.cli.guided.GuidedScanner.automatic_mode", new_callable=AsyncMock) @patch("edgewalker.utils.ensure_telemetry_choice") def test_run_guided_scan_verbose_flag(mock_telemetry, mock_auto): diff --git a/tests/test_theme.py b/tests/test_theme.py index 777b032..7adc7ca 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -32,3 +32,50 @@ def test_icons(): assert theme.ICON_PLUS == "[+]" assert theme.ICON_INFO == "[*]" assert theme.ICON_ALERT == "[!]" + + +def test_colorblind_skin_exists_and_has_required_keys(): + """colorblind.yaml skin exists and defines all required color roles.""" + # Standard Library + from pathlib import Path + + skin_path = Path(__file__).parent.parent / "src/edgewalker/skins/colorblind.yaml" + assert skin_path.exists(), "colorblind.yaml skin file missing" + + # Third Party + import yaml + + data = yaml.safe_load(skin_path.read_text()) + theme_section = data.get("theme", {}) + for key in ("primary", "accent", "success", "warning", "error", "foreground"): + assert key in theme_section, f"colorblind skin missing key: {key}" + + +def test_colorblind_skin_loads_via_theme_manager(): + """ThemeManager can load the colorblind skin without errors.""" + # First Party + from edgewalker.core.theme_manager import ThemeManager + + tm = ThemeManager() + data = tm.load_theme("colorblind") + assert data.get("theme", {}).get("accent") is not None + + +def test_colorblind_flag_reloads_theme(): + """apply_colorblind_theme switches the active theme.""" + # First Party + from edgewalker import theme as t + from edgewalker.cli.cli import apply_colorblind_theme + + original_accent = t.ACCENT + # Test without persistence first + apply_colorblind_theme(persist=False) + # accent should now be the colorblind skin's value, not the periphery cyan + assert t.ACCENT != "#00FFFF" + + # restore + # First Party + from edgewalker.core.config import settings + + settings.theme = "periphery" + t.load_active_theme()