diff --git a/README.md b/README.md
index 53256fb..6f35354 100644
--- a/README.md
+++ b/README.md
@@ -16,10 +16,16 @@
```bash
-pip install cognis-keyhunt
+pip install "git+https://github.com/cognis-digital/keyhunt.git"
keyhunt scan . # → prioritized findings in seconds
```
+
+## What is this?
+
+keyhunt scans firmware images, router configuration files, and filesystem dumps for secrets that should never have been shipped — things like hardcoded passwords, private encryption keys, embedded API tokens, and default login credentials. Point it at a file or folder and it returns a prioritized list of findings in seconds, ready for your terminal, a CI pipeline, or an AI agent. It is aimed at security researchers, IoT/embedded developers, and product teams who need to catch credential leaks before a device ships or after one shows up in the wild.
+
+
## Contents
- [Why keyhunt?](#why) · [Features](#features) · [Quick start](#quick-start) · [Example](#example) · [Architecture](#architecture) · [AI stack](#ai-stack) · [How it compares](#how-it-compares) · [Integrations](#integrations) · [Install anywhere](#install-anywhere) · [Related](#related) · [Contributing](#contributing)
@@ -46,10 +52,46 @@ Instant gratification — point at any router firmware and get 'hardcoded root S
+
+## Install
+
+`keyhunt` is source-available (not published to PyPI) — every method below installs
+straight from GitHub. Pick whichever you prefer; the one-line scripts auto-detect
+the best tool available on your machine.
+
+**One-liner (Linux / macOS):**
+```sh
+curl -fsSL https://raw.githubusercontent.com/cognis-digital/keyhunt/HEAD/install.sh | sh
+```
+
+**One-liner (Windows PowerShell):**
+```powershell
+irm https://raw.githubusercontent.com/cognis-digital/keyhunt/HEAD/install.ps1 | iex
+```
+
+**Or install manually — any one of:**
+```sh
+pipx install "git+https://github.com/cognis-digital/keyhunt.git" # isolated (recommended)
+uv tool install "git+https://github.com/cognis-digital/keyhunt.git" # uv
+pip install "git+https://github.com/cognis-digital/keyhunt.git" # pip
+```
+
+**From source:**
+```sh
+git clone https://github.com/cognis-digital/keyhunt.git
+cd keyhunt && pip install .
+```
+
+Then run:
+```sh
+keyhunt --help
+```
+
+
## Quick start
```bash
-pip install cognis-keyhunt
+pip install "git+https://github.com/cognis-digital/keyhunt.git"
keyhunt --version
keyhunt scan . # scan current project
keyhunt scan . --format json # machine-readable
diff --git a/demos/01-basic/firmware_dump.txt b/demos/01-basic/firmware_dump.txt
index 79161e2..859f041 100644
--- a/demos/01-basic/firmware_dump.txt
+++ b/demos/01-basic/firmware_dump.txt
@@ -1,6 +1,6 @@
# ---- extracted from /etc/init.d/rcS (busybox) ----
# default management login left in the image
-telnetd -l /bin/sh -b 0.0.0.0 admin
+busybox telnetd -l admin -p 23 -b 0.0.0.0
# ---- /etc/config/httpd.conf ----
admin_user=admin
diff --git a/install.ps1 b/install.ps1
new file mode 100644
index 0000000..b6d00f8
--- /dev/null
+++ b/install.ps1
@@ -0,0 +1,29 @@
+# Comprehensive installer for cognis-digital/keyhunt (Windows PowerShell).
+# Tries: pipx -> uv -> pip (git+https) -> from source.
+# keyhunt is source-available and not on PyPI; all paths install from GitHub.
+$ErrorActionPreference = "Stop"
+$Repo = "keyhunt"
+$Url = "git+https://github.com/cognis-digital/keyhunt.git"
+$Git = "https://github.com/cognis-digital/keyhunt.git"
+function Say($m) { Write-Host "[$Repo] $m" -ForegroundColor Magenta }
+function Have($c) { [bool](Get-Command $c -ErrorAction SilentlyContinue) }
+
+if (-not (Have python) -and -not (Have py)) {
+ Say "Python 3.9+ is required but was not found. Install Python first."; exit 1
+}
+if (Have pipx) {
+ Say "Installing with pipx (isolated, recommended)..."
+ pipx install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: keyhunt"; exit 0 }
+}
+if (Have uv) {
+ Say "Installing with uv..."
+ uv tool install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: keyhunt"; exit 0 }
+}
+if (Have pip) {
+ Say "Installing with pip (user site)..."
+ pip install --user $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: keyhunt"; exit 0 }
+}
+Say "No packaging tool worked; falling back to a source clone."
+$Tmp = Join-Path $env:TEMP "$Repo-src"
+git clone --depth 1 $Git $Tmp
+Say "Cloned to $Tmp - run: cd $Tmp; python -m pip install ."
diff --git a/install.sh b/install.sh
index a5f775d..38b19f1 100644
--- a/install.sh
+++ b/install.sh
@@ -1,10 +1,34 @@
-#!/usr/bin/env sh
-# Universal installer for keyhunt. Prefers uv > pipx > pip; installs from the repo.
-set -e
-SRC="git+https://github.com/cognis-digital/keyhunt.git"
-echo "Installing keyhunt ..."
-if command -v uv >/dev/null 2>&1; then uv tool install "$SRC"
-elif command -v pipx >/dev/null 2>&1; then pipx install "$SRC"
-elif command -v python3 >/dev/null 2>&1; then python3 -m pip install --user "$SRC"
-else echo "Need uv, pipx, or python3+pip"; exit 1; fi
-echo "Done. Run: keyhunt --help"
+#!/usr/bin/env sh
+# Comprehensive installer for cognis-digital/keyhunt (Linux / macOS).
+# Tries the best available method: pipx -> uv -> pip (git+https) -> from source.
+# keyhunt is source-available and not on PyPI; all paths install from GitHub.
+set -eu
+
+REPO="keyhunt"
+URL="git+https://github.com/cognis-digital/keyhunt.git"
+GITURL="https://github.com/cognis-digital/keyhunt.git"
+
+say() { printf '\033[1;35m[%s]\033[0m %s\n' "$REPO" "$1"; }
+have() { command -v "$1" >/dev/null 2>&1; }
+
+if ! have python3 && ! have python; then
+ say "Python 3.9+ is required but was not found. Install Python first."; exit 1
+fi
+
+if have pipx; then
+ say "Installing with pipx (isolated, recommended)..."
+ pipx install "$URL" && { say "Done. Run: keyhunt"; exit 0; }
+fi
+if have uv; then
+ say "Installing with uv..."
+ uv tool install "$URL" && { say "Done. Run: keyhunt"; exit 0; }
+fi
+if have pip3 || have pip; then
+ PIP="$(command -v pip3 || command -v pip)"
+ say "Installing with pip (user site)..."
+ "$PIP" install --user "$URL" && { say "Done. Run: keyhunt"; exit 0; }
+fi
+
+say "No packaging tool worked; falling back to a source clone."
+TMP="$(mktemp -d)"; git clone --depth 1 "$GITURL" "$TMP/$REPO"
+say "Cloned to $TMP/$REPO — run: cd $TMP/$REPO && python3 -m pip install ."
diff --git a/integrations/webhook.py b/integrations/webhook.py
index 91e0211..82a59f6 100644
--- a/integrations/webhook.py
+++ b/integrations/webhook.py
@@ -5,7 +5,9 @@
Usage: scan . --format json | python integrations/webhook.py --url URL
"""
from __future__ import annotations
-import argparse, json, sys, urllib.request
+import argparse
+import sys
+import urllib.request
def main() -> int:
ap = argparse.ArgumentParser()
diff --git a/keyhunt/cli.py b/keyhunt/cli.py
index a5820b8..5357c90 100644
--- a/keyhunt/cli.py
+++ b/keyhunt/cli.py
@@ -115,17 +115,18 @@ def _render_json(findings: List[Finding], show_secrets: bool) -> str:
def main(argv: Optional[List[str]] = None) -> int:
parser = _build_parser()
- args = parser.parse_args(argv)
+ try:
+ args = parser.parse_args(argv)
+ except SystemExit as exc:
+ # argparse already printed usage/error; map to exit code 2
+ return int(exc.code) if exc.code is not None else 2
if not args.command:
- parser.print_help()
+ parser.print_help(sys.stderr)
return 2
if args.command == "scan":
- fmt = args.format or args.format if args.format else None
- # subcommand --format overrides global; fall back to global default
- fmt = args.format if getattr(args, "format", None) else "table"
- # argparse stores global --format on the same attribute name; resolve:
+ # subcommand --format overrides global; fall back to "table"
fmt = args.format or "table"
if not os.path.exists(args.path):
@@ -134,9 +135,12 @@ def main(argv: Optional[List[str]] = None) -> int:
try:
findings = scan_path(args.path)
- except Exception as exc: # pragma: no cover - defensive
+ except (OSError, FileNotFoundError) as exc:
print(f"{TOOL_NAME}: error: {exc}", file=sys.stderr)
return 2
+ except Exception as exc: # pragma: no cover - last-resort safety net
+ print(f"{TOOL_NAME}: unexpected error: {exc}", file=sys.stderr)
+ return 2
findings = _filter_severity(findings, args.severity)
@@ -147,7 +151,7 @@ def main(argv: Optional[List[str]] = None) -> int:
return 1 if findings else 0
- parser.print_help()
+ parser.print_help(sys.stderr)
return 2
diff --git a/keyhunt/core.py b/keyhunt/core.py
index 94d59d5..1a47685 100644
--- a/keyhunt/core.py
+++ b/keyhunt/core.py
@@ -15,11 +15,15 @@
"""
from __future__ import annotations
+import json
import math
import os
import re
-from dataclasses import dataclass, field, asdict
-from typing import Iterable, Iterator, List, Optional
+from dataclasses import dataclass, asdict
+from typing import Iterator, List, Optional
+
+TOOL_NAME = "keyhunt"
+TOOL_VERSION = "0.1.0"
# ---------------------------------------------------------------------------
# Detector definitions
@@ -128,7 +132,7 @@ def _shannon_entropy(s: str) -> float:
description="Hardcoded password assignment",
severity="high",
regex=re.compile(
- r"(?i)(?:^|[^A-Za-z0-9_])(?:passwd|password|pwd|admin_pass|root_pass)"
+ r"(?i)(?:^|[^A-Za-z0-9_])(?:passwd|password|pwd|admin_pass|admin_password|root_pass|root_password)"
r"\s*[:=]\s*['\"]([^'\"\n]{3,64})['\"]"
),
secret_group=1,
@@ -303,7 +307,13 @@ def scan_path(
skip_ext: Optional[set] = None,
max_bytes: int = DEFAULT_MAX_BYTES,
) -> List[Finding]:
- """Scan a file or directory tree and return all findings."""
+ """Scan a file or directory tree and return all findings.
+
+ Raises FileNotFoundError if *root* does not exist.
+ Returns an empty list if the tree contains no scannable files.
+ """
+ if not os.path.exists(root):
+ raise FileNotFoundError(f"path not found: {root}")
findings: List[Finding] = []
for path in iter_files(root, skip_ext=skip_ext, max_bytes=max_bytes):
findings.extend(scan_file(path, max_bytes=max_bytes))
@@ -311,3 +321,24 @@ def scan_path(
sev_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3}
findings.sort(key=lambda f: (sev_rank.get(f.severity, 9), f.path, f.line))
return findings
+
+
+# ---------------------------------------------------------------------------
+# Convenience aliases (used by mcp_server and external callers)
+# ---------------------------------------------------------------------------
+
+
+def scan(target: str, **kwargs) -> List[Finding]:
+ """Alias for scan_path; raises FileNotFoundError on missing target."""
+ return scan_path(target, **kwargs)
+
+
+def to_json(findings: List[Finding], *, redact: bool = True) -> str:
+ """Serialise a list of Finding objects to a JSON string."""
+ payload = {
+ "tool": TOOL_NAME,
+ "version": TOOL_VERSION,
+ "count": len(findings),
+ "findings": [f.to_dict(redact=redact) for f in findings],
+ }
+ return json.dumps(payload, indent=2)
diff --git a/keyhunt/mcp_server.py b/keyhunt/mcp_server.py
index 3739938..dead43b 100644
--- a/keyhunt/mcp_server.py
+++ b/keyhunt/mcp_server.py
@@ -1,22 +1,31 @@
-"""KEYHUNT MCP server — exposes scan() as an MCP tool for Cognis.Studio."""
-from __future__ import annotations
-from keyhunt.core import scan, to_json
-
-def serve() -> int:
- """Start an MCP stdio server. Requires the optional 'mcp' extra:
- pip install "cognis-keyhunt[mcp]"
- """
- try:
- from mcp.server.fastmcp import FastMCP
- except Exception:
- print("Install the MCP extra: pip install 'cognis-keyhunt[mcp]'")
- return 1
- app = FastMCP("keyhunt")
-
- @app.tool()
- def keyhunt_scan(target: str) -> str:
- """Scan firmware blobs and filesystem dumps for hardcoded private keys, API tokens, default creds, and weak RSA/ECC material.. Returns JSON findings."""
- return to_json(scan(target))
-
- app.run()
- return 0
+"""KEYHUNT MCP server — exposes scan() as an MCP tool for Cognis.Studio."""
+from __future__ import annotations
+
+from keyhunt.core import scan, to_json
+
+
+def serve() -> int:
+ """Start an MCP stdio server. Requires the optional 'mcp' extra:
+ pip install "cognis-keyhunt[mcp]"
+ """
+ try:
+ from mcp.server.fastmcp import FastMCP
+ except Exception:
+ print("Install the MCP extra: pip install 'cognis-keyhunt[mcp]'")
+ return 1
+ app = FastMCP("keyhunt")
+
+ @app.tool()
+ def keyhunt_scan(target: str) -> str:
+ """Scan firmware blobs and filesystem dumps for hardcoded keys.
+
+ Detects private keys, API tokens, default creds, and weak RSA/ECC
+ material. Returns JSON findings.
+ """
+ try:
+ return to_json(scan(target))
+ except FileNotFoundError:
+ return to_json([]) # return empty findings on bad path
+
+ app.run()
+ return 0
diff --git a/layman.md b/layman.md
new file mode 100644
index 0000000..d828e00
--- /dev/null
+++ b/layman.md
@@ -0,0 +1 @@
+keyhunt scans firmware images, router configuration files, and filesystem dumps for secrets that should never have been shipped — things like hardcoded passwords, private encryption keys, embedded API tokens, and default login credentials. Point it at a file or folder and it returns a prioritized list of findings in seconds, ready for your terminal, a CI pipeline, or an AI agent. It is aimed at security researchers, IoT/embedded developers, and product teams who need to catch credential leaks before a device ships or after one shows up in the wild.
diff --git a/tests/test_hardening.py b/tests/test_hardening.py
new file mode 100644
index 0000000..bd042ad
--- /dev/null
+++ b/tests/test_hardening.py
@@ -0,0 +1,202 @@
+"""Hardening tests: edge cases, bad input, and error paths."""
+from __future__ import annotations
+
+import json
+import os
+import sys
+
+import pytest
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from keyhunt.core import ( # noqa: E402
+ TOOL_NAME,
+ TOOL_VERSION,
+ Finding,
+ scan,
+ scan_bytes,
+ scan_file,
+ scan_path,
+ to_json,
+ _shannon_entropy,
+)
+from keyhunt.cli import main # noqa: E402
+
+
+# ---------------------------------------------------------------------------
+# core.py — edge cases
+# ---------------------------------------------------------------------------
+
+
+def test_scan_bytes_empty():
+ """Empty input must return an empty list, not raise."""
+ assert scan_bytes(b"") == []
+
+
+def test_scan_bytes_binary_nul():
+ """Null bytes should not crash the decoder."""
+ assert scan_bytes(b"\x00\x01\x02\x03") == []
+
+
+def test_scan_path_missing_root():
+ """scan_path must raise FileNotFoundError on a non-existent path."""
+ with pytest.raises(FileNotFoundError):
+ scan_path("/no/such/path/keyhunt_xyz_missing")
+
+
+def test_scan_alias_missing_root():
+ """scan() alias must also raise FileNotFoundError on a non-existent path."""
+ with pytest.raises(FileNotFoundError):
+ scan("/no/such/path/keyhunt_xyz_missing")
+
+
+def test_scan_path_empty_directory(tmp_path):
+ """scan_path on an empty directory must return an empty list."""
+ empty_dir = tmp_path / "empty"
+ empty_dir.mkdir()
+ assert scan_path(str(empty_dir)) == []
+
+
+def test_scan_path_directory_with_skipped_extensions_only(tmp_path):
+ """A directory containing only image files returns no findings."""
+ img = tmp_path / "photo.jpg"
+ img.write_bytes(b"\xff\xd8\xff" + b"A" * 100)
+ findings = scan_path(str(tmp_path))
+ assert findings == []
+
+
+def test_scan_file_unreadable(tmp_path):
+ """scan_file on an unreadable path returns [] without raising."""
+ # Use a path that won't exist
+ result = scan_file(str(tmp_path / "nonexistent.txt"))
+ assert result == []
+
+
+def test_shannon_entropy_empty_string():
+ """_shannon_entropy on empty string must return 0.0 without ZeroDivisionError."""
+ assert _shannon_entropy("") == 0.0
+
+
+def test_shannon_entropy_single_char():
+ """Single repeated character has entropy 0.0."""
+ assert _shannon_entropy("aaaa") == 0.0
+
+
+def test_tool_constants():
+ """TOOL_NAME and TOOL_VERSION must be properly set in core."""
+ assert TOOL_NAME == "keyhunt"
+ assert TOOL_VERSION.count(".") == 2
+
+
+# ---------------------------------------------------------------------------
+# Finding.to_dict — redact flag
+# ---------------------------------------------------------------------------
+
+
+def test_to_dict_redact_false_includes_match():
+ """to_dict(redact=False) must include the 'match' field with the real secret."""
+ f = Finding(
+ detector="aws-access-key",
+ description="AWS access key id",
+ severity="critical",
+ path="test.txt",
+ line=1,
+ column=1,
+ match="AKIAIOSFODNN7EXAMPLE",
+ secret="AKIAIOSFODNN7EXAMPLE",
+ )
+ d = f.to_dict(redact=False)
+ assert d["secret"] == "AKIAIOSFODNN7EXAMPLE"
+ assert "match" in d
+
+
+def test_to_dict_redact_true_masks_secret():
+ """to_dict(redact=True) must mask the secret and omit 'match'."""
+ f = Finding(
+ detector="aws-access-key",
+ description="AWS access key id",
+ severity="critical",
+ path="test.txt",
+ line=1,
+ column=1,
+ match="AKIAIOSFODNN7EXAMPLE",
+ secret="AKIAIOSFODNN7EXAMPLE",
+ )
+ d = f.to_dict(redact=True)
+ assert d["secret"] != "AKIAIOSFODNN7EXAMPLE"
+ assert "*" in d["secret"]
+ assert "match" not in d
+
+
+# ---------------------------------------------------------------------------
+# to_json helper
+# ---------------------------------------------------------------------------
+
+
+def test_to_json_empty_findings():
+ """to_json on an empty list must produce valid JSON with count=0."""
+ result = to_json([])
+ data = json.loads(result)
+ assert data["count"] == 0
+ assert data["findings"] == []
+ assert data["tool"] == "keyhunt"
+
+
+def test_to_json_redact_default():
+ """to_json redacts secrets by default."""
+ f = Finding(
+ detector="aws-access-key",
+ description="AWS access key id",
+ severity="critical",
+ path="x.txt",
+ line=1,
+ column=1,
+ match="AKIAIOSFODNN7EXAMPLE",
+ secret="AKIAIOSFODNN7EXAMPLE",
+ )
+ data = json.loads(to_json([f]))
+ assert data["findings"][0]["secret"] != "AKIAIOSFODNN7EXAMPLE"
+
+
+# ---------------------------------------------------------------------------
+# cli.py — error paths
+# ---------------------------------------------------------------------------
+
+
+def test_cli_missing_path_returns_exit_two(capsys):
+ """CLI must return exit code 2 and print to stderr when path is missing."""
+ rc = main(["scan", "/absolutely/no/such/path/keyhunt_xyz"])
+ err = capsys.readouterr().err
+ assert rc == 2
+ assert "not found" in err
+
+
+def test_cli_no_subcommand_exits_two(capsys):
+ """Calling CLI with no subcommand must return exit code 2."""
+ rc = main([])
+ assert rc == 2
+
+
+def test_cli_severity_high_filters_lower(tmp_path, capsys):
+ """--severity high must exclude medium/low findings."""
+ # inject a medium finding
+ code = tmp_path / "config.py"
+ code.write_text('api_key = "SomeFakeKeyWith1234567890AbcXyz"\n')
+ main(["scan", str(tmp_path), "--format", "json", "--severity", "high"])
+ out = capsys.readouterr().out
+ data = json.loads(out)
+ for finding in data["findings"]:
+ assert finding["severity"] in ("critical", "high")
+
+
+def test_cli_json_empty_dir(tmp_path, capsys):
+ """Scanning an empty directory should return exit 0 with count=0 JSON."""
+ rc = main(["scan", str(tmp_path), "--format", "json"])
+ out = capsys.readouterr().out
+ assert rc == 0
+ data = json.loads(out)
+ assert data["count"] == 0
+
+
+if __name__ == "__main__":
+ sys.exit(pytest.main([__file__, "-v"]))