Files
vulncheck/tests/test_readonly_gate_inventory.py
T
vulncheck 6fdb800cef feat(auth): passwordless sign-in with passkeys for local accounts
"Sign in with a passkey" on the login page replaces password and TOTP.
User verification is required, so the passkey is two factors on its own;
password login stays alongside. Users add, list and remove passkeys under
Settings -> Profile after confirming their password. WebAuthn checks
(challenge, origin, RP ID, signature, sign count) come from py_webauthn;
the browser side uses the native PublicKeyCredential JSON helpers.

Off unless WEBAUTHN_ORIGIN is set; its hostname is the RP ID. Local
accounts only: a passkey sign-in never asks the directory, so an LDAP user
disabled or demoted in AD would keep access and role. Deactivated and
permanently locked users are refused. Challenges are 5-minute signed
tokens burnt on first use (token_revocation.claim, check and revoke under
one lock). The admin "Reset MFA" also removes the user's passkeys.

New table passkeys (migration 067). Rules live in app/auth/passkey.py,
endpoints under /auth/passkeys in app/routers/auth_passkey.py.
2026-11-03 15:40:31 +01:00

134 lines
5.6 KiB
Python

"""
The sidebar hides admin/editor pages from readonly, but the URL does not —
so the server must refuse what those pages load. This mounts every router,
signs in as readonly and GETs every route: everything not on the allow-list
must be 403. A new endpoint that readonly may see goes on the list on purpose.
Found this way: /api/v1/groups handed group membership (usernames) to
readonly, /api/v1/groups/default and /api/v1/policies answered too — the
Groups / Policies / Auth Providers pages were reachable by typing the URL.
"""
import os
import re
import sys
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "0" * 64)
# The EOL tests park a fake `app.routers.vulnerabilities` in sys.modules for
# the whole session; scans.py imports real names from it. Take the real one.
_stub = sys.modules.get("app.routers.vulnerabilities")
if _stub is not None and not hasattr(_stub, "sync_agent_vulnerabilities"):
del sys.modules["app.routers.vulnerabilities"]
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app.auth.dependencies import get_current_user # noqa: E402
from app.database import get_db # noqa: E402
from app.models.user import User, UserRole # noqa: E402
from app.routers import ( # noqa: E402
advisories, assets, audit, auth, auth_admin, auth_passkey, compliance, groups, https_certificate, igel, intune,
nessus, notifications, policies, reports, scans, settings, trust_store, vcenter, vulnerabilities,
)
ROUTERS = (auth, auth_admin, auth_passkey, vulnerabilities, nessus, intune, vcenter, igel, advisories, assets,
policies, scans, settings, trust_store, https_certificate, notifications, groups, reports, audit, compliance)
# What a readonly user may GET. Path params are substituted with "1".
READONLY_MAY_GET = {
"/auth/providers",
"/auth/me",
"/auth/passkeys", # own passkeys only
"/api/v1/vulnerabilities",
"/api/v1/vulnerabilities/by-package",
"/api/v1/vulnerabilities/ai-history",
"/api/v1/vulnerabilities/reports/dashboard",
"/api/v1/vulnerabilities/1",
"/api/v1/vulnerabilities/override/check",
"/api/v1/vulnerabilities/override/vulnrichment/status/1",
"/api/v1/vulnerabilities/override/vulnrichment/jobs",
"/api/v1/vulnerabilities/eol-check/status",
"/api/v1/vulnerabilities/app-cve-scan/status",
"/api/v1/vulnerabilities/msrc-scan/status",
"/api/v1/vulnerabilities/ai-remediation/status",
"/api/v1/vulnerabilities/1/remediations",
"/api/v1/vulnerabilities/override/stats",
"/api/v1/advisories/feeds",
"/api/v1/advisories/kev-recent",
"/api/v1/advisories/kev-alerts",
"/api/v1/assets/1/coverage-gap",
"/api/v1/assets/1/software",
"/api/v1/assets/1/containers", # a Docker host's images — rows of the asset list
"/api/v1/assets",
# The host picker on the Vulnerabilities page — a readonly user filters by
# host, so this has to be as open as the list it replaces.
"/api/v1/assets/names",
"/api/v1/assets/export",
"/api/v1/assets/1",
"/api/v1/assets/1/vulnerabilities",
"/api/v1/scans/schedules", # dashboard shows the next run to everyone
"/api/v1/notifications/log", # own rows only (filtered in the handler)
"/api/v1/groups", # filter dropdowns; membership stripped
"/api/v1/reports/vulnerabilities/csv",
"/api/v1/reports/executive-summary/pdf",
"/api/v1/reports/patching-progress/pdf",
"/api/v1/reports/compliance/iso27001/pdf",
"/api/v1/compliance/summary",
"/api/v1/compliance/assets",
"/api/v1/compliance/1",
"/api/v1/compliance/1/1/checks",
"/api/v1/compliance/impacts/stats",
"/api/v1/compliance/urs/1",
"/api/v1/compliance/urs",
}
def _client(role: UserRole, db=None) -> TestClient:
app = FastAPI()
for r in ROUTERS:
app.include_router(r.router)
user = User(id=1, username="u", email="u@x", role=role, is_active=True)
app.dependency_overrides[get_current_user] = lambda: user
app.dependency_overrides[get_db] = lambda: db or MagicMock()
return TestClient(app, raise_server_exceptions=False)
def _get_paths():
for r in ROUTERS:
for route in r.router.routes:
if "GET" in (getattr(route, "methods", None) or set()):
yield re.sub(r"\{[^}]+\}", "1", route.path)
def test_readonly_reaches_exactly_the_allow_list():
c = _client(UserRole.READONLY)
reached = {p for p in _get_paths() if c.get(p).status_code != 403}
assert reached == READONLY_MAY_GET, {
"open_but_should_be_gated": sorted(reached - READONLY_MAY_GET),
"gated_but_listed_as_open": sorted(READONLY_MAY_GET - reached),
}
@pytest.mark.parametrize("path", ["/api/v1/groups/default", "/api/v1/policies"])
def test_editor_or_admin_still_pass(path):
role = UserRole.ADMIN if "groups" in path else UserRole.EDITOR
assert _client(role).get(path).status_code != 403
def test_group_membership_only_for_admin():
# SimpleNamespace, not MagicMock: `name` is a reserved MagicMock kwarg.
member = SimpleNamespace(id=7, username="alice")
group = SimpleNamespace(id=1, name="g", description=None, created_at=datetime(2026, 1, 1), users=[member])
db = MagicMock()
db.query.return_value.all.return_value = [group]
ro = _client(UserRole.READONLY, db).get("/api/v1/groups").json()
assert ro[0]["members"] == [] and ro[0]["member_count"] == 1
admin = _client(UserRole.ADMIN, db).get("/api/v1/groups").json()
assert admin[0]["members"] == [{"id": 7, "username": "alice"}]