mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-29 20:10:21 +00:00
Related to #637 The sensor-proxy was failing to start on systems with read-only filesystems because audit logging required a writable /var/log/pulse/sensor-proxy directory. Changes: - Modified newAuditLogger() to automatically fall back to stderr (systemd journal) if the audit log file cannot be opened - Removed error return from newAuditLogger() since it now always succeeds - Added warning logs when fallback mode is used to alert operators - Updated tests to handle the new signature - Added better debugging to audit log tests This allows the sensor-proxy to run on: - Immutable/read-only root filesystems - Hardened systems with restricted /var mounts - Containerized environments with limited write access Audit events are still captured via systemd journal when file logging is unavailable, maintaining the security audit trail.
318 lines
8.1 KiB
Go
318 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// auditLogger emits append-only, hash-chained audit events.
|
|
type auditLogger struct {
|
|
mu sync.Mutex
|
|
file *os.File
|
|
logger zerolog.Logger
|
|
prevHash []byte
|
|
sequence uint64
|
|
}
|
|
|
|
// AuditEvent captures a single security-relevant action.
|
|
type AuditEvent struct {
|
|
Sequence uint64 `json:"seq"`
|
|
Timestamp time.Time `json:"ts"`
|
|
EventType string `json:"event_type"`
|
|
CorrelationID string `json:"correlation_id,omitempty"`
|
|
PeerUID *uint32 `json:"peer_uid,omitempty"`
|
|
PeerGID *uint32 `json:"peer_gid,omitempty"`
|
|
PeerPID *uint32 `json:"peer_pid,omitempty"`
|
|
RemoteAddr string `json:"remote_addr,omitempty"`
|
|
Command string `json:"command,omitempty"`
|
|
Args []string `json:"args,omitempty"`
|
|
Target string `json:"target,omitempty"`
|
|
Decision string `json:"decision,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Limiter string `json:"limiter,omitempty"`
|
|
ExitCode *int `json:"exit_code,omitempty"`
|
|
DurationMs *int64 `json:"duration_ms,omitempty"`
|
|
StdoutHash string `json:"stdout_sha256,omitempty"`
|
|
StderrHash string `json:"stderr_sha256,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
PrevHash string `json:"prev_hash"`
|
|
EventHash string `json:"event_hash"`
|
|
}
|
|
|
|
// newAuditLogger opens the audit log file and prepares hash chaining.
|
|
// If the file cannot be opened (e.g., read-only filesystem), it automatically
|
|
// falls back to stderr which integrates with systemd journal.
|
|
// This function always succeeds and returns a valid audit logger.
|
|
func newAuditLogger(path string) *auditLogger {
|
|
// Try to open the audit log file
|
|
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
|
|
var writer zerolog.Logger
|
|
var usedFallback bool
|
|
|
|
if err != nil {
|
|
// Fallback to stderr if file cannot be opened
|
|
log.Warn().
|
|
Err(err).
|
|
Str("path", path).
|
|
Msg("Cannot open audit log file, falling back to stderr (systemd journal)")
|
|
|
|
writer = zerolog.New(os.Stderr).With().Timestamp().Logger()
|
|
usedFallback = true
|
|
file = nil
|
|
} else {
|
|
writer = zerolog.New(file).With().Timestamp().Logger()
|
|
}
|
|
|
|
// Log initialization event to standard logger (not to audit log itself)
|
|
if usedFallback {
|
|
log.Warn().
|
|
Str("path", path).
|
|
Str("mode", "stderr").
|
|
Msg("Audit logger initialized with stderr fallback due to filesystem constraints")
|
|
} else {
|
|
log.Info().
|
|
Str("path", path).
|
|
Str("mode", "file").
|
|
Msg("Audit logger initialized with file backend")
|
|
}
|
|
|
|
return &auditLogger{
|
|
file: file,
|
|
logger: writer,
|
|
}
|
|
}
|
|
|
|
// Close flushes and closes the audit log file.
|
|
func (a *auditLogger) Close() error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.file == nil {
|
|
return nil
|
|
}
|
|
err := a.file.Close()
|
|
a.file = nil
|
|
return err
|
|
}
|
|
|
|
// LogConnectionAccepted records an authorized connection.
|
|
func (a *auditLogger) LogConnectionAccepted(correlationID string, cred *peerCredentials, remote string) {
|
|
event := AuditEvent{
|
|
EventType: "connection.accepted",
|
|
CorrelationID: correlationID,
|
|
RemoteAddr: remote,
|
|
Decision: "allowed",
|
|
}
|
|
event.applyPeer(cred)
|
|
a.log(&event)
|
|
}
|
|
|
|
// LogConnectionDenied records a rejected connection attempt.
|
|
func (a *auditLogger) LogConnectionDenied(correlationID string, cred *peerCredentials, remote, reason string) {
|
|
event := AuditEvent{
|
|
EventType: "connection.denied",
|
|
CorrelationID: correlationID,
|
|
RemoteAddr: remote,
|
|
Decision: "denied",
|
|
Reason: reason,
|
|
}
|
|
event.applyPeer(cred)
|
|
a.log(&event)
|
|
}
|
|
|
|
// LogRateLimitHit records limiter rejections.
|
|
func (a *auditLogger) LogRateLimitHit(correlationID string, cred *peerCredentials, remote, limiter string) {
|
|
event := AuditEvent{
|
|
EventType: "limiter.rejection",
|
|
CorrelationID: correlationID,
|
|
RemoteAddr: remote,
|
|
Decision: "denied",
|
|
Limiter: limiter,
|
|
}
|
|
event.applyPeer(cred)
|
|
a.log(&event)
|
|
}
|
|
|
|
// LogCommandStart records command execution approval.
|
|
func (a *auditLogger) LogCommandStart(correlationID string, cred *peerCredentials, remote, target, command string, args []string) {
|
|
event := AuditEvent{
|
|
EventType: "command.start",
|
|
CorrelationID: correlationID,
|
|
RemoteAddr: remote,
|
|
Decision: "allowed",
|
|
Command: command,
|
|
Args: args,
|
|
Target: target,
|
|
}
|
|
event.applyPeer(cred)
|
|
a.log(&event)
|
|
}
|
|
|
|
// LogCommandResult records command completion.
|
|
func (a *auditLogger) LogCommandResult(correlationID string, cred *peerCredentials, remote, target, command string, args []string, exitCode int, duration time.Duration, stdoutHash, stderrHash string, execErr error) {
|
|
event := AuditEvent{
|
|
EventType: "command.finish",
|
|
CorrelationID: correlationID,
|
|
RemoteAddr: remote,
|
|
Command: command,
|
|
Args: args,
|
|
Target: target,
|
|
ExitCode: intPtr(exitCode),
|
|
StdoutHash: stdoutHash,
|
|
StderrHash: stderrHash,
|
|
}
|
|
event.applyPeer(cred)
|
|
if duration > 0 {
|
|
ms := duration.Milliseconds()
|
|
event.DurationMs = int64Ptr(ms)
|
|
}
|
|
if execErr != nil {
|
|
event.Error = execErr.Error()
|
|
event.Decision = "failed"
|
|
} else {
|
|
event.Decision = "completed"
|
|
}
|
|
a.log(&event)
|
|
}
|
|
|
|
// LogValidationFailure records validator rejections.
|
|
func (a *auditLogger) LogValidationFailure(correlationID string, cred *peerCredentials, remote, command string, args []string, reason string) {
|
|
event := AuditEvent{
|
|
EventType: "command.validation_failed",
|
|
CorrelationID: correlationID,
|
|
RemoteAddr: remote,
|
|
Command: command,
|
|
Args: args,
|
|
Decision: "denied",
|
|
Reason: reason,
|
|
}
|
|
event.applyPeer(cred)
|
|
a.log(&event)
|
|
}
|
|
|
|
func (e *AuditEvent) applyPeer(cred *peerCredentials) {
|
|
if cred == nil {
|
|
return
|
|
}
|
|
e.PeerUID = uint32Ptr(cred.uid)
|
|
e.PeerGID = uint32Ptr(cred.gid)
|
|
e.PeerPID = uint32Ptr(cred.pid)
|
|
}
|
|
|
|
// log persists the event with hash chaining.
|
|
func (a *auditLogger) log(event *AuditEvent) {
|
|
if event == nil {
|
|
log.Error().Msg("audit log called with nil event")
|
|
return
|
|
}
|
|
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
|
|
a.sequence++
|
|
event.Sequence = a.sequence
|
|
|
|
if event.Timestamp.IsZero() {
|
|
event.Timestamp = time.Now().UTC()
|
|
} else {
|
|
event.Timestamp = event.Timestamp.UTC()
|
|
}
|
|
|
|
event.PrevHash = hex.EncodeToString(a.prevHash)
|
|
|
|
payload, err := eventMarshalForHash(event)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to marshal audit event")
|
|
return
|
|
}
|
|
|
|
sum := sha256.Sum256(append(a.prevHash, payload...))
|
|
a.prevHash = sum[:]
|
|
event.EventHash = hex.EncodeToString(sum[:])
|
|
|
|
a.logger.Info().Fields(eventToMap(event)).Send()
|
|
}
|
|
|
|
func eventMarshalForHash(event *AuditEvent) ([]byte, error) {
|
|
clone := *event
|
|
clone.EventHash = ""
|
|
return json.Marshal(clone)
|
|
}
|
|
|
|
func eventToMap(event *AuditEvent) map[string]interface{} {
|
|
m := map[string]interface{}{
|
|
"ts": event.Timestamp.Format(time.RFC3339Nano),
|
|
"event_type": event.EventType,
|
|
"seq": event.Sequence,
|
|
"prev_hash": event.PrevHash,
|
|
"event_hash": event.EventHash,
|
|
"decision": event.Decision,
|
|
"correlation_id": event.CorrelationID,
|
|
}
|
|
|
|
if event.PeerUID != nil {
|
|
m["peer_uid"] = *event.PeerUID
|
|
}
|
|
if event.PeerGID != nil {
|
|
m["peer_gid"] = *event.PeerGID
|
|
}
|
|
if event.PeerPID != nil {
|
|
m["peer_pid"] = *event.PeerPID
|
|
}
|
|
if event.RemoteAddr != "" {
|
|
m["remote_addr"] = event.RemoteAddr
|
|
}
|
|
if event.Command != "" {
|
|
m["command"] = event.Command
|
|
}
|
|
if len(event.Args) > 0 {
|
|
m["args"] = event.Args
|
|
}
|
|
if event.Target != "" {
|
|
m["target"] = event.Target
|
|
}
|
|
if event.Reason != "" {
|
|
m["reason"] = event.Reason
|
|
}
|
|
if event.Limiter != "" {
|
|
m["limiter"] = event.Limiter
|
|
}
|
|
if event.ExitCode != nil {
|
|
m["exit_code"] = *event.ExitCode
|
|
}
|
|
if event.DurationMs != nil {
|
|
m["duration_ms"] = *event.DurationMs
|
|
}
|
|
if event.StdoutHash != "" {
|
|
m["stdout_sha256"] = event.StdoutHash
|
|
}
|
|
if event.StderrHash != "" {
|
|
m["stderr_sha256"] = event.StderrHash
|
|
}
|
|
if event.Error != "" {
|
|
m["error"] = event.Error
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
func uint32Ptr(v uint32) *uint32 {
|
|
value := v
|
|
return &value
|
|
}
|
|
|
|
func intPtr(v int) *int {
|
|
value := v
|
|
return &value
|
|
}
|
|
|
|
func int64Ptr(v int64) *int64 {
|
|
value := v
|
|
return &value
|
|
}
|