From 014ba94c3a330f38116694d63e0fb16c2b64160c Mon Sep 17 00:00:00 2001 From: Simon Morley Date: Fri, 6 Mar 2026 13:44:26 +0000 Subject: [PATCH 1/2] feat: add --colorblind flag with Okabe-Ito safe palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a colorblind.yaml skin using the Okabe-Ito (2008) palette — the scientific standard for colorblind accessibility, safe for deuteranopia, protanopia, and tritanopia. Sky blue replaces cyan, orange replaces yellow warnings, vermillion replaces red for danger, and all icons fall back to ASCII text forms ([OK], [X], [!]) so meaning is never conveyed by color alone. --colorblind hot-swaps the theme before the scan runs so all output including results, risk grades, and CVE badges renders in the safe palette. --- src/edgewalker/cli/cli.py | 15 ++++++++ src/edgewalker/skins/colorblind.yaml | 53 ++++++++++++++++++++++++++++ tests/test_cli.py | 14 ++++++++ tests/test_theme.py | 35 ++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 src/edgewalker/skins/colorblind.yaml diff --git a/src/edgewalker/cli/cli.py b/src/edgewalker/cli/cli.py index 3d55586..14e14bd 100644 --- a/src/edgewalker/cli/cli.py +++ b/src/edgewalker/cli/cli.py @@ -41,6 +41,15 @@ print_logo, ) + +def apply_colorblind_theme() -> None: + """Switch the active theme to the colorblind-safe skin.""" + # First Party + from edgewalker import theme as _theme + from edgewalker.core.config import settings + settings.theme = "colorblind" + _theme.load_active_theme() + # ============================================================================ # TYPER APP SETUP # ============================================================================ @@ -174,6 +183,9 @@ def run_guided_scan( verbose: bool = typer.Option( False, "--verbose", help="Print detailed nmap progress and discovered hosts/ports." ), + colorblind: bool = typer.Option( + False, "--colorblind", help="Use colorblind-safe palette (Okabe-Ito) instead of default theme." + ), ) -> None: """Run a guided security scan. @@ -220,6 +232,9 @@ def run_guided_scan( ) raise typer.Exit() + if colorblind: + apply_colorblind_theme() + ensure_telemetry_choice() controller = ScanController() guided = GuidedScanner(controller) 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 0cb2368..75aa7c8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -476,6 +476,20 @@ 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.""" + from edgewalker import theme as t + original_accent = t.ACCENT + result = runner.invoke(app, ["scan", "--colorblind", "--target", "1.1.1.1"]) + assert result.exit_code == 0 + # restore default theme so other tests aren't affected + 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..97bfc20 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -32,3 +32,38 @@ 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.""" + from pathlib import Path + skin_path = Path(__file__).parent.parent / "src/edgewalker/skins/colorblind.yaml" + assert skin_path.exists(), "colorblind.yaml skin file missing" + + 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.""" + 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(): + """Passing colorblind=True to apply_colorblind_theme switches the active theme.""" + from edgewalker import theme as t + from edgewalker.cli.cli import apply_colorblind_theme + original_accent = t.ACCENT + apply_colorblind_theme() + # accent should now be the colorblind skin's value, not the periphery cyan + assert t.ACCENT != "#00FFFF" + # restore + from edgewalker.core.config import settings + settings.theme = "periphery" + t.load_active_theme() From a1fcb53b5fc6bac23918fd8217d745db557d4579 Mon Sep 17 00:00:00 2001 From: Steven Marks Date: Mon, 9 Mar 2026 16:32:48 +0000 Subject: [PATCH 2/2] feat: colorblind mode updates config theme this avoids a user having to run --colorblind every time they use the tool --- docs/configuration.md | 1 + src/edgewalker/cli/cli.py | 65 +++++++++++++++----------------- src/edgewalker/core/telemetry.py | 4 +- src/edgewalker/main.py | 5 +-- tests/test_cli.py | 7 +++- tests/test_theme.py | 16 +++++++- 6 files changed, 54 insertions(+), 44 deletions(-) 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 e751d29..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 @@ -42,14 +43,20 @@ ) -def apply_colorblind_theme() -> None: - """Switch the active theme to the colorblind-safe skin.""" - # First Party - from edgewalker import theme as _theme - from edgewalker.core.config import settings - settings.theme = "colorblind" +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 # ============================================================================ @@ -129,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}]" ) @@ -185,9 +190,6 @@ def run_guided_scan( verbose: bool = typer.Option( False, "--verbose", help="Print detailed nmap progress and discovered hosts/ports." ), - colorblind: bool = typer.Option( - False, "--colorblind", help="Use colorblind-safe palette (Okabe-Ito) instead of default theme." - ), ) -> None: """Run a guided security scan. @@ -241,9 +243,6 @@ def run_guided_scan( f"despite security warnings.[/{theme.WARNING}]" ) - if colorblind: - apply_colorblind_theme() - ensure_telemetry_choice() controller = ScanController() guided = GuidedScanner(controller) @@ -326,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 = ( @@ -340,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(): @@ -369,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 = [ @@ -438,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 @@ -449,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/tests/test_cli.py b/tests/test_cli.py index 393f3c0..b4538ee 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -482,12 +482,17 @@ def test_prompt_next_scan_suggest_cve(mock_run, mock_input, mock_status): @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 - result = runner.invoke(app, ["scan", "--colorblind", "--target", "1.1.1.1"]) + # 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() diff --git a/tests/test_theme.py b/tests/test_theme.py index 97bfc20..7adc7ca 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -36,11 +36,15 @@ def test_icons(): 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"): @@ -49,21 +53,29 @@ def test_colorblind_skin_exists_and_has_required_keys(): 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(): - """Passing colorblind=True to apply_colorblind_theme switches the active 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 - apply_colorblind_theme() + # 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()