Files
Pulse/pkg/audit/export.go
rcourtman d06ed2edb3 refactor: Add testability improvements to core packages
hostagent/commands.go:
- Extract execCommandContext as mockable variable

hostagent/proxmox_setup.go:
- Convert stateFilePath constants to variables (testable)
- Extract runCommand and lookPath as mockable functions
- Add duplicate comment (minor cleanup needed)

notifications/notifications.go:
- Add GetQueueStats() method for interface compliance
- Used by NotificationMonitor interface

updates/manager.go:
- Add AddSSEClient, RemoveSSEClient, GetSSECachedStatus methods
- Enables interface-based SSE client management

pkg/audit/export.go:
- Minor testability improvements

go.mod/go.sum:
- Add stretchr/objx v0.5.2 (test mocking dependency)
2026-01-19 19:25:38 +00:00

258 lines
6.6 KiB
Go

package audit
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"time"
)
// ExportFormat defines the export file format.
type ExportFormat string
const (
ExportFormatCSV ExportFormat = "csv"
ExportFormatJSON ExportFormat = "json"
)
// ExportResult contains export data and metadata.
type ExportResult struct {
Data []byte
ContentType string
Filename string
EventCount int
}
// ExportEvent extends Event with verification status for exports.
type ExportEvent struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
EventType string `json:"event_type"`
User string `json:"user,omitempty"`
IP string `json:"ip,omitempty"`
Path string `json:"path,omitempty"`
Success bool `json:"success"`
Details string `json:"details,omitempty"`
Signature string `json:"signature,omitempty"`
SignatureValid *bool `json:"signature_valid,omitempty"`
}
// PersistentLogger defines the interface for loggers that support querying and verification.
type PersistentLogger interface {
Query(filter QueryFilter) ([]Event, error)
VerifySignature(event Event) bool
}
// Exporter provides export functionality for audit logs.
type Exporter struct {
logger PersistentLogger
}
// NewExporter creates a new exporter for the given logger.
func NewExporter(logger PersistentLogger) *Exporter {
return &Exporter{logger: logger}
}
// Export generates an export in the specified format.
func (e *Exporter) Export(filter QueryFilter, format ExportFormat, includeVerification bool) (*ExportResult, error) {
// Remove limit for export (get all matching events)
filter.Limit = 0
filter.Offset = 0
events, err := e.logger.Query(filter)
if err != nil {
return nil, fmt.Errorf("failed to query events for export: %w", err)
}
// Convert to export events
exportEvents := make([]ExportEvent, len(events))
for i, event := range events {
exportEvents[i] = ExportEvent{
ID: event.ID,
Timestamp: event.Timestamp,
EventType: event.EventType,
User: event.User,
IP: event.IP,
Path: event.Path,
Success: event.Success,
Details: event.Details,
Signature: event.Signature,
}
if includeVerification && event.Signature != "" {
valid := e.logger.VerifySignature(event)
exportEvents[i].SignatureValid = &valid
}
}
// Generate timestamp for filename
timestamp := time.Now().Format("20060102-150405")
switch format {
case ExportFormatCSV:
return e.exportCSV(exportEvents, timestamp, includeVerification)
case ExportFormatJSON:
return e.exportJSON(exportEvents, timestamp)
default:
return nil, fmt.Errorf("unsupported export format: %s", format)
}
}
// exportCSV generates a CSV export.
func (e *Exporter) exportCSV(events []ExportEvent, timestamp string, includeVerification bool) (*ExportResult, error) {
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
// Write header
header := []string{"ID", "Timestamp", "Event Type", "User", "IP", "Path", "Success", "Details", "Signature"}
if includeVerification {
header = append(header, "Signature Valid")
}
if err := writer.Write(header); err != nil {
return nil, fmt.Errorf("failed to write CSV header: %w", err)
}
// Write rows
for _, event := range events {
success := "false"
if event.Success {
success = "true"
}
row := []string{
event.ID,
event.Timestamp.Format(time.RFC3339),
event.EventType,
event.User,
event.IP,
event.Path,
success,
event.Details,
event.Signature,
}
if includeVerification {
sigValid := ""
if event.SignatureValid != nil {
if *event.SignatureValid {
sigValid = "true"
} else {
sigValid = "false"
}
}
row = append(row, sigValid)
}
if err := writer.Write(row); err != nil {
return nil, fmt.Errorf("failed to write CSV row: %w", err)
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return nil, fmt.Errorf("CSV writer error: %w", err)
}
return &ExportResult{
Data: buf.Bytes(),
ContentType: "text/csv; charset=utf-8",
Filename: fmt.Sprintf("audit-log-%s.csv", timestamp),
EventCount: len(events),
}, nil
}
// exportJSON generates a JSON export.
func (e *Exporter) exportJSON(events []ExportEvent, timestamp string) (*ExportResult, error) {
// Wrap in an object for better structure
export := struct {
ExportedAt time.Time `json:"exported_at"`
EventCount int `json:"event_count"`
Events []ExportEvent `json:"events"`
}{
ExportedAt: time.Now(),
EventCount: len(events),
Events: events,
}
data, err := json.MarshalIndent(export, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal JSON export: %w", err)
}
return &ExportResult{
Data: data,
ContentType: "application/json; charset=utf-8",
Filename: fmt.Sprintf("audit-log-%s.json", timestamp),
EventCount: len(events),
}, nil
}
// ExportSummary generates a summary of audit activity.
type ExportSummary struct {
TotalEvents int `json:"total_events"`
SuccessCount int `json:"success_count"`
FailureCount int `json:"failure_count"`
EventsByType map[string]int `json:"events_by_type"`
EventsByUser map[string]int `json:"events_by_user"`
StartTime *time.Time `json:"start_time,omitempty"`
EndTime *time.Time `json:"end_time,omitempty"`
InvalidSigCount int `json:"invalid_signature_count,omitempty"`
}
// GenerateSummary creates a summary of audit events matching the filter.
func (e *Exporter) GenerateSummary(filter QueryFilter, verifySignatures bool) (*ExportSummary, error) {
// Remove limit for summary
filter.Limit = 0
filter.Offset = 0
events, err := e.logger.Query(filter)
if err != nil {
return nil, fmt.Errorf("failed to query events for summary: %w", err)
}
summary := &ExportSummary{
TotalEvents: len(events),
EventsByType: make(map[string]int),
EventsByUser: make(map[string]int),
}
var minTime, maxTime *time.Time
for _, event := range events {
if event.Success {
summary.SuccessCount++
} else {
summary.FailureCount++
}
summary.EventsByType[event.EventType]++
if event.User != "" {
summary.EventsByUser[event.User]++
}
// Track time range
if minTime == nil || event.Timestamp.Before(*minTime) {
t := event.Timestamp
minTime = &t
}
if maxTime == nil || event.Timestamp.After(*maxTime) {
t := event.Timestamp
maxTime = &t
}
// Verify signatures if requested
if verifySignatures && event.Signature != "" {
if !e.logger.VerifySignature(event) {
summary.InvalidSigCount++
}
}
}
summary.StartTime = minTime
summary.EndTime = maxTime
return summary, nil
}