projection: begin the exact-path public epoch

The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally.

Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
Fimeg 2026-09-08 21:59:33 -04:00
commit 67e26be2d9
838 changed files with 157302 additions and 0 deletions

View file

@ -0,0 +1,43 @@
package logging
// example_integration_test.go — Pre-fix tests for dead example code.
//
// F-D1-3 LOW: example_integration.go is dead code calling machineid.ID()
// directly, bypassing the canonical hashMachineID().
//
// Run: cd agent && go test ./internal/logging/... -v -run TestExample
import (
"os"
"testing"
)
// ---------------------------------------------------------------------------
// Test 4.1 — Documents dead code exists (F-D1-3)
//
// Category: PASS-NOW (documents dead code)
// ---------------------------------------------------------------------------
func TestExampleIntegrationFileIsDeadCode(t *testing.T) {
// POST-FIX (F-D1-3): File deleted.
_, err := os.Stat("example_integration.go")
if err == nil {
t.Error("[ERROR] [agent] [logging] F-D1-3 NOT FIXED: dead code file still exists")
}
t.Log("[INFO] [agent] [logging] F-D1-3 FIXED: dead example code deleted")
}
// ---------------------------------------------------------------------------
// Test 4.2 — File should not exist (assert fix)
//
// Category: FAIL-NOW / PASS-AFTER-FIX
// ---------------------------------------------------------------------------
func TestExampleIntegrationFileDoesNotExist(t *testing.T) {
// F-D1-3: After fix, delete the file.
_, err := os.Stat("example_integration.go")
if err == nil {
t.Errorf("[ERROR] [agent] [logging] example_integration.go still exists.\n" +
"F-D1-3: delete dead example code with incorrect usage patterns.")
}
}

View file

@ -0,0 +1,63 @@
package logging
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"runtime"
"sync"
"github.com/Fimeg/RedFlag/agent/internal/constants"
)
var (
processLoggerMu sync.Mutex
processLoggerFile *os.File
)
// ConfigureProcessLogger routes the standard library logger to the primary
// Windows agent log. Linux keeps its existing service/journal behavior.
func ConfigureProcessLogger() error {
if runtime.GOOS != "windows" {
return nil
}
return ConfigureProcessLoggerFile(constants.GetAgentLogPath(), os.Stderr)
}
// ConfigureProcessLoggerFile wires log.Printf/log.Fatal output to both the
// console stream and a durable file. This preserves CLI feedback while making
// Windows service diagnostics available without Event Viewer.
func ConfigureProcessLoggerFile(logPath string, console io.Writer) error {
if logPath == "" {
return fmt.Errorf("agent log path is empty")
}
if console == nil {
console = io.Discard
}
logDir := filepath.Dir(logPath)
if err := os.MkdirAll(logDir, 0750); err != nil {
return fmt.Errorf("failed to create agent log directory %q: %w", logDir, err)
}
file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640)
if err != nil {
return fmt.Errorf("failed to open agent log file %q: %w", logPath, err)
}
processLoggerMu.Lock()
defer processLoggerMu.Unlock()
oldFile := processLoggerFile
processLoggerFile = file
log.SetFlags(log.LstdFlags | log.LUTC)
log.SetOutput(io.MultiWriter(file, console))
if oldFile != nil {
_ = oldFile.Close()
}
log.Printf("[INFO] [agent] [logging] process_logger_initialized path=%s", logPath)
return nil
}

View file

@ -0,0 +1,84 @@
package logging
import (
"bytes"
"errors"
"log"
"os"
"path/filepath"
"strings"
"testing"
)
func TestConfigureProcessLoggerFileWritesFileAndConsole(t *testing.T) {
previousOutput := log.Writer()
previousFlags := log.Flags()
t.Cleanup(func() {
processLoggerMu.Lock()
if processLoggerFile != nil {
_ = processLoggerFile.Close()
processLoggerFile = nil
}
processLoggerMu.Unlock()
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
logPath := filepath.Join(t.TempDir(), "agent.log")
var console bytes.Buffer
if err := ConfigureProcessLoggerFile(logPath, &console); err != nil {
t.Fatalf("ConfigureProcessLoggerFile() error = %v", err)
}
entry := "[INFO] [agent] [logging] process_logger_test"
log.Print(entry)
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read log file: %v", err)
}
if !strings.Contains(string(data), entry) {
t.Fatalf("log file missing entry %q: %s", entry, data)
}
if !strings.Contains(console.String(), entry) {
t.Fatalf("console output missing entry %q: %s", entry, console.String())
}
}
type failingWriter struct{}
func (f failingWriter) Write([]byte) (int, error) {
return 0, errors.New("invalid handle")
}
func TestConfigureProcessLoggerFileWritesFileWhenConsoleFails(t *testing.T) {
previousOutput := log.Writer()
previousFlags := log.Flags()
t.Cleanup(func() {
processLoggerMu.Lock()
if processLoggerFile != nil {
_ = processLoggerFile.Close()
processLoggerFile = nil
}
processLoggerMu.Unlock()
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
logPath := filepath.Join(t.TempDir(), "agent.log")
if err := ConfigureProcessLoggerFile(logPath, failingWriter{}); err != nil {
t.Fatalf("ConfigureProcessLoggerFile() error = %v", err)
}
entry := "[INFO] [agent] [logging] service_console_unavailable"
log.Print(entry)
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read log file: %v", err)
}
if !strings.Contains(string(data), entry) {
t.Fatalf("log file missing entry %q after console write failure: %s", entry, data)
}
}

View file

@ -0,0 +1,497 @@
package logging
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/Fimeg/RedFlag/agent/internal/config"
)
// SecurityEvent represents a security event on the agent side
// This is a simplified version of the server model to avoid circular dependencies
type SecurityEvent struct {
Timestamp time.Time `json:"timestamp"`
Level string `json:"level"` // CRITICAL, WARNING, INFO, DEBUG
EventType string `json:"event_type"`
Message string `json:"message"`
Details map[string]interface{} `json:"details,omitempty"`
}
// SecurityLogConfig holds configuration for security logging on the agent
type SecurityLogConfig struct {
Enabled bool `json:"enabled" env:"REDFLAG_AGENT_SECURITY_LOG_ENABLED" default:"true"`
Level string `json:"level" env:"REDFLAG_AGENT_SECURITY_LOG_LEVEL" default:"warning"` // none, error, warn, info, debug
LogSuccesses bool `json:"log_successes" env:"REDFLAG_AGENT_SECURITY_LOG_SUCCESSES" default:"false"`
FilePath string `json:"file_path" env:"REDFLAG_AGENT_SECURITY_LOG_PATH"` // Relative to agent data directory
MaxSizeMB int `json:"max_size_mb" env:"REDFLAG_AGENT_SECURITY_LOG_MAX_SIZE" default:"50"`
MaxFiles int `json:"max_files" env:"REDFLAG_AGENT_SECURITY_LOG_MAX_FILES" default:"5"`
BatchSize int `json:"batch_size" env:"REDFLAG_AGENT_SECURITY_LOG_BATCH_SIZE" default:"10"`
SendToServer bool `json:"send_to_server" env:"REDFLAG_AGENT_SECURITY_LOG_SEND" default:"true"`
}
// SecurityLogger handles security event logging on the agent
type SecurityLogger struct {
config SecurityLogConfig
logger *log.Logger
file *os.File
mu sync.Mutex
buffer []*SecurityEvent
fileCursor int
flushTimer *time.Timer
lastFlush time.Time
closed bool
}
// SecurityEventTypes defines all possible security event types on the agent
var SecurityEventTypes = struct {
CmdSignatureVerificationFailed string
CmdSignatureVerificationSuccess string
UpdateNonceInvalid string
UpdateSignatureVerificationFailed string
MachineIDChangeDetected string
ConfigTamperingWarning string
UnauthorizedCommandAttempt string
KeyRotationDetected string
}{
CmdSignatureVerificationFailed: "CMD_SIGNATURE_VERIFICATION_FAILED",
CmdSignatureVerificationSuccess: "CMD_SIGNATURE_VERIFICATION_SUCCESS",
UpdateNonceInvalid: "UPDATE_NONCE_INVALID",
UpdateSignatureVerificationFailed: "UPDATE_SIGNATURE_VERIFICATION_FAILED",
MachineIDChangeDetected: "MACHINE_ID_CHANGE_DETECTED",
ConfigTamperingWarning: "CONFIG_TAMPERING_WARNING",
UnauthorizedCommandAttempt: "UNAUTHORIZED_COMMAND_ATTEMPT",
KeyRotationDetected: "KEY_ROTATION_DETECTED",
}
// NewSecurityLogger creates a new agent security logger
func NewSecurityLogger(agentConfig *config.Config, logDir string) (*SecurityLogger, error) {
// Create default security log config
secConfig := SecurityLogConfig{
Enabled: true,
Level: "warning",
LogSuccesses: false,
FilePath: "security.log",
MaxSizeMB: 50,
MaxFiles: 5,
BatchSize: 10,
SendToServer: true,
}
// Ensure log directory exists
if err := os.MkdirAll(logDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create security log directory: %w", err)
}
// Open log file
logPath := filepath.Join(logDir, secConfig.FilePath)
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return nil, fmt.Errorf("failed to open security log file: %w", err)
}
logger := &SecurityLogger{
config: secConfig,
logger: log.New(file, "[SECURITY] ", log.LstdFlags|log.LUTC),
file: file,
buffer: make([]*SecurityEvent, 0, secConfig.BatchSize),
lastFlush: time.Now().UTC(),
}
// Start flush timer
logger.flushTimer = time.AfterFunc(30*time.Second, logger.flushBuffer)
return logger, nil
}
// Log writes a security event
func (sl *SecurityLogger) Log(event *SecurityEvent) error {
if !sl.config.Enabled || sl.config.Level == "none" {
return nil
}
// Skip successes unless configured to log them
if !sl.config.LogSuccesses && event.EventType == SecurityEventTypes.CmdSignatureVerificationSuccess {
return nil
}
// Filter by log level
if !sl.shouldLogLevel(event.Level) {
return nil
}
sl.mu.Lock()
defer sl.mu.Unlock()
if sl.closed {
return fmt.Errorf("security logger is closed")
}
// Add prefix to distinguish security events
event.Message = "SECURITY: " + event.Message
criticalWritten := false
if event.Level == "CRITICAL" {
if err := sl.writeEvent(event); err != nil {
log.Printf("[ERROR] [agent] [security] critical_event_write_failed error=%v", err)
} else {
criticalWritten = true
}
}
// Add to buffer for eventual server delivery. File flushes advance
// fileCursor; server delivery clears only after a successful POST.
sl.buffer = append(sl.buffer, event)
if criticalWritten && sl.fileCursor == len(sl.buffer)-1 {
sl.fileCursor++
}
if len(sl.buffer) >= sl.config.BatchSize {
sl.flushBufferUnsafe()
}
return nil
}
// LogCommandVerificationFailure logs a command signature verification failure
func (sl *SecurityLogger) LogCommandVerificationFailure(commandID string, reason string) {
if sl == nil {
return
}
event := &SecurityEvent{
Timestamp: time.Now().UTC(),
Level: "CRITICAL",
EventType: SecurityEventTypes.CmdSignatureVerificationFailed,
Message: "Command signature verification failed",
Details: map[string]interface{}{
"command_id": commandID,
"reason": reason,
},
}
_ = sl.Log(event)
}
// LogNonceValidationFailure logs a nonce validation failure
func (sl *SecurityLogger) LogNonceValidationFailure(nonce string, reason string) {
if sl == nil {
return
}
event := &SecurityEvent{
Timestamp: time.Now().UTC(),
Level: "WARNING",
EventType: SecurityEventTypes.UpdateNonceInvalid,
Message: "Update nonce validation failed",
Details: map[string]interface{}{
"nonce": nonce[:min(len(nonce), 16)] + "...", // Truncate for security
"reason": reason,
},
}
_ = sl.Log(event)
}
// LogUpdateSignatureVerificationFailure logs an update signature verification failure
func (sl *SecurityLogger) LogUpdateSignatureVerificationFailure(updateID string, reason string) {
if sl == nil {
return
}
event := &SecurityEvent{
Timestamp: time.Now().UTC(),
Level: "CRITICAL",
EventType: SecurityEventTypes.UpdateSignatureVerificationFailed,
Message: "Update signature verification failed",
Details: map[string]interface{}{
"update_id": updateID,
"reason": reason,
},
}
_ = sl.Log(event)
}
// LogMachineIDChangeDetected logs when machine ID changes
func (sl *SecurityLogger) LogMachineIDChangeDetected(oldID, newID string) {
if sl == nil {
return
}
event := &SecurityEvent{
Timestamp: time.Now().UTC(),
Level: "WARNING",
EventType: SecurityEventTypes.MachineIDChangeDetected,
Message: "Machine ID change detected",
Details: map[string]interface{}{
"old_machine_id": oldID,
"new_machine_id": newID,
},
}
_ = sl.Log(event)
}
// LogConfigTamperingWarning logs when configuration tampering is suspected
func (sl *SecurityLogger) LogConfigTamperingWarning(configPath string, reason string) {
if sl == nil {
return
}
event := &SecurityEvent{
Timestamp: time.Now().UTC(),
Level: "WARNING",
EventType: SecurityEventTypes.ConfigTamperingWarning,
Message: "Configuration file tampering detected",
Details: map[string]interface{}{
"config_file": configPath,
"reason": reason,
},
}
_ = sl.Log(event)
}
// LogUnauthorizedCommandAttempt logs an attempt to run an unauthorized command
func (sl *SecurityLogger) LogUnauthorizedCommandAttempt(command string, reason string) {
if sl == nil {
return
}
event := &SecurityEvent{
Timestamp: time.Now().UTC(),
Level: "WARNING",
EventType: SecurityEventTypes.UnauthorizedCommandAttempt,
Message: "Unauthorized command execution attempt",
Details: map[string]interface{}{
"command": command,
"reason": reason,
},
}
_ = sl.Log(event)
}
// LogCommandVerificationSuccess logs a successful command signature verification
func (sl *SecurityLogger) LogCommandVerificationSuccess(commandID string) {
if sl == nil {
return
}
event := &SecurityEvent{
Timestamp: time.Now().UTC(),
Level: "INFO",
EventType: SecurityEventTypes.CmdSignatureVerificationSuccess,
Message: "Command signature verified successfully",
Details: map[string]interface{}{
"command_id": commandID,
},
}
_ = sl.Log(event)
}
// LogCommandVerificationFailed logs a failed command signature verification
func (sl *SecurityLogger) LogCommandVerificationFailed(commandID, reason string) {
if sl == nil {
return
}
event := &SecurityEvent{
Timestamp: time.Now().UTC(),
Level: "CRITICAL",
EventType: SecurityEventTypes.CmdSignatureVerificationFailed,
Message: "Command signature verification failed",
Details: map[string]interface{}{
"command_id": commandID,
"reason": reason,
},
}
_ = sl.Log(event)
}
// LogKeyRotationDetected logs when a new signing key is detected and cached.
// This occurs when a command arrives with a key_id not previously cached by the agent,
// indicating the server has rotated its signing key.
func (sl *SecurityLogger) LogKeyRotationDetected(keyID string) {
if sl == nil {
return
}
event := &SecurityEvent{
Timestamp: time.Now().UTC(),
Level: "INFO",
EventType: SecurityEventTypes.KeyRotationDetected,
Message: "New signing key detected and cached",
Details: map[string]interface{}{
"key_id": keyID,
},
}
_ = sl.Log(event)
}
// LogCommandSkipped logs when a command is skipped due to signing configuration
func (sl *SecurityLogger) LogCommandSkipped(commandID, reason string) {
if sl == nil {
return
}
event := &SecurityEvent{
Timestamp: time.Now().UTC(),
Level: "INFO",
EventType: "COMMAND_SKIPPED",
Message: "Command skipped due to signing configuration",
Details: map[string]interface{}{
"command_id": commandID,
"reason": reason,
},
}
_ = sl.Log(event)
}
// GetBatch returns a batch of events for sending to server
func (sl *SecurityLogger) GetBatch() []*SecurityEvent {
sl.mu.Lock()
defer sl.mu.Unlock()
if len(sl.buffer) == 0 {
return nil
}
batch := make([]*SecurityEvent, len(sl.buffer))
copy(batch, sl.buffer)
return batch
}
// ClearBatch removes the oldest events after successful server delivery.
func (sl *SecurityLogger) ClearBatch(count int) {
sl.mu.Lock()
defer sl.mu.Unlock()
if count <= 0 {
return
}
if count >= len(sl.buffer) {
for i := range sl.buffer {
sl.buffer[i] = nil
}
sl.buffer = sl.buffer[:0]
sl.fileCursor = 0
return
}
copy(sl.buffer, sl.buffer[count:])
for i := len(sl.buffer) - count; i < len(sl.buffer); i++ {
sl.buffer[i] = nil
}
sl.buffer = sl.buffer[:len(sl.buffer)-count]
sl.fileCursor -= count
if sl.fileCursor < 0 {
sl.fileCursor = 0
}
}
// writeEvent writes an event to the log file
func (sl *SecurityLogger) writeEvent(event *SecurityEvent) error {
jsonData, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("failed to marshal security event: %w", err)
}
sl.logger.Println(string(jsonData))
return nil
}
// flushBuffer flushes all buffered events to file
func (sl *SecurityLogger) flushBuffer() {
sl.mu.Lock()
defer sl.mu.Unlock()
sl.flushBufferUnsafe()
}
// flushBufferUnsafe flushes buffer without acquiring lock (must be called with lock held)
func (sl *SecurityLogger) flushBufferUnsafe() {
if sl.fileCursor > len(sl.buffer) {
sl.fileCursor = 0
}
for sl.fileCursor < len(sl.buffer) {
event := sl.buffer[sl.fileCursor]
if err := sl.writeEvent(event); err != nil {
log.Printf("[ERROR] [agent] [security] security_event_write_failed error=%v", err)
break
}
sl.fileCursor++
}
if sl.fileCursor == len(sl.buffer) {
sl.lastFlush = time.Now().UTC()
}
// Reset timer if not closed
if !sl.closed && sl.flushTimer != nil {
sl.flushTimer.Stop()
sl.flushTimer.Reset(30 * time.Second)
}
}
// shouldLogLevel checks if the event should be logged based on the configured level
func (sl *SecurityLogger) shouldLogLevel(eventLevel string) bool {
levels := map[string]int{
"NONE": 0,
"ERROR": 1,
"WARNING": 2,
"INFO": 3,
"DEBUG": 4,
}
configLevel := levels[strings.ToUpper(sl.config.Level)]
eventLvl, exists := levels[eventLevel]
if !exists {
eventLvl = 2 // Default to WARNING
}
return eventLvl <= configLevel
}
// Close closes the security logger
func (sl *SecurityLogger) Close() error {
sl.mu.Lock()
defer sl.mu.Unlock()
if sl.closed {
return nil
}
// Stop flush timer
if sl.flushTimer != nil {
sl.flushTimer.Stop()
}
// Flush remaining events
sl.flushBufferUnsafe()
// Close file
if sl.file != nil {
err := sl.file.Close()
sl.closed = true
return err
}
sl.closed = true
return nil
}
// min returns the minimum of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -0,0 +1,63 @@
package logging
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/Fimeg/RedFlag/agent/internal/config"
)
func TestSecurityLoggerGetBatchDoesNotClearUntilSuccessfulSend(t *testing.T) {
sl, err := NewSecurityLogger(&config.Config{}, t.TempDir())
if err != nil {
t.Fatal(err)
}
defer sl.Close()
if err := sl.Log(&SecurityEvent{Level: "WARNING", EventType: "TEST_EVENT", Message: "first"}); err != nil {
t.Fatal(err)
}
first := sl.GetBatch()
if len(first) != 1 {
t.Fatalf("first batch len = %d, want 1", len(first))
}
second := sl.GetBatch()
if len(second) != 1 {
t.Fatalf("second batch len = %d, want retained event before ClearBatch", len(second))
}
sl.ClearBatch(len(first))
if got := sl.GetBatch(); len(got) != 0 {
t.Fatalf("batch len after ClearBatch = %d, want 0", len(got))
}
}
func TestSecurityLoggerFlushDoesNotDrainServerBufferOrDuplicateFileWrites(t *testing.T) {
dir := t.TempDir()
sl, err := NewSecurityLogger(&config.Config{}, dir)
if err != nil {
t.Fatal(err)
}
defer sl.Close()
if err := sl.Log(&SecurityEvent{Level: "WARNING", EventType: "TEST_EVENT", Message: "first"}); err != nil {
t.Fatal(err)
}
sl.flushBuffer()
sl.flushBuffer()
if got := sl.GetBatch(); len(got) != 1 {
t.Fatalf("batch len after file flush = %d, want 1", len(got))
}
body, err := os.ReadFile(filepath.Join(dir, "security.log"))
if err != nil {
t.Fatal(err)
}
if count := strings.Count(string(body), "TEST_EVENT"); count != 1 {
t.Fatalf("file event count = %d, want 1\n%s", count, string(body))
}
}