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
7 changes: 1 addition & 6 deletions reconscript/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,7 @@
<strong>ReconScript</strong>
</div>
<nav aria-label="Primary">
{% if current_user.is_authenticated %}
<a href="{{ url_for('index') }}">Dashboard</a>
<a href="{{ url_for('logout') }}">Logout</a>
{% else %}
<a href="{{ url_for('login') }}">Login</a>
{% endif %}
<a href="{{ url_for('index') }}">Dashboard</a>
</nav>
</header>
<main id="main-content" tabindex="-1">
Expand Down
168 changes: 39 additions & 129 deletions reconscript/ui.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,25 @@
"""Flask UI for ReconScript with consent enforcement and RBAC."""
"""Flask UI for ReconScript without authentication or RBAC."""

from __future__ import annotations

import json
import logging
import os
import uuid
from functools import wraps
from pathlib import Path
from typing import Optional

from flask import (
Flask,
abort,
flash,
current_app,
redirect,
render_template,
Response,
request,
send_from_directory,
url_for,
)
from flask_login import (
LoginManager,
UserMixin,
current_user,
login_required,
login_user,
logout_user,
)

from .consent import ConsentError, load_manifest, validate_manifest
from .core import ReconError, run_recon
Expand All @@ -42,36 +32,21 @@
DEV_KEYS_DIR = Path(__file__).resolve().parents[1] / "keys"


class StaticUser(UserMixin):
def __init__(self, username: str, role: str) -> None:
self.id = username
self.role = role


<<<<<<< HEAD
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}).")
=======
def _load_secret_key() -> bytes:
secret_path = Path(
os.environ.get("FLASK_SECRET_KEY_FILE", "keys/dev_flask_secret.key")
)
>>>>>>> 74be2f0 (style: fix reporters.py syntax and apply Black formatting)
try:
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."
)
except RuntimeError:
raise
except Exception:
pass
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


Expand All @@ -85,46 +60,12 @@ def _load_secret_key() -> bytes:
try:
secret = secret_path.read_bytes().strip()
except OSError as exc:
<<<<<<< HEAD
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
=======
raise RuntimeError(
f"Unable to read Flask secret key from {secret_path}: {exc}"
) from exc
>>>>>>> 74be2f0 (style: fix reporters.py syntax and apply Black formatting)


def _rbac_required(role: str):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for("login"))
if (
current_app.config.get("RBAC_ENABLED")
and getattr(current_user, "role", None) != role
):
abort(403)
return func(*args, **kwargs)

return wrapper

return decorator


def _load_user_credentials() -> tuple[str, str]:
username = os.environ.get("ADMIN_USER", "").strip()
password = os.environ.get("ADMIN_PASSWORD", "").strip()
if not username or not password:
raise RuntimeError("ADMIN_USER and ADMIN_PASSWORD must be set for the ReconScript UI.")
if not _allow_dev_secrets() and (username == "admin" or password == "changeme"):
raise RuntimeError("Default credentials are not permitted. Set strong ADMIN_USER and ADMIN_PASSWORD values.")
if len(password) < 12 and not _allow_dev_secrets():
raise RuntimeError("ADMIN_PASSWORD must be at least 12 characters long.")
return username, password
if not secret:
raise RuntimeError(f"Secret key file {secret_path} is empty.")
return secret


def _store_upload(file_storage) -> Path:
Expand All @@ -139,38 +80,15 @@ def _store_upload(file_storage) -> Path:
def create_app() -> Flask:
configure_logging()

public_ui = os.environ.get("ENABLE_PUBLIC_UI", "false").lower() == "true"
rbac_enabled = os.environ.get("ENABLE_RBAC", "false").lower() == "true"
if public_ui and not rbac_enabled:
raise RuntimeError(
"ENABLE_PUBLIC_UI=true requires ENABLE_RBAC=true to protect access."
)

app = Flask(__name__)
app.config["RBAC_ENABLED"] = rbac_enabled
app.config["PUBLIC_UI"] = public_ui
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()

login_manager = LoginManager(app)
login_manager.login_view = "login"

username, password = _load_user_credentials()
user = StaticUser(username, "admin")

@login_manager.user_loader
def load_user(
user_id: str,
) -> Optional[StaticUser]: # pragma: no cover - simple lookup
if user_id == user.id:
return user
return None

@app.context_processor
def inject_globals():
return {
"public_ui": app.config["PUBLIC_UI"],
"rbac_enabled": app.config["RBAC_ENABLED"],
}

@app.route("/healthz")
Expand All @@ -184,26 +102,6 @@ def metrics() -> Response:
return Response("", status=204)
return Response(payload, mimetype=content_type)

@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
submitted_user = request.form.get("username", "")
submitted_pass = request.form.get("password", "")
LOGGER.info(
"ui.login.attempt",
extra={"event": "ui.login.attempt", "username": submitted_user, "success": submitted_user == username and submitted_pass == password},
)
if submitted_user == username and submitted_pass == password:
login_user(user)
return redirect(url_for("index"))
flash("Invalid credentials", "error")
return render_template("login.html")

@app.route("/logout")
def logout():
logout_user()
return redirect(url_for("login"))

def _handle_manifest() -> tuple[Optional[Path], Optional[object]]:
file = request.files.get("consent_file")
if not file or not file.filename:
Expand All @@ -214,8 +112,6 @@ def _handle_manifest() -> tuple[Optional[Path], Optional[object]]:
return temp_path, manifest

@app.route("/", methods=["GET", "POST"])
@login_required
@_rbac_required("admin")
def index():
if request.method == "POST":
target = request.form.get("target", "")
Expand All @@ -242,7 +138,14 @@ def index():
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)})
LOGGER.warning(
"ui.consent.invalid",
extra={
"event": "ui.consent.invalid",
"target": target,
"error": str(exc),
},
)
return render_template("index.html")

try:
Expand All @@ -269,10 +172,19 @@ def index():
flash(str(exc), "error")
if consent_path:
consent_path.unlink(missing_ok=True)
LOGGER.error("ui.scan.failed", extra={"event": "ui.scan.failed", "target": target, "error": str(exc)})
LOGGER.error(
"ui.scan.failed",
extra={
"event": "ui.scan.failed",
"target": target,
"error": str(exc),
},
)
return render_template("index.html")

persisted = persist_report(report, consent_source=consent_path, sign=False)
persisted = persist_report(
report, consent_source=consent_path, sign=False
)
if consent_path:
consent_path.unlink(missing_ok=True)
LOGGER.info(
Expand All @@ -288,8 +200,6 @@ def index():
return render_template("index.html")

@app.route("/reports/<report_id>")
@login_required
@_rbac_required("admin")
def report_detail(report_id: str):
report_dir = ensure_results_dir() / report_id
report_file = report_dir / "report.json"
Expand All @@ -301,10 +211,10 @@ def report_detail(report_id: str):
)

@app.route("/results/<path:filename>")
@login_required
@_rbac_required("admin")
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

Expand All @@ -313,14 +223,14 @@ def main() -> None:
app = create_app()
host = "0.0.0.0" if app.config["PUBLIC_UI"] else "127.0.0.1"
port = int(os.environ.get("DEFAULT_PORT", "5000"))
LOGGER.warning(
"UI running with RBAC %s",
"enabled" if app.config["RBAC_ENABLED"] else "disabled",
)
if app.config["PUBLIC_UI"]:
LOGGER.warning(
"Public UI mode enabled — ensure reverse proxy protections are in place."
)
else:
LOGGER.warning(
"Running ReconScript UI without authentication. Restrict access appropriately."
)
app.run(host=host, port=port, threaded=True)


Expand Down
Loading