Files
hydrogen/modules/systemd/systemd_security.py

179 lines
5.9 KiB
Python

import os
import subprocess
from pathlib import Path
from core.base import BaseWorker
from core.schemas.results import AuditFindings, AuditResults
from core.schemas.status import AuditSeverity, AuditStatus
from .config import SystemdModuleConfig
SERVICE_DIRS = [
Path("/etc/systemd/system"),
Path("/usr/lib/systemd/system"),
Path("/run/systemd/system"),
]
class SystemdWorker(BaseWorker):
config_model = SystemdModuleConfig
def __init__(self, config: SystemdModuleConfig | None = None) -> None:
super().__init__(config)
self.module_config = config or SystemdModuleConfig()
def run(self) -> AuditResults:
if not self.module_config.enabled:
return AuditResults(
status=AuditStatus.SKIPPED,
findings=[],
risk_level=0.0,
)
if os.name != "posix":
return AuditResults(
status=AuditStatus.SKIPPED,
findings=[],
risk_level=0.0,
)
findings: list[AuditFindings] = []
if self.module_config.check_failed_services:
findings.extend(self._check_failed_services())
if self.module_config.check_masked_services:
findings.extend(self._check_masked_services())
if self.module_config.check_timers:
findings.extend(self._check_timers())
findings.extend(self._check_service_permissions())
return AuditResults(
status=AuditStatus.PASS if not findings else AuditStatus.FAIL,
findings=findings,
risk_level=self._calculate_risk(findings),
)
def _run_systemctl(self, *args: str) -> list[str]:
try:
result = subprocess.run(
["systemctl", *args],
capture_output=True,
text=True,
timeout=30,
)
return result.stdout.splitlines()
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
def _check_failed_services(self) -> list[AuditFindings]:
findings: list[AuditFindings] = []
output = self._run_systemctl("--failed", "--no-legend", "--no-pager")
for line in output:
parts = line.strip().split()
if len(parts) >= 3 and parts[1] == "failed":
findings.append(
AuditFindings(
name=f"failed_service_{parts[0]}",
description=f"Systemd service {parts[0]} is in failed state.",
severity=AuditSeverity.MEDIUM,
)
)
return findings
def _check_masked_services(self) -> list[AuditFindings]:
findings: list[AuditFindings] = []
output = self._run_systemctl("list-unit-files", "--no-legend", "--no-pager")
for line in output:
parts = line.strip().split()
if len(parts) >= 2 and parts[1] == "masked":
findings.append(
AuditFindings(
name=f"masked_service_{parts[0]}",
description=f"Service {parts[0]} is masked and cannot be started.",
severity=AuditSeverity.LOW,
)
)
return findings
def _check_timers(self) -> list[AuditFindings]:
findings: list[AuditFindings] = []
output = self._run_systemctl("list-timers", "--no-legend", "--no-pager")
for line in output:
if "n/a" in line and "n/a" in line.split()[:3]:
parts = line.strip().split()
if parts:
findings.append(
AuditFindings(
name=f"missed_timer_{parts[-1]}",
description=f"Timer {parts[-1]} has missed its scheduled trigger.",
severity=AuditSeverity.MEDIUM,
)
)
return findings
def _check_service_permissions(self) -> list[AuditFindings]:
findings: list[AuditFindings] = []
for service_dir in SERVICE_DIRS:
if not service_dir.exists():
continue
if not os.access(str(service_dir), os.R_OK | os.X_OK):
findings.append(
AuditFindings(
name=f"restricted_service_dir_{service_dir.name}",
description=f"Service directory {service_dir} is not accessible.",
severity=AuditSeverity.HIGH,
)
)
continue
for unit in sorted(service_dir.glob("*.service")):
self._check_unit_permissions(unit, findings)
return findings
def _check_unit_permissions(
self, unit: Path, findings: list[AuditFindings]
) -> None:
stat = unit.stat()
if stat.st_uid != 0:
findings.append(
AuditFindings(
name=f"{unit.stem}_not_owned_by_root",
description=f"Unit file {unit} should be owned by root.",
severity=AuditSeverity.HIGH,
)
)
if stat.st_mode & 0o022:
findings.append(
AuditFindings(
name=f"{unit.stem}_world_writable",
description=f"Unit file {unit} has overly permissive permissions.",
severity=AuditSeverity.CRITICAL,
)
)
def _calculate_risk(self, findings: list[AuditFindings]) -> float:
if not findings:
return 0.0
severity_scores = {
AuditSeverity.CRITICAL: 0.45,
AuditSeverity.HIGH: 0.3,
AuditSeverity.MEDIUM: 0.2,
AuditSeverity.LOW: 0.1,
}
risk = sum(severity_scores[finding.severity] for finding in findings)
return min(1.0, risk)