From 05e71780c5e9f73bb1703fe2fc540669ba020044 Mon Sep 17 00:00:00 2001 From: Cognis Digital <215970675+cognis-digital@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:25:15 +0000 Subject: [PATCH 1/3] Repo hardening: install instructions, dead imports, hygiene - fix 2 broken `pip install` line(s) in README (package is not on PyPI; use the working git+https install) - remove 5 unused import(s) (ruff F401/F811) --- README.md | 4 ++-- integrations/webhook.py | 2 +- keyhunt/core.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 53256fb..da167ed 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ```bash -pip install cognis-keyhunt +pip install "git+https://github.com/cognis-digital/keyhunt.git" keyhunt scan . # → prioritized findings in seconds ``` @@ -49,7 +49,7 @@ Instant gratification — point at any router firmware and get 'hardcoded root S ## 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/integrations/webhook.py b/integrations/webhook.py index 91e0211..9bf7258 100644 --- a/integrations/webhook.py +++ b/integrations/webhook.py @@ -5,7 +5,7 @@ Usage: scan . --format json | python integrations/webhook.py --url URL """ from __future__ import annotations -import argparse, json, sys, urllib.request +import argparse, sys, urllib.request def main() -> int: ap = argparse.ArgumentParser() diff --git a/keyhunt/core.py b/keyhunt/core.py index 94d59d5..a02ea95 100644 --- a/keyhunt/core.py +++ b/keyhunt/core.py @@ -18,8 +18,8 @@ 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 # --------------------------------------------------------------------------- # Detector definitions From 2c229da65ec3c92a3642ca0feeaa2797c7ab8886 Mon Sep 17 00:00:00 2001 From: Cognis Digital Date: Sat, 13 Jun 2026 04:14:22 -0400 Subject: [PATCH 2/3] Fix hardcoded-password and telnet-default-cred detectors; add plain-language overview and install instructions - keyhunt/core.py: extend hardcoded-password regex to also match admin_password and root_password field names (underscore-separated compound names were blocked by the lookbehind exclusion class) - demos/01-basic/firmware_dump.txt: update telnet line from "telnetd -l /bin/sh" (shell path, not a credential) to "busybox telnetd -l admin" so the telnet-default-cred detector fires as the demo scenario intends - README.md: add "What is this?" plain-language section and comprehensive Install section - install.sh / install.ps1: cross-platform install scripts (pipx/uv/pip) - layman.md: non-technical description of the tool --- README.md | 42 ++++++++++++++++++++++++++++++ demos/01-basic/firmware_dump.txt | 2 +- install.ps1 | 29 +++++++++++++++++++++ install.sh | 44 ++++++++++++++++++++++++-------- keyhunt/core.py | 2 +- layman.md | 1 + 6 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 install.ps1 create mode 100644 layman.md diff --git a/README.md b/README.md index da167ed..6f35354 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ 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,6 +52,42 @@ Instant gratification — point at any router firmware and get 'hardcoded root S
↑ back to top
+ +## 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 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/keyhunt/core.py b/keyhunt/core.py index a02ea95..ab9c72b 100644 --- a/keyhunt/core.py +++ b/keyhunt/core.py @@ -128,7 +128,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, 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. From 3f78f21eddbed906197fdab81c1f0e09c1b5661a Mon Sep 17 00:00:00 2001 From: Cognis Digital Date: Sun, 14 Jun 2026 20:02:51 -0400 Subject: [PATCH 3/3] harden: input validation, error handling, and edge-case tests - core.py: add TOOL_NAME/TOOL_VERSION constants so the module is self-contained; add scan() and to_json() public aliases used by mcp_server and external callers; scan_path now raises FileNotFoundError on a missing root instead of silently returning []. - cli.py: remove the triple-redundant fmt resolution; wrap parse_args so a SystemExit propagates the correct code; route the no-subcommand help to stderr; narrow the except clause to OSError/FileNotFoundError with a last-resort catch-all. - mcp_server.py: fix broken imports (scan/to_json now exist in core); guard keyhunt_scan against FileNotFoundError; fix E501 docstring. - integrations/webhook.py: split multi-import line (ruff E401). - tests/test_hardening.py: 18 new tests covering empty bytes, empty dir, missing root, unreadable file, entropy edge cases, to_dict redact flag, to_json, and CLI error paths. --- integrations/webhook.py | 4 +- keyhunt/cli.py | 20 ++-- keyhunt/core.py | 33 ++++++- keyhunt/mcp_server.py | 53 ++++++----- tests/test_hardening.py | 202 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 280 insertions(+), 32 deletions(-) create mode 100644 tests/test_hardening.py diff --git a/integrations/webhook.py b/integrations/webhook.py index 9bf7258..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, 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 ab9c72b..1a47685 100644 --- a/keyhunt/core.py +++ b/keyhunt/core.py @@ -15,12 +15,16 @@ """ from __future__ import annotations +import json import math import os import re from dataclasses import dataclass, asdict from typing import Iterator, List, Optional +TOOL_NAME = "keyhunt" +TOOL_VERSION = "0.1.0" + # --------------------------------------------------------------------------- # Detector definitions # --------------------------------------------------------------------------- @@ -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/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"]))