feat: add versioned multi-component assessment registry

This commit is contained in:
Luftmensch
2026-09-04 10:33:36 +07:00
parent 4f4c7df4ed
commit 9f34cf8afd
9 changed files with 906 additions and 38 deletions
+187 -25
View File
@@ -14,7 +14,7 @@ import audit_decryption
from colorama import Fore
import uvicorn
import secrets
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
import json
import socket
import struct
@@ -27,6 +27,9 @@ import re
import requests as http_requests
from authenticator.radius_login import check_radius_login, check_radius_login_extended
from config_upload import install_config_files, normalize_json, validate_xlsx
from registry_service import (
RegistryError, RegistryNotFoundError, RegistryService, RegistryValidationError
)
# Database imports
import models
@@ -188,6 +191,35 @@ OS_CONFIG = {
}
}
# Versioned registry. CONFIG_FOLDER is already a persistent Docker bind mount.
REGISTRY_ROOT = os.getenv("AUDIT_REGISTRY_ROOT", os.path.join(CONFIG_FOLDER, "registry"))
registry_service = RegistryService(REGISTRY_ROOT, legacy_config_root=CONFIG_FOLDER)
try:
registry_service.seed_legacy_profiles()
except RegistryError as exc:
# Keep the application bootable so an administrator can repair a bad mount.
print(Fore.RED + f"[REGISTRY] Initialization failed: {exc}" + Fore.RESET)
def resolve_audit_profile(os_type: Optional[str] = None, profile_id: Optional[str] = None):
"""Resolve explicit profile selection or a legacy OS alias to its active version."""
return registry_service.resolve(os_type=os_type, profile_id=profile_id)
def active_profile_options():
"""Flat active-profile list used by the audit form without exposing filesystem paths."""
result = []
for category in registry_service.public_catalog(active_only=True).get("categories", []):
for component in category.get("components", []):
for profile in component.get("profiles", []):
active = profile.get("versions", [])
if active:
result.append({"id": profile["id"], "name": profile["name"],
"category_id": category["id"], "category": category["name"],
"component_id": component["id"], "component": component["name"],
"version": active[0]["version"]})
return result
def detect_os_from_filename(filename: str) -> Optional[str]:
"""
Detect OS type from filename pattern.
@@ -285,6 +317,11 @@ class CompatTemplates(Jinja2Templates):
raise ValueError("request is required in TemplateResponse context")
if "docs_list" not in context and "get_available_docs" in globals():
context["docs_list"] = get_available_docs()
if "active_profiles" not in context and "registry_service" in globals():
try:
context["active_profiles"] = active_profile_options()
except RegistryError:
context["active_profiles"] = []
# Call new-style API: super().TemplateResponse(request, name, context, ...)
return super().TemplateResponse(request=request, name=name, context=context, **kwargs)
@@ -1130,6 +1167,7 @@ async def index(request: Request):
{
"request": request,
"os_config": OS_CONFIG,
"registry": registry_service.public_catalog(),
"message": None,
"is_logged_in": is_logged_in,
"user_info": user_info,
@@ -1474,7 +1512,8 @@ async def api_detect_os_from_content(files: List[UploadFile] = File(...)):
"message": f"Error: {str(e)}"
}
def process_files_background(task_id: str, session_id: str, os_type: str, enc_paths: List[str], base_url: str):
def process_files_background(task_id: str, session_id: str, os_type: str, enc_paths: List[str], base_url: str,
profile_id: Optional[str] = None):
"""Background task to process uploaded files"""
try:
tasks_status = processing_tasks.get(task_id)
@@ -1485,10 +1524,10 @@ def process_files_background(task_id: str, session_id: str, os_type: str, enc_pa
tasks_status['start_time'] = time.time()
# Validation checks
if os_type != 'auto' and os_type not in OS_CONFIG:
tasks_status['status'] = 'error'
tasks_status['message'] = "Please select a valid OS type!"
return
if profile_id:
resolve_audit_profile(profile_id=profile_id)
elif os_type != 'auto':
resolve_audit_profile(os_type=os_type)
if not enc_paths:
tasks_status['status'] = 'error'
@@ -1519,19 +1558,20 @@ def process_files_background(task_id: str, session_id: str, os_type: str, enc_pa
# Fallback to content detection
file_os_type = detect_os_from_decrypted_file(enc_path)
if not file_os_type or file_os_type not in OS_CONFIG:
if not profile_id and (not file_os_type or file_os_type not in OS_CONFIG):
print(Fore.YELLOW + f"[BACKGROUND] Cannot detect OS for {enc_filename}, skipping." + Fore.RESET)
continue
os_info = OS_CONFIG[file_os_type]
checklist_path = os.path.join(CONFIG_FOLDER, os_info['checklist'])
config_path = os.path.join(CONFIG_FOLDER, os_info['config'])
resolved = resolve_audit_profile(profile_id=profile_id) if profile_id else resolve_audit_profile(os_type=file_os_type)
checklist_path = resolved['checklist_path']
config_path = resolved['rules_path']
display_name = resolved['profile']['name']
if not os.path.exists(checklist_path) or not os.path.exists(config_path):
print(Fore.RED + f"[BACKGROUND] Missing checklist/config for {file_os_type}, skipping." + Fore.RESET)
continue
print(Fore.BLUE + f"\n=== [BACKGROUND] Processing: {enc_filename} ({os_info['name']}) ===" + Fore.RESET)
print(Fore.BLUE + f"\n=== [BACKGROUND] Processing: {enc_filename} ({display_name}) ===" + Fore.RESET)
original_dir = os.getcwd()
try:
@@ -1660,6 +1700,7 @@ async def api_upload_async(
request: Request,
background_tasks: BackgroundTasks,
os_type: str = Form(...),
profile_id: Optional[str] = Form(None),
encrypted_files: List[UploadFile] = File(...)
):
# Authenticated users and explicit dev mode may upload; admin routes stay protected.
@@ -1696,7 +1737,7 @@ async def api_upload_async(
try:
# Detect OS dynamically from filenames (fast, no decryption needed)
if not os_type or os_type == 'auto':
if not profile_id and (not os_type or os_type == 'auto'):
filenames = [f.filename for f in encrypted_files if f.filename]
detected_os, all_same = detect_os_from_multiple_files(filenames)
if detected_os and all_same:
@@ -1720,7 +1761,7 @@ async def api_upload_async(
enc_paths.append(enc_path)
base_url = f"{request.url.scheme}://{request.url.netloc}"
background_tasks.add_task(process_files_background, task_id, session_id, os_type, enc_paths, base_url)
background_tasks.add_task(process_files_background, task_id, session_id, os_type, enc_paths, base_url, profile_id)
return {"task_id": task_id, "status": "pending"}
except Exception as e:
@@ -1786,6 +1827,7 @@ async def task_result(request: Request, task_id: str):
async def upload_files(
request: Request,
os_type: str = Form(...),
profile_id: Optional[str] = Form(None),
encrypted_files: List[UploadFile] = File(...)
):
# Tạo response object để set cookie
@@ -1808,7 +1850,7 @@ async def upload_files(
_is_logged_in_ctx = _user_info_ctx is not None
# Auto-detect OS from filenames (fast, no decryption needed)
if not os_type or os_type == 'auto':
if not profile_id and (not os_type or os_type == 'auto'):
print(Fore.CYAN + "[AUTO-DETECT] Mode: auto – detecting OS from filenames..." + Fore.RESET)
filenames = [f.filename for f in encrypted_files if f.filename]
detected_os, all_same = detect_os_from_multiple_files(filenames)
@@ -1830,7 +1872,7 @@ async def upload_files(
)
# Validate OS type
if os_type not in OS_CONFIG:
if not profile_id and os_type not in OS_CONFIG:
return templates.TemplateResponse(
"index.html",
{
@@ -1852,9 +1894,15 @@ async def upload_files(
)
# Get checklist and config paths
os_info = OS_CONFIG[os_type]
checklist_path = os.path.join(CONFIG_FOLDER, os_info['checklist'])
config_path = os.path.join(CONFIG_FOLDER, os_info['config'])
try:
resolved_profile = resolve_audit_profile(os_type=os_type, profile_id=profile_id)
except RegistryError as exc:
return templates.TemplateResponse("index.html", {"request": request, "os_config": OS_CONFIG,
"message": {"type": "error", "text": str(exc)}})
checklist_path = resolved_profile['checklist_path']
config_path = resolved_profile['rules_path']
os_info = {"name": resolved_profile['profile']['name'],
"checklist": os.path.basename(checklist_path), "config": os.path.basename(config_path)}
# Verify files exist
if not os.path.exists(checklist_path):
@@ -2578,6 +2626,97 @@ def require_tool_manager_session(request: Request):
return user
return None
def _admin_or_403(request: Request):
user = require_admin_session(request)
if not user:
raise HTTPException(status_code=403, detail="Admin role required")
return user
@app.get("/api/v2/registry", name="registry_catalog_v2")
async def registry_catalog_v2(active_only: bool = False):
"""Read-only registry catalog. Artifact paths are intentionally not exposed."""
return registry_service.public_catalog(active_only=active_only)
@app.get("/api/v2/profiles/active", name="active_profiles_v2")
async def active_profiles_v2():
return {"profiles": active_profile_options()}
@app.post("/api/v2/admin/categories", name="registry_create_category_v2")
async def registry_create_category_v2(request: Request):
_admin_or_403(request)
body = await request.json()
try:
return registry_service.create_category(body.get("id", ""), body.get("name", ""))
except RegistryError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/v2/admin/components", name="registry_create_component_v2")
async def registry_create_component_v2(request: Request):
_admin_or_403(request)
body = await request.json()
try:
return registry_service.create_component(body.get("category_id", ""), body.get("id", ""), body.get("name", ""))
except RegistryNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except RegistryError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/v2/admin/profiles", name="registry_create_profile_v2")
async def registry_create_profile_v2(request: Request):
_admin_or_403(request)
body = await request.json()
try:
return registry_service.create_profile(body.get("component_id", ""), body.get("id", ""),
body.get("name", ""), body.get("aliases", []))
except RegistryNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except RegistryError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/v2/admin/profiles/{profile_id}/versions", name="registry_upload_version_v2")
async def registry_upload_version_v2(
profile_id: str, request: Request, version: str = Form(...), activate: bool = Form(False),
checklist_file: UploadFile = File(...), config_file: UploadFile = File(...),
):
user = _admin_or_403(request)
try:
return registry_service.create_version(profile_id, version, await checklist_file.read(),
await config_file.read(), activate=activate,
created_by=user.get("username", "admin"))
except RegistryNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except RegistryError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/v2/admin/profiles/{profile_id}/versions/{version}/activate", name="registry_activate_v2")
async def registry_activate_v2(profile_id: str, version: str, request: Request):
_admin_or_403(request)
try:
return registry_service.activate_version(profile_id, version)
except RegistryNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except RegistryError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/v2/admin/profiles/{profile_id}/rollback", name="registry_rollback_v2")
async def registry_rollback_v2(profile_id: str, request: Request):
_admin_or_403(request)
try:
return registry_service.rollback(profile_id)
except RegistryNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except RegistryError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/admin/tools", response_class=HTMLResponse, name="admin_tools")
async def admin_tools_page(request: Request, msg: str = None, msg_type: str = "success"):
"""Admin page for managing tools - requires session authentication"""
@@ -2618,6 +2757,7 @@ async def admin_tools_page(request: Request, msg: str = None, msg_type: str = "s
"os_metadata": OS_METADATA,
"os_config": OS_CONFIG,
"message": message,
"registry": registry_service.public_catalog(),
"user": user,
"user_info": user,
"is_logged_in": True,
@@ -2971,7 +3111,22 @@ async def admin_upload_checklist_config(
normalized_config = normalize_json(await config_file.read())
pending_files.append((os_info['config'], normalized_config))
install_config_files(CONFIG_FOLDER, pending_files)
# Compatibility endpoint: translate the fixed OS form into a new immutable
# profile version. ``install_config_files`` remains available for old
# deployments, but registry artifacts are now the authoritative source.
resolved = resolve_audit_profile(os_type=os_type)
with open(resolved['checklist_path'], 'rb') as stream:
active_checklist = stream.read()
with open(resolved['rules_path'], 'rb') as stream:
active_rules = stream.read()
checklist_content = next((content for name, content in pending_files if name == os_info['checklist']),
active_checklist)
normalized_config = next((content for name, content in pending_files if name == os_info['config']),
active_rules)
version_id = datetime.now(timezone.utc).strftime("upload-%Y%m%dT%H%M%S%fZ")
registry_service.create_version(resolved['profile']['id'], version_id, checklist_content,
normalized_config, activate=True,
created_by=user.get('username', 'admin'))
print(Fore.GREEN + f"[ADMIN] Checklist/config updated for {os_type} by {user.get('username')}" + Fore.RESET)
changed = ', '.join(name for name, _ in pending_files)
@@ -4067,6 +4222,7 @@ def get_client_ip(request: Request) -> str:
async def api_upload_files(
request: Request,
os_type: str = Form(...),
profile_id: Optional[str] = Form(None),
api_key: str = Form(...),
encrypted_files: List[UploadFile] = File(...)
):
@@ -4093,7 +4249,7 @@ async def api_upload_files(
try:
# Auto-detect OS from filename if os_type is 'auto'
if not os_type or os_type == 'auto':
if not profile_id and (not os_type or os_type == 'auto'):
filenames = [f.filename for f in encrypted_files if f.filename]
detected_os, _ = detect_os_from_multiple_files(filenames)
if detected_os:
@@ -4103,16 +4259,20 @@ async def api_upload_files(
raise HTTPException(status_code=400, detail="Cannot auto-detect OS. Please specify os_type.")
# Validate OS type
if os_type not in OS_CONFIG:
if not profile_id and os_type not in OS_CONFIG:
raise HTTPException(status_code=400, detail=f"Invalid os_type: {os_type}")
# Validate file exists
if not encrypted_files:
raise HTTPException(status_code=400, detail="No encrypted files uploaded")
os_info = OS_CONFIG[os_type]
checklist_path = os.path.join(CONFIG_FOLDER, os_info['checklist'])
config_path = os.path.join(CONFIG_FOLDER, os_info['config'])
try:
resolved_profile = resolve_audit_profile(os_type=os_type, profile_id=profile_id)
except RegistryError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
checklist_path = resolved_profile['checklist_path']
config_path = resolved_profile['rules_path']
os_info = {"checklist": os.path.basename(checklist_path), "config": os.path.basename(config_path)}
# Verify exists
if not os.path.exists(checklist_path):
@@ -4187,6 +4347,8 @@ async def api_upload_files(
"processed": len(output_files),
"output_files": output_files,
"os_type": os_type,
"profile_id": resolved_profile["profile"]["id"],
"profile_version": resolved_profile["version"]["version"],
"download_urls": [f"/api/download/{f}" for f in output_files]
}