feat: Security Audit Auto-Fix Buttons + manuelle Dokumentation (docs/security-audit.md)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user