feat: add versioned multi-component assessment registry
This commit is contained in:
+187
-25
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user