mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 12:15:42 +00:00
221 lines
6.2 KiB
Go
221 lines
6.2 KiB
Go
// Package unified provides a unified alert/finding system.
|
|
package unified
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// FilePersistence implements UnifiedPersistence using JSON files
|
|
type FilePersistence struct {
|
|
mu sync.Mutex
|
|
dataDir string
|
|
filename string
|
|
}
|
|
|
|
// NewFilePersistence creates a new file-based persistence
|
|
func NewFilePersistence(dataDir string) *FilePersistence {
|
|
return &FilePersistence{
|
|
dataDir: dataDir,
|
|
filename: "unified_findings.json",
|
|
}
|
|
}
|
|
|
|
func findingsMapFromList(list []*UnifiedFinding) map[string]*UnifiedFinding {
|
|
findings := make(map[string]*UnifiedFinding, len(list))
|
|
for _, f := range list {
|
|
if f == nil || f.ID == "" {
|
|
continue
|
|
}
|
|
findings[f.ID] = f
|
|
}
|
|
return findings
|
|
}
|
|
|
|
// SaveFindings saves findings to disk
|
|
func (p *FilePersistence) SaveFindings(findings map[string]*UnifiedFinding) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
// Ensure directory exists
|
|
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
|
|
return fmt.Errorf("unified.FilePersistence.SaveFindings: create data directory %q: %w", p.dataDir, err)
|
|
}
|
|
|
|
// Convert to list for cleaner JSON
|
|
findingsList := []*UnifiedFinding{}
|
|
for _, f := range findings {
|
|
findingsList = append(findingsList, f)
|
|
}
|
|
|
|
data, err := json.MarshalIndent(findingsList, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("unified.FilePersistence.SaveFindings: marshal findings: %w", err)
|
|
}
|
|
|
|
filePath := filepath.Join(p.dataDir, p.filename)
|
|
tempPath := filePath + ".tmp"
|
|
|
|
// Write to temp file first
|
|
if err := os.WriteFile(tempPath, data, 0600); err != nil {
|
|
return fmt.Errorf("unified.FilePersistence.SaveFindings: write temp file %q: %w", tempPath, err)
|
|
}
|
|
|
|
// Atomic rename
|
|
if err := os.Rename(tempPath, filePath); err != nil {
|
|
removeErr := os.Remove(tempPath)
|
|
renameErr := fmt.Errorf("unified.FilePersistence.SaveFindings: rename %q to %q: %w", tempPath, filePath, err)
|
|
if removeErr != nil && !os.IsNotExist(removeErr) {
|
|
return errors.Join(renameErr, fmt.Errorf("unified.FilePersistence.SaveFindings: remove temp file %q: %w", tempPath, removeErr))
|
|
}
|
|
return renameErr
|
|
}
|
|
|
|
log.Debug().
|
|
Int("findings_count", len(findings)).
|
|
Str("file", filePath).
|
|
Msg("Saved unified findings")
|
|
|
|
return nil
|
|
}
|
|
|
|
// LoadFindings loads findings from disk
|
|
func (p *FilePersistence) LoadFindings() (map[string]*UnifiedFinding, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
filePath := filepath.Join(p.dataDir, p.filename)
|
|
|
|
data, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return make(map[string]*UnifiedFinding), nil
|
|
}
|
|
return nil, fmt.Errorf("unified.FilePersistence.LoadFindings: read file %q: %w", filePath, err)
|
|
}
|
|
|
|
findingsList := []*UnifiedFinding{}
|
|
if err := json.Unmarshal(data, &findingsList); err != nil {
|
|
log.Error().Err(err).Msg("failed to parse unified findings, starting fresh")
|
|
return make(map[string]*UnifiedFinding), nil
|
|
}
|
|
|
|
findings := findingsMapFromList(findingsList)
|
|
|
|
log.Debug().
|
|
Int("findings_count", len(findings)).
|
|
Str("file", filePath).
|
|
Msg("Loaded unified findings")
|
|
|
|
return findings, nil
|
|
}
|
|
|
|
// persistedState is the internal format for persistence
|
|
type persistedState struct {
|
|
Version int `json:"version"`
|
|
Findings []*UnifiedFinding `json:"findings"`
|
|
}
|
|
|
|
func emptyPersistedState(version int) persistedState {
|
|
state := persistedState{Version: version}
|
|
if state.Findings == nil {
|
|
state.Findings = []*UnifiedFinding{}
|
|
}
|
|
return state
|
|
}
|
|
|
|
// VersionedPersistence adds versioning to persistence
|
|
type VersionedPersistence struct {
|
|
*FilePersistence
|
|
currentVersion int
|
|
}
|
|
|
|
// NewVersionedPersistence creates a versioned persistence layer
|
|
func NewVersionedPersistence(dataDir string) *VersionedPersistence {
|
|
return &VersionedPersistence{
|
|
FilePersistence: NewFilePersistence(dataDir),
|
|
currentVersion: 1,
|
|
}
|
|
}
|
|
|
|
// SaveFindings saves findings with version info
|
|
func (p *VersionedPersistence) SaveFindings(findings map[string]*UnifiedFinding) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
// Ensure directory exists
|
|
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
|
|
return fmt.Errorf("unified.VersionedPersistence.SaveFindings: create data directory %q: %w", p.dataDir, err)
|
|
}
|
|
|
|
state := emptyPersistedState(p.currentVersion)
|
|
state.Findings = make([]*UnifiedFinding, 0, len(findings))
|
|
|
|
for _, f := range findings {
|
|
state.Findings = append(state.Findings, f)
|
|
}
|
|
|
|
data, err := json.MarshalIndent(state, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("unified.VersionedPersistence.SaveFindings: marshal findings state: %w", err)
|
|
}
|
|
|
|
filePath := filepath.Join(p.dataDir, p.filename)
|
|
tempPath := filePath + ".tmp"
|
|
|
|
if err := os.WriteFile(tempPath, data, 0600); err != nil {
|
|
return fmt.Errorf("unified.VersionedPersistence.SaveFindings: write temp file %q: %w", tempPath, err)
|
|
}
|
|
|
|
if err := os.Rename(tempPath, filePath); err != nil {
|
|
removeErr := os.Remove(tempPath)
|
|
renameErr := fmt.Errorf("unified.VersionedPersistence.SaveFindings: rename %q to %q: %w", tempPath, filePath, err)
|
|
if removeErr != nil && !os.IsNotExist(removeErr) {
|
|
return errors.Join(renameErr, fmt.Errorf("unified.VersionedPersistence.SaveFindings: remove temp file %q: %w", tempPath, removeErr))
|
|
}
|
|
return renameErr
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// LoadFindings loads findings with version handling
|
|
func (p *VersionedPersistence) LoadFindings() (map[string]*UnifiedFinding, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
filePath := filepath.Join(p.dataDir, p.filename)
|
|
|
|
data, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return make(map[string]*UnifiedFinding), nil
|
|
}
|
|
return nil, fmt.Errorf("unified.VersionedPersistence.LoadFindings: read file %q: %w", filePath, err)
|
|
}
|
|
|
|
// Try versioned format first
|
|
var state persistedState
|
|
if err := json.Unmarshal(data, &state); err == nil && state.Version > 0 {
|
|
if state.Findings == nil {
|
|
state.Findings = []*UnifiedFinding{}
|
|
}
|
|
return findingsMapFromList(state.Findings), nil
|
|
}
|
|
|
|
// Fall back to legacy format (list only)
|
|
findingsList := []*UnifiedFinding{}
|
|
if err := json.Unmarshal(data, &findingsList); err != nil {
|
|
log.Error().Err(err).Msg("failed to parse unified findings")
|
|
return make(map[string]*UnifiedFinding), nil
|
|
}
|
|
|
|
return findingsMapFromList(findingsList), nil
|
|
}
|