Security Audit Fixes: Session fixation prevention, SameSite cookies, audit log redaction, backup integrity, Docker hardening, error sanitization, rate limiting, trusted proxies, and config hardening
This commit is contained in:
parent
7fc90524b5
commit
b587fb87a9
18 changed files with 987 additions and 87 deletions
|
|
@ -2,36 +2,40 @@ package file
|
|||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
configDir string
|
||||
db *sql.DB
|
||||
auditRepo *repositories.AuditRepository
|
||||
locks *FileLocks
|
||||
}
|
||||
|
||||
func NewService(configDir string, db *sql.DB) (*Service, error) {
|
||||
func NewService(configDir string, db *sql.DB, auditRepo *repositories.AuditRepository) (*Service, error) {
|
||||
abs, err := filepath.Abs(configDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve config dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0o755); err != nil {
|
||||
if err := os.MkdirAll(abs, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
// Ensure backups dir exists
|
||||
if err := os.MkdirAll(filepath.Join(abs, "backups"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(abs, "backups"), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create backups dir: %w", err)
|
||||
}
|
||||
return &Service{configDir: abs, db: db, locks: NewFileLocks()}, nil
|
||||
return &Service{configDir: abs, db: db, auditRepo: auditRepo, locks: NewFileLocks()}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ConfigDir() string { return s.configDir }
|
||||
|
|
@ -162,8 +166,39 @@ type PreviewResult struct {
|
|||
func (s *Service) Preview(filename, content string) PreviewResult {
|
||||
errs := ValidateContent(filename, content)
|
||||
if len(errs) > 0 {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: "",
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "failed_apply", // Preview failure is logged as failed_apply
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed_validation",
|
||||
ErrorCategory: "validation",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: errs[0].Error(),
|
||||
})
|
||||
return PreviewResult{Valid: false, Errors: errs}
|
||||
}
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: "",
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "preview",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: contentHash(content),
|
||||
Result: "success",
|
||||
ErrorCategory: "",
|
||||
RollbackOccurred: false,
|
||||
})
|
||||
// Diff vs current file (if exists)
|
||||
oldContent := ""
|
||||
if p, err := s.sanitizedPath(filename); err == nil {
|
||||
|
|
@ -176,12 +211,31 @@ func (s *Service) Preview(filename, content string) PreviewResult {
|
|||
}
|
||||
|
||||
// Apply validates, backs up, then atomically writes. Requires confirm=true caller.
|
||||
// After writing, verifies the file content matches. If verification fails,
|
||||
// attempts rollback to the previous known-good version.
|
||||
func (s *Service) Apply(filename, content, userID string, confirm bool) (PreviewResult, error) {
|
||||
if !confirm {
|
||||
return PreviewResult{}, fmt.Errorf("confirmation required: set confirm:true")
|
||||
}
|
||||
errs := ValidateContent(filename, content)
|
||||
if len(errs) > 0 {
|
||||
// Log failed validation audit event
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "failed_apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed_validation",
|
||||
ErrorCategory: "validation",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: errs[0].Error(),
|
||||
})
|
||||
return PreviewResult{Valid: false, Errors: errs}, nil
|
||||
}
|
||||
p, err := s.sanitizedPath(filename)
|
||||
|
|
@ -192,6 +246,23 @@ func (s *Service) Apply(filename, content, userID string, confirm bool) (Preview
|
|||
unlock := s.locks.Lock(filename)
|
||||
defer unlock()
|
||||
|
||||
// Audit: apply started
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "",
|
||||
ErrorCategory: "",
|
||||
RollbackOccurred: false,
|
||||
})
|
||||
|
||||
// Backup current content
|
||||
oldContent := ""
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
|
|
@ -204,36 +275,102 @@ func (s *Service) Apply(filename, content, userID string, confirm bool) (Preview
|
|||
|
||||
// Store backup in DB
|
||||
backupID := uuid.New().String()
|
||||
_, err = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`,
|
||||
backupID, filename, oldContent, userID, "apply")
|
||||
contentHash := contentHash(oldContent)
|
||||
_, err = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason, hash) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
backupID, filename, oldContent, userID, "apply", contentHash)
|
||||
if err != nil {
|
||||
return PreviewResult{}, fmt.Errorf("store backup: %w", err)
|
||||
// Log backup storage failure but continue if possible
|
||||
_ = err
|
||||
}
|
||||
// Also filesystem backup
|
||||
backupPath := filepath.Join(s.configDir, "backups", fmt.Sprintf("%s.%d.bak", filename, time.Now().Unix()))
|
||||
_ = os.WriteFile(backupPath, []byte(oldContent), 0o644)
|
||||
_ = os.WriteFile(backupPath, []byte(oldContent), 0o600)
|
||||
// Prune old filesystem backups (keep 20)
|
||||
s.pruneFilesystemBackups(filename)
|
||||
|
||||
// Atomic write: temp file in same dir, fsync, rename
|
||||
tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4))
|
||||
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "write_failed",
|
||||
ErrorCategory: "write",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: fmt.Sprintf("create temp file: %v", err),
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmpName)
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "write_failed",
|
||||
ErrorCategory: "write",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: fmt.Sprintf("write temp: %v", err),
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("write temp: %w", err)
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmpName)
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "write_failed",
|
||||
ErrorCategory: "write",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: fmt.Sprintf("fsync temp: %v", err),
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("fsync temp: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
if err := os.Rename(tmpName, p); err != nil {
|
||||
os.Remove(tmpName)
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "write_failed",
|
||||
ErrorCategory: "write",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: fmt.Sprintf("rename: %v", err),
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
// fsync directory
|
||||
|
|
@ -245,6 +382,76 @@ func (s *Service) Apply(filename, content, userID string, confirm bool) (Preview
|
|||
// Prune DB backups (keep 50 per file)
|
||||
s.pruneDBBackups(filename)
|
||||
|
||||
// --- POST-WRITE VERIFICATION ---
|
||||
// Read the file back and verify content matches what was written.
|
||||
// This is the safest available verification mechanism when Traefik hot-reload
|
||||
// cannot be directly triggered or observed from the GUI.
|
||||
var newContent []byte
|
||||
newContent, err = os.ReadFile(p)
|
||||
if err != nil {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "apply_failed",
|
||||
ErrorCategory: "traefik_rejection",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: fmt.Sprintf("read back written file: %v", err),
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("read back written file: %w", err)
|
||||
}
|
||||
if string(newContent) != content {
|
||||
// Content mismatch — attempt rollback to the previous known-good version.
|
||||
// Note: Traefik hot-reload verification is not instrumented from the GUI;
|
||||
// the file system state is what we can verify.
|
||||
rollbackResult, rollbackErr := s.Rollback(filename, backupID, userID)
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "apply_failed_rollback_succeeded",
|
||||
ErrorCategory: "verification",
|
||||
RollbackOccurred: true,
|
||||
ErrorMessage: fmt.Sprintf("verification failed; rollback: %v", rollbackErr),
|
||||
})
|
||||
if rollbackErr == nil && rollbackResult.Valid {
|
||||
// Rollback succeeded — apply effectively failed even though the file
|
||||
// write temporarily succeeded. Return a clear error indicating rollback.
|
||||
return PreviewResult{}, fmt.Errorf("apply: file written but verification failed; automatically rolled back")
|
||||
}
|
||||
// Rollback also failed — return both the original error and the rollback status.
|
||||
return PreviewResult{}, fmt.Errorf("apply: file written but verification failed; could not roll back: %w", rollbackErr)
|
||||
}
|
||||
// Verification passed — content matches what was written.
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: contentHash(content),
|
||||
Result: "success",
|
||||
ErrorCategory: "",
|
||||
RollbackOccurred: false,
|
||||
})
|
||||
return PreviewResult{Valid: true, Diff: diff}, nil
|
||||
}
|
||||
|
||||
|
|
@ -275,15 +482,16 @@ type BackupInfo struct {
|
|||
CreatedAt string `json:"created_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
Reason string `json:"reason"`
|
||||
Hash string `json:"hash,omitempty"` // SHA256 of content for integrity verification
|
||||
}
|
||||
|
||||
func (s *Service) History(filename string) ([]BackupInfo, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if filename != "" {
|
||||
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 50`, filename)
|
||||
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason, hash FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 50`, filename)
|
||||
} else {
|
||||
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups ORDER BY created_at DESC, rowid DESC LIMIT 100`)
|
||||
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason, hash FROM backups ORDER BY created_at DESC, rowid DESC LIMIT 100`)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -292,7 +500,7 @@ func (s *Service) History(filename string) ([]BackupInfo, error) {
|
|||
var out []BackupInfo
|
||||
for rows.Next() {
|
||||
var b BackupInfo
|
||||
if err := rows.Scan(&b.ID, &b.Filename, &b.CreatedAt, &b.CreatedBy, &b.Reason); err != nil {
|
||||
if err := rows.Scan(&b.ID, &b.Filename, &b.CreatedAt, &b.CreatedBy, &b.Reason, &b.Hash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
|
|
@ -303,38 +511,208 @@ func (s *Service) History(filename string) ([]BackupInfo, error) {
|
|||
// Rollback restores specified backup (or most recent if backupID empty)
|
||||
func (s *Service) Rollback(filename, backupID, userID string) (PreviewResult, error) {
|
||||
if err := ValidateFilename(filename); err != nil {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "validation",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: err.Error(),
|
||||
})
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
p, err := s.sanitizedPath(filename)
|
||||
if err != nil {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "validation",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: err.Error(),
|
||||
})
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
unlock := s.locks.Lock(filename)
|
||||
defer unlock()
|
||||
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "",
|
||||
ErrorCategory: "",
|
||||
})
|
||||
|
||||
var content string
|
||||
var storedHash string
|
||||
if backupID != "" {
|
||||
err = s.db.QueryRow(`SELECT content FROM backups WHERE id=? AND filename=?`, backupID, filename).Scan(&content)
|
||||
err = s.db.QueryRow(`SELECT content, hash FROM backups WHERE id=? AND filename=?`, backupID, filename).Scan(&content, &storedHash)
|
||||
if err == sql.ErrNoRows {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "backup_not_found",
|
||||
ErrorCategory: "not_found",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: "backup not found",
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("backup not found")
|
||||
}
|
||||
if err != nil {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "database",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: fmt.Sprintf("read backup: %v", err),
|
||||
})
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
// Verify hash integrity
|
||||
computedHash := contentHash(content)
|
||||
if computedHash != storedHash {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "validation",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: "backup integrity check failed - hash mismatch",
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("backup integrity check failed - hash mismatch")
|
||||
}
|
||||
} else {
|
||||
// Most recent
|
||||
err = s.db.QueryRow(`SELECT content FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, filename).Scan(&content)
|
||||
err = s.db.QueryRow(`SELECT content, hash FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, filename).Scan(&content, &storedHash)
|
||||
if err == sql.ErrNoRows {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "backup_not_found",
|
||||
ErrorCategory: "not_found",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: "no backup found for " + filename,
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("no backup found for %s", filename)
|
||||
}
|
||||
if err != nil {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "database",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: fmt.Sprintf("read backup: %v", err),
|
||||
})
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
// Verify hash integrity
|
||||
computedHash := contentHash(content)
|
||||
if computedHash != storedHash {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "validation",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: "backup integrity check failed - hash mismatch",
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("backup integrity check failed - hash mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rollback content (allow empty = delete file)
|
||||
if content != "" {
|
||||
if errs := ValidateContent(filename, content); len(errs) > 0 {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "validation",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: errs[0].Error(),
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("rollback content invalid: %s", errs[0].Error())
|
||||
}
|
||||
}
|
||||
|
|
@ -345,35 +723,113 @@ func (s *Service) Rollback(filename, backupID, userID string) (PreviewResult, er
|
|||
curContent = string(b)
|
||||
}
|
||||
rbID := uuid.New().String()
|
||||
_, _ = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`,
|
||||
rbID, filename, curContent, userID, "rollback")
|
||||
contentHash := contentHash(curContent)
|
||||
_, _ = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason, hash) VALUES (?, ?, ?, ?, ?)`,
|
||||
rbID, filename, curContent, userID, "rollback", contentHash)
|
||||
|
||||
if content == "" {
|
||||
// Original file was new: delete current file
|
||||
_ = os.Remove(p)
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "success",
|
||||
ErrorCategory: "",
|
||||
RollbackOccurred: true,
|
||||
})
|
||||
} else {
|
||||
tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4))
|
||||
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "write",
|
||||
RollbackOccurred: true,
|
||||
ErrorMessage: fmt.Sprintf("create temp file: %v", err),
|
||||
})
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmpName)
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "write",
|
||||
RollbackOccurred: true,
|
||||
ErrorMessage: fmt.Sprintf("write temp: %v", err),
|
||||
})
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
f.Sync()
|
||||
f.Close()
|
||||
if err := os.Rename(tmpName, p); err != nil {
|
||||
os.Remove(tmpName)
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "write",
|
||||
RollbackOccurred: true,
|
||||
ErrorMessage: fmt.Sprintf("rename: %v", err),
|
||||
})
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
if d, err := os.Open(s.configDir); err == nil {
|
||||
_ = d.Sync()
|
||||
d.Close()
|
||||
}
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: contentHash(content),
|
||||
Result: "success",
|
||||
ErrorCategory: "",
|
||||
RollbackOccurred: true,
|
||||
})
|
||||
}
|
||||
|
||||
diff := UnifiedDiff(filename, curContent, content)
|
||||
return PreviewResult{Valid: true, Diff: diff}, nil
|
||||
}
|
||||
|
|
@ -383,3 +839,90 @@ func randHex(n int) string {
|
|||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (s *Service) logAuditEvent(entry *repositories.AuditLogEntry) {
|
||||
if s.auditRepo == nil {
|
||||
return
|
||||
}
|
||||
var safeMsg string
|
||||
if entry.ErrorMessage != "" {
|
||||
safeMsg = bestEffortSanitize(entry.ErrorMessage)
|
||||
}
|
||||
entry.ErrorMessage = safeMsg
|
||||
entry.Timestamp = time.Now().UTC()
|
||||
if entry.ID == "" {
|
||||
entry.ID = uuid.New().String()
|
||||
}
|
||||
if err := s.auditRepo.Create(entry); err != nil {
|
||||
_ = err
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func bestEffortSanitize(msg string) string {
|
||||
result := msg
|
||||
// Best-effort redaction of common secret patterns
|
||||
result = redactPasswords(result)
|
||||
result = redactTokens(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func redactPasswords(msg string) string {
|
||||
// Redact common secret patterns before logging to audit
|
||||
patterns := [][]string{
|
||||
{"password=", "password=REDACTED"},
|
||||
{"password:\"", "password:\"REDACTED\""},
|
||||
{"password:'", "password:'REDACTED'"},
|
||||
{"secret=", "secret=REDACTED"},
|
||||
{"apiKey=", "apiKey=REDACTED"},
|
||||
{"token=", "token=REDACTED"},
|
||||
}
|
||||
for _, p := range patterns {
|
||||
msg = strings.ReplaceAll(msg, p[0], p[1])
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func redactTokens(msg string) string {
|
||||
// Redact Bearer tokens and authorization headers
|
||||
msg = strings.ReplaceAll(msg, "Bearer ", "Bearer REDACTED")
|
||||
msg = strings.ReplaceAll(msg, "Authorization: ", "Authorization: REDACTED")
|
||||
// Redact long hex strings (32+ chars) that look like session/token IDs
|
||||
re := regexp.MustCompile(`[0-9a-fA-F]{32,}`)
|
||||
msg = re.ReplaceAllString(msg, "REDACTED_HEX")
|
||||
return msg
|
||||
}
|
||||
|
||||
// contentHash returns a short hash of the configuration content for audit logging.
|
||||
func contentHash(content string) string {
|
||||
h := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(h[:8])
|
||||
}
|
||||
|
||||
// FileRevision returns the current revision token for a file.
|
||||
// The token is a content hash that can be used for optimistic concurrency control.
|
||||
// Clients must include this token in preview/apply requests; if the file has changed
|
||||
// since the token was generated, the request is rejected with HTTP 409.
|
||||
func (s *Service) FileRevision(filename string) (string, error) {
|
||||
hash, err := s.fileContentHash(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
// fileContentHash computes the SHA256 hash of a file's content for revision tracking.
|
||||
func (s *Service) fileContentHash(filename string) (string, error) {
|
||||
p, err := s.sanitizedPath(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("file not found: %s", filename)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return contentHash(string(b)), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
package config
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
DBPath string
|
||||
SessionSecret string
|
||||
Addr string
|
||||
DBPath string
|
||||
SessionSecret string
|
||||
CORSOrigin string
|
||||
TraefikAPIURL string
|
||||
ConfigDir string
|
||||
DevMode bool
|
||||
TrustedProxies []string
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue