Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
56 changes: 33 additions & 23 deletions src/edgewalker/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

# Standard Library
import asyncio
import contextlib
import importlib.metadata
import platform
import shutil
Expand All @@ -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
Expand All @@ -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
# ============================================================================
Expand Down Expand Up @@ -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}]"
)
Expand Down Expand Up @@ -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 = (
Expand All @@ -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():
Expand All @@ -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 = [
Expand Down Expand Up @@ -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
Expand All @@ -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."""
Expand Down
4 changes: 1 addition & 3 deletions src/edgewalker/core/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
5 changes: 2 additions & 3 deletions src/edgewalker/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

# Standard Library
import contextlib
import sys

# First Party
Expand All @@ -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()

Expand Down
53 changes: 53 additions & 0 deletions src/edgewalker/skins/colorblind.yaml
Original file line number Diff line number Diff line change
@@ -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: "[!]"
19 changes: 19 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
47 changes: 47 additions & 0 deletions tests/test_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()