feat: Security Audit Auto-Fix Buttons + manuelle Dokumentation (docs/security-audit.md)

This commit is contained in:
sysops
2026-03-17 15:23:31 +01:00
parent 4668150727
commit 5e69c29f16
4 changed files with 498 additions and 40 deletions
+121
View File
@@ -106,6 +106,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/admin/system/stats", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSystemStats)))
s.mux.HandleFunc("GET /api/admin/security/audit", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSecurityAudit)))
s.mux.HandleFunc("POST /api/admin/security/fix", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSecurityFix)))
// Export routes
s.mux.HandleFunc("GET /api/export/pdf/{id}", s.authMiddleware(s.requireMailAccess(s.handleExportPDF)))
@@ -1598,3 +1599,123 @@ func (s *Server) handleSecurityAudit(w http.ResponseWriter, r *http.Request) {
"run_at": time.Now().UTC().Format(time.RFC3339),
})
}
// ── Security Fix ────────────────────────────────────────────────────────────
// allowedFixActions is a strict whitelist — only these actions may be executed.
var allowedFixActions = map[string]bool{
"install_fail2ban": true,
"enable_firewall": true,
"fix_ssh_password_auth": true,
"fix_ssh_root_login": true,
}
func (s *Server) handleSecurityFix(w http.ResponseWriter, r *http.Request) {
var body struct {
Action string `json:"action"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if !allowedFixActions[body.Action] {
writeError(w, http.StatusBadRequest, "unknown action: "+body.Action)
return
}
ctx := r.Context()
var msg string
var fixErr error
switch body.Action {
case "install_fail2ban":
// Install fail2ban if not present
if _, err := exec.LookPath("fail2ban-client"); err != nil {
out, err := exec.CommandContext(ctx, "apt-get", "install", "-y", "fail2ban").CombinedOutput()
if err != nil {
writeError(w, http.StatusInternalServerError, "apt-get install fail2ban: "+string(out))
return
}
}
// Write a minimal jail.local if not already present
jailPath := "/etc/fail2ban/jail.local"
if _, err := os.Stat(jailPath); os.IsNotExist(err) {
jailConf := "[sshd]\nenabled = true\nmaxretry = 5\nbantime = 3600\nfindtime = 600\n"
if err := os.WriteFile(jailPath, []byte(jailConf), 0644); err != nil {
writeError(w, http.StatusInternalServerError, "could not write jail.local: "+err.Error())
return
}
}
// Enable and start
exec.CommandContext(ctx, "systemctl", "enable", "fail2ban").Run()
out, err := exec.CommandContext(ctx, "systemctl", "restart", "fail2ban").CombinedOutput()
if err != nil {
fixErr = fmt.Errorf("systemctl restart fail2ban: %s", string(out))
} else {
msg = "Fail2ban installiert, SSH-Jail aktiviert und Dienst gestartet."
}
case "enable_firewall":
// Reload rules from /etc/nftables.conf and enable service
out, err := exec.CommandContext(ctx, "nft", "-f", "/etc/nftables.conf").CombinedOutput()
if err != nil {
writeError(w, http.StatusInternalServerError, "nft -f /etc/nftables.conf: "+string(out))
return
}
exec.CommandContext(ctx, "systemctl", "enable", "nftables").Run()
msg = "nftables-Regeln neu geladen und Dienst aktiviert."
case "fix_ssh_password_auth":
fixErr = sshConfigSet("PasswordAuthentication", "no")
if fixErr == nil {
out, err := exec.CommandContext(ctx, "systemctl", "restart", "ssh").CombinedOutput()
if err != nil {
fixErr = fmt.Errorf("systemctl restart ssh: %s", string(out))
} else {
msg = "PasswordAuthentication auf 'no' gesetzt, SSH neu gestartet."
}
}
case "fix_ssh_root_login":
fixErr = sshConfigSet("PermitRootLogin", "prohibit-password")
if fixErr == nil {
out, err := exec.CommandContext(ctx, "systemctl", "restart", "ssh").CombinedOutput()
if err != nil {
fixErr = fmt.Errorf("systemctl restart ssh: %s", string(out))
} else {
msg = "PermitRootLogin auf 'prohibit-password' gesetzt, SSH neu gestartet."
}
}
}
if fixErr != nil {
writeError(w, http.StatusInternalServerError, fixErr.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": msg})
}
// sshConfigSet sets or replaces a directive in /etc/ssh/sshd_config.
// Commented-out lines are left untouched; the active directive is updated or appended.
func sshConfigSet(key, value string) error {
const path = "/etc/ssh/sshd_config"
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read sshd_config: %w", err)
}
lines := strings.Split(string(data), "\n")
keyLower := strings.ToLower(key)
found := false
for i, l := range lines {
tl := strings.ToLower(strings.TrimSpace(l))
if strings.HasPrefix(tl, keyLower) && !strings.HasPrefix(strings.TrimSpace(l), "#") {
lines[i] = key + " " + value
found = true
}
}
if !found {
lines = append(lines, key+" "+value)
}
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644)
}