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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ edgewalker cve # Check for known CVEs
edgewalker report # View security report
```

### CI/CD & Automation
EdgeWalker supports non-interactive execution for automated environments:
```bash
# Run a silent scan with explicit telemetry opt-in
edgewalker --silent --accept-telemetry scan --target 192.168.1.0/24
```
See the [Configuration Guide](docs/configuration.md#non-interactive-silent-mode) for more details.

---

## The Periphery Mission
Expand Down
24 changes: 24 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,34 @@ Environment variables prefixed with `EW_` override all settings. `edgewalker/con
| `EW_THEME` | `periphery` | Active UI theme slug |
| `EW_IOT_PORTS` | `[21, 22, ...]` | Common IoT ports for quick scan |
| `EW_TELEMETRY_ENABLED` | `None` | User opt-in status for anonymous data sharing |
| `EW_SILENT_MODE` | `False` | Run in non-interactive mode (bypass prompts) |
| `EW_SUPPRESS_WARNINGS` | `False` | Suppress configuration and security warnings in the console |
| `EW_CONFIG_DIR` | `~/.config/edgewalker` | Configuration directory override |
| `EW_CACHE_DIR` | `~/.cache/edgewalker` | Cache directory override |
| `EW_DEMO_MODE` | `0` | Set to `1` to enable demo mode with mock data |

## Non-Interactive (Silent) Mode

For CI/CD pipelines and automated environments, EdgeWalker provides a non-interactive mode that bypasses all user prompts.

### Global Flags

These flags can be used with any command:

- `--silent` or `-s`: Enables non-interactive mode.
- `--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).

### CI/CD Usage

When running in a fresh environment (like a GitHub Action), you must provide a telemetry choice if you use `--silent`. If no choice is provided, the CLI will exit with an error to ensure an explicit decision is made.

```bash
# Run a scan in CI/CD without any prompts
edgewalker --silent --suppress-warnings --accept-telemetry scan --target 192.168.1.0/24
```

## Security Validation

EdgeWalker enforces security best practices for its configuration:
Expand Down
10 changes: 10 additions & 0 deletions docs/data-privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ The findings feed back into improving EdgeWalker's credential database and infor
### How to Opt Out

- **During first run**: Select "No thanks" when prompted.
- **In Silent Mode**: Use the `--decline-telemetry` flag.
- **After opting in**: Opt out via the TUI settings menu or by deleting the configuration file:
```bash
# On macOS:
Expand All @@ -65,6 +66,15 @@ The findings feed back into improving EdgeWalker's credential database and infor
rm ~/.config/edgewalker/config.yaml
```

### Non-Interactive (Silent) Mode Telemetry

When running EdgeWalker in automated environments (CI/CD) using the `--silent` flag, the tool requires an explicit telemetry choice if one has not been previously set. This ensures that data sharing is never enabled by default without a conscious decision.

- Use `--accept-telemetry` to opt-in.
- Use `--decline-telemetry` to opt-out.

If neither flag is provided in silent mode on a fresh installation, EdgeWalker will exit with an error.

### Server-Side Security

The data collection API features hardening:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,4 @@ color = true

[tool.pytest.ini_options]
pythonpath = ["src"]
addopts = "--cov=src --cov-report=term-missing --cov-fail-under=85"
addopts = "--cov=src --cov-report=term-missing --cov-fail-under=90"
54 changes: 46 additions & 8 deletions src/edgewalker/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ def run_guided_scan(
overrides = get_active_overrides()

if (security_warnings or overrides) and not allow_override:
if security_warnings:
if security_warnings and not settings.suppress_warnings:
console.print(
f"\n[bold {theme.RISK_CRITICAL}]SECURITY WARNING: "
f"Non-standard or insecure API endpoints detected![/bold {theme.RISK_CRITICAL}]"
Expand All @@ -193,7 +193,7 @@ def run_guided_scan(
"sensitive data like API keys.[/dim]"
)

if overrides:
if overrides and not settings.suppress_warnings:
sources = ", ".join(sorted(set(overrides.values())))
console.print(
f"\n[bold {theme.WARNING}]CONFIGURATION OVERRIDES ACTIVE "
Expand All @@ -205,14 +205,21 @@ def run_guided_scan(
"\n[dim]These settings will take precedence over your config.yaml file.[/dim]"
)

console.print("")
confirm = typer.confirm("Do you want to proceed with the scan using these settings?")
if not confirm:
if not settings.silent_mode:
console.print("")
confirm = typer.confirm("Do you want to proceed with the scan using these settings?")
if not confirm:
console.print(
"\n[dim]Scan cancelled. Use [bold]--allow-override[/bold] or "
"[bold]-ao[/bold] to bypass this check.[/dim]"
)
raise typer.Exit()
elif (security_warnings or overrides) and not settings.suppress_warnings:
console.print("")
console.print(
"\n[dim]Scan cancelled. Use [bold]--allow-override[/bold] or "
"[bold]-ao[/bold] to bypass this check.[/dim]"
f"[{theme.WARNING}]Silent mode active: proceeding with scan "
f"despite security warnings.[/{theme.WARNING}]"
)
raise typer.Exit()

ensure_telemetry_choice()
controller = ScanController()
Expand Down Expand Up @@ -371,8 +378,39 @@ def main(
log_file: Optional[str] = typer.Option(
None, "--log-file", help="Path to write logs to a file."
),
silent: bool = typer.Option(
False,
"--silent",
"-s",
help="Run in non-interactive mode (bypass prompts).",
),
suppress_warnings: bool = typer.Option(
False,
"--suppress-warnings",
help="Suppress configuration and security warnings in the console.",
),
accept_telemetry: bool = typer.Option(
False,
"--accept-telemetry",
help="Explicitly opt-in to telemetry (used in silent mode).",
),
decline_telemetry: bool = typer.Option(
False,
"--decline-telemetry",
help="Explicitly opt-out of telemetry (used in silent mode).",
),
) -> None:
"""EdgeWalker - IoT Home Network Security Scanner."""
# Update settings with global flags
if silent:
update_setting("silent_mode", True)
if suppress_warnings:
update_setting("suppress_warnings", True)
if accept_telemetry:
update_setting("accept_telemetry", True)
if decline_telemetry:
update_setting("decline_telemetry", True)

# Configure logging using the Typer options
setup_logging(verbosity=verbose, log_file=log_file)

Expand Down
30 changes: 26 additions & 4 deletions src/edgewalker/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,28 @@ def handle_demo_mode(cls, v: Path) -> Path:
description="User opt-in status for anonymous data sharing",
)

silent_mode: bool = Field(
default=False,
description="Run in non-interactive mode (bypass prompts)",
)

suppress_warnings: bool = Field(
default=False,
description="Suppress configuration and security warnings in the console",
)

accept_telemetry: bool = Field(
default=False,
description="Explicitly opt-in to telemetry (used in silent mode)",
exclude=True,
)

decline_telemetry: bool = Field(
default=False,
description="Explicitly opt-out of telemetry (used in silent mode)",
exclude=True,
)

theme: str = Field(
default="periphery",
description="Active theme slug",
Expand All @@ -319,8 +341,8 @@ def get_security_warnings(self) -> list[str]:
Returns:
A list of warning messages.
"""
# Skip security warnings during tests to avoid confirmation prompts
if os.environ.get("PYTEST_CURRENT_TEST"):
# Skip security warnings during tests or if suppressed
if os.environ.get("PYTEST_CURRENT_TEST") or self.suppress_warnings:
return []

warnings = []
Expand Down Expand Up @@ -417,7 +439,7 @@ def get_active_overrides() -> dict[str, str]:
('environment variable' or '.env file').
"""
# Skip overrides during tests to ensure consistent behavior
if os.environ.get("PYTEST_CURRENT_TEST"):
if os.environ.get("PYTEST_CURRENT_TEST") and not os.environ.get("EW_ALLOW_OVERRIDES_IN_TESTS"):
return {}

overrides = {}
Expand All @@ -438,7 +460,7 @@ def get_active_overrides() -> dict[str, str]:

# Check environment variables (higher precedence)
for key in os.environ:
if key.startswith("EW_"):
if key.startswith("EW_") and key != "EW_ALLOW_OVERRIDES_IN_TESTS":
overrides[key] = "environment variable"

return overrides
Expand Down
55 changes: 15 additions & 40 deletions src/edgewalker/modules/cve_scan/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

# Third Party
import httpx
from loguru import logger

# First Party
from edgewalker import __version__, utils
Expand Down Expand Up @@ -46,25 +47,32 @@ async def search_cves_async(

async with semaphore:
try:
if verbose:
# Use logger or print only if not using rich progress
pass

logger.debug(f"Searching NVD for: {params['keywordSearch']}")
response = await client.get(
settings.nvd_api_url, params=params, headers=headers, timeout=30
)
logger.debug(f"NVD Response: {response.status_code}")

if response.status_code == 403:
# Rate limit hit, wait and retry once
logger.warning(
f"NVD Rate limit hit (403). Waiting {settings.nvd_rate_limit_delay * 2}s..."
)
await asyncio.sleep(settings.nvd_rate_limit_delay * 2)
response = await client.get(
settings.nvd_api_url, params=params, headers=headers, timeout=30
)
logger.debug(f"NVD Retry Response: {response.status_code}")

if response.status_code != 200:
logger.error(f"NVD API error: {response.status_code} - {response.text[:200]}")
return []

data = response.json()
vulnerabilities = data.get("vulnerabilities", [])
logger.debug(
f"Found {len(vulnerabilities)} vulnerabilities for {params['keywordSearch']}"
)

cves = []
for vuln in vulnerabilities:
Expand Down Expand Up @@ -94,7 +102,8 @@ async def search_cves_async(
"score": base_score,
})
return cves
except Exception:
except Exception as e:
logger.error(f"Error searching CVEs for {product}: {e}")
return []


Expand Down Expand Up @@ -224,6 +233,7 @@ async def _scan_service(
rich_progress: Optional[tuple[utils.Progress, utils.TaskID]] = None,
) -> CveScanResultModel:
"""Scan a single service for CVEs asynchronously."""
logger.debug(f"Checking {svc['product']} {svc['version']} on {svc['ip']}:{svc['port']}")
if self.progress_callback:
self.progress_callback(
"cve_check",
Expand Down Expand Up @@ -266,41 +276,6 @@ async def _scan_service(
version=svc["version"],
cves=cves,
)
"""Scan a single service for CVEs asynchronously."""
if self.progress_callback:
self.progress_callback(
"cve_check",
f"Checking {svc['product']} {svc['version']} on {svc['ip']}:{svc['port']}",
)

cve_dicts = await search_cves_async(
client, svc["product"], svc["version"], self.verbose, semaphore
)

cves = [
CveModel(
id=c["id"],
description=c.get("description", ""),
severity=c["severity"],
score=c["score"],
)
for c in cve_dicts
]

if cves and self.progress_callback:
self.progress_callback(
"cve_found",
f"{len(cves)} CVE(s) found for {svc['product']} {svc['version']}",
)

return CveScanResultModel(
ip=svc["ip"],
port=svc["port"],
service=svc["service"],
product=svc["product"],
version=svc["version"],
cves=cves,
)

def _build_empty_model(self, skipped_no_version: int) -> CveScanModel:
return CveScanModel(
Expand Down
13 changes: 11 additions & 2 deletions src/edgewalker/modules/mac_lookup/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

# Third Party
import httpx
from loguru import logger

# First Party
from edgewalker.core.config import settings
Expand Down Expand Up @@ -66,18 +67,22 @@ def _lookup_mac_api(mac: str) -> dict | None:
params["apiKey"] = settings.mac_api_key

try:
logger.debug(f"Looking up MAC: {mac} via API")
with httpx.Client() as client:
resp = client.get(
f"{settings.mac_api_url}/{mac}",
params=params,
timeout=settings.api_timeout,
)
except Exception:
logger.debug(f"MAC API Response: {resp.status_code}")
except Exception as e:
logger.error(f"MAC API request failed for {mac}: {e}")
return None

if resp.status_code == 429:
# Rate limited - respect Retry-After header
retry_after = float(resp.headers.get("Retry-After", "1"))
logger.warning(f"MAC API Rate limit hit (429). Retrying after {retry_after}s...")
time.sleep(retry_after)
try:
with httpx.Client() as client:
Expand All @@ -86,12 +91,15 @@ def _lookup_mac_api(mac: str) -> dict | None:
params=params,
timeout=settings.api_timeout,
)
except Exception:
logger.debug(f"MAC API Retry Response: {resp.status_code}")
except Exception as e:
logger.error(f"MAC API retry failed for {mac}: {e}")
return None

if resp.status_code == 200:
return resp.json()

logger.error(f"MAC API error: {resp.status_code} - {resp.text[:200]}")
return None


Expand Down Expand Up @@ -124,6 +132,7 @@ def _get_csv_vendors() -> dict:

def _csv_fallback_vendor(normalized: str) -> str:
"""Look up vendor from local CSV fallback."""
logger.debug(f"Falling back to local CSV for MAC: {normalized}")
vendors = _get_csv_vendors()

if len(normalized) < 6:
Expand Down
Loading