MrMasterbay 7f919114c0 compliance: add French frameworks — Diffusion Restreinte (II 901) + RGS
- both reference ANSSI-BP-028 v2.0 as the Linux-hardening baseline (same way
  vs-nfd inherits BSI): new ANSSI_BP028 mapping with R-numbers + French titles
  verified against the official ANSSI guide (Liste des recommandations, p.71-73)
- checks with no defensible BP-028 recommendation are INTENTIONALLY unmapped —
  BP-028 defers OpenSSH to a separate ANSSI note, so ssh_* is not mapped (no fabrication)
- _HARDENING_PROFILES dr/rgs scope + report-profile whitelist + FRAMEWORK_META +
  GLOSSARY (ANSSI / II 901 / RGS) + French ANSSI family labels
- compliance dashboard: DR + RGS frameworks + an 'EU / France' profile group
- verified: standalone mapping asserts green + live _profile_control_set('dr')=25
  controls (ssh excluded, rgs==dr) on Testi; SSH hardening-run reuses the proven
  vs-nfd/bsi path (Testi is API-token-only, so the live SSH run was n/a there)
2026-07-04 09:58:37 +02:00

988 lines
34 KiB
Python

# -*- coding: utf-8 -*-
"""reports + legacy tags routes - split from monolith dec 2025, NS"""
import time
import os
import logging
import re
import sqlite3 # kept for Row + type re-exports
import threading
from urllib.parse import urlparse
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request
# MK May 2026 — direct DB connections must go through dbcrypto so SQLCipher
# unlocks the encrypted DB with the master key.
from pegaprox.core import dbcrypto
from pegaprox.constants import *
from pegaprox.globals import *
from pegaprox.models.permissions import *
from pegaprox.utils.auth import require_auth, load_users
from pegaprox.utils.rbac import get_user_clusters
from pegaprox.api.helpers import check_cluster_access, load_server_settings
from pegaprox.background.metrics import load_metrics_history, start_metrics_collector
from pegaprox.background.syslog_server import DB_FILE, SEVERITY_MAP
from pegaprox.api.schedules import start_scheduler
bp = Blueprint('reports', __name__)
def _syslog_search_terms(search_text):
return [term for term in re.split(r'\s+', search_text.strip()) if term]
def _syslog_escape_fts_term(term):
sanitized = ''.join(ch for ch in term if ch.isprintable() and ch not in '\x00\r\n\t')
sanitized = sanitized.replace('"', '""').strip()
return f'"{sanitized}"*' if sanitized else ''
def _syslog_fts_query(search_text):
terms = _syslog_search_terms(search_text)
if not terms:
return ''
escaped_terms = [_syslog_escape_fts_term(term) for term in terms]
escaped_terms = [term for term in escaped_terms if term]
return ' AND '.join(escaped_terms)
def _syslog_like_clause(search_text):
like = f'%{search_text}%'
return (
"""(
timestamp LIKE ? COLLATE NOCASE OR
source_ip LIKE ? COLLATE NOCASE OR
hostname LIKE ? COLLATE NOCASE OR
severity_text LIKE ? COLLATE NOCASE OR
message LIKE ? COLLATE NOCASE OR
protocol LIKE ? COLLATE NOCASE
)""",
[like, like, like, like, like, like],
)
def _syslog_hostname_tokens(value):
value = str(value or '').strip().lower()
if not value:
return set()
if '://' in value:
parsed = urlparse(value)
value = parsed.hostname or value
value = value.split('/')[0].split('@')[-1]
if value.startswith('[') and ']' in value:
value = value[1:value.index(']')]
elif ':' in value and value.count(':') == 1:
value = value.rsplit(':', 1)[0]
value = value.strip('.')
if not value:
return set()
tokens = {value}
if '.' in value:
tokens.add(value.split('.', 1)[0])
return tokens
def _syslog_cluster_hostnames(cluster_id):
manager = cluster_managers.get(cluster_id)
if not manager:
return set()
hostnames = set()
config = getattr(manager, 'config', None)
for value in (
getattr(manager, 'host', ''),
getattr(config, 'host', '') if config else '',
getattr(config, 'name', '') if config else '',
):
hostnames.update(_syslog_hostname_tokens(value))
try:
node_status = manager.get_node_status() or {}
for node_name in node_status.keys():
hostnames.update(_syslog_hostname_tokens(node_name))
except Exception as exc:
logging.debug(f"[Syslog] Could not load nodes for cluster filter {cluster_id}: {exc}")
for node_name in getattr(manager, 'ha_node_status', {}).keys():
hostnames.update(_syslog_hostname_tokens(node_name))
return hostnames
@bp.route('/api/reports/summary', methods=['GET'])
@require_auth()
def get_reports_summary():
"""Get summary report across all clusters
Query params:
- period: 'hour', 'day', 'week' (default: day)
"""
period = request.args.get('period', 'day')
# NS: Feb 2026 - tenant filtering for multi-tenant security
usr = getattr(request, 'session', {}).get('user', 'system')
users_db = load_users()
user_data = users_db.get(usr, {})
accessible_clusters = get_user_clusters(user_data) # None = admin (all clusters)
history = load_metrics_history()
snapshots = history.get('snapshots', [])
if not snapshots:
return jsonify({'error': 'No historical data available yet'}), 404
# Filter by period
now = datetime.now()
if period == 'hour':
cutoff = now - timedelta(hours=1)
elif period == 'week':
cutoff = now - timedelta(days=7)
else: # day
cutoff = now - timedelta(days=1)
cutoff_str = cutoff.isoformat()
filtered = [s for s in snapshots if s.get('timestamp', '') >= cutoff_str]
if not filtered:
return jsonify({'error': f'No data for the last {period}'}), 404
# Calculate averages and trends
report = {
'period': period,
'data_points': len(filtered),
'start_time': filtered[0].get('timestamp'),
'end_time': filtered[-1].get('timestamp'),
'clusters': {}
}
# Aggregate per cluster
for snapshot in filtered:
for cluster_id, cluster_data in snapshot.get('clusters', {}).items():
# Skip clusters the user cannot access
if accessible_clusters is not None and cluster_id not in accessible_clusters:
continue
if cluster_id not in report['clusters']:
report['clusters'][cluster_id] = {
'name': cluster_data.get('name', cluster_id),
'cpu_samples': [],
'mem_samples': [],
'vm_samples': []
}
totals = cluster_data.get('totals', {})
if totals.get('cpu_total', 0) > 0:
cpu_percent = totals['cpu_used'] / totals['cpu_total'] * 100
report['clusters'][cluster_id]['cpu_samples'].append(cpu_percent)
if totals.get('mem_total', 0) > 0:
mem_percent = totals['mem_used'] / totals['mem_total'] * 100
report['clusters'][cluster_id]['mem_samples'].append(mem_percent)
vm_count = totals.get('vms_running', 0) + totals.get('cts_running', 0)
report['clusters'][cluster_id]['vm_samples'].append(vm_count)
# Calculate stats
for cluster_id, data in report['clusters'].items():
cpu = data.pop('cpu_samples', [])
mem = data.pop('mem_samples', [])
vms = data.pop('vm_samples', [])
data['cpu'] = {
'avg': round(sum(cpu) / len(cpu), 1) if cpu else 0,
'min': round(min(cpu), 1) if cpu else 0,
'max': round(max(cpu), 1) if cpu else 0,
'current': round(cpu[-1], 1) if cpu else 0
}
data['memory'] = {
'avg': round(sum(mem) / len(mem), 1) if mem else 0,
'min': round(min(mem), 1) if mem else 0,
'max': round(max(mem), 1) if mem else 0,
'current': round(mem[-1], 1) if mem else 0
}
data['vms_running'] = {
'avg': round(sum(vms) / len(vms), 1) if vms else 0,
'min': min(vms) if vms else 0,
'max': max(vms) if vms else 0,
'current': vms[-1] if vms else 0
}
return jsonify(report)
@bp.route('/api/syslog/events', methods=['GET'])
@require_auth(perms=['admin.audit'])
def get_integrated_syslog_events():
"""Paginated overview of events stored by the integrated syslog server."""
try:
page = max(int(request.args.get('page', 1)), 1)
except (TypeError, ValueError):
page = 1
try:
per_page = int(request.args.get('per_page', 50))
except (TypeError, ValueError):
per_page = 50
per_page = min(max(per_page, 1), 50)
search = (request.args.get('search') or '').strip()
severity = (request.args.get('severity') or '').strip()
protocol = (request.args.get('protocol') or '').strip().upper()
hostname = (request.args.get('hostname') or '').strip()
source_ip = (request.args.get('source_ip') or '').strip()
facility = (request.args.get('facility') or '').strip()
cluster_id = (request.args.get('cluster_id') or '').strip()
sort_map = {
'id': 'logs.id',
'timestamp': 'logs.timestamp',
'source_ip': 'logs.source_ip',
'hostname': 'logs.hostname',
'facility': 'logs.facility',
'severity': 'logs.severity',
'severity_text': 'logs.severity_text',
'message': 'logs.message',
'protocol': 'logs.protocol',
}
sort_by = sort_map.get(request.args.get('sort_by', 'timestamp'), 'timestamp')
sort_dir = 'asc' if request.args.get('sort_dir', 'desc').lower() == 'asc' else 'desc'
# NS: belt-and-braces gegen die SQLi-finding bei semgrep am 2026-05-06.
# sort_by ist eh aus dem map.get() mit hardcoded fallback 'timestamp', und
# sort_dir aus dem ternary nur asc/desc -- aber semgrep sieht das nicht
# weil der validate-step 80 zeilen frueher steht. hier nochmal explizit
# damit's am sink offensichtlich ist und ich nicht in einem halben jahr
# an dieser stelle nochmal grübeln muss.
if sort_by not in sort_map.values():
sort_by = 'logs.timestamp'
if sort_dir not in ('asc', 'desc'):
sort_dir = 'desc'
db_path = os.path.abspath(DB_FILE)
if not os.path.exists(db_path):
return jsonify({
'items': [],
'pagination': {'page': page, 'per_page': per_page, 'total': 0, 'total_pages': 0},
'filters': {
'protocols': [],
'severities': [{'value': level, 'label': text} for level, text in sorted(SEVERITY_MAP.items())]
}
})
where = []
params = []
joins = []
# MK May 2026: route via dbcrypto so SQLCipher handshake runs first.
conn = dbcrypto.connect(db_path)
conn.row_factory = dbcrypto.Row
conn.execute("PRAGMA temp_store=MEMORY")
fts_available = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'logs_fts'"
).fetchone() is not None
if search:
fts_query = _syslog_fts_query(search)
if fts_available and fts_query:
joins.append("JOIN logs_fts ON logs_fts.rowid = logs.id")
where.append("logs_fts MATCH ?")
params.append(fts_query)
else:
like_clause, like_params = _syslog_like_clause(search)
where.append(like_clause)
params.extend(like_params)
if severity != '':
try:
severity_value = int(severity)
where.append('logs.severity = ?')
params.append(severity_value)
except ValueError:
pass
if protocol:
where.append("logs.protocol = ?")
params.append(protocol)
if hostname:
where.append("logs.hostname LIKE ? COLLATE NOCASE")
params.append(f'{hostname}%')
if source_ip:
where.append("logs.source_ip LIKE ? COLLATE NOCASE")
params.append(f'{source_ip}%')
if facility != '':
try:
facility_value = int(facility)
where.append('logs.facility = ?')
params.append(facility_value)
except ValueError:
pass
if cluster_id and load_server_settings().get('syslog_filter_by_selected_cluster', False):
ok, err = check_cluster_access(cluster_id)
if not ok:
return err
cluster_hostnames = sorted(_syslog_cluster_hostnames(cluster_id))
if cluster_hostnames:
cluster_hostname_where = []
for value in cluster_hostnames:
cluster_hostname_where.append("LOWER(logs.hostname) = ?")
params.append(value)
cluster_hostname_where.append("LOWER(logs.hostname) LIKE ?")
params.append(f"{value}.%")
where.append(f"({' OR '.join(cluster_hostname_where)})")
else:
where.append("1 = 0")
where_sql = f"WHERE {' AND '.join(where)}" if where else ''
joins_sql = f"{' '.join(joins)}" if joins else ''
offset = (page - 1) * per_page
try:
total = conn.execute(
f"SELECT COUNT(*) AS count FROM logs {joins_sql} {where_sql}",
params
).fetchone()['count']
rows = conn.execute(
f"""
SELECT
logs.id,
logs.timestamp,
logs.source_ip,
logs.hostname,
logs.facility,
logs.severity,
logs.severity_text,
logs.message,
logs.protocol
FROM logs
{joins_sql}
{where_sql}
ORDER BY {sort_by} {sort_dir}, logs.id DESC
LIMIT ? OFFSET ?
""",
[*params, per_page, offset]
).fetchall()
protocol_rows = conn.execute(
"""
SELECT DISTINCT protocol
FROM logs
WHERE protocol IS NOT NULL AND TRIM(protocol) != ''
ORDER BY protocol ASC
"""
).fetchall()
finally:
conn.close()
total_pages = (total + per_page - 1) // per_page if total else 0
return jsonify({
'items': [dict(row) for row in rows],
'pagination': {
'page': page,
'per_page': per_page,
'total': total,
'total_pages': total_pages,
},
'filters': {
'protocols': [row['protocol'] for row in protocol_rows],
'severities': [{'value': level, 'label': text} for level, text in sorted(SEVERITY_MAP.items())]
}
})
@bp.route('/api/reports/timeline', methods=['GET'])
@require_auth()
def get_reports_timeline():
"""Get timeline data for charts
Query params:
- period: 'hour', 'day', 'week'
- cluster_id: Optional - filter to specific cluster
- metric: 'cpu', 'memory', 'vms' (default: all)
"""
period = request.args.get('period', 'day')
filter_cluster = request.args.get('cluster_id')
metric = request.args.get('metric', 'all')
# NS: Feb 2026 - tenant filtering for multi-tenant security
usr = getattr(request, 'session', {}).get('user', 'system')
users_db = load_users()
user_data = users_db.get(usr, {})
accessible_clusters = get_user_clusters(user_data) # None = admin (all clusters)
history = load_metrics_history()
snapshots = history.get('snapshots', [])
if not snapshots:
return jsonify({'error': 'No historical data available'}), 404
# Filter by period
now = datetime.now()
if period == 'hour':
cutoff = now - timedelta(hours=1)
elif period == 'week':
cutoff = now - timedelta(days=7)
else:
cutoff = now - timedelta(days=1)
cutoff_str = cutoff.isoformat()
filtered = [s for s in snapshots if s.get('timestamp', '') >= cutoff_str]
# Build timeline
timeline = {
'period': period,
'timestamps': [],
'data': {}
}
for snapshot in filtered:
timestamp = snapshot.get('timestamp', '')
timeline['timestamps'].append(timestamp)
for cluster_id, cluster_data in snapshot.get('clusters', {}).items():
if filter_cluster and cluster_id != filter_cluster:
continue
# Skip clusters the user cannot access
if accessible_clusters is not None and cluster_id not in accessible_clusters:
continue
if cluster_id not in timeline['data']:
timeline['data'][cluster_id] = {
'name': cluster_data.get('name', cluster_id),
'cpu': [],
'memory': [],
'vms': []
}
totals = cluster_data.get('totals', {})
# CPU
if metric in ['all', 'cpu']:
cpu = 0
if totals.get('cpu_total', 0) > 0:
cpu = round(totals['cpu_used'] / totals['cpu_total'] * 100, 1)
timeline['data'][cluster_id]['cpu'].append(cpu)
# Memory
if metric in ['all', 'memory']:
mem = 0
if totals.get('mem_total', 0) > 0:
mem = round(totals['mem_used'] / totals['mem_total'] * 100, 1)
timeline['data'][cluster_id]['memory'].append(mem)
# VMs
if metric in ['all', 'vms']:
vms = totals.get('vms_running', 0) + totals.get('cts_running', 0)
timeline['data'][cluster_id]['vms'].append(vms)
return jsonify(timeline)
@bp.route('/api/reports/top-vms', methods=['GET'])
@require_auth()
def get_top_vms():
"""Get top VMs by resource usage
Query params:
- metric: 'cpu' or 'memory' (default: cpu)
- limit: Number of results (default: 10)
"""
metric = request.args.get('metric', 'cpu')
limit = int(request.args.get('limit', 10))
# NS: Feb 2026 - tenant filtering for multi-tenant security
usr = getattr(request, 'session', {}).get('user', 'system')
users_db = load_users()
user_data = users_db.get(usr, {})
accessible_clusters = get_user_clusters(user_data) # None = admin (all clusters)
vms = []
for cluster_id, mgr in cluster_managers.items():
# Skip clusters the user cannot access
if accessible_clusters is not None and cluster_id not in accessible_clusters:
continue
if not mgr.is_connected:
continue
try:
resources = mgr.get_vm_resources()
for r in resources:
if r.get('status') != 'running':
continue
vm_data = {
'cluster_id': cluster_id,
'cluster_name': mgr.config.name,
'vmid': r.get('vmid'),
'name': r.get('name'),
'node': r.get('node'),
'type': r.get('type'),
'cpu': r.get('cpu', 0),
'mem': r.get('mem', 0),
'maxmem': r.get('maxmem', 0),
'mem_percent': round(r.get('mem', 0) / max(r.get('maxmem', 1), 1) * 100, 1)
}
vms.append(vm_data)
except:
pass
# Sort by metric
if metric == 'memory':
vms.sort(key=lambda x: x.get('mem_percent', 0), reverse=True)
else:
vms.sort(key=lambda x: x.get('cpu', 0), reverse=True)
return jsonify(vms[:limit])
# Start background threads when server starts
# MK: Move this to main() later, for now it's fine here
threading.Thread(target=lambda: (time.sleep(5), start_scheduler()), daemon=True).start()
threading.Thread(target=lambda: (time.sleep(10), start_metrics_collector()), daemon=True).start()
# ============================================
# CVE / Package Vulnerability Scanner
# MK: Mar 2026 - per-node security scanning
# ============================================
@bp.route('/api/clusters/<cluster_id>/reports/cve-scan', methods=['POST'])
@require_auth(perms=['node.view'])
def scan_all_nodes_cves(cluster_id):
"""Scan all nodes in a cluster for package vulnerabilities"""
ok, err = check_cluster_access(cluster_id)
if not ok:
return err
if cluster_id not in cluster_managers:
return jsonify({'error': 'Cluster not found'}), 404
mgr = cluster_managers[cluster_id]
if not mgr.is_connected:
return jsonify({'error': 'Cluster not connected'}), 503
try:
node_status = mgr.get_node_status()
except:
return jsonify({'error': 'Failed to get node list'}), 500
results = []
for node_name in node_status:
# #199: skip offline nodes — no point trying SSH on dead nodes
ns = node_status.get(node_name, {})
if ns.get('offline') or ns.get('status') == 'offline':
results.append({'node': node_name, 'error': 'Node offline'})
continue
try:
scan = mgr.scan_node_packages(node_name)
results.append(scan)
except Exception as e:
results.append({'node': node_name, 'error': str(e)})
total_sec = sum(r.get('security_count', 0) for r in results)
total_upd = sum(r.get('total_count', 0) for r in results)
total_cves = sum(r.get('cve_count', 0) for r in results)
has_debsecan = any(r.get('debsecan_available') for r in results)
return jsonify({
'cluster_id': cluster_id,
'cluster_name': getattr(mgr.config, 'name', cluster_id),
'scanned_at': datetime.now().isoformat(),
'nodes': results,
'summary': {
'nodes_scanned': len(results),
'nodes_ok': sum(1 for r in results if not r.get('error') and r.get('cve_count', 0) == 0 and r.get('security_count', 0) == 0),
'total_cves': total_cves,
'total_security': total_sec,
'total_updates': total_upd,
'debsecan_available': has_debsecan,
}
})
@bp.route('/api/clusters/<cluster_id>/nodes/<node>/cve-scan', methods=['POST'])
@require_auth(perms=['node.view'])
def scan_single_node_cves(cluster_id, node):
"""Scan a single node for package vulnerabilities"""
ok, err = check_cluster_access(cluster_id)
if not ok:
return err
if cluster_id not in cluster_managers:
return jsonify({'error': 'Cluster not found'}), 404
mgr = cluster_managers[cluster_id]
if not mgr.is_connected:
return jsonify({'error': 'Cluster not connected'}), 503
result = mgr.scan_node_packages(node)
return jsonify(result)
@bp.route('/api/clusters/<cluster_id>/reports/install-debsecan', methods=['POST'])
@require_auth(perms=['node.maintenance'])
def install_debsecan(cluster_id):
"""Install debsecan on all nodes in the cluster via SSH"""
ok, err = check_cluster_access(cluster_id)
if not ok:
return err
if cluster_id not in cluster_managers:
return jsonify({'error': 'Cluster not found'}), 404
mgr = cluster_managers[cluster_id]
if not mgr.is_connected:
return jsonify({'error': 'Cluster not connected'}), 503
try:
node_status = mgr.get_node_status()
except:
return jsonify({'error': 'Failed to get node list'}), 500
results = []
for node_name in node_status:
out = mgr._ssh_node_output(node_name, 'DEBIAN_FRONTEND=noninteractive /usr/bin/apt-get install -y debsecan 2>&1 | tail -3', timeout=120)
if out is not None:
results.append({'node': node_name, 'success': True, 'output': out.strip()[-200:]})
else:
results.append({'node': node_name, 'success': False, 'error': 'SSH failed'})
ok_count = sum(1 for r in results if r['success'])
return jsonify({
'installed': ok_count,
'total': len(results),
'nodes': results
})
# ============================================
# CIS Hardening Endpoints - MK Mar 2026
# ============================================
@bp.route('/api/clusters/<cluster_id>/nodes/<node>/hardening', methods=['GET'])
@require_auth(perms=['node.maintenance'])
def check_hardening(cluster_id, node):
ok, err = check_cluster_access(cluster_id)
if not ok:
return err
if cluster_id not in cluster_managers:
return jsonify({'error': 'Cluster not found'}), 404
mgr = cluster_managers[cluster_id]
if not mgr.is_connected:
return jsonify({'error': 'Cluster offline'}), 503
# NS Apr 2026 (#322): verbose mode — returns per-control evidence for audit reports
verbose = str(request.args.get('verbose', '')).lower() in ('1', 'true', 'yes')
# NS Apr 2026 — profile filter; Harden PVE Node UI + Compliance Dashboard share these.
profile = (request.args.get('profile', '') or '').strip().lower() or None
if profile and profile not in {'cis-l1', 'cis-l2', 'vs-nfd', 'bsi', 'iso', 'nis2',
'cmmc1', 'cmmc2', 'nist53', 'stig', 'dr', 'rgs'}:
return jsonify({'error': f'unknown profile: {profile}'}), 400
result = mgr.check_node_hardening(node, verbose=verbose, profile=profile)
if result is None:
return jsonify({'error': f'SSH to {node} failed'}), 502
return jsonify({'node': node, 'controls': result, 'verbose': verbose, 'profile': profile or 'cis-l1'})
@bp.route('/api/clusters/<cluster_id>/nodes/<node>/hardening', methods=['POST'])
@require_auth(perms=['node.maintenance'])
def apply_hardening(cluster_id, node):
"""Apply selected CIS controls"""
ok, err = check_cluster_access(cluster_id)
if not ok:
return err
if cluster_id not in cluster_managers:
return jsonify({'error': 'Cluster not found'}), 404
mgr = cluster_managers[cluster_id]
if not mgr.is_connected:
return jsonify({'error': 'Cluster offline'}), 503
data = request.get_json() or {}
controls = data.get('controls', [])
if not controls:
return jsonify({'error': 'No controls specified'}), 400
ctrl_params = data.get('params', {})
results = mgr.apply_node_hardening(node, controls, params=ctrl_params)
ok_count = sum(1 for v in results.values() if v.get('success'))
from pegaprox.utils.audit import log_audit
log_audit('node.hardening_applied', {
'node': node, 'controls': controls,
'success': ok_count, 'total': len(controls)
})
return jsonify({
'node': node, 'results': results,
'applied': ok_count, 'total': len(controls)
})
@bp.route('/api/clusters/<cluster_id>/nodes/<node>/hardening/rollback', methods=['POST'])
@require_auth(perms=['node.maintenance'])
def rollback_hardening(cluster_id, node):
"""Restore selected CIS controls to their pre-apply state (#386)"""
ok, err = check_cluster_access(cluster_id)
if not ok:
return err
if cluster_id not in cluster_managers:
return jsonify({'error': 'Cluster not found'}), 404
mgr = cluster_managers[cluster_id]
if not mgr.is_connected:
return jsonify({'error': 'Cluster offline'}), 503
data = request.get_json() or {}
controls = data.get('controls', [])
if not controls:
return jsonify({'error': 'No controls specified'}), 400
results = mgr.rollback_node_hardening(node, controls)
ok_count = sum(1 for v in results.values() if v.get('success'))
from pegaprox.utils.audit import log_audit
log_audit('node.hardening_rolled_back', {
'node': node, 'controls': controls,
'restored': ok_count, 'total': len(controls)
})
return jsonify({
'node': node, 'results': results,
'restored': ok_count, 'total': len(controls)
})
# ============================================
# Compliance framework mapping - MK Apr 2026
# Frontend pulls these to render the compliance PDFs with real
# CMMC / NIST / STIG / ISO / BSI control IDs instead of our internal
# names like "pam_faillock". See pegaprox/core/compliance_mapping.py
# ============================================
@bp.route('/api/compliance/mapping', methods=['GET'])
@require_auth()
def compliance_mapping_api():
framework = (request.args.get('framework', '') or '').strip().lower()
from pegaprox.core import compliance_mapping as cm
payload_full = {
'family_labels': cm.FAMILY_LABELS,
'mappings': cm.FRAMEWORK_MAPPING,
'remediation': cm.REMEDIATION,
'severity': cm.SEVERITY,
'recommended_timeline': cm.RECOMMENDED_TIMELINE,
'priority_level': cm.PRIORITY_LEVEL,
'framework_meta': cm.FRAMEWORK_META,
'posture_levels': cm.POSTURE_LEVELS,
'glossary': cm.GLOSSARY,
'methodology': cm.METHODOLOGY,
}
if not framework:
return jsonify(payload_full)
if framework not in cm.FRAMEWORK_MAPPING:
return jsonify({'error': f'unknown framework: {framework}'}), 400
return jsonify({
'framework': framework,
'family_labels': cm.FAMILY_LABELS,
'mapping': cm.FRAMEWORK_MAPPING[framework],
'remediation': cm.REMEDIATION,
'severity': cm.SEVERITY,
'recommended_timeline': cm.RECOMMENDED_TIMELINE,
'priority_level': cm.PRIORITY_LEVEL,
'framework_meta': cm.FRAMEWORK_META.get(framework, {}),
'posture_levels': cm.POSTURE_LEVELS,
'glossary': cm.GLOSSARY,
'methodology': cm.METHODOLOGY,
})
# ============================================
# Legacy Fallback Endpoints
# old tags endpoints, kept for compat, these prevent 404s
# ============================================
@bp.route('/api/tags', methods=['GET'])
@require_auth()
def get_tags_legacy():
"""Legacy: Returns empty for old Settings UI"""
return jsonify({'tags': {}, 'available_tags': []})
@bp.route('/api/tags/available', methods=['GET'])
@require_auth()
def get_available_tags_legacy():
"""Legacy: Returns empty list"""
return jsonify([])
@bp.route('/api/tags/available', methods=['POST'])
@require_auth()
def create_tag_legacy():
"""Legacy: Redirect to cluster-based tags"""
return jsonify({'error': 'Please use VM-based tags (click tag icon on VMs)'}), 400
@bp.route('/api/tags/available/<tag_name>', methods=['DELETE'])
@require_auth()
def delete_tag_legacy(tag_name):
"""Legacy: No-op"""
return jsonify({'success': True})
# ============================================
# Cluster-Based Reports Endpoint
# reports are now per-cluster, not global
# ============================================
@bp.route('/api/clusters/<cluster_id>/reports/summary', methods=['GET'])
@require_auth()
def get_cluster_report_summary(cluster_id):
"""Get report summary for a specific cluster
Returns both historical data (if available) and current live data
"""
ok, err = check_cluster_access(cluster_id)
if not ok:
return err
if cluster_id not in cluster_managers:
return jsonify({'error': 'Cluster not found'}), 404
mgr = cluster_managers[cluster_id]
period = request.args.get('period', 'day')
# Get LIVE current data from cluster using get_node_status()
live_cpu = 0
live_vms = 0
live_cts = 0
cpu_total = 0
mem_total = 0
mem_used = 0
nodes_online = 0
if mgr.is_connected:
try:
# Use get_node_status which actually fetches live data from Proxmox
node_status = mgr.get_node_status()
for node_name, node_data in node_status.items():
if not node_data:
continue
# Check status
status = node_data.get('status', '')
if status in ['online', 'running']:
nodes_online += 1
# CPU and memory from get_node_status are already percentages
cpu_pct = node_data.get('cpu_percent', 0) or 0
mem_pct = node_data.get('mem_percent', 0) or 0
mem_t = node_data.get('mem_total', 0) or 0
mem_u = node_data.get('mem_used', 0) or 0
# Accumulate (we'll average later)
live_cpu += cpu_pct
mem_total += mem_t
mem_used += mem_u
# Average CPU across nodes
if nodes_online > 0:
live_cpu = live_cpu / nodes_online
except Exception as e:
logging.error(f"Error getting node status for reports: {e}")
# Count running VMs
try:
resources = mgr.get_vm_resources() or []
for r in resources:
if r and r.get('status') == 'running':
if r.get('type') == 'qemu':
live_vms += 1
else:
live_cts += 1
except Exception as e:
logging.error(f"Error getting VM resources: {e}")
# Calculate live percentages
live_cpu_pct = round(live_cpu, 1)
live_mem_pct = round(mem_used / max(mem_total, 1) * 100, 1) if mem_total > 0 else 0
# Load historical metrics
history = load_metrics_history()
snapshots = history.get('snapshots', [])
# Filter by period
now = datetime.now()
if period == 'hour':
cutoff = now - timedelta(hours=1)
elif period == 'week':
cutoff = now - timedelta(days=7)
else:
cutoff = now - timedelta(days=1)
cutoff_str = cutoff.isoformat()
filtered = [s for s in snapshots if s.get('timestamp', '') >= cutoff_str]
# Extract data for this cluster only
report = {
'period': period,
'cluster_id': cluster_id,
'cluster_name': getattr(mgr.config, 'name', None) or cluster_id,
'data_points': 0,
'cpu': {'avg': live_cpu_pct, 'min': live_cpu_pct, 'max': live_cpu_pct, 'current': live_cpu_pct, 'samples': []},
'memory': {'avg': live_mem_pct, 'min': live_mem_pct, 'max': live_mem_pct, 'current': live_mem_pct, 'samples': []},
'vms_running': {'avg': live_vms + live_cts, 'min': live_vms + live_cts, 'max': live_vms + live_cts, 'current': live_vms + live_cts, 'samples': []},
'timestamps': [],
# Add live data section
'live': {
'cpu_percent': live_cpu_pct,
'mem_percent': live_mem_pct,
'vms_running': live_vms,
'cts_running': live_cts,
'cpu_total': cpu_total,
'mem_total': mem_total,
'mem_used': mem_used
}
}
for snapshot in filtered:
cluster_data = snapshot.get('clusters', {}).get(cluster_id)
if not cluster_data:
continue
report['timestamps'].append(snapshot.get('timestamp', ''))
report['data_points'] += 1
totals = cluster_data.get('totals', {})
# CPU
if totals.get('cpu_total', 0) > 0:
cpu = round(totals['cpu_used'] / totals['cpu_total'] * 100, 1)
report['cpu']['samples'].append(cpu)
# Memory
if totals.get('mem_total', 0) > 0:
mem = round(totals['mem_used'] / totals['mem_total'] * 100, 1)
report['memory']['samples'].append(mem)
# VMs
vms = totals.get('vms_running', 0) + totals.get('cts_running', 0)
report['vms_running']['samples'].append(vms)
# Calculate stats from historical data (keep samples for charts)
for metric in ['cpu', 'memory', 'vms_running']:
samples = report[metric].get('samples', [])
if samples:
report[metric]['avg'] = round(sum(samples) / len(samples), 1)
report[metric]['min'] = round(min(samples), 1)
report[metric]['max'] = round(max(samples), 1)
report[metric]['current'] = round(samples[-1], 1) if samples else report[metric]['current']
return jsonify(report)
# predictive-analysis endpoint moved to api/clusters.py (was duplicate registration —
# clusters_bp wins URL match anyway). LW May 2026.