Files
ServerKit/backend/app/api/security.py
Juan DenisandClaude Fable 5 9f92850cbd feat(security)!: extract the install-gated security suite to extensions (plan 47 Ph3b-5 / plan 55 Ph3)
The 1791-line security omnibus splits along tool lines and the install-gated
surfaces leave core for five standalone extensions - serverkit-clamav
(ClamAV+YARA scanning, quarantine, scan jobs), serverkit-fail2ban
(management UI; probes stay core on Fail2banJailService), serverkit-lynis,
serverkit-auto-updates, and serverkit-image-scan (headless CVE/SBOM
scanning; models stay core per the plan 52 D1 data seam). Each mounts its
routes back at the same /api/v1/security prefix when installed, so route
shapes never change; absent extensions mean plain 404s, no dead UI.

Core keeps the zero-host-package baseline: status/config, legacy integrity,
suspicious activity/events, SSH keys, IP lists, the audit (fail2ban check
now probes via the jail service; updates check was already self-contained),
scoped FIM, and the scan/alert logs + notification helpers extensions write
through. Job handlers for security.malware_scan/lynis_scan/image_scan now
come exclusively from the extensions' manifest jobs key.

Security page becomes core-tabs-only; extension tabs contribute into the
security tab group (3a groupId). Overview posture drops the hardcoded
ClamAV rows and offers the Marketplace when no security extension is
installed. Upgrade parity: run_registry_auto_install() installs each
extension from the registry once, gated on the host tool actually being
present, fail-soft until the registry entry is published.

Wizard round 2 (Ph5): a security-posture chooser (minimal/recommended/
hardened) on the Summary step, catalog-resolved so unpublished slugs never
render dead installs; persisted as onboarding_security_posture.

Moved test suites re-home to the extension repos; panel keeps
test_security_suite_extraction.py (18 proving tests: absent-shape 404s,
lean summary, jail-service probes, migration gates, posture wiring).
docs/API_SURFACE.md regenerated (34 operations moved out).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 13:53:04 -04:00

260 lines
9.1 KiB
Python

# Bucket: PER-APP (plan 29 #9). Per-app scan/security routes gate on the shared
# app-access seam; host-level surfaces stay admin-only.
#
# Lean core (plan 47 Ph3b-4 / plan 55 Phase 3): this blueprint keeps only the
# baseline that works with zero host packages — status/config, legacy
# integrity, suspicious activity, events, SSH keys, IP lists, the audit and
# scoped FIM. The install-gated tool surfaces (/clamav/*, /scan/*,
# /quarantine/*, /yara/*, /fail2ban/*, /lynis/*, /auto-updates/*,
# /image-scans/*, /sboms/*) are mounted at this same /api/v1/security prefix
# by their extensions (serverkit-clamav, serverkit-fail2ban, serverkit-lynis,
# serverkit-auto-updates, serverkit-image-scan) and 404 when absent.
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.middleware.rbac import admin_required
from app.services.security_service import SecurityService
from app.error_reporting import unexpected_response
security_bp = Blueprint('security', __name__)
# ==========================================
# STATUS & CONFIG
# ==========================================
@security_bp.route('/status', methods=['GET'])
@jwt_required()
def get_security_status():
"""Get overall security status summary."""
summary = SecurityService.get_security_summary()
return jsonify(summary), 200
@security_bp.route('/config', methods=['GET'])
@admin_required
def get_config():
"""Get security configuration."""
config = SecurityService.get_config()
return jsonify(config), 200
@security_bp.route('/config', methods=['PUT'])
@admin_required
def update_config():
"""Update security configuration."""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
current_config = SecurityService.get_config()
# Update nested config sections
for key in ['clamav', 'file_integrity', 'suspicious_activity', 'notifications']:
if key in data:
current_config[key] = {**current_config.get(key, {}), **data[key]}
result = SecurityService.save_config(current_config)
return jsonify(result), 200 if result['success'] else 400
# ==========================================
# FILE INTEGRITY
# ==========================================
@security_bp.route('/integrity/initialize', methods=['POST'])
@admin_required
def initialize_integrity():
"""Create baseline for file integrity monitoring."""
data = request.get_json() or {}
paths = data.get('paths')
result = SecurityService.initialize_integrity_database(paths)
return jsonify(result), 200 if result['success'] else 400
@security_bp.route('/integrity/check', methods=['GET'])
@admin_required
def check_integrity():
"""Check files against integrity database."""
result = SecurityService.check_file_integrity()
return jsonify(result), 200 if result['success'] else 400
# ==========================================
# SUSPICIOUS ACTIVITY
# ==========================================
@security_bp.route('/failed-logins', methods=['GET'])
@admin_required
def check_failed_logins():
"""Check for failed login attempts."""
hours = request.args.get('hours', 24, type=int)
result = SecurityService.check_failed_logins(hours)
return jsonify(result), 200 if result['success'] else 400
# ==========================================
# EVENTS & ALERTS
# ==========================================
@security_bp.route('/events', methods=['GET'])
@jwt_required()
def get_security_events():
"""Get recent security events/alerts."""
limit = request.args.get('limit', 100, type=int)
result = SecurityService.get_security_events(limit)
return jsonify(result), 200 if result['success'] else 400
# ==========================================
# SSH KEYS
# ==========================================
@security_bp.route('/ssh-keys', methods=['GET'])
@admin_required
def get_ssh_keys():
"""Get SSH authorized keys."""
user = request.args.get('user', 'root')
result = SecurityService.get_ssh_keys(user)
return jsonify(result), 200 if result['success'] else 400
@security_bp.route('/ssh-keys', methods=['POST'])
@admin_required
def add_ssh_key():
"""Add an SSH public key."""
data = request.get_json()
if not data or 'key' not in data:
return jsonify({'error': 'SSH key required'}), 400
user = data.get('user', 'root')
result = SecurityService.add_ssh_key(data['key'], user)
return jsonify(result), 200 if result['success'] else 400
@security_bp.route('/ssh-keys/<int:key_id>', methods=['DELETE'])
@admin_required
def remove_ssh_key(key_id):
"""Remove an SSH key."""
user = request.args.get('user', 'root')
result = SecurityService.remove_ssh_key(key_id, user)
return jsonify(result), 200 if result['success'] else 400
# ==========================================
# IP ALLOWLIST/BLOCKLIST
# ==========================================
@security_bp.route('/ip-lists', methods=['GET'])
@admin_required
def get_ip_lists():
"""Get IP allowlist and blocklist."""
result = SecurityService.get_ip_lists()
return jsonify(result), 200 if result['success'] else 400
@security_bp.route('/ip-lists/<list_type>', methods=['POST'])
@admin_required
def add_to_ip_list(list_type):
"""Add IP to allowlist or blocklist."""
data = request.get_json()
if not data or 'ip' not in data:
return jsonify({'error': 'IP address required'}), 400
comment = data.get('comment', '')
result = SecurityService.add_to_ip_list(data['ip'], list_type, comment)
return jsonify(result), 200 if result['success'] else 400
@security_bp.route('/ip-lists/<list_type>/<ip>', methods=['DELETE'])
@admin_required
def remove_from_ip_list(list_type, ip):
"""Remove IP from allowlist or blocklist."""
result = SecurityService.remove_from_ip_list(ip, list_type)
return jsonify(result), 200 if result['success'] else 400
# ==========================================
# SECURITY AUDIT
# ==========================================
@security_bp.route('/audit', methods=['GET'])
@admin_required
def generate_audit():
"""Generate a security audit report."""
result = SecurityService.generate_security_audit()
return jsonify(result), 200 if result['success'] else 400
# FILE INTEGRITY MONITORING (SCOPED FIM)
# ==========================================
# Baseline-and-diff over ServerKit-managed paths (nginx / systemd /
# opted-in app docroots). The legacy /integrity/* endpoints above are kept
# as-is for compatibility; this is the scoped surface the UI uses.
@security_bp.route('/fim', methods=['GET'])
@jwt_required()
def get_fim_status():
"""Get FIM scopes, baselines and last check results."""
from app.services.file_integrity_service import FileIntegrityService
return jsonify(FileIntegrityService.get_status()), 200
@security_bp.route('/fim/<scope>/baseline', methods=['POST'])
@admin_required
def fim_baseline(scope):
"""Create (or recreate) the baseline for a scope."""
from app.services.file_integrity_service import (
FileIntegrityService, FileIntegrityScopeError,
)
data = request.get_json(silent=True) or {}
options = data.get('options') if isinstance(data.get('options'), dict) else None
try:
result = FileIntegrityService.baseline(scope, options=options)
except FileIntegrityScopeError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
return jsonify(result), 200
@security_bp.route('/fim/<scope>/check', methods=['POST'])
@admin_required
def fim_check(scope):
"""Diff the scope against its baseline."""
from app.services.file_integrity_service import (
FileIntegrityService, FileIntegrityScopeError,
)
try:
result = FileIntegrityService.check(scope)
except FileIntegrityScopeError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
return jsonify(result), 200
@security_bp.route('/fim/<scope>/accept', methods=['POST'])
@admin_required
def fim_accept(scope):
"""Accept current state (re-baseline the scope)."""
from app.services.file_integrity_service import (
FileIntegrityService, FileIntegrityScopeError,
)
try:
result = FileIntegrityService.accept(scope)
except FileIntegrityScopeError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
return jsonify(result), 200
@security_bp.route('/fim/apps', methods=['PUT'])
@admin_required
def fim_set_app_optins():
"""Set the per-app FIM opt-in list: {app_ids: [1, 2, ...]}."""
from app.services.file_integrity_service import (
FileIntegrityService, FileIntegrityScopeError,
)
data = request.get_json(silent=True)
if not data or 'app_ids' not in data:
return jsonify({'error': 'app_ids required'}), 400
try:
ids = FileIntegrityService.set_app_optins(data['app_ids'])
except FileIntegrityScopeError as exc:
return jsonify({'error': str(exc)}), 400
return jsonify({'app_optins': ids}), 200