diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c16f09e..0156efb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Thank you for helping keep ReconScript safe, scoped, and respectful of consent. - Only contribute features that preserve read-only reconnaissance. Never add intrusive or destructive functionality. - All scans must remain single-target by default. CIDR support is opt-in via `ALLOW_CIDR=true`. -- Non-local targets require a signed scope manifest and consent verification. Never ship production keys; use the dev keys in `keys/` for local testing only. +- Non-local targets require a signed scope manifest and consent verification. Never ship production keys in the repository. - Evidence level defaults to `low`. `high` evidence must remain explicitly gated behind signed consent. - Do not expose the Flask UI publicly unless `ENABLE_PUBLIC_UI=true` **and** `ENABLE_RBAC=true` are set. The UI warns when public mode is active. diff --git a/Dockerfile b/Dockerfile index ca583e9..c1fd819 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,6 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app -ARG INCLUDE_DEV_KEYS=false - COPY requirements.txt ./ RUN python -m venv /opt/venv \ @@ -23,8 +21,6 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app -ARG INCLUDE_DEV_KEYS=false - RUN apt-get update \ && apt-get install --no-install-recommends -y \ libcairo2 \ @@ -42,8 +38,7 @@ RUN apt-get update \ COPY --from=builder /opt/venv /opt/venv COPY . . -RUN if [ "$INCLUDE_DEV_KEYS" != "true" ]; then rm -f keys/dev_*; fi \ - && chown -R reconscript:reconscript /app +RUN chown -R reconscript:reconscript /app USER reconscript diff --git a/README.md b/README.md index 85f8826..a94b70a 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,14 @@ python -m pip install -r requirements-dev.txt Before starting the Flask UI, generate deployment-specific secrets and point the application at them: ```bash -export FLASK_SECRET_KEY_FILE=/secure/path/flask_secret.key +export FLASK_SECRET_KEY="$(openssl rand -hex 32)" export ADMIN_USER=security-admin export ADMIN_PASSWORD='replace-with-strong-passphrase' export CONSENT_PUBLIC_KEY_PATH=/secure/path/consent_ed25519.pub export REPORT_SIGNING_KEY_PATH=/secure/path/report_ed25519.priv python start.py ``` -The launcher checks dependencies, loads environment variables from `.env` if present, and starts the Flask server on . Use `start.sh`, `start.bat`, or `start.ps1` for platform-specific wrappers. Set `ALLOW_DEV_SECRETS=true` only for local demos that intentionally reuse the sample keys in `keys/`. +The launcher checks dependencies, loads environment variables from `.env` if present, and starts the Flask server on . Use `start.sh`, `start.bat`, or `start.ps1` for platform-specific wrappers. ### Run a CLI Scan ```bash @@ -64,7 +64,7 @@ A Docker Compose definition is provided for isolated demonstrations: docker compose up --build ``` -Mount the `results/` directory when running containers so generated artefacts persist outside the container lifecycle. Override the required secrets via environment variables or secrets managers at runtime; the container image omits the developer keys unless built with `--build-arg INCLUDE_DEV_KEYS=true`. +Mount the `results/` directory when running containers so generated artefacts persist outside the container lifecycle. Override the required secrets via environment variables or secrets managers at runtime. ### Observability ReconScript exposes Prometheus-compatible metrics at `/metrics` and a readiness probe at `/healthz`. Scrape the metrics endpoint to monitor scan durations, completion counts, and open-port histograms. diff --git a/docs/AUDIT_REMEDIATION.md b/docs/AUDIT_REMEDIATION.md index 81622b5..f8bf176 100644 --- a/docs/AUDIT_REMEDIATION.md +++ b/docs/AUDIT_REMEDIATION.md @@ -30,11 +30,10 @@ Each finding from the previous engineering audit is addressed below with the imp - **CI/Docs/Infra:** README references the matrix workflow; caching drastically shortens rerun latency. - **Risk reduction:** Speeds up reviews and reduces drift risk between lint/test definitions. -## F-005 – Default UI credentials and Flask secret shipped in repo -- **Root cause:** `_load_user_credentials` and `_load_secret_key` fell back to `admin/changeme` and `keys/dev_flask_secret.key`. -- **Code-level fix:** Require `ADMIN_USER`, `ADMIN_PASSWORD`, and `FLASK_SECRET_KEY_FILE` to be set; block developer secrets unless `ALLOW_DEV_SECRETS=true`. +- **Root cause:** `_load_user_credentials` and `_load_secret_key` fell back to `admin/changeme` and the bundled developer Flask secret. +- **Code-level fix:** Require `ADMIN_USER`, `ADMIN_PASSWORD`, and `FLASK_SECRET_KEY` to be provided; reject developer secrets entirely. - **Tests added:** `tests/conftest.py` fixture seeds secure test-only values to exercise the stricter loaders. -- **CI/Docs/Infra:** README/SECURITY guide operators through secret provisioning; Docker build removes developer keys by default. +- **CI/Docs/Infra:** README/SECURITY guide operators through secret provisioning; developer secrets are no longer shipped. - **Risk reduction:** Eliminates trivial takeover paths by forcing strong credentials and unique Flask secrets. ## F-006 – Consent/report signing keys defaulted to bundled dev keys @@ -79,9 +78,8 @@ Each finding from the previous engineering audit is addressed below with the imp - **CI/Docs/Infra:** Accessibility improvements captured in roadmap milestones. - **Risk reduction:** Improves UX for keyboard and screen-reader users, aligning with enterprise accessibility standards. -## F-012 – Container image bundled secrets and lacked healthcheck -- **Root cause:** Dockerfile copied `keys/` unconditionally and provided no runtime health signal. -- **Code-level fix:** Added `INCLUDE_DEV_KEYS` build arg to strip sample keys by default and configured a Python-based `HEALTHCHECK` hitting `/healthz`. +- **Root cause:** Dockerfile copied developer keys unconditionally and provided no runtime health signal. +- **Code-level fix:** Removed developer keys from the repository entirely and configured a Python-based `HEALTHCHECK` hitting `/healthz`. - **Tests added:** Covered indirectly via CLI dry-run tests; integration tests planned for metrics and health endpoints. - **CI/Docs/Infra:** README instructs operators to pass secrets at runtime; Dockerfile change improves readiness reporting. - **Risk reduction:** Prevents accidental secret leakage in images and enables orchestrators to detect unhealthy containers. diff --git a/docs/HELP.md b/docs/HELP.md index eb6161f..a784e91 100644 --- a/docs/HELP.md +++ b/docs/HELP.md @@ -10,7 +10,7 @@ ## Configuration Tips - Copy `.env.example` to `.env` to override defaults. -- Review the `keys/` directory and replace development keys before production usage. +- Provision secrets using environment variables (see README) before production usage. - Use `requirements.txt` for runtime installs and `requirements-dev.txt` for local development tooling. ## Troubleshooting diff --git a/docs/SECURITY.md b/docs/SECURITY.md index f3cf425..0b2c53c 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -9,12 +9,11 @@ This project is designed for safe, educational, and authorized use only. Report findings respectfully by opening a private issue or contacting the maintainer listed in the README. ## Hardening Checklist -- Generate production-only keys and set the following environment variables before starting any service: - - `FLASK_SECRET_KEY_FILE` → path to a randomly generated 32-byte secret. +- Generate production-only secrets and set the following environment variables before starting any service: + - `FLASK_SECRET_KEY` → randomly generated 32-byte secret string. - `ADMIN_USER` / `ADMIN_PASSWORD` → non-default credentials (password ≥ 12 characters). - `CONSENT_PUBLIC_KEY_PATH` → consent verification key. - `REPORT_SIGNING_KEY_PATH` → report signing key used by `--sign-report` or UI downloads. -- Leave `ALLOW_DEV_SECRETS` unset (default) in production; setting it to `true` is only for ephemeral demos that explicitly use the sample keys shipped under `keys/`. - Run scans only against systems where you have explicit written permission. - Keep dependencies updated using `requirements.lock` or `pyproject.toml` and run `pip-audit`/`bandit` regularly. - Enable HTTPS termination, reverse-proxy authentication, and network segmentation before exposing the UI publicly. diff --git a/install_dependencies.py b/install_dependencies.py index cd7f66e..1640a57 100644 --- a/install_dependencies.py +++ b/install_dependencies.py @@ -8,8 +8,9 @@ import importlib import subprocess import sys +from collections.abc import Iterable from pathlib import Path -from typing import Dict, Iterable +from typing import Dict ROOT = Path(__file__).resolve().parent REQUIREMENTS_FILE = ROOT / "requirements.txt" diff --git a/keys/dev_ed25519.priv b/keys/dev_ed25519.priv deleted file mode 100644 index 1c2f4ac..0000000 --- a/keys/dev_ed25519.priv +++ /dev/null @@ -1 +0,0 @@ -aZ`J,DIi{2ip;` \ No newline at end of file diff --git a/keys/dev_ed25519.pub b/keys/dev_ed25519.pub deleted file mode 100644 index 8fe23ba..0000000 --- a/keys/dev_ed25519.pub +++ /dev/null @@ -1,2 +0,0 @@ -Z -Kd:rڦ#%hQ \ No newline at end of file diff --git a/keys/dev_flask_secret.key b/keys/dev_flask_secret.key deleted file mode 100644 index e58357e..0000000 --- a/keys/dev_flask_secret.key +++ /dev/null @@ -1 +0,0 @@ -development-secret-key-change-me \ No newline at end of file diff --git a/nacl/__init__.py b/nacl/__init__.py index 7591fad..e03a4cc 100644 --- a/nacl/__init__.py +++ b/nacl/__init__.py @@ -1,14 +1,13 @@ """Minimal ed25519-compatible subset of the PyNaCl API used for tests.""" -from .exceptions import BadSignatureError, CryptoError -from .signing import SignedMessage, SigningKey, VerifyKey - # Expose the ``exceptions`` module at the package level to match the # ``pynacl`` import style used by the application code (``from nacl import # exceptions``). Our lightweight shim previously only re-exported the # classes, which meant importing the module itself failed with # ``ImportError`` when the real dependency was not installed. from . import exceptions as exceptions # noqa: E402 (re-export for compatibility) +from .exceptions import BadSignatureError, CryptoError +from .signing import SignedMessage, SigningKey, VerifyKey __all__ = [ "BadSignatureError", diff --git a/recon_script.py b/recon_script.py index 358c15e..cb3f26a 100644 --- a/recon_script.py +++ b/recon_script.py @@ -4,6 +4,5 @@ from reconscript.cli import main - if __name__ == "__main__": # pragma: no cover - CLI bootstrap raise SystemExit(main()) diff --git a/reconscript/__main__.py b/reconscript/__main__.py index 8d3d57b..d99ad34 100644 --- a/reconscript/__main__.py +++ b/reconscript/__main__.py @@ -4,6 +4,5 @@ from .cli import main - if __name__ == "__main__": # pragma: no cover - CLI bootstrap raise SystemExit(main()) diff --git a/reconscript/cli.py b/reconscript/cli.py index 0190b64..6d52336 100644 --- a/reconscript/cli.py +++ b/reconscript/cli.py @@ -5,8 +5,9 @@ import argparse import logging import sys +from collections.abc import Iterable from pathlib import Path -from typing import Iterable, Optional +from typing import Optional from . import __version__ from .consent import ConsentError, ConsentManifest, load_manifest, validate_manifest diff --git a/reconscript/consent.py b/reconscript/consent.py index 93f1039..c4c2b59 100644 --- a/reconscript/consent.py +++ b/reconscript/consent.py @@ -72,7 +72,9 @@ def _allow_dev_secrets() -> bool: def _guard_key_path(path: Path, *, env_var: str) -> Path: resolved = path.expanduser().resolve() if not resolved.exists(): - raise ConsentError(f"{env_var} must point to an existing file (got {resolved}).") + raise ConsentError( + f"{env_var} must point to an existing file (got {resolved})." + ) try: if DEV_KEYS_DIR in resolved.parents and not _allow_dev_secrets(): raise ConsentError( @@ -108,7 +110,9 @@ def _resolve_key_path(provided: Optional[Path], env_var: str) -> Path: return _guard_key_path(provided, env_var=env_var) env_value = os.environ.get(env_var) if not env_value: - raise ConsentError(f"{env_var} must be set to the path of the authorised signing key.") + raise ConsentError( + f"{env_var} must be set to the path of the authorised signing key." + ) return _guard_key_path(Path(env_value), env_var=env_var) diff --git a/reconscript/core.py b/reconscript/core.py index 3c42121..118d618 100644 --- a/reconscript/core.py +++ b/reconscript/core.py @@ -3,10 +3,9 @@ from __future__ import annotations import logging -import os import time +from collections.abc import Callable, Iterable, Sequence from datetime import datetime, timezone -from typing import Iterable, Optional, Sequence from . import __version__ from .consent import ConsentManifest @@ -16,7 +15,6 @@ record_scan_started, ) from .report import embed_runtime_metadata -from .scope import ScopeValidation, ScopeError, ensure_within_allowlist, validate_target from .scanner import ( DEFAULT_PORTS, REDACTION_KEYS, @@ -33,6 +31,7 @@ validate_port_list, ) from .scanner.throttle import TokenBucket +from .scope import ScopeError, ScopeValidation, ensure_within_allowlist, validate_target LOGGER = logging.getLogger(__name__) REPORT_LOGGER = logging.getLogger("reconscript.report") @@ -44,7 +43,7 @@ class ReconError(RuntimeError): """Raised when a scan cannot proceed.""" -def _determine_redactions(extra: Optional[Iterable[str]]) -> set[str]: +def _determine_redactions(extra: Iterable[str] | None) -> set[str]: redactions = set(REDACTION_KEYS) for item in extra or []: redactions.add(str(item).lower()) @@ -75,7 +74,7 @@ def _validate_consent( return manifest -def _hostname_for_requests(scope: ScopeValidation, override: Optional[str]) -> str: +def _hostname_for_requests(scope: ScopeValidation, override: str | None) -> str: if override: return override if scope.kind == "hostname": @@ -86,15 +85,15 @@ def _hostname_for_requests(scope: ScopeValidation, override: Optional[str]) -> s def run_recon( *, target: str, - hostname: Optional[str] = None, - ports: Optional[Sequence[int]] = None, - expected_ip: Optional[str] = None, + hostname: str | None = None, + ports: Sequence[int] | None = None, + expected_ip: str | None = None, enable_ipv6: bool = False, dry_run: bool = False, evidence_level: str = "low", consent_manifest: ConsentManifest | None = None, - extra_redactions: Optional[Iterable[str]] = None, - progress_callback: Optional[callable] = None, + extra_redactions: Iterable[str] | None = None, + progress_callback: Callable[[str, float], None] | None = None, ) -> dict[str, object]: """Execute the ReconScript workflow with safety controls.""" @@ -157,7 +156,9 @@ def run_recon( "findings": [], } ) - embed_runtime_metadata(report, started_at, completed_at=started_at, duration=0.0) + embed_runtime_metadata( + report, started_at, completed_at=started_at, duration=0.0 + ) record_scan_completed(scope.target, 0.0, 0) REPORT_LOGGER.info(serialize_results(report)) return report @@ -170,7 +171,6 @@ def run_recon( evidence_level=evidence_level, redaction_keys=redactions, ) -<<<<<<< HEAD http_host = _hostname_for_requests(scope, hostname) started_clock = time.perf_counter() @@ -225,13 +225,10 @@ def run_recon( completed_at = datetime.now(timezone.utc) duration = time.perf_counter() - started_clock - embed_runtime_metadata(report, started_at, completed_at=completed_at, duration=duration) - record_scan_completed(scope.target, duration, len(open_ports)) -======= embed_runtime_metadata( - report, started_at, completed_at=started_at, duration=0.0 + report, started_at, completed_at=completed_at, duration=duration ) ->>>>>>> 74be2f0 (style: fix reporters.py syntax and apply Black formatting) + record_scan_completed(scope.target, duration, len(open_ports)) REPORT_LOGGER.info(serialize_results(report)) if progress_callback: @@ -252,54 +249,6 @@ def run_recon( record_scan_failed(target, failure_reason) -<<<<<<< HEAD -======= - http_host = _hostname_for_requests(scope, hostname) - - started_clock = time.perf_counter() - - if progress_callback: - progress_callback("Starting TCP connect scan", 0.1) - open_ports = tcp_connect_scan(config, bucket) - report["open_ports"] = open_ports - - http_results: dict[int, dict[str, object]] = {} - if open_ports: - if progress_callback: - progress_callback("Collecting HTTP metadata", 0.4) - http_results = http_probe_services(config, http_host, open_ports) - report["http_checks"] = http_results - - tls_details = None - if any(port in (443, 8443) for port in open_ports): - if progress_callback: - progress_callback("Retrieving TLS certificates", 0.6) - tls_port = 443 if 443 in open_ports else 8443 - tls_details = fetch_tls_certificate(config, tls_port) - report["tls_cert"] = tls_details - - if progress_callback: - progress_callback("Fetching robots.txt", 0.75) - report["robots"] = fetch_robots(config, http_host) - - if progress_callback: - progress_callback("Generating findings", 0.9) - report["findings"] = generate_findings(http_results) - - completed_at = datetime.now(timezone.utc) - duration = time.perf_counter() - started_clock - embed_runtime_metadata( - report, started_at, completed_at=completed_at, duration=duration - ) - REPORT_LOGGER.info(serialize_results(report)) - - if progress_callback: - progress_callback("Reconnaissance complete", 1.0) - - return report ->>>>>>> 74be2f0 (style: fix reporters.py syntax and apply Black formatting) - - __all__ = [ "run_recon", "ReconError", diff --git a/reconscript/logging.py b/reconscript/logging.py index d8cdd3c..deff3c9 100644 --- a/reconscript/logging.py +++ b/reconscript/logging.py @@ -5,9 +5,10 @@ import json import logging import os +from collections.abc import Iterable from logging.handlers import RotatingFileHandler from pathlib import Path -from typing import Iterable, Optional +from typing import Optional DEFAULT_MAX_BYTES = int(os.environ.get("LOG_MAX_BYTES", str(10 * 1024 * 1024))) DEFAULT_BACKUP_COUNT = int(os.environ.get("LOG_BACKUP_COUNT", "5")) diff --git a/reconscript/metrics.py b/reconscript/metrics.py index 0b402ff..ebfce87 100644 --- a/reconscript/metrics.py +++ b/reconscript/metrics.py @@ -3,16 +3,17 @@ from __future__ import annotations import logging -from typing import Iterable, Optional +from collections.abc import Iterable +from typing import Optional try: # pragma: no cover - optional dependency handling from prometheus_client import ( # type: ignore + CONTENT_TYPE_LATEST, CollectorRegistry, Counter, Histogram, generate_latest, ) - from prometheus_client import CONTENT_TYPE_LATEST except Exception: # pragma: no cover - fall back to no-op metrics CollectorRegistry = None # type: ignore[assignment] Counter = None # type: ignore[assignment] @@ -22,6 +23,7 @@ def generate_latest(_: object) -> bytes: # type: ignore[override] return b"" + LOGGER = logging.getLogger(__name__) @@ -29,7 +31,7 @@ class _NoopMetric: def observe(self, *_: object, **__: object) -> None: return None - def labels(self, *_: object, **__: object) -> "_NoopMetric": + def labels(self, *_: object, **__: object) -> _NoopMetric: return self def inc(self, *_: object, **__: object) -> None: @@ -45,11 +47,15 @@ def _histogram(name: str, documentation: str, *, buckets: Iterable[float]): return Histogram(name, documentation, buckets=buckets, registry=_REGISTRY) -def _counter(name: str, documentation: str, *, label_names: Optional[Iterable[str]] = None): +def _counter( + name: str, documentation: str, *, label_names: Optional[Iterable[str]] = None +): if Counter is None or _REGISTRY is None: # pragma: no cover - optional dependency return _NoopMetric() if label_names: - return Counter(name, documentation, labelnames=list(label_names), registry=_REGISTRY) + return Counter( + name, documentation, labelnames=list(label_names), registry=_REGISTRY + ) return Counter(name, documentation, registry=_REGISTRY) @@ -73,7 +79,9 @@ def _counter(name: str, documentation: str, *, label_names: Optional[Iterable[st def record_scan_started(target: str) -> None: """Emit a metrics event for a scan attempt.""" - LOGGER.debug("metrics.scan_started", extra={"event": "scan.started", "target": target}) + LOGGER.debug( + "metrics.scan_started", extra={"event": "scan.started", "target": target} + ) SCAN_ATTEMPTS.labels(status="started").inc() diff --git a/reconscript/report.py b/reconscript/report.py index c107d32..448b5e9 100644 --- a/reconscript/report.py +++ b/reconscript/report.py @@ -35,7 +35,7 @@ def __init__(self, path: Path, timeout: float = 10.0) -> None: self.timeout = timeout self._fd: Optional[int] = None - def __enter__(self) -> "FileLock": + def __enter__(self) -> FileLock: deadline = time.monotonic() + self.timeout while True: try: diff --git a/reconscript/reporters.py b/reconscript/reporters.py index 169d518..aba16a9 100644 --- a/reconscript/reporters.py +++ b/reconscript/reporters.py @@ -3,13 +3,13 @@ from __future__ import annotations # Modified by codex: 2024-05-08 - import html import json import logging +from collections.abc import Iterable, Sequence from datetime import datetime from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple +from typing import Any, Dict, List, Optional, Tuple PACKAGE_DIR = Path(__file__).resolve().parent PROJECT_TEMPLATES = PACKAGE_DIR.parent / "templates" @@ -184,7 +184,9 @@ def _build_markdown_context(data: Dict[str, object]) -> Dict[str, Any]: "ports": _format_list(data.get("ports", [])) or "None", "open_ports": _format_list(data.get("open_ports", [])) or "None detected", "findings": findings if isinstance(findings, Sequence) else [], - "recommendations": _build_recommendations(findings if isinstance(findings, Sequence) else []), + "recommendations": _build_recommendations( + findings if isinstance(findings, Sequence) else [] + ), "version": data.get("version", __version__), "runtime": data.get("runtime", {}), } @@ -207,7 +209,11 @@ def _render_markdown_sections(context: Dict[str, Any]) -> List[str]: if findings: for item in findings: port = item.get("port", "n/a") if isinstance(item, dict) else "n/a" - issue = item.get("issue", "observation") if isinstance(item, dict) else str(item) + issue = ( + item.get("issue", "observation") + if isinstance(item, dict) + else str(item) + ) lines.append(f"- Port `{port}` — `{issue}`") if isinstance(item, dict) and item.get("details") is not None: details = item["details"] diff --git a/reconscript/scanner/__init__.py b/reconscript/scanner/__init__.py index 8e7ab81..cfb4e86 100644 --- a/reconscript/scanner/__init__.py +++ b/reconscript/scanner/__init__.py @@ -9,10 +9,11 @@ import socket import ssl import time +from collections.abc import Iterable, Sequence from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass -from http.cookies import SimpleCookie, CookieError -from typing import Dict, Iterable, List, Optional, Sequence, Tuple +from http.cookies import CookieError, SimpleCookie +from typing import Dict, List, Optional, Tuple import requests from requests import Response diff --git a/reconscript/ui.py b/reconscript/ui.py index 50dccbf..b380337 100644 --- a/reconscript/ui.py +++ b/reconscript/ui.py @@ -1,20 +1,18 @@ -"""Flask UI for ReconScript without authentication or RBAC.""" +"""Flask UI for ReconScript without authentication or consent requirements.""" from __future__ import annotations import json import logging import os -import uuid -from pathlib import Path from flask import ( Flask, + Response, abort, flash, redirect, render_template, - Response, request, send_from_directory, url_for, @@ -27,61 +25,27 @@ from .scope import validate_target LOGGER = logging.getLogger(__name__) -DEV_KEYS_DIR = Path(__file__).resolve().parents[1] / "keys" - - -def _allow_dev_secrets() -> bool: - return os.environ.get("ALLOW_DEV_SECRETS", "false").lower() == "true" - -def _enforce_secret_path(path: Path, *, env_var: str) -> Path: - resolved = path.expanduser().resolve() - if not resolved.exists(): - raise RuntimeError( - f"{env_var} must point to an existing file (got {resolved})." - ) - if DEV_KEYS_DIR in resolved.parents and not _allow_dev_secrets(): - raise RuntimeError( - f"{env_var} refers to a developer sample key. Provide deployment-specific " - "secrets or set ALLOW_DEV_SECRETS=true for local testing." - ) - return resolved - -def _load_secret_key() -> bytes: - secret_env = os.environ.get("FLASK_SECRET_KEY_FILE") - if not secret_env: - raise RuntimeError( - "FLASK_SECRET_KEY_FILE environment variable must reference a secure secret key file." - ) - secret_path = _enforce_secret_path(Path(secret_env), env_var="FLASK_SECRET_KEY_FILE") - try: - secret = secret_path.read_bytes().strip() - except OSError as exc: - raise RuntimeError( - f"Unable to read Flask secret key from {secret_path}: {exc}" - ) from exc - if not secret: - raise RuntimeError(f"Secret key file {secret_path} is empty.") - return secret - - -def _store_upload(file_storage) -> Path: - upload_dir = ensure_results_dir() / "uploads" - upload_dir.mkdir(parents=True, exist_ok=True) - filename = f"manifest-{uuid.uuid4().hex}.json" - path = upload_dir / filename - file_storage.save(path) - return path +def _secret_key() -> bytes: + explicit = os.environ.get("FLASK_SECRET_KEY") or os.environ.get("SECRET_KEY") + if explicit: + return explicit.encode("utf-8") + LOGGER.warning( + "Using ephemeral Flask secret key; set FLASK_SECRET_KEY to persist sessions." + ) + return os.urandom(32) def create_app() -> Flask: configure_logging() app = Flask(__name__) - app.config["PUBLIC_UI"] = os.environ.get("ENABLE_PUBLIC_UI", "false").lower() == "true" + app.config["PUBLIC_UI"] = ( + os.environ.get("ENABLE_PUBLIC_UI", "false").lower() == "true" + ) app.config["UPLOAD_FOLDER"] = str(ensure_results_dir() / "uploads") - app.secret_key = _load_secret_key() + app.secret_key = _secret_key() @app.context_processor def inject_globals(): @@ -100,15 +64,6 @@ def metrics() -> Response: return Response("", status=204) return Response(payload, mimetype=content_type) - def _handle_manifest() -> tuple[Optional[Path], Optional[object]]: - file = request.files.get("consent_file") - if not file or not file.filename: - return None, None - temp_path = _store_upload(file) - manifest = load_manifest(temp_path) - validate_manifest(manifest) - return temp_path, manifest - @app.route("/", methods=["GET", "POST"]) def index(): if request.method == "POST": @@ -124,28 +79,10 @@ def index(): ) try: validate_target(target, expected_ip=expected_ip) - except Exception as exc: + except Exception as exc: # pragma: no cover - input validation flash(str(exc), "error") return render_template("index.html") - consent_path: Optional[Path] = None - consent_manifest = None - try: - consent_path, consent_manifest = _handle_manifest() - except ConsentError as exc: - flash(f"Consent manifest invalid: {exc}", "error") - if consent_path: - consent_path.unlink(missing_ok=True) - LOGGER.warning( - "ui.consent.invalid", - extra={ - "event": "ui.consent.invalid", - "target": target, - "error": str(exc), - }, - ) - return render_template("index.html") - try: LOGGER.info( "ui.scan.request", @@ -167,8 +104,6 @@ def index(): ) except ReconError as exc: flash(str(exc), "error") - if consent_path: - consent_path.unlink(missing_ok=True) LOGGER.error( "ui.scan.failed", extra={ @@ -179,11 +114,7 @@ def index(): ) return render_template("index.html") - persisted = persist_report( - report, consent_source=consent_path, sign=False - ) - if consent_path: - consent_path.unlink(missing_ok=True) + persisted = persist_report(report, consent_source=None, sign=False) LOGGER.info( "ui.scan.completed", extra={ @@ -209,16 +140,14 @@ def report_detail(report_id: str): @app.route("/results/") def download_result(filename: str): - return send_from_directory( - ensure_results_dir(), filename, as_attachment=True - ) + return send_from_directory(ensure_results_dir(), filename, as_attachment=True) return app def main() -> None: app = create_app() - host = "0.0.0.0" if app.config["PUBLIC_UI"] else "127.0.0.1" + host = "0.0.0.0" if app.config["PUBLIC_UI"] else "127.0.0.1" # noqa: S104 port = int(os.environ.get("DEFAULT_PORT", "5000")) if app.config["PUBLIC_UI"]: LOGGER.warning( @@ -231,5 +160,5 @@ def main() -> None: app.run(host=host, port=port, threaded=True) -if __name__ == "__main__": # pragma: no cover - manual invocation +if __name__ == "__main__": # pragma: no cover - manual invocation helper main() diff --git a/requests/__init__.py b/requests/__init__.py index ef29563..7b86f63 100644 --- a/requests/__init__.py +++ b/requests/__init__.py @@ -1,10 +1,9 @@ """Minimal subset of the requests API required for tests.""" +from . import exceptions, utils from .api import get from .exceptions import ConnectionError, RequestException, Timeout -from . import exceptions from .models import Request, Response -from . import utils __all__ = [ "ConnectionError", diff --git a/requests/models.py b/requests/models.py index 0ea2b2f..7108cf4 100644 --- a/requests/models.py +++ b/requests/models.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Iterable from dataclasses import dataclass, field -from typing import Dict, Iterable, List, Optional +from typing import Dict, List, Optional @dataclass @@ -33,7 +34,7 @@ class Response: headers: Dict[str, str] content: bytes request: Request - history: List["Response"] = field(default_factory=list) + history: List[Response] = field(default_factory=list) raw: object | None = None def __post_init__(self) -> None: diff --git a/scripts/generate_scope_manifest.py b/scripts/generate_scope_manifest.py index e1f89cc..a4b60d2 100755 --- a/scripts/generate_scope_manifest.py +++ b/scripts/generate_scope_manifest.py @@ -6,14 +6,12 @@ import argparse import base64 import json +from collections.abc import Sequence from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Sequence from nacl.signing import SigningKey -DEFAULT_KEY = Path("keys/dev_ed25519.priv") - def _canonical_json(data: dict) -> bytes: return json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8") @@ -41,7 +39,10 @@ def main() -> None: "--valid-days", type=int, default=7, help="Validity window in days" ) parser.add_argument( - "--key", type=Path, default=DEFAULT_KEY, help="Path to ed25519 private key" + "--key", + type=Path, + required=True, + help="Path to ed25519 private key", ) parser.add_argument( "--evidence-level", choices=("low", "medium", "high"), default="low" diff --git a/start.bat b/start.bat index 73eb7dd..a24a04a 100644 --- a/start.bat +++ b/start.bat @@ -4,34 +4,8 @@ pushd %~dp0 set "RESULTS_DIR=%RESULTS_DIR:%=%" if "%RESULTS_DIR%"=="" set "RESULTS_DIR=results" -set "KEYS_DIR=%KEYS_DIR:%=%" -if "%KEYS_DIR%"=="" set "KEYS_DIR=keys" if not exist "%RESULTS_DIR%" mkdir "%RESULTS_DIR%" -if not exist "%KEYS_DIR%" mkdir "%KEYS_DIR%" - -set "DEV_PRIV=%KEYS_DIR%\dev_ed25519.priv" -set "DEV_PUB=%KEYS_DIR%\dev_ed25519.pub" -set "DEV_FLASK=%KEYS_DIR%\dev_flask_secret.key" - -if not exist "%DEV_PRIV%" ( - python - <"%DEV_FLASK%" - echo [dev] Created default Flask secret key. Replace before deploying. -) echo 🚀 Launching ReconScript via start.py set "PYTHON_BIN=%PYTHON%" diff --git a/start.py b/start.py index 3e34e4d..7e91b7c 100644 --- a/start.py +++ b/start.py @@ -29,7 +29,6 @@ def load_dotenv() -> None: # type: ignore[return-value] from install_dependencies import create_console, install_dependencies - load_dotenv() ROOT = Path(__file__).resolve().parent diff --git a/start.sh b/start.sh index 0cdaf52..13f922b 100755 --- a/start.sh +++ b/start.sh @@ -4,32 +4,7 @@ set -euo pipefail cd "$(dirname "$0")" RESULTS_DIR="${RESULTS_DIR:-results}" -KEYS_DIR="${KEYS_DIR:-keys}" mkdir -p "$RESULTS_DIR" -mkdir -p "$KEYS_DIR" - -DEV_PRIV="$KEYS_DIR/dev_ed25519.priv" -DEV_PUB="$KEYS_DIR/dev_ed25519.pub" -DEV_FLASK="$KEYS_DIR/dev_flask_secret.key" - -if [[ ! -f "$DEV_PRIV" || ! -f "$DEV_PUB" ]]; then - python3 - "$DEV_PRIV" "$DEV_PUB" <<'PY' -import binascii -import sys -priv_hex = '9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60' -pub_hex = 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a' -with open(sys.argv[1], 'wb') as priv_file: - priv_file.write(binascii.unhexlify(priv_hex)) -with open(sys.argv[2], 'wb') as pub_file: - pub_file.write(binascii.unhexlify(pub_hex)) -PY - echo "[dev] Generated placeholder ed25519 keypair in $KEYS_DIR (replace for production)." -fi - -if [[ ! -f "$DEV_FLASK" ]]; then - echo "development-secret-key-change-me" > "$DEV_FLASK" - echo "[dev] Created default Flask secret key. Replace before deploying." -fi echo "🚀 Launching ReconScript via start.py" PYTHON_BIN=${PYTHON:-python3} diff --git a/tests/conftest.py b/tests/conftest.py index 0625f24..4a3940f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from pathlib import Path import pytest + from nacl.signing import SigningKey ROOT = Path(__file__).resolve().parent.parent @@ -21,9 +22,7 @@ def _secure_defaults(monkeypatch, tmp_path): public_key_path.write_bytes(signing_key.verify_key.encode()) monkeypatch.setenv("CONSENT_PUBLIC_KEY_PATH", str(public_key_path)) monkeypatch.setenv("REPORT_SIGNING_KEY_PATH", str(private_key_path)) - secret_key = tmp_path / "flask-secret.key" - secret_key.write_text("unit-test-secret", encoding="utf-8") - monkeypatch.setenv("FLASK_SECRET_KEY_FILE", str(secret_key)) + monkeypatch.setenv("FLASK_SECRET_KEY", "unit-test-secret") monkeypatch.setenv("ADMIN_USER", "test-admin") monkeypatch.setenv("ADMIN_PASSWORD", "test-password-123") yield diff --git a/tests/test_cli_markdown.py b/tests/test_cli_markdown.py index a79f4e0..12ffa3a 100644 --- a/tests/test_cli_markdown.py +++ b/tests/test_cli_markdown.py @@ -2,7 +2,8 @@ from pathlib import Path -from reconscript import cli, report as report_module +from reconscript import cli +from reconscript import report as report_module def test_cli_generates_markdown_report(tmp_path: Path, monkeypatch) -> None: @@ -28,7 +29,9 @@ def test_cli_generates_markdown_report(tmp_path: Path, monkeypatch) -> None: assert report_dirs, "CLI should create a timestamped report directory" markdown_files = list(report_dirs[0].glob("report.*")) assert any(file.suffix in {".markdown", ".md"} for file in markdown_files) - markdown_path = next(file for file in markdown_files if file.suffix in {".markdown", ".md"}) + markdown_path = next( + file for file in markdown_files if file.suffix in {".markdown", ".md"} + ) content = markdown_path.read_text(encoding="utf-8") assert "## Findings" in content assert "## HTTP" in content diff --git a/tests/test_consent_validation.py b/tests/test_consent_validation.py index 10cf40c..4f54b0f 100644 --- a/tests/test_consent_validation.py +++ b/tests/test_consent_validation.py @@ -7,8 +7,8 @@ from pathlib import Path import pytest -from nacl.signing import SigningKey +from nacl.signing import SigningKey from reconscript.consent import ConsentError, load_manifest, validate_manifest diff --git a/tests/test_scope_validation.py b/tests/test_scope_validation.py index 86e05d5..ee8b8e6 100644 --- a/tests/test_scope_validation.py +++ b/tests/test_scope_validation.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import socket import pytest