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,382 @@
package services
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
"time"
)
// AgentBuilder handles generating embedded agent configurations
// Deprecated: superseded by AgentHandler.Rebuild (ConfigService was removed unwired)
type AgentBuilder struct {
buildContext string
}
// NewAgentBuilder creates a new agent builder
func NewAgentBuilder() *AgentBuilder {
return &AgentBuilder{}
}
// BuildAgentWithConfig generates agent configuration and prepares signed binary
// Deprecated: superseded by AgentHandler.Rebuild (ConfigService was removed unwired)
func (ab *AgentBuilder) BuildAgentWithConfig(config *AgentConfiguration) (*BuildResult, error) {
// Create temporary build directory
buildDir, err := os.MkdirTemp("", "agent-build-")
if err != nil {
return nil, fmt.Errorf("failed to create build directory: %w", err)
}
// Generate config.json (not embedded in binary)
configJSONPath := filepath.Join(buildDir, "config.json")
configJSON, err := ab.generateConfigJSON(config)
if err != nil {
os.RemoveAll(buildDir)
return nil, fmt.Errorf("failed to generate config JSON: %w", err)
}
// Write config.json to file
if err := os.WriteFile(configJSONPath, []byte(configJSON), 0600); err != nil {
os.RemoveAll(buildDir)
return nil, fmt.Errorf("failed to write config file: %w", err)
}
// Note: Binary is pre-built and stored in /app/binaries/{platform}/
// We don't build or modify the binary here - it's generic for all agents
// The signing happens at the platform level, not per-agent
return &BuildResult{
BuildDir: buildDir,
AgentID: config.AgentID,
ConfigFile: configJSONPath,
ConfigJSON: configJSON,
Platform: config.Platform,
BuildTime: time.Now().UTC(),
}, nil
}
// generateConfigJSON converts configuration to JSON format
func (ab *AgentBuilder) generateConfigJSON(config *AgentConfiguration) (string, error) {
// Create complete configuration
completeConfig := make(map[string]interface{})
// Copy public configuration
for k, v := range config.PublicConfig {
completeConfig[k] = v
}
// Add secrets (they will be protected by file permissions at runtime)
for k, v := range config.Secrets {
completeConfig[k] = v
}
// CRITICAL: Add both version fields explicitly
// These MUST be present or middleware will block the agent
completeConfig["version"] = config.ConfigVersion // Config schema version (e.g., "5")
completeConfig["agent_version"] = config.AgentVersion // Agent binary version (e.g., "0.1.23.6")
// Add agent metadata
completeConfig["agent_id"] = config.AgentID
completeConfig["server_url"] = config.ServerURL
completeConfig["organization"] = config.Organization
completeConfig["environment"] = config.Environment
completeConfig["template"] = config.Template
completeConfig["build_time"] = config.BuildTime.Format(time.RFC3339)
// Convert to JSON
jsonBytes, err := json.MarshalIndent(completeConfig, "", " ")
if err != nil {
return "", fmt.Errorf("failed to marshal config to JSON: %w", err)
}
return string(jsonBytes), nil
}
// BuildResult contains the results of the build process
type BuildResult struct {
BuildDir string `json:"build_dir"`
AgentID string `json:"agent_id"`
ConfigFile string `json:"config_file"`
ConfigJSON string `json:"config_json"`
Platform string `json:"platform"`
BuildTime time.Time `json:"build_time"`
}
// generateEmbeddedConfig generates the embedded configuration Go file
func (ab *AgentBuilder) generateEmbeddedConfig(filename string, config *AgentConfiguration) error {
// Create directory structure
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
return err
}
// Convert configuration to JSON for embedding
configJSON, err := ab.configToJSON(config)
if err != nil {
return err
}
// Generate Go source file with embedded configuration
tmpl := `// Code generated by dynamic build system. DO NOT EDIT.
package embedded
import (
"encoding/json"
"time"
)
// EmbeddedAgentConfiguration contains the pre-built agent configuration
var EmbeddedAgentConfiguration = []byte(` + "`" + `{{.ConfigJSON}}` + "`" + `)
// EmbeddedAgentID contains the agent ID
var EmbeddedAgentID = "{{.AgentID}}"
// EmbeddedServerURL contains the server URL
var EmbeddedServerURL = "{{.ServerURL}}"
// EmbeddedOrganization contains the organization
var EmbeddedOrganization = "{{.Organization}}"
// EmbeddedEnvironment contains the environment
var EmbeddedEnvironment = "{{.Environment}}"
// EmbeddedTemplate contains the template type
var EmbeddedTemplate = "{{.Template}}"
// EmbeddedBuildTime contains the build time
var EmbeddedBuildTime, _ = time.Parse(time.RFC3339, "{{.BuildTime}}")
// GetEmbeddedConfig returns the embedded configuration as a map
func GetEmbeddedConfig() (map[string]interface{}, error) {
var config map[string]interface{}
err := json.Unmarshal(EmbeddedAgentConfiguration, &config)
return config, err
}
// SecretsMapping maps configuration fields to Docker secrets
var SecretsMapping = map[string]string{
{{range $key, $value := .Secrets}}"{{$key}}": "{{$value}}",
{{end}}
}
`
// Execute template
t, err := template.New("embedded").Parse(tmpl)
if err != nil {
return fmt.Errorf("failed to parse template: %w", err)
}
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer file.Close()
data := struct {
ConfigJSON string
AgentID string
ServerURL string
Organization string
Environment string
Template string
BuildTime string
Secrets map[string]string
}{
ConfigJSON: configJSON,
AgentID: config.AgentID,
ServerURL: config.ServerURL,
Organization: config.Organization,
Environment: config.Environment,
Template: config.Template,
BuildTime: config.BuildTime.Format(time.RFC3339),
Secrets: config.Secrets,
}
if err := t.Execute(file, data); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
return nil
}
// generateDockerCompose generates a docker-compose.yml file
func (ab *AgentBuilder) generateDockerCompose(filename string, config *AgentConfiguration) error {
tmpl := `# Generated dynamically based on configuration
version: '3.8'
services:
redflag-agent:
image: {{.ImageTag}}
container_name: redflag-agent-{{.AgentID}}
restart: unless-stopped
secrets:
{{range $key := .SecretsKeys}}- {{$key}}
{{end}}
volumes:
- /var/lib/redflag:/var/lib/redflag
- /var/run/docker.sock:/var/run/docker.sock
environment:
- REDFLAG_AGENT_ID={{.AgentID}}
- REDFLAG_ENVIRONMENT={{.Environment}}
- REDFLAG_SERVER_URL={{.ServerURL}}
- REDFLAG_ORGANIZATION={{.Organization}}
networks:
- redflag
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
secrets:
{{range $key, $value := .Secrets}}{{$key}}:
external: true
{{end}}
networks:
redflag:
external: true
`
t, err := template.New("compose").Parse(tmpl)
if err != nil {
return err
}
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
// Extract secret keys for template
secretsKeys := make([]string, 0, len(config.Secrets))
for key := range config.Secrets {
secretsKeys = append(secretsKeys, key)
}
data := struct {
ImageTag string
AgentID string
Environment string
ServerURL string
Organization string
Secrets map[string]string
SecretsKeys []string
}{
ImageTag: fmt.Sprintf("redflag-agent:%s", config.AgentID[:8]),
AgentID: config.AgentID,
Environment: config.Environment,
ServerURL: config.ServerURL,
Organization: config.Organization,
Secrets: config.Secrets,
SecretsKeys: secretsKeys,
}
return t.Execute(file, data)
}
// generateDockerfile generates a Dockerfile for building the agent
func (ab *AgentBuilder) generateDockerfile(filename string, config *AgentConfiguration) error {
tmpl := `# Dockerfile for RedFlag Agent with embedded configuration
FROM golang:1.21-alpine AS builder
# Install ca-certificates for SSL/TLS
RUN apk add --no-cache ca-certificates git
WORKDIR /app
# Copy go mod files (these should be in the same directory as the Dockerfile)
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Copy generated embedded configuration
COPY pkg/embedded/config.go ./pkg/embedded/config.go
# Build the agent with embedded configuration
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-w -s -X main.version=dynamic-build-{{.AgentID}}" \
-o redflag-agent \
./cmd/agent
# Final stage
FROM scratch
# Copy ca-certificates for SSL/TLS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy the agent binary
COPY --from=builder /app/redflag-agent /redflag-agent
# Set environment variables (these can be overridden by docker-compose)
ENV REDFLAG_AGENT_ID="{{.AgentID}}"
ENV REDFLAG_ENVIRONMENT="{{.Environment}}"
ENV REDFLAG_SERVER_URL="{{.ServerURL}}"
ENV REDFLAG_ORGANIZATION="{{.Organization}}"
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/redflag-agent", "--health-check"]
# Run the agent
ENTRYPOINT ["/redflag-agent"]
`
t, err := template.New("dockerfile").Parse(tmpl)
if err != nil {
return err
}
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
data := struct {
AgentID string
Environment string
ServerURL string
Organization string
}{
AgentID: config.AgentID,
Environment: config.Environment,
ServerURL: config.ServerURL,
Organization: config.Organization,
}
return t.Execute(file, data)
}
// configToJSON converts configuration to JSON string
func (ab *AgentBuilder) configToJSON(config *AgentConfiguration) (string, error) {
// Create complete configuration with embedded values
completeConfig := make(map[string]interface{})
// Copy public configuration
for k, v := range config.PublicConfig {
completeConfig[k] = v
}
// Add secrets values (they will be overridden by Docker secrets at runtime)
for k, v := range config.Secrets {
completeConfig[k] = v
}
// Convert to JSON with proper escaping
jsonBytes, err := json.MarshalIndent(completeConfig, "", " ")
if err != nil {
return "", fmt.Errorf("failed to marshal config to JSON: %w", err)
}
// Escape backticks for Go string literal
jsonStr := string(jsonBytes)
jsonStr = strings.ReplaceAll(jsonStr, "`", "` + \"`\" + `")
return jsonStr, nil
}

View file

@ -0,0 +1,125 @@
package services
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
)
// BuildOrchestratorService handles building and signing agent binaries
type BuildOrchestratorService struct {
signingService *SigningService
packageQueries *queries.PackageQueries
agentDir string // Directory containing pre-built binaries
}
// NewBuildOrchestratorService creates a new build orchestrator service
func NewBuildOrchestratorService(signingService *SigningService, packageQueries *queries.PackageQueries, agentDir string) *BuildOrchestratorService {
return &BuildOrchestratorService{
signingService: signingService,
packageQueries: packageQueries,
agentDir: agentDir,
}
}
// BuildAndSignAgent builds (or retrieves) and signs an agent binary.
//
// Idempotent across server restarts: if a signed package already exists for
// (version, platform, architecture) and the on-disk binary's checksum matches,
// the existing row is returned unchanged. Previously the server would re-sign
// and insert a fresh row every boot, accumulating duplicate packages and
// confusing the dashboard's "update available" list.
//
// Signing is required. If the signing service is disabled, returns an error.
// The pre-v0.2.1 unsigned fallback path has been removed — every binary served
// must carry an Ed25519 signature.
func (s *BuildOrchestratorService) BuildAndSignAgent(version, platform, architecture string) (*models.AgentUpdatePackage, error) {
binaryName := "redflag-agent"
if strings.HasPrefix(platform, "windows") {
binaryName += ".exe"
}
binaryPath := filepath.Join(s.agentDir, "binaries", platform+"-"+architecture, binaryName)
if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
return nil, fmt.Errorf("binary not found for platform %s: %w", platform, err)
}
if !s.signingService.IsEnabled() {
return nil, fmt.Errorf("cannot build agent: signing is disabled")
}
// Compute the on-disk checksum once so we can compare against any
// existing row before paying the cost of a fresh sign.
diskChecksum, checksumErr := s.signingService.ComputeFileChecksum(binaryPath)
if checksumErr != nil {
log.Printf("[WARNING] [server] [build_orchestrator] checksum_failed path=%s error=%v — falling through to fresh sign", binaryPath, checksumErr)
} else if existing, getErr := s.packageQueries.GetSignedPackage(version, platform, architecture); getErr == nil && existing != nil && existing.Checksum == diskChecksum && existing.Signature != "" {
log.Printf("[INFO] [server] [build_orchestrator] package_reused version=%s platform=%s arch=%s id=%s", version, platform, architecture, existing.ID)
return existing, nil
}
signedPackage, err := s.signingService.SignFile(binaryPath)
if err != nil {
return nil, fmt.Errorf("failed to sign agent binary: %w", err)
}
signedPackage.Version = version
signedPackage.Platform = platform
signedPackage.Architecture = architecture
err = s.packageQueries.StoreSignedPackage(signedPackage)
if err != nil {
return nil, fmt.Errorf("failed to store signed package: %w", err)
}
log.Printf("[INFO] [server] [build_orchestrator] package_signed id=%s version=%s platform=%s arch=%s", signedPackage.ID, version, platform, architecture)
return signedPackage, nil
}
// SignExistingBinary signs an existing binary file
func (s *BuildOrchestratorService) SignExistingBinary(binaryPath, version, platform, architecture string) (*models.AgentUpdatePackage, error) {
// Check if file exists
if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
return nil, fmt.Errorf("binary not found: %s", binaryPath)
}
// Sign the binary if signing is enabled
if !s.signingService.IsEnabled() {
return nil, fmt.Errorf("signing service is disabled")
}
signedPackage, err := s.signingService.SignFile(binaryPath)
if err != nil {
return nil, fmt.Errorf("failed to sign agent binary: %w", err)
}
// Set additional fields
signedPackage.Version = version
signedPackage.Platform = platform
signedPackage.Architecture = architecture
// Store signed package in database
err = s.packageQueries.StoreSignedPackage(signedPackage)
if err != nil {
return nil, fmt.Errorf("failed to store signed package: %w", err)
}
log.Printf("Successfully signed and stored agent binary: %s (%s/%s)", signedPackage.ID, platform, architecture)
return signedPackage, nil
}
// GetSignedPackage retrieves a signed package by version and platform
func (s *BuildOrchestratorService) GetSignedPackage(version, platform, architecture string) (*models.AgentUpdatePackage, error) {
return s.packageQueries.GetSignedPackage(version, platform, architecture)
}
// ListSignedPackages lists all signed packages (with optional filters)
func (s *BuildOrchestratorService) ListSignedPackages(version, platform string, limit, offset int) ([]models.AgentUpdatePackage, error) {
return s.packageQueries.ListUpdatePackages(version, platform, limit, offset)
}

View file

@ -0,0 +1,308 @@
package services
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/Fimeg/RedFlag/server/internal/common"
)
// NewBuildRequest represents a request for a new agent build
type NewBuildRequest struct {
ServerURL string `json:"server_url" binding:"required"`
Environment string `json:"environment" binding:"required"`
AgentType string `json:"agent_type" binding:"required,oneof=linux-server windows-workstation docker-host"`
Organization string `json:"organization" binding:"required"`
RegistrationToken string `json:"registration_token" binding:"required"`
CustomSettings map[string]interface{} `json:"custom_settings,omitempty"`
DeploymentID string `json:"deployment_id,omitempty"`
AgentID string `json:"agent_id,omitempty"` // For upgrades when preserving ID
}
// UpgradeBuildRequest represents a request for an agent upgrade
type UpgradeBuildRequest struct {
ServerURL string `json:"server_url" binding:"required"`
Environment string `json:"environment"`
AgentType string `json:"agent_type"`
Organization string `json:"organization"`
CustomSettings map[string]interface{} `json:"custom_settings,omitempty"`
DeploymentID string `json:"deployment_id,omitempty"`
PreserveExisting bool `json:"preserve_existing"`
DetectionPath string `json:"detection_path,omitempty"`
}
// DetectionRequest represents a request to detect existing agent installation
type DetectionRequest struct {
DetectionPath string `json:"detection_path,omitempty"`
}
// InstallationDetection represents the result of detecting an existing installation
type InstallationDetection struct {
HasExistingAgent bool `json:"has_existing_agent"`
AgentID string `json:"agent_id,omitempty"`
CurrentVersion string `json:"current_version,omitempty"`
ConfigVersion int `json:"config_version,omitempty"`
RequiresMigration bool `json:"requires_migration"`
Inventory *AgentFileInventory `json:"inventory,omitempty"`
MigrationPlan *MigrationDetection `json:"migration_plan,omitempty"`
DetectionPath string `json:"detection_path"`
DetectionTime string `json:"detection_time"`
RecommendedAction string `json:"recommended_action"`
}
// AgentFileInventory represents all files associated with an agent installation
type AgentFileInventory struct {
ConfigFiles []common.AgentFile `json:"config_files"`
StateFiles []common.AgentFile `json:"state_files"`
BinaryFiles []common.AgentFile `json:"binary_files"`
LogFiles []common.AgentFile `json:"log_files"`
CertificateFiles []common.AgentFile `json:"certificate_files"`
ExistingPaths []string `json:"existing_paths"`
MissingPaths []string `json:"missing_paths"`
}
// MigrationDetection represents migration detection results (from existing migration system)
type MigrationDetection struct {
CurrentAgentVersion string `json:"current_agent_version"`
CurrentConfigVersion int `json:"current_config_version"`
RequiresMigration bool `json:"requires_migration"`
RequiredMigrations []string `json:"required_migrations"`
MissingSecurityFeatures []string `json:"missing_security_features"`
Inventory *AgentFileInventory `json:"inventory"`
DetectionTime string `json:"detection_time"`
}
// InstallationDetector handles detection of existing agent installations
type InstallationDetector struct{}
// NewInstallationDetector creates a new installation detector
func NewInstallationDetector() *InstallationDetector {
return &InstallationDetector{}
}
// DetectExistingInstallation detects if there's an existing agent installation
func (id *InstallationDetector) DetectExistingInstallation(agentID string) (*InstallationDetection, error) {
result := &InstallationDetection{
HasExistingAgent: false,
DetectionTime: time.Now().UTC().Format(time.RFC3339),
RecommendedAction: "new_installation",
}
if agentID != "" {
result.HasExistingAgent = true
result.AgentID = agentID
result.RecommendedAction = "upgrade"
}
return result, nil
}
// scanDirectory scans a directory for agent-related files
func (id *InstallationDetector) scanDirectory(dirPath string) ([]common.AgentFile, error) {
var files []common.AgentFile
entries, err := os.ReadDir(dirPath)
if err != nil {
if os.IsNotExist(err) {
return files, nil // Directory doesn't exist, return empty
}
return nil, err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
fullPath := filepath.Join(dirPath, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
// Calculate checksum
checksum, err := id.calculateChecksum(fullPath)
if err != nil {
checksum = ""
}
file := common.AgentFile{
Path: fullPath,
Size: info.Size(),
ModifiedTime: info.ModTime(),
Checksum: checksum,
Required: id.isRequiredFile(entry.Name()),
Migrate: id.shouldMigrateFile(entry.Name()),
Description: id.getFileDescription(entry.Name()),
}
files = append(files, file)
}
return files, nil
}
// categorizeFile categorizes a file into the appropriate inventory section
func (id *InstallationDetector) categorizeFile(file common.AgentFile, inventory *AgentFileInventory) {
filename := filepath.Base(file.Path)
switch {
case filename == "config.json":
inventory.ConfigFiles = append(inventory.ConfigFiles, file)
case filename == "pending_acks.json" || filename == "public_key.cache" || filename == "last_scan.json" || filename == "metrics.json":
inventory.StateFiles = append(inventory.StateFiles, file)
case filename == "redflag-agent" || filename == "redflag-agent.exe":
inventory.BinaryFiles = append(inventory.BinaryFiles, file)
case strings.HasSuffix(filename, ".log"):
inventory.LogFiles = append(inventory.LogFiles, file)
case strings.HasSuffix(filename, ".crt") || strings.HasSuffix(filename, ".key") || strings.HasSuffix(filename, ".pem"):
inventory.CertificateFiles = append(inventory.CertificateFiles, file)
}
}
// extractAgentInfo extracts agent ID, version, and config version from config files
func (id *InstallationDetector) extractAgentInfo(inventory *AgentFileInventory) (string, string, int, error) {
var agentID, version string
var configVersion int
// Look for config.json first
for _, configFile := range inventory.ConfigFiles {
if strings.Contains(configFile.Path, "config.json") {
data, err := os.ReadFile(configFile.Path)
if err != nil {
continue
}
var config map[string]interface{}
if err := json.Unmarshal(data, &config); err != nil {
continue
}
// Extract agent ID
if id, ok := config["agent_id"].(string); ok {
agentID = id
}
// Extract version information
if ver, ok := config["agent_version"].(string); ok {
version = ver
}
if ver, ok := config["version"].(float64); ok {
configVersion = int(ver)
}
break
}
}
// If no agent ID found in config, we don't have a valid installation
if agentID == "" {
return "", "", 0, fmt.Errorf("no agent ID found in configuration")
}
return agentID, version, configVersion, nil
}
// determineMigrationRequired determines if migration is needed
func (id *InstallationDetector) determineMigrationRequired(inventory *AgentFileInventory) bool {
// Check for old directory paths
for _, configFile := range inventory.ConfigFiles {
if strings.Contains(configFile.Path, "/etc/aggregator/") || strings.Contains(configFile.Path, "/var/lib/aggregator/") {
return true
}
}
for _, stateFile := range inventory.StateFiles {
if strings.Contains(stateFile.Path, "/etc/aggregator/") || strings.Contains(stateFile.Path, "/var/lib/aggregator/") {
return true
}
}
// Check config version (older than v5 needs migration)
for _, configFile := range inventory.ConfigFiles {
if strings.Contains(configFile.Path, "config.json") {
if _, _, configVersion, err := id.extractAgentInfo(inventory); err == nil {
if configVersion < 5 {
return true
}
}
}
}
return false
}
// calculateChecksum calculates SHA256 checksum of a file
func (id *InstallationDetector) calculateChecksum(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// isRequiredFile determines if a file is required for agent operation
func (id *InstallationDetector) isRequiredFile(filename string) bool {
requiredFiles := []string{
"config.json",
"redflag-agent",
"redflag-agent.exe",
}
for _, required := range requiredFiles {
if filename == required {
return true
}
}
return false
}
// shouldMigrateFile determines if a file should be migrated
func (id *InstallationDetector) shouldMigrateFile(filename string) bool {
migratableFiles := []string{
"config.json",
"pending_acks.json",
"public_key.cache",
"last_scan.json",
"metrics.json",
}
for _, migratable := range migratableFiles {
if filename == migratable {
return true
}
}
return false
}
// getFileDescription returns a human-readable description of a file
func (id *InstallationDetector) getFileDescription(filename string) string {
descriptions := map[string]string{
"config.json": "Agent configuration file",
"pending_acks.json": "Pending command acknowledgments",
"public_key.cache": "Server public key cache",
"last_scan.json": "Last scan results",
"metrics.json": "Agent metrics data",
"redflag-agent": "Agent binary",
"redflag-agent.exe": "Windows agent binary",
}
if desc, ok := descriptions[filename]; ok {
return desc
}
return "Agent file"
}

View file

@ -0,0 +1,97 @@
package services
import (
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/server/internal/capability"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/gofrs/uuid/v5"
)
// DefaultTokenTTL bounds how long a minted token stays valid. Short by design:
// a token authorizes one already-approved operation that the agent should pick
// up promptly. Long enough to survive normal poll/delivery latency, short enough
// that a leaked token is not a standing capability.
const DefaultTokenTTL = time.Hour
// CapabilityMinter builds, signs, and persists supply-chain capability tokens at
// approval time. It is the server's authority role: it only mints after the
// caller's policy checks (OSV, age, hash) have cleared.
//
// The signing key stays inside SigningService; the minter never touches key
// material directly. Full off-web-process signer isolation (plan constraint #2)
// is an infrastructure step layered on top of this seam — this type is the only
// place that asks for a signature, so relocating the signer means swapping the
// SigningService dependency here, not rewriting callers.
type CapabilityMinter struct {
signing *SigningService
tokens *queries.CapabilityTokenQueries
ttl time.Duration
}
func NewCapabilityMinter(signing *SigningService, tokens *queries.CapabilityTokenQueries) *CapabilityMinter {
return &CapabilityMinter{signing: signing, tokens: tokens, ttl: DefaultTokenTTL}
}
// SetTTL overrides the token validity window.
func (m *CapabilityMinter) SetTTL(d time.Duration) {
if d > 0 {
m.ttl = d
}
}
// Enabled reports whether minting can produce signed tokens. When signing is
// disabled the gate is simply not active; that is not an error condition.
func (m *CapabilityMinter) Enabled() bool {
return m != nil && m.signing != nil && m.signing.IsEnabled()
}
// MintForUpdate builds a token authorizing the approved update over the supplied
// resolved closure, signs it, and persists it for delivery. The caller assembles
// the closure (today: the top-level package plus its expected hash; later: the
// full transitive set) so this stays the single signing chokepoint.
//
// Returns (nil, nil) when signing is disabled — the gate is optional and its
// absence must not break the approval path it hangs off.
func (m *CapabilityMinter) MintForUpdate(update *models.UpdateState, closure []capability.ClosureEntry) (*capability.Token, error) {
if !m.Enabled() {
return nil, nil
}
if len(closure) == 0 {
return nil, fmt.Errorf("capability: refusing to mint a token over an empty closure")
}
operation := "upgrade"
if update.CurrentVersion == "" {
operation = "install"
}
now := time.Now().UTC()
token := &capability.Token{
Version: capability.Version,
TokenID: uuid.Must(uuid.NewV4()).String(),
AgentID: update.AgentID.String(),
PackageType: update.PackageType,
Operation: operation,
Closure: closure,
IssuedAt: now.Unix(),
NotBefore: now.Unix(),
ExpiresAt: now.Add(m.ttl).Unix(),
}
if err := m.signing.SignCapabilityToken(token); err != nil {
return nil, fmt.Errorf("capability: sign failed: %w", err)
}
if err := m.tokens.Insert(token, update.ID); err != nil {
return nil, fmt.Errorf("capability: persist failed: %w", err)
}
log.Printf("[SECURITY] [server] [capability] token_minted token_id=%s agent_id=%s package_type=%s operation=%s closure_size=%d key_id=%s expires_at=%d",
token.TokenID, token.AgentID, token.PackageType, token.Operation, len(token.Closure), token.KeyID, token.ExpiresAt)
return token, nil
}

View file

@ -0,0 +1,776 @@
package services
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/gofrs/uuid/v5"
"github.com/jmoiron/sqlx"
)
// AgentTemplate defines a template for different agent types
type AgentTemplate struct {
Name string `json:"name"`
Description string `json:"description"`
BaseConfig map[string]interface{} `json:"base_config"`
Secrets []string `json:"required_secrets"`
Validation ValidationRules `json:"validation"`
}
// ValidationRules defines validation rules for configuration
type ValidationRules struct {
RequiredFields []string `json:"required_fields"`
AllowedValues map[string][]string `json:"allowed_values"`
Patterns map[string]string `json:"patterns"`
Constraints map[string]interface{} `json:"constraints"`
}
// PublicKeyResponse represents the server's public key response
type PublicKeyResponse struct {
PublicKey string `json:"public_key"`
Fingerprint string `json:"fingerprint"`
Algorithm string `json:"algorithm"`
KeySize int `json:"key_size"`
}
// ConfigBuilder handles dynamic agent configuration generation
type ConfigBuilder struct {
serverURL string
templates map[string]AgentTemplate
httpClient *http.Client
publicKeyCache map[string]string
scannerConfigQ *queries.ScannerConfigQueries
}
// NewConfigBuilder creates a new configuration builder
func NewConfigBuilder(serverURL string, db *sqlx.DB) *ConfigBuilder {
return &ConfigBuilder{
serverURL: serverURL,
templates: getAgentTemplates(),
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
publicKeyCache: make(map[string]string),
scannerConfigQ: queries.NewScannerConfigQueries(db),
}
}
// AgentSetupRequest represents a request to set up a new agent
type AgentSetupRequest struct {
ServerURL string `json:"server_url" binding:"required"`
Environment string `json:"environment" binding:"required"`
AgentType string `json:"agent_type" binding:"required,oneof=linux-server windows-workstation docker-host"`
Organization string `json:"organization" binding:"required"`
CustomSettings map[string]interface{} `json:"custom_settings,omitempty"`
DeploymentID string `json:"deployment_id,omitempty"`
AgentID string `json:"agent_id,omitempty"` // Optional: existing agent ID for upgrades
}
// BuildAgentConfig builds a complete agent configuration
func (cb *ConfigBuilder) BuildAgentConfig(req AgentSetupRequest) (*AgentConfiguration, error) {
// Validate request
if err := cb.validateRequest(req); err != nil {
return nil, err
}
// Determine agent ID - use existing if provided and valid, otherwise generate new
agentID := cb.determineAgentID(req.AgentID)
// Fetch server public key
serverPublicKey, err := cb.fetchServerPublicKey(req.ServerURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch server public key: %w", err)
}
// Generate registration token
registrationToken, err := cb.generateRegistrationToken(agentID)
if err != nil {
return nil, fmt.Errorf("failed to generate registration token: %w", err)
}
// Get template
template, exists := cb.templates[req.AgentType]
if !exists {
return nil, fmt.Errorf("unknown agent type: %s", req.AgentType)
}
// Build base configuration
config := cb.buildFromTemplate(template, req.CustomSettings)
// Override scanner timeouts from database (user-configurable)
cb.overrideScannerTimeoutsFromDB(config)
// Inject deployment-specific values
cb.injectDeploymentValues(config, req, agentID, registrationToken, serverPublicKey)
// Apply environment-specific defaults
cb.applyEnvironmentDefaults(config, req.Environment)
// Validate final configuration
if err := cb.validateConfiguration(config, template); err != nil {
return nil, fmt.Errorf("configuration validation failed: %w", err)
}
// Separate sensitive and non-sensitive data
publicConfig, secrets := cb.separateSecrets(config)
// Create Docker secrets if needed
var secretsCreated bool
var secretsPath string
if len(secrets) > 0 {
secretsManager := NewSecretsManager()
// Generate encryption key if not set
if secretsManager.GetEncryptionKey() == "" {
key, err := secretsManager.GenerateEncryptionKey()
if err != nil {
return nil, fmt.Errorf("failed to generate encryption key: %w", err)
}
secretsManager.SetEncryptionKey(key)
}
// Create Docker secrets
if err := secretsManager.CreateDockerSecrets(secrets); err != nil {
return nil, fmt.Errorf("failed to create Docker secrets: %w", err)
}
secretsCreated = true
secretsPath = secretsManager.GetSecretsPath()
}
// Determine platform from agent type
platform := "linux-amd64" // Default
if req.AgentType == "windows-workstation" {
platform = "windows-amd64"
}
return &AgentConfiguration{
AgentID: agentID,
PublicConfig: publicConfig,
Secrets: secrets,
Template: req.AgentType,
Environment: req.Environment,
ServerURL: req.ServerURL,
Organization: req.Organization,
Platform: platform,
ConfigVersion: "5", // Config schema version
AgentVersion: "0.1.23.6", // Agent binary version
BuildTime: time.Now().UTC(),
SecretsCreated: secretsCreated,
SecretsPath: secretsPath,
}, nil
}
// AgentConfiguration represents a complete agent configuration
type AgentConfiguration struct {
AgentID string `json:"agent_id"`
PublicConfig map[string]interface{} `json:"public_config"`
Secrets map[string]string `json:"secrets"`
Template string `json:"template"`
Environment string `json:"environment"`
ServerURL string `json:"server_url"`
Organization string `json:"organization"`
Platform string `json:"platform"`
ConfigVersion string `json:"config_version"` // Config schema version (e.g., "5")
AgentVersion string `json:"agent_version"` // Agent binary version (e.g., "0.1.23.6")
BuildTime time.Time `json:"build_time"`
SecretsCreated bool `json:"secrets_created"`
SecretsPath string `json:"secrets_path,omitempty"`
}
// validateRequest validates the setup request
func (cb *ConfigBuilder) validateRequest(req AgentSetupRequest) error {
if req.ServerURL == "" {
return fmt.Errorf("server_url is required")
}
if req.Environment == "" {
return fmt.Errorf("environment is required")
}
if req.AgentType == "" {
return fmt.Errorf("agent_type is required")
}
if req.Organization == "" {
return fmt.Errorf("organization is required")
}
// Check if agent type exists
if _, exists := cb.templates[req.AgentType]; !exists {
return fmt.Errorf("unknown agent type: %s", req.AgentType)
}
return nil
}
// fetchServerPublicKey fetches the server's public key with caching
func (cb *ConfigBuilder) fetchServerPublicKey(serverURL string) (string, error) {
// Check cache first
if cached, exists := cb.publicKeyCache[serverURL]; exists {
return cached, nil
}
// Fetch from server
resp, err := cb.httpClient.Get(serverURL + "/api/v1/public-key")
if err != nil {
return "", fmt.Errorf("failed to fetch public key: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("server returned status %d", resp.StatusCode)
}
var keyResp PublicKeyResponse
if err := json.NewDecoder(resp.Body).Decode(&keyResp); err != nil {
return "", fmt.Errorf("failed to decode public key response: %w", err)
}
// Cache the key
cb.publicKeyCache[serverURL] = keyResp.PublicKey
return keyResp.PublicKey, nil
}
// generateRegistrationToken generates a secure registration token
func (cb *ConfigBuilder) generateRegistrationToken(agentID string) (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
// Combine agent ID with random bytes for uniqueness
data := append([]byte(agentID), bytes...)
token := hex.EncodeToString(data)
// Ensure token doesn't exceed reasonable length
if len(token) > 128 {
token = token[:128]
}
return token, nil
}
// buildFromTemplate builds configuration from template
func (cb *ConfigBuilder) buildFromTemplate(template AgentTemplate, customSettings map[string]interface{}) map[string]interface{} {
config := make(map[string]interface{})
// Deep copy base configuration
for k, v := range template.BaseConfig {
config[k] = cb.deepCopy(v)
}
// Apply custom settings
if customSettings != nil {
cb.mergeSettings(config, customSettings)
}
return config
}
// injectDeploymentValues injects deployment-specific values into configuration
func (cb *ConfigBuilder) injectDeploymentValues(config map[string]interface{}, req AgentSetupRequest, agentID, registrationToken, serverPublicKey string) {
config["version"] = "5" // Config schema version (for migration system)
config["agent_version"] = "0.1.23.6" // Agent binary version (MUST match the binary being served)
config["server_url"] = req.ServerURL
config["agent_id"] = agentID
config["registration_token"] = registrationToken
config["server_public_key"] = serverPublicKey
config["organization"] = req.Organization
config["environment"] = req.Environment
config["agent_type"] = req.AgentType
if req.DeploymentID != "" {
config["deployment_id"] = req.DeploymentID
}
}
// determineAgentID checks if an existing agent ID is provided and valid, otherwise generates new
func (cb *ConfigBuilder) determineAgentID(providedAgentID string) string {
if providedAgentID != "" {
// Validate it's a proper UUID
if _, err := uuid.FromString(providedAgentID); err == nil {
return providedAgentID
}
}
// Generate new UUID if none provided or invalid
return uuid.Must(uuid.NewV4()).String()
}
// applyEnvironmentDefaults applies environment-specific configuration defaults
func (cb *ConfigBuilder) applyEnvironmentDefaults(config map[string]interface{}, environment string) {
environmentDefaults := map[string]interface{}{
"development": map[string]interface{}{
"logging": map[string]interface{}{
"level": "debug",
"max_size": 50,
"max_backups": 2,
"max_age": 7,
},
"check_in_interval": 60, // More frequent polling in development
},
"staging": map[string]interface{}{
"logging": map[string]interface{}{
"level": "info",
"max_size": 100,
"max_backups": 3,
"max_age": 14,
},
"check_in_interval": 180,
},
"production": map[string]interface{}{
"logging": map[string]interface{}{
"level": "warn",
"max_size": 200,
"max_backups": 5,
"max_age": 30,
},
"check_in_interval": 300, // 5 minutes for production
},
"testing": map[string]interface{}{
"logging": map[string]interface{}{
"level": "debug",
"max_size": 10,
"max_backups": 1,
"max_age": 1,
},
"check_in_interval": 30, // Very frequent for testing
},
}
if defaults, exists := environmentDefaults[environment]; exists {
if defaultsMap, ok := defaults.(map[string]interface{}); ok {
cb.mergeSettings(config, defaultsMap)
}
}
}
// validateConfiguration validates the final configuration
func (cb *ConfigBuilder) validateConfiguration(config map[string]interface{}, template AgentTemplate) error {
// Check required fields
for _, field := range template.Validation.RequiredFields {
if _, exists := config[field]; !exists {
return fmt.Errorf("required field missing: %s", field)
}
}
// Validate allowed values
for field, allowedValues := range template.Validation.AllowedValues {
if value, exists := config[field]; exists {
if strValue, ok := value.(string); ok {
if !cb.containsString(allowedValues, strValue) {
return fmt.Errorf("invalid value for %s: %s (allowed: %v)", field, strValue, allowedValues)
}
}
}
}
// Validate constraints
for field, constraint := range template.Validation.Constraints {
if value, exists := config[field]; exists {
if err := cb.validateConstraint(field, value, constraint); err != nil {
return err
}
}
}
return nil
}
// separateSecrets separates sensitive data from public configuration
func (cb *ConfigBuilder) separateSecrets(config map[string]interface{}) (map[string]interface{}, map[string]string) {
publicConfig := make(map[string]interface{})
secrets := make(map[string]string)
// Copy all values to public config initially
for k, v := range config {
publicConfig[k] = cb.deepCopy(v)
}
// Extract known sensitive fields
sensitiveFields := []string{
"registration_token",
"server_public_key",
}
for _, field := range sensitiveFields {
if value, exists := publicConfig[field]; exists {
if strValue, ok := value.(string); ok {
secrets[field] = strValue
delete(publicConfig, field)
}
}
}
// Extract nested sensitive fields
if proxy, exists := publicConfig["proxy"].(map[string]interface{}); exists {
if username, exists := proxy["username"].(string); exists && username != "" {
secrets["proxy_username"] = username
delete(proxy, "username")
}
if password, exists := proxy["password"].(string); exists && password != "" {
secrets["proxy_password"] = password
delete(proxy, "password")
}
}
if tls, exists := publicConfig["tls"].(map[string]interface{}); exists {
if certFile, exists := tls["cert_file"].(string); exists && certFile != "" {
secrets["tls_cert"] = certFile
delete(tls, "cert_file")
}
if keyFile, exists := tls["key_file"].(string); exists && keyFile != "" {
secrets["tls_key"] = keyFile
delete(tls, "key_file")
}
if caFile, exists := tls["ca_file"].(string); exists && caFile != "" {
secrets["tls_ca"] = caFile
delete(tls, "ca_file")
}
}
return publicConfig, secrets
}
// Helper functions
func (cb *ConfigBuilder) deepCopy(value interface{}) interface{} {
if m, ok := value.(map[string]interface{}); ok {
result := make(map[string]interface{})
for k, v := range m {
result[k] = cb.deepCopy(v)
}
return result
}
if s, ok := value.([]interface{}); ok {
result := make([]interface{}, len(s))
for i, v := range s {
result[i] = cb.deepCopy(v)
}
return result
}
return value
}
func (cb *ConfigBuilder) mergeSettings(target map[string]interface{}, source map[string]interface{}) {
for key, value := range source {
if existing, exists := target[key]; exists {
if existingMap, ok := existing.(map[string]interface{}); ok {
if sourceMap, ok := value.(map[string]interface{}); ok {
cb.mergeSettings(existingMap, sourceMap)
continue
}
}
}
target[key] = cb.deepCopy(value)
}
}
func (cb *ConfigBuilder) containsString(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// GetTemplates returns the available agent templates
func (cb *ConfigBuilder) GetTemplates() map[string]AgentTemplate {
return getAgentTemplates()
}
// GetTemplate returns a specific agent template
func (cb *ConfigBuilder) GetTemplate(agentType string) (AgentTemplate, bool) {
template, exists := getAgentTemplates()[agentType]
return template, exists
}
func (cb *ConfigBuilder) validateConstraint(field string, value interface{}, constraint interface{}) error {
constraints, ok := constraint.(map[string]interface{})
if !ok {
return nil
}
if numValue, ok := value.(float64); ok {
if min, exists := constraints["min"].(float64); exists && numValue < min {
return fmt.Errorf("value for %s is below minimum: %f < %f", field, numValue, min)
}
if max, exists := constraints["max"].(float64); exists && numValue > max {
return fmt.Errorf("value for %s is above maximum: %f > %f", field, numValue, max)
}
}
return nil
}
// getAgentTemplates returns the available agent templates
// overrideScannerTimeoutsFromDB overrides scanner timeouts with values from database
// This allows users to configure scanner timeouts via the web UI
func (cb *ConfigBuilder) overrideScannerTimeoutsFromDB(config map[string]interface{}) {
if cb.scannerConfigQ == nil {
// No database connection, use defaults
return
}
// Get subsystems section
subsystems, exists := config["subsystems"].(map[string]interface{})
if !exists {
return
}
// List of scanners that can have configurable timeouts
scannerNames := []string{"apt", "dnf", "docker", "windows", "winget", "system", "storage"}
for _, scannerName := range scannerNames {
scannerConfig, exists := subsystems[scannerName].(map[string]interface{})
if !exists {
continue
}
// Get timeout from database
timeout := cb.scannerConfigQ.GetScannerTimeoutWithDefault(scannerName, 30*time.Minute)
scannerConfig["timeout"] = int(timeout.Nanoseconds())
}
}
func getAgentTemplates() map[string]AgentTemplate {
return map[string]AgentTemplate{
"linux-server": {
Name: "Linux Server Agent",
Description: "Optimized for Linux server deployments with package management",
BaseConfig: map[string]interface{}{
"check_in_interval": 300,
"network": map[string]interface{}{
"timeout": 30000000000,
"retry_count": 3,
"retry_delay": 5000000000,
"max_idle_conn": 10,
},
"proxy": map[string]interface{}{
"enabled": false,
},
"tls": map[string]interface{}{
"insecure_skip_verify": false,
},
"logging": map[string]interface{}{
"level": "info",
"max_size": 100,
"max_backups": 3,
"max_age": 28,
},
"subsystems": map[string]interface{}{
"apt": map[string]interface{}{
"enabled": true,
"timeout": 30000000000,
"circuit_breaker": map[string]interface{}{
"enabled": true,
"failure_threshold": 3,
"failure_window": 600000000000,
"open_duration": 1800000000000,
"half_open_attempts": 2,
},
},
"dnf": map[string]interface{}{
"enabled": true,
"timeout": 1800000000000, // 30 minutes - configurable via server settings
"circuit_breaker": map[string]interface{}{
"enabled": true,
"failure_threshold": 3,
"failure_window": 600000000000,
"open_duration": 1800000000000,
"half_open_attempts": 2,
},
},
"docker": map[string]interface{}{
"enabled": true,
"timeout": 60000000000,
"circuit_breaker": map[string]interface{}{
"enabled": true,
"failure_threshold": 3,
"failure_window": 600000000000,
"open_duration": 1800000000000,
"half_open_attempts": 2,
},
},
"windows": map[string]interface{}{
"enabled": false,
},
"winget": map[string]interface{}{
"enabled": false,
},
"storage": map[string]interface{}{
"enabled": true,
"timeout": 10000000000,
"circuit_breaker": map[string]interface{}{
"enabled": true,
"failure_threshold": 3,
"failure_window": 600000000000,
"open_duration": 1800000000000,
"half_open_attempts": 2,
},
},
},
},
Secrets: []string{"registration_token", "server_public_key"},
Validation: ValidationRules{
RequiredFields: []string{"server_url", "organization"},
AllowedValues: map[string][]string{
"environment": {"development", "staging", "production", "testing"},
},
Patterns: map[string]string{
"server_url": "^https?://.+",
},
Constraints: map[string]interface{}{
"check_in_interval": map[string]interface{}{"min": 30, "max": 3600},
},
},
},
"windows-workstation": {
Name: "Windows Workstation Agent",
Description: "Optimized for Windows workstation deployments",
BaseConfig: map[string]interface{}{
"check_in_interval": 300,
"network": map[string]interface{}{
"timeout": 30000000000,
"retry_count": 3,
"retry_delay": 5000000000,
"max_idle_conn": 10,
},
"proxy": map[string]interface{}{
"enabled": false,
},
"tls": map[string]interface{}{
"insecure_skip_verify": false,
},
"logging": map[string]interface{}{
"level": "info",
"max_size": 100,
"max_backups": 3,
"max_age": 28,
},
"subsystems": map[string]interface{}{
"apt": map[string]interface{}{
"enabled": false,
},
"dnf": map[string]interface{}{
"enabled": false,
},
"docker": map[string]interface{}{
"enabled": false,
},
"windows": map[string]interface{}{
"enabled": true,
"timeout": 600000000000,
"circuit_breaker": map[string]interface{}{
"enabled": true,
"failure_threshold": 2,
"failure_window": 900000000000,
"open_duration": 3600000000000,
"half_open_attempts": 3,
},
},
"winget": map[string]interface{}{
"enabled": true,
"timeout": 120000000000,
"circuit_breaker": map[string]interface{}{
"enabled": true,
"failure_threshold": 3,
"failure_window": 600000000000,
"open_duration": 1800000000000,
"half_open_attempts": 2,
},
},
"storage": map[string]interface{}{
"enabled": false,
},
},
},
Secrets: []string{"registration_token", "server_public_key"},
Validation: ValidationRules{
RequiredFields: []string{"server_url", "organization"},
AllowedValues: map[string][]string{
"environment": {"development", "staging", "production", "testing"},
},
Patterns: map[string]string{
"server_url": "^https?://.+",
},
Constraints: map[string]interface{}{
"check_in_interval": map[string]interface{}{"min": 30, "max": 3600},
},
},
},
"docker-host": {
Name: "Docker Host Agent",
Description: "Optimized for Docker host deployments",
BaseConfig: map[string]interface{}{
"check_in_interval": 300,
"network": map[string]interface{}{
"timeout": 30000000000,
"retry_count": 3,
"retry_delay": 5000000000,
"max_idle_conn": 10,
},
"proxy": map[string]interface{}{
"enabled": false,
},
"tls": map[string]interface{}{
"insecure_skip_verify": false,
},
"logging": map[string]interface{}{
"level": "info",
"max_size": 100,
"max_backups": 3,
"max_age": 28,
},
"subsystems": map[string]interface{}{
"apt": map[string]interface{}{
"enabled": false,
},
"dnf": map[string]interface{}{
"enabled": false,
},
"docker": map[string]interface{}{
"enabled": true,
"timeout": 60000000000,
"circuit_breaker": map[string]interface{}{
"enabled": true,
"failure_threshold": 3,
"failure_window": 600000000000,
"open_duration": 1800000000000,
"half_open_attempts": 2,
},
},
"windows": map[string]interface{}{
"enabled": false,
},
"winget": map[string]interface{}{
"enabled": false,
},
"storage": map[string]interface{}{
"enabled": false,
},
},
},
Secrets: []string{"registration_token", "server_public_key"},
Validation: ValidationRules{
RequiredFields: []string{"server_url", "organization"},
AllowedValues: map[string][]string{
"environment": {"development", "staging", "production", "testing"},
},
Patterns: map[string]string{
"server_url": "^https?://.+",
},
Constraints: map[string]interface{}{
"check_in_interval": map[string]interface{}{"min": 30, "max": 3600},
},
},
},
}
}

View file

@ -0,0 +1,116 @@
package services
import (
"context"
"fmt"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client"
)
// DockerSecretsService manages Docker secrets via Docker API
type DockerSecretsService struct {
cli *client.Client
}
// NewDockerSecretsService creates a new Docker secrets service
func NewDockerSecretsService() (*DockerSecretsService, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("failed to create Docker client: %w", err)
}
// Test connection
ctx := context.Background()
if _, err := cli.Ping(ctx); err != nil {
return nil, fmt.Errorf("failed to connect to Docker daemon: %w", err)
}
return &DockerSecretsService{cli: cli}, nil
}
// CreateSecret creates a new Docker secret
func (s *DockerSecretsService) CreateSecret(name, value string) error {
ctx := context.Background()
// Check if secret already exists
secrets, err := s.cli.SecretList(ctx, types.SecretListOptions{})
if err != nil {
return fmt.Errorf("failed to list secrets: %w", err)
}
for _, secret := range secrets {
if secret.Spec.Name == name {
return fmt.Errorf("secret %s already exists", name)
}
}
// Create the secret
secretSpec := swarm.SecretSpec{
Annotations: swarm.Annotations{
Name: name,
Labels: map[string]string{
"created-by": "redflag-setup",
"created-at": fmt.Sprintf("%d", 0), // Use current timestamp in real implementation
},
},
Data: []byte(value),
}
if _, err := s.cli.SecretCreate(ctx, secretSpec); err != nil {
return fmt.Errorf("failed to create secret %s: %w", name, err)
}
return nil
}
// DeleteSecret deletes a Docker secret
func (s *DockerSecretsService) DeleteSecret(name string) error {
ctx := context.Background()
// Find the secret
secrets, err := s.cli.SecretList(ctx, types.SecretListOptions{})
if err != nil {
return fmt.Errorf("failed to list secrets: %w", err)
}
var secretID string
for _, secret := range secrets {
if secret.Spec.Name == name {
secretID = secret.ID
break
}
}
if secretID == "" {
return fmt.Errorf("secret %s not found", name)
}
if err := s.cli.SecretRemove(ctx, secretID); err != nil {
return fmt.Errorf("failed to remove secret %s: %w", name, err)
}
return nil
}
// Close closes the Docker client
func (s *DockerSecretsService) Close() error {
if s.cli != nil {
return s.cli.Close()
}
return nil
}
// IsDockerAvailable checks if Docker API is accessible
func IsDockerAvailable() bool {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return false
}
defer cli.Close()
ctx := context.Background()
_, err = cli.Ping(ctx)
return err == nil
}

View file

@ -0,0 +1,37 @@
package services_test
// ethos_logging_test.go — Pre-fix tests for fmt.Printf in services.
// D-2: security_settings_service.go uses fmt.Printf for audit log warning.
import (
"os"
"strings"
"testing"
)
func TestServicesUseFmtPrintfForLogging(t *testing.T) {
content, err := os.ReadFile("security_settings_service.go")
if err != nil {
t.Fatalf("failed to read security_settings_service.go: %v", err)
}
count := strings.Count(string(content), "fmt.Printf")
if count > 0 {
t.Errorf("[ERROR] [server] [services] D-2 NOT FIXED: %d fmt.Printf in security_settings_service.go", count)
}
t.Log("[INFO] [server] [services] D-2 FIXED: no fmt.Printf in security_settings_service.go")
}
func TestServicesUseStructuredLogging(t *testing.T) {
content, err := os.ReadFile("security_settings_service.go")
if err != nil {
t.Fatalf("failed to read security_settings_service.go: %v", err)
}
count := strings.Count(string(content), "fmt.Printf")
if count > 0 {
t.Errorf("[ERROR] [server] [services] %d fmt.Printf calls in security_settings_service.go.\n"+
"D-2: use log.Printf with ETHOS format.", count)
}
}

View file

@ -0,0 +1,172 @@
package services
import "fmt"
// Event renderer — single source of operator-facing verbiage. Dashboards must
// never compose strings from raw enum names like `verify_command` or stitch
// `${result} System event: ${action}`. When the UI needs to display an
// event, it reads the `narrative` field that handlers populate using these
// functions.
//
// To add a new event narrative: extend the switch with the (type, subtype) or
// (action, result) pair you want to render. The default branch falls back to
// the existing free-form message or — failing that — the raw enum tokens, so
// nothing breaks silently when a renderer entry is missing.
// RenderSystemEvent returns a single-line operator narrative for a
// system_events row. eventType + "." + eventSubtype form the key; metadata
// supplies template values. message is the free-form field the producer
// already wrote (the reconciler, for instance, writes a thorough message);
// when present and the (type, subtype) has no dedicated renderer, message
// passes through unchanged.
func RenderSystemEvent(eventType, eventSubtype, message string, metadata map[string]interface{}) string {
switch eventType + "." + eventSubtype {
case "agent_update.initiated":
oldV := metaString(metadata, "old_version")
newV := metaString(metadata, "new_version")
platform := metaString(metadata, "platform")
if platform != "" {
return fmt.Sprintf("Update queued: %s → %s (%s)", orUnknown(oldV), orUnknown(newV), platform)
}
return fmt.Sprintf("Update queued: %s → %s", orUnknown(oldV), orUnknown(newV))
case "agent_update.succeeded":
newV := metaString(metadata, "new_version")
return fmt.Sprintf("Update succeeded — agent now running %s", orUnknown(newV))
case "agent_update.timed_out":
// Reconciler writes a complete operator-facing message including the
// remediation hint. Pass it through.
if message != "" {
return message
}
return "Agent update timed out without reporting a new version"
case "agent_update.failed":
if reason := metaString(metadata, "failure_reason"); reason != "" {
return "Update failed: " + reason
}
return "Update failed"
case "command_failed.signature_invalid":
return "Agent rejected a command because the signature did not validate against the server's signing key"
case "agent_registration.success":
return "Agent registered with the server"
case "agent_registration.failed":
if reason := metaString(metadata, "reason"); reason != "" {
return "Agent registration failed: " + reason
}
return "Agent registration failed"
}
if message != "" {
return message
}
return fmt.Sprintf("%s · %s", eventType, eventSubtype)
}
// RenderUpdateLog returns a single-line operator narrative for an update_logs
// row (agent-reported command outcome). action+result form the key; stderr
// is used only when no dedicated renderer matches and we want at least some
// detail in the fallback.
func RenderUpdateLog(action, result, stderr string) string {
switch action {
case "verify_command":
if result == "failed" {
return "Agent rejected command — signature did not validate against the server's signing key"
}
if result == "success" {
return "Agent verified command signature"
}
case "update_agent":
switch result {
case "started":
return "Agent binary update initiated"
case "success":
return "Agent applied binary update"
case "failed":
return "Agent failed to apply binary update"
case "dry_run_success":
return "Agent update dry run succeeded"
case "partial":
return "Agent update completed with non-fatal errors"
}
case "install_updates", "install":
switch result {
case "success":
return "Package install completed"
case "failed", "failure":
return "Package install failed"
case "partial_failure", "partial":
return "Package install partially failed — some packages did not install"
}
case "dry_run_update":
switch result {
case "success":
return "Dry run succeeded — dependencies confirmed installable"
case "failed", "dry_run_failed":
return "Dry run failed — dependency check did not pass"
}
case "confirm_dependencies":
switch result {
case "success":
return "Agent confirmed dependencies for the pending install"
case "failed":
return "Agent could not confirm dependencies for the pending install"
}
case "rollback_update":
switch result {
case "success":
return "Rollback completed — previous version restored"
case "failed":
return "Rollback failed — manual recovery required"
}
case "reboot":
switch result {
case "success":
return "Agent host rebooted"
case "failed":
return "Agent host reboot failed"
}
case "enable_heartbeat":
if result == "success" {
return "Agent entered rapid-polling mode"
}
case "disable_heartbeat":
if result == "success" {
return "Agent exited rapid-polling mode"
}
}
if stderr != "" {
return fmt.Sprintf("%s · %s · %s", action, result, stderr)
}
return fmt.Sprintf("%s · %s", action, result)
}
func metaString(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
if v, ok := m[key].(string); ok {
return v
}
return ""
}
func orUnknown(s string) string {
if s == "" {
return "unknown"
}
return s
}

View file

@ -0,0 +1,237 @@
package services
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"text/template"
)
// TemplateRenderData is the union of all fields across the three install
// template data structs. Template execution fails on unknown variables, so
// this test data exists for the sole purpose of verifying every {{.Field}}
// in the template resolves.
type TemplateRenderData struct {
AgentID string
BinaryURL string
ConfigURL string
Platform string
Architecture string
Version string
ServerURL string
RegistrationToken string
AgentUser string
AgentHome string
ConfigDir string
LogDir string
AgentConfigDir string
AgentLogDir string
ServerKeyDir string
ServerPublicKey string
}
func TestInstallTemplateRenders(t *testing.T) {
data := TemplateRenderData{
AgentID: "00000000-0000-0000-0000-000000000000",
BinaryURL: "https://example.com/redflag-agent",
ConfigURL: "https://example.com/config",
Platform: "linux",
Architecture: "amd64",
Version: "0.2.9.0",
ServerURL: "https://example.com",
RegistrationToken: "rt_test",
AgentUser: "redflag-agent",
AgentHome: "/var/lib/redflag/agent",
ConfigDir: "/etc/redflag",
LogDir: "/var/log/redflag",
AgentConfigDir: "/etc/redflag/agent",
AgentLogDir: "/var/log/redflag/agent",
ServerKeyDir: "/etc/redflag/server",
ServerPublicKey: "abcdef0123456789",
}
scripts := []string{
"templates/install/scripts/linux.sh.tmpl",
"templates/install/scripts/windows.ps1.tmpl",
}
for _, name := range scripts {
t.Run(filepath.Base(name), func(t *testing.T) {
tmpl, err := template.ParseFS(installScriptTemplates, name)
if err != nil {
t.Fatalf("parse template %s: %v", name, err)
}
var buf strings.Builder
if err := tmpl.Execute(&buf, data); err != nil {
t.Fatalf("render template %s: %v", name, err)
}
output := buf.String()
if len(output) < 100 {
t.Fatalf("rendered template %s too short (%d bytes): likely empty or error", name, len(output))
}
})
}
}
// TestFreshInstallConfigKeys verifies the install template's default JSON
// includes the config keys the agent expects at minimum. This catches drift
// where a new config struct field gets added but the template never writes it
// for fresh installs.
//
// THIS TEST IS NOT EXHAUSTIVE. It maintains a canonical list of keys that must
// appear in the fresh-install config template. Add new keys here when they are
// added to the agent Config struct and the template should set them.
func TestFreshInstallConfigKeys(t *testing.T) {
// Read and parse the linux.sh.tmpl to extract its default JSON block.
raw, err := installScriptTemplates.ReadFile("templates/install/scripts/linux.sh.tmpl")
if err != nil {
t.Fatalf("read template: %v", err)
}
source := string(raw)
// Extract the default JSON between the heredoc markers.
// The template writes: sudo tee "..." > /dev/null <<EOF
// Find the heredoc marker line, then the first `{` on the line after it.
heredoc := `sudo tee "${AGENT_CONFIG_DIR}/config.json" > /dev/null <<EOF`
heredocIdx := strings.Index(source, heredoc)
if heredocIdx == -1 {
t.Fatal("default JSON heredoc marker not found in install template")
}
// Skip past the <<EOF marker and the newline that follows.
afterHeredoc := heredocIdx + len(heredoc)
jsonStart := strings.Index(source[afterHeredoc:], "\n{")
if jsonStart == -1 {
t.Fatal("JSON start (\\n{) not found after heredoc marker")
}
jsonBlockStart := afterHeredoc + jsonStart + 1 // skip the newline
jsonEnd := strings.Index(source[jsonBlockStart:], "\nEOF")
if jsonEnd == -1 {
t.Fatal("JSON end (\\nEOF) not found after heredoc")
}
jsonBlock := source[jsonBlockStart : jsonBlockStart+jsonEnd]
// Parse the JSON block and collect top-level keys.
var parsed map[string]json.RawMessage
if err := json.Unmarshal([]byte(jsonBlock), &parsed); err != nil {
t.Fatalf("parse default JSON: %v\nblock:\n%s", err, jsonBlock)
}
// Canonical required keys — every Config struct field with a json tag
// that MUST appear in the fresh-install template. Keys with "omitempty"
// or that default to zero-value-safe are optional and not listed here.
required := []string{
"version",
"agent_version",
"agent_id",
"token",
"refresh_token",
"registration_token",
"check_in_interval",
"server_url",
"network",
"proxy",
"tls",
"logging",
"subsystems",
}
// "machine_id" in the template JSON is a stale key not present in the
// Config struct — the agent reads machine identity from hostname/TOFU.
// Not a required key; retained in the template for backward compat with
// config files that carry it.
for _, key := range required {
if _, exists := parsed[key]; !exists {
t.Errorf("required config key %q missing from fresh-install template JSON", key)
}
}
// Optional keys — nice-to-have in the template but not required for
// correct operation (zero values are handled by the agent).
recommended := []string{
"desktop",
"polling",
"command_signing",
"security_logging",
"security",
}
for _, key := range recommended {
if _, exists := parsed[key]; !exists {
t.Logf("[INFO] recommended config key %q not in template — fresh installs will use defaults", key)
}
}
}
// TestInstallTemplateScriptletSyntax performs a basic syntax sanity check on the
// shell script portion of the install template — enough to catch common errors
// like unterminated heredocs, unbalanced braces, or missing shell tokens.
func TestInstallTemplateScriptletSyntax(t *testing.T) {
// Render with test data first to substitute template variables.
data := TemplateRenderData{
AgentID: "00000000-0000-0000-0000-000000000000",
BinaryURL: "https://example.com/binary",
ConfigURL: "https://example.com/config",
Platform: "linux",
Architecture: "amd64",
Version: "0.2.9.0",
ServerURL: "https://example.com",
RegistrationToken: "rt_test",
AgentUser: "redflag-agent",
AgentHome: "/var/lib/redflag/agent",
ConfigDir: "/etc/redflag",
LogDir: "/var/log/redflag",
AgentConfigDir: "/etc/redflag/agent",
AgentLogDir: "/var/log/redflag/agent",
ServerKeyDir: "/etc/redflag/server",
ServerPublicKey: "abcdef0123456789",
}
tmpl, err := template.ParseFS(installScriptTemplates, "templates/install/scripts/linux.sh.tmpl")
if err != nil {
t.Fatalf("parse template: %v", err)
}
var buf strings.Builder
if err := tmpl.Execute(&buf, data); err != nil {
t.Fatalf("render template: %v", err)
}
rendered := buf.String()
// Check major sections are present.
sections := []string{
"#!/bin/bash",
"set -e",
"systemctl",
"sudoers",
"config.json",
"redflag-helper",
"redflag-desktop",
}
for _, s := range sections {
if !strings.Contains(rendered, s) {
t.Errorf("rendered template missing expected section: %q", s)
}
}
// Rough heredoc balance check — count open/close markers.
heredocOpens := strings.Count(rendered, "<<EOF")
heredocCloses := strings.Count(rendered, "EOF")
if heredocCloses < heredocOpens {
t.Errorf("possible unterminated heredoc: %d <<EOF markers but only %d EOF terminators", heredocOpens, heredocCloses)
}
}
// TestMain ensures the working directory is the server package root
// so the embedded templates resolve correctly.
func TestMain(m *testing.M) {
// Allow override for test runner flexibility.
if d := os.Getenv("REDFLAG_SERVER_DIR"); d != "" {
os.Chdir(d)
}
os.Exit(m.Run())
}

View file

@ -0,0 +1,235 @@
package services
import (
"bytes"
"embed"
"fmt"
"log"
"strings"
"text/template"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/gofrs/uuid/v5"
)
//go:embed templates/install/scripts/*.tmpl
var installScriptTemplates embed.FS
// InstallTemplateService renders installation scripts from templates
type InstallTemplateService struct{}
// NewInstallTemplateService creates a new template service
func NewInstallTemplateService() *InstallTemplateService {
return &InstallTemplateService{}
}
// RenderInstallScript renders an installation script for the specified platform
func (s *InstallTemplateService) RenderInstallScript(agent *models.Agent, binaryURL, configURL string) (string, error) {
// Define template data
data := struct {
AgentID string
BinaryURL string
ConfigURL string
Platform string
Architecture string
Version string
AgentUser string
AgentHome string
ConfigDir string
LogDir string
AgentConfigDir string
AgentLogDir string
ServerKeyDir string
}{
AgentID: agent.ID.String(),
BinaryURL: binaryURL,
ConfigURL: configURL,
Platform: agent.OSType,
Architecture: agent.OSArchitecture,
Version: agent.CurrentVersion,
AgentUser: "redflag-agent",
AgentHome: "/var/lib/redflag/agent",
ConfigDir: "/etc/redflag",
LogDir: "/var/log/redflag",
AgentConfigDir: "/etc/redflag/agent",
AgentLogDir: "/var/log/redflag/agent",
ServerKeyDir: "/etc/redflag/server",
}
// Choose template based on platform
var templateName string
if strings.Contains(agent.OSType, "windows") {
templateName = "templates/install/scripts/windows.ps1.tmpl"
} else {
templateName = "templates/install/scripts/linux.sh.tmpl"
}
// Load and parse template
tmpl, err := template.ParseFS(installScriptTemplates, templateName)
if err != nil {
return "", fmt.Errorf("failed to load template: %w", err)
}
// Render template
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to render template: %w", err)
}
return buf.String(), nil
}
// RenderInstallScriptFromBuild renders script using build response
// ISSUE-002: Added serverPublicKey parameter for signature verification
func (s *InstallTemplateService) RenderInstallScriptFromBuild(
agentIDParam string,
platform string,
architecture string,
version string,
serverURL string,
registrationToken string,
serverPublicKey string,
) (string, error) {
// Extract or generate agent ID
agentID := s.extractOrGenerateAgentID(agentIDParam)
// Build correct URLs in Go, not templates
binaryURL := fmt.Sprintf("%s/api/v1/downloads/%s-%s?version=%s", serverURL, platform, architecture, version)
configURL := fmt.Sprintf("%s/api/v1/downloads/config/%s", serverURL, agentID)
data := struct {
AgentID string
BinaryURL string
ConfigURL string
Platform string
Architecture string
Version string
ServerURL string
RegistrationToken string
AgentUser string
AgentHome string
ConfigDir string
LogDir string
AgentConfigDir string
AgentLogDir string
ServerKeyDir string
ServerPublicKey string // ISSUE-002: For signature verification
}{
AgentID: agentID,
BinaryURL: binaryURL,
ConfigURL: configURL,
Platform: platform,
Architecture: architecture,
Version: version,
ServerURL: serverURL,
RegistrationToken: registrationToken,
AgentUser: "redflag-agent",
AgentHome: "/var/lib/redflag/agent",
ConfigDir: "/etc/redflag",
LogDir: "/var/log/redflag",
AgentConfigDir: "/etc/redflag/agent",
AgentLogDir: "/var/log/redflag/agent",
ServerKeyDir: "/etc/redflag/server",
ServerPublicKey: serverPublicKey, // ISSUE-002
}
templateName := "templates/install/scripts/linux.sh.tmpl"
if strings.Contains(platform, "windows") {
templateName = "templates/install/scripts/windows.ps1.tmpl"
}
tmpl, err := template.ParseFS(installScriptTemplates, templateName)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// BuildAgentConfigWithAgentID builds config for an existing agent (for upgrades)
func (s *InstallTemplateService) BuildAgentConfigWithAgentID(
agentID string,
platform string,
architecture string,
version string,
serverURL string,
) (string, error) {
// Validate agent ID
if _, err := uuid.FromString(agentID); err != nil {
return "", fmt.Errorf("invalid agent ID: %w", err)
}
// Build correct URLs using existing agent ID
binaryURL := fmt.Sprintf("%s/api/v1/downloads/%s-%s?version=%s", serverURL, platform, architecture, version)
configURL := fmt.Sprintf("%s/api/v1/downloads/config/%s", serverURL, agentID)
data := struct {
AgentID string
BinaryURL string
ConfigURL string
Platform string
Architecture string
Version string
ServerURL string
AgentUser string
AgentHome string
ConfigDir string
LogDir string
ServerKeyDir string
}{
AgentID: agentID,
BinaryURL: binaryURL,
ConfigURL: configURL,
Platform: platform,
Architecture: architecture,
Version: version,
ServerURL: serverURL,
AgentUser: "redflag-agent",
AgentHome: "/var/lib/redflag-agent",
ConfigDir: "/etc/redflag",
LogDir: "/var/log/redflag",
ServerKeyDir: "/etc/redflag/server",
}
templateName := "templates/install/scripts/linux.sh.tmpl"
if strings.Contains(platform, "windows") {
templateName = "templates/install/scripts/windows.ps1.tmpl"
}
tmpl, err := template.ParseFS(installScriptTemplates, templateName)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// extractOrGenerateAgentID extracts or generates a valid agent ID
func (s *InstallTemplateService) extractOrGenerateAgentID(param string) string {
log.Printf("[DEBUG] extractOrGenerateAgentID received param: %s", param)
// If we got a real agent ID (UUID format), validate and use it
if param != "" && param != "<AGENT_ID>" {
// Validate it's a UUID
if _, err := uuid.FromString(param); err == nil {
log.Printf("[DEBUG] Using passed UUID: %s", param)
return param
}
log.Printf("[DEBUG] Invalid UUID format, generating new one")
}
// Placeholder case - generate new UUID for fresh installation
newID := uuid.Must(uuid.NewV4()).String()
log.Printf("[DEBUG] Generated new UUID: %s", newID)
return newID
}

View file

@ -0,0 +1,244 @@
// Package notifier dispatches system events to external notification sinks
// (ntfy, SMTP email). Best-effort, fail-open — the dashboard is the source
// of truth; notifications are a courtesy tap on the shoulder.
//
// Design of record: docs/tasks/NOTIFY-001-notification-system.md
package notifier
import (
"context"
"fmt"
"log"
"strings"
"sync"
"time"
)
// SettingsReader is the subset of SecuritySettingsService that the
// notifier needs. Implemented by the services package; avoids a
// circular import.
type SettingsReader interface {
GetSetting(category, key string) (interface{}, error)
}
// Setup builds and registers sinks on a Dispatcher from security_settings.
// Pass the dispatcher and a settings reader (typically SecuritySettingsService).
// Returns the count of sinks registered.
func Setup(d *Dispatcher, settings SettingsReader) int {
count := 0
// --- ntfy ---
if url := getString(settings, "notify", "ntfy_url"); url != "" {
classes := ParseEventClasses(getString(settings, "notify", "ntfy_events"))
severity := getString(settings, "notify", "ntfy_severity")
if severity == "" {
severity = "error,critical"
}
sink := NewNtfySink(url, severity, classes)
d.Register(sink, classes...)
log.Printf("[INFO] [notifier] ntfy_configured url=%s severity=%s classes=%v",
url, severity, classes)
count++
}
// --- SMTP ---
if host := getString(settings, "notify", "smtp_host"); host != "" {
from := getString(settings, "notify", "smtp_from")
to := getString(settings, "notify", "smtp_to")
if from != "" && to != "" {
classes := ParseEventClasses(getString(settings, "notify", "smtp_events"))
severity := getString(settings, "notify", "smtp_severity")
if severity == "" {
severity = "error,critical"
}
sink := NewSmtpSink(host, from, to, severity, classes)
d.Register(sink, classes...)
log.Printf("[INFO] [notifier] smtp_configured host=%s to=%s severity=%s classes=%v",
host, to, severity, classes)
count++
} else {
log.Printf("[WARN] [notifier] smtp_host_configured_but_missing_from_or_to host=%s", host)
}
}
return count
}
// getString reads a string-valued setting with a default fallback.
func getString(settings SettingsReader, category, key string) string {
v, err := settings.GetSetting(category, key)
if err != nil {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
// EventClass categorises a notification by what happened.
type EventClass string
const (
ClassSecurity EventClass = "security"
ClassAgentOffline EventClass = "agent_offline"
ClassUpdateFailed EventClass = "update_failed"
ClassThreat EventClass = "threat"
ClassEOL EventClass = "eol"
)
// NotifyEvent is the payload passed to every configured sink.
type NotifyEvent struct {
Class EventClass
Severity string // info, warning, error, critical
Title string
Message string
AgentID string // optional — empty for server-scoped events
Fingerprint string // dedup key — same fingerprint within class suppresses repeats
}
// Sink is one external notification target.
type Sink interface {
// ID returns a stable short name for logging (e.g. "ntfy", "smtp").
ID() string
// Send delivers the event. Must not panic. Returns error for logging.
Send(ctx context.Context, event NotifyEvent) error
}
// Dispatcher routes events to configured sinks based on event class.
// Zero-value is usable (no sinks).
type Dispatcher struct {
mu sync.RWMutex
sinks map[EventClass][]Sink
// Dedup window — suppress repeated events with the same class+fingerprint
// inside this window.
dedupWindow time.Duration
dedup map[string]time.Time
dedupMu sync.Mutex
}
// NewDispatcher creates a Dispatcher with the given dedup window.
// A 5-minute window is reasonable for most deployments.
func NewDispatcher(dedupWindow time.Duration) *Dispatcher {
return &Dispatcher{
sinks: make(map[EventClass][]Sink),
dedupWindow: dedupWindow,
dedup: make(map[string]time.Time),
}
}
// Register adds a sink for one or more event classes. Pass zero classes to
// subscribe to everything.
func (d *Dispatcher) Register(sink Sink, classes ...EventClass) {
d.mu.Lock()
defer d.mu.Unlock()
if len(classes) == 0 {
classes = []EventClass{ClassSecurity, ClassAgentOffline, ClassUpdateFailed, ClassThreat, ClassEOL}
}
for _, c := range classes {
d.sinks[c] = append(d.sinks[c], sink)
}
}
// Dispatch sends the event to every sink subscribed to its class.
// Best-effort: individual sink failures are logged and do not block
// other sinks or the caller. Dedup suppresses events whose class+fingerprint
// have been seen within the dedup window.
func (d *Dispatcher) Dispatch(ctx context.Context, event NotifyEvent) {
// Dedup check — default fingerprint is the message if none set.
if event.Fingerprint == "" {
event.Fingerprint = event.Message
}
d.dedupMu.Lock()
key := fmt.Sprintf("%s|%s", event.Class, event.Fingerprint)
if last, ok := d.dedup[key]; ok && time.Since(last) < d.dedupWindow {
d.dedupMu.Unlock()
return
}
d.dedup[key] = time.Now()
// Prune expired entries so the map doesn't grow unbounded.
for k, v := range d.dedup {
if time.Since(v) > d.dedupWindow*2 {
delete(d.dedup, k)
}
}
d.dedupMu.Unlock()
d.mu.RLock()
sinks := d.sinks[event.Class]
d.mu.RUnlock()
for _, sink := range sinks {
go func(s Sink) {
if err := s.Send(ctx, event); err != nil {
log.Printf("[WARN] [notifier] [%s] send_failed class=%s title=%q error=%v",
s.ID(), event.Class, event.Title, err)
}
}(sink)
}
}
// SinkIDs returns the IDs of all registered sinks for diagnostics.
func (d *Dispatcher) SinkIDs() []string {
d.mu.RLock()
defer d.mu.RUnlock()
seen := map[string]bool{}
for _, sinks := range d.sinks {
for _, s := range sinks {
seen[s.ID()] = true
}
}
ids := make([]string, 0, len(seen))
for id := range seen {
ids = append(ids, id)
}
return ids
}
// ParseEventClasses splits a comma-separated config string into event classes.
// Unknown tokens are dropped with a log warning.
func ParseEventClasses(raw string) []EventClass {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
classes := make([]EventClass, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(strings.ToLower(p))
switch p {
case "security":
classes = append(classes, ClassSecurity)
case "agent_offline", "agent-offline":
classes = append(classes, ClassAgentOffline)
case "update_failed", "update-failed":
classes = append(classes, ClassUpdateFailed)
case "threat":
classes = append(classes, ClassThreat)
case "eol":
classes = append(classes, ClassEOL)
default:
log.Printf("[WARN] [notifier] unknown_event_class class=%q", p)
}
}
return classes
}
// SeverityAll is the default — all severities pass through.
const SeverityAll = "info,warning,error,critical"
// SeverityPasses returns true when a given severity string meets a
// comma-separated minimum-severity filter. e.g. "error,critical" passes
// error and critical but not warning/info.
func SeverityPasses(filter, severity string) bool {
if filter == "" || filter == SeverityAll {
return true
}
for _, s := range strings.Split(filter, ",") {
if strings.TrimSpace(s) == severity {
return true
}
}
return false
}

View file

@ -0,0 +1,123 @@
package notifier
import (
"context"
"sync/atomic"
"testing"
"time"
)
func TestParseEventClasses(t *testing.T) {
tests := []struct {
name string
raw string
want int
}{
{"empty", "", 0},
{"single known", "security", 1},
{"two known", "security,agent_offline", 2},
{"with dashes", "agent-offline,update-failed", 2},
{"mixed case and spaces", " Security , THREAT ", 2},
{"unknown dropped", "security,bogus,threat", 2},
{"all unknown", "foo,bar", 0},
{"all five", "security,agent_offline,update_failed,threat,eol", 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParseEventClasses(tt.raw)
if len(got) != tt.want {
t.Errorf("ParseEventClasses(%q) = %d classes, want %d: %v", tt.raw, len(got), tt.want, got)
}
})
}
}
func TestSeverityPasses(t *testing.T) {
tests := []struct {
filter string
severity string
want bool
}{
// Empty/any filter passes everything
{"", "info", true},
{"info,warning,error,critical", "info", true},
// Exact match
{"error,critical", "error", true},
{"error,critical", "critical", true},
// Below threshold
{"error,critical", "warning", false},
{"error,critical", "info", false},
// Single filter
{"warning", "warning", true},
{"warning", "error", false},
}
for _, tt := range tests {
t.Run(tt.filter+"_"+tt.severity, func(t *testing.T) {
got := SeverityPasses(tt.filter, tt.severity)
if got != tt.want {
t.Errorf("SeverityPasses(%q, %q) = %v, want %v", tt.filter, tt.severity, got, tt.want)
}
})
}
}
func TestDispatcherDedup(t *testing.T) {
d := NewDispatcher(5 * time.Second) // long window so dups are caught
cs := &countingSink{}
d.Register(cs, ClassSecurity)
event := NotifyEvent{
Class: ClassSecurity,
Severity: "error",
Title: "Test",
Message: "test message",
Fingerprint: "test-dedup-fp",
}
// First dispatch — goes through, goroutine fires Send.
d.Dispatch(context.Background(), event)
// Second dispatch — dedup map hit, goroutine NOT fired.
d.Dispatch(context.Background(), event)
// Third — same.
d.Dispatch(context.Background(), event)
// Let goroutines settle.
time.Sleep(20 * time.Millisecond)
if cs.count.Load() != 1 {
t.Errorf("expected exactly 1 delivery (dedup suppressed the rest), got %d", cs.count.Load())
}
}
func TestDispatcherNoDedupDifferentFP(t *testing.T) {
d := NewDispatcher(5 * time.Second)
cs := &countingSink{}
d.Register(cs, ClassSecurity)
d.Dispatch(context.Background(), NotifyEvent{
Class: ClassSecurity, Severity: "error", Title: "A", Message: "msg A", Fingerprint: "fp-a",
})
d.Dispatch(context.Background(), NotifyEvent{
Class: ClassSecurity, Severity: "error", Title: "B", Message: "msg B", Fingerprint: "fp-b",
})
time.Sleep(20 * time.Millisecond)
if cs.count.Load() != 2 {
t.Errorf("expected 2 deliveries for different fingerprints, got %d", cs.count.Load())
}
}
type countingSink struct {
count atomic.Int64
}
func (s *countingSink) ID() string { return "test" }
func (s *countingSink) Send(_ context.Context, _ NotifyEvent) error {
s.count.Add(1)
return nil
}

View file

@ -0,0 +1,116 @@
package notifier
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// NtfySink delivers events to an ntfy server via HTTP POST.
// ntfy is a self-hostable pub-sub notification service — https://ntfy.sh.
type NtfySink struct {
url string
severity string // comma-separated minimum severity
eventClass []EventClass
client *http.Client
}
// NewNtfySink creates an ntfy sink. url is the full topic URL
// (e.g. "https://ntfy.example.com/redflag"). severity is the minimum
// severity filter ("error,critical" = only error+critical).
// eventClass is the parsed list of event classes to subscribe to.
func NewNtfySink(url, severity string, eventClass []EventClass) *NtfySink {
return &NtfySink{
url: strings.TrimRight(url, "/"),
severity: severity,
eventClass: eventClass,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (s *NtfySink) ID() string { return "ntfy" }
// ntfyPayload is the JSON body for an ntfy publish request.
type ntfyPayload struct {
Topic string `json:"topic"`
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
Tags []string `json:"tags,omitempty"`
}
func (s *NtfySink) Send(ctx context.Context, event NotifyEvent) error {
if !SeverityPasses(s.severity, event.Severity) {
return nil
}
prio := ntfyPriority(event)
payload := ntfyPayload{
Title: event.Title,
Message: event.Message,
Priority: prio,
Tags: ntfyTags(event),
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal ntfy payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build ntfy request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("ntfy post: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("ntfy returned %d", resp.StatusCode)
}
return nil
}
func ntfyPriority(event NotifyEvent) int {
switch event.Class {
case ClassSecurity:
return 5 // max — immediate
case ClassAgentOffline:
return 4 // high
case ClassUpdateFailed:
return 4
case ClassThreat:
return 4
case ClassEOL:
return 3 // default
default:
return 3
}
}
func ntfyTags(event NotifyEvent) []string {
switch event.Class {
case ClassSecurity:
return []string{"lock", "redflag"}
case ClassAgentOffline:
return []string{"no_entry", "redflag"}
case ClassUpdateFailed:
return []string{"x", "redflag"}
case ClassThreat:
return []string{"warning", "redflag"}
case ClassEOL:
return []string{"hourglass", "redflag"}
default:
return []string{"redflag"}
}
}

View file

@ -0,0 +1,164 @@
package notifier
import (
"context"
"crypto/tls"
"fmt"
"log"
"net"
"net/smtp"
"strings"
"time"
)
// SmtpSink delivers events via plain-text SMTP email.
// Uses net/smtp only — no HTML templates, no third-party mail frameworks.
// Auth preference: STARTTLS → plain auth → CRAM-MD5.
type SmtpSink struct {
host string // host:port
from string
to string
severity string
eventClass []EventClass
}
// NewSmtpSink creates an SMTP sink. host is the SMTP server address
// (e.g. "smtp.example.com:587"). from is the sender address. to is
// the recipient. severity filters by minimum severity.
func NewSmtpSink(host, from, to, severity string, eventClass []EventClass) *SmtpSink {
return &SmtpSink{
host: host,
from: from,
to: to,
severity: severity,
eventClass: eventClass,
}
}
func (s *SmtpSink) ID() string { return "smtp" }
func (s *SmtpSink) Send(ctx context.Context, event NotifyEvent) error {
if !SeverityPasses(s.severity, event.Severity) {
return nil
}
msg := buildEmail(s.from, s.to, event)
// Split host:port — net/smtp needs them separate.
host, port := s.splitHostPort()
// Try STARTTLS first (port 587 path), then fall back to plain auth.
// net/smtp.SendMail with TLS config triggers STARTTLS when available.
tlsConfig := &tls.Config{
ServerName: host,
MinVersion: tls.VersionTLS12,
}
done := make(chan error, 1)
go func() {
defer func() {
if r := recover(); r != nil {
done <- fmt.Errorf("smtp panic: %v", r)
}
}()
addr := net.JoinHostPort(host, port)
if port == "465" {
// SMTPS — TLS from the start, not STARTTLS.
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 15 * time.Second}, "tcp", addr, tlsConfig)
if err != nil {
done <- fmt.Errorf("smtps dial: %w", err)
return
}
client, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
done <- fmt.Errorf("smtps client: %w", err)
return
}
defer client.Close()
if err := sendMailNoAuth(client, s.from, []string{s.to}, msg); err != nil {
done <- fmt.Errorf("smtps send: %w", err)
return
}
} else {
// Standard SMTP — attempt STARTTLS, then send without auth.
// No SMTP AUTH credentials configured: RedFlag SMTP is
// designed for internal relays that accept unauthenticated
// mail from trusted networks (postfix null client, msmtp,
// or a local MTA on localhost:25).
err := smtp.SendMail(addr, nil, s.from, []string{s.to}, msg)
if err != nil {
done <- fmt.Errorf("smtp send: %w", err)
return
}
}
done <- nil
}()
select {
case err := <-done:
if err != nil {
log.Printf("[WARN] [notifier] [smtp] send_failed host=%s error=%v", s.host, err)
return err
}
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *SmtpSink) splitHostPort() (string, string) {
parts := strings.SplitN(s.host, ":", 2)
if len(parts) == 2 {
return parts[0], parts[1]
}
return s.host, "25"
}
// sendMailNoAuth sends mail through an already-established SMTP client
// without attempting authentication.
func sendMailNoAuth(client *smtp.Client, from string, to []string, msg []byte) error {
if err := client.Mail(from); err != nil {
return err
}
for _, addr := range to {
if err := client.Rcpt(addr); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
_, err = w.Write(msg)
if err != nil {
return err
}
return w.Close()
}
// buildEmail composes a plain-text RFC 5322 message.
func buildEmail(from, to string, event NotifyEvent) []byte {
now := time.Now().UTC().Format(time.RFC1123Z)
subject := fmt.Sprintf("[RedFlag] %s: %s", strings.ToUpper(string(event.Class)), event.Title)
var body strings.Builder
body.WriteString(fmt.Sprintf("From: RedFlag <%s>\r\n", from))
body.WriteString(fmt.Sprintf("To: <%s>\r\n", to))
body.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
body.WriteString(fmt.Sprintf("Date: %s\r\n", now))
body.WriteString("MIME-Version: 1.0\r\n")
body.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n")
body.WriteString("X-Mailer: RedFlag Notifier\r\n")
body.WriteString("\r\n")
body.WriteString(event.Message)
body.WriteString("\r\n")
body.WriteString(fmt.Sprintf("\r\n---\r\nSeverity: %s | Class: %s", event.Severity, event.Class))
if event.AgentID != "" {
body.WriteString(fmt.Sprintf(" | Agent: %s", event.AgentID))
}
body.WriteString(fmt.Sprintf("\r\nSent: %s\r\n", now))
return []byte(body.String())
}

View file

@ -0,0 +1,247 @@
package services
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/Fimeg/RedFlag/server/internal/httpx"
)
// PackageAgeResult is what GetPackagePublishDate returns. PublishedAt is the
// upstream maintainer's release timestamp for the specific version. Source
// identifies which registry produced it.
//
// Fail-open semantics match CheckOSVVulnerabilities: nil result means we
// couldn't determine the age (registry down, ecosystem unsupported, version
// not found). Callers decide what to do with "unknown" — the gating policy
// in the approve handler treats unknown as "allow" so that infrastructure
// issues never block legitimate work.
type PackageAgeResult struct {
PublishedAt time.Time `json:"published_at"`
Source string `json:"source"`
}
var packageRegistryHTTPClient = httpx.NewClient(10 * time.Second)
// GetPackagePublishDate queries the upstream registry for a specific package
// version's release timestamp. Currently supports npm (registry.npmjs.org)
// and PyPI (pypi.org). Returns nil on any failure — callers must handle that.
//
// This is intentionally separate from OSV.dev: OSV is "what's wrong with this
// version", publish-date is "how old is it." Conflating them means an OSV
// outage would also blind us to recency, which would weaken the Shai-Hulud
// defense (worm waves rely on fresh-release windows).
func GetPackagePublishDate(pkgName, ecosystem, version string) *PackageAgeResult {
switch ecosystem {
case "npm":
return fetchNpmPublishDate(pkgName, version)
case "PyPI":
return fetchPyPIPublishDate(pkgName, version)
default:
return nil
}
}
// fetchNpmPublishDate hits https://registry.npmjs.org/<name>/<version>.
// Response shape: { "time": { "<version>": "RFC3339 timestamp" }, ... }
// We request the version-specific URL but the response still includes the
// full time map; pull the requested version.
func fetchNpmPublishDate(pkgName, version string) *PackageAgeResult {
// The package name may contain a scope ("@scope/name") which must stay
// path-encoded but not URL-escaped at the slash. PathEscape on each
// segment, then join.
endpoint := fmt.Sprintf("https://registry.npmjs.org/%s", url.PathEscape(pkgName))
resp, err := packageRegistryHTTPClient.Get(endpoint)
if err != nil {
log.Printf("[WARNING] [supply_chain] npm_fetch_failed pkg=%s error=%v", pkgName, err)
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("[WARNING] [supply_chain] npm_status_non_ok pkg=%s status=%d", pkgName, resp.StatusCode)
return nil
}
var body struct {
Time map[string]string `json:"time"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
log.Printf("[WARNING] [supply_chain] npm_decode_failed pkg=%s error=%v", pkgName, err)
return nil
}
tsStr, ok := body.Time[version]
if !ok {
log.Printf("[WARNING] [supply_chain] npm_version_not_in_time_map pkg=%s version=%s", pkgName, version)
return nil
}
ts, err := time.Parse(time.RFC3339, tsStr)
if err != nil {
log.Printf("[WARNING] [supply_chain] npm_parse_time_failed pkg=%s version=%s ts=%q error=%v", pkgName, version, tsStr, err)
return nil
}
return &PackageAgeResult{PublishedAt: ts, Source: "registry.npmjs.org"}
}
// fetchPyPIPublishDate hits https://pypi.org/pypi/<name>/<version>/json.
// Response includes a urls[] array with upload_time_iso_8601 per dist file;
// take the earliest as the canonical publish moment for the version.
func fetchPyPIPublishDate(pkgName, version string) *PackageAgeResult {
endpoint := fmt.Sprintf("https://pypi.org/pypi/%s/%s/json", url.PathEscape(pkgName), url.PathEscape(version))
resp, err := packageRegistryHTTPClient.Get(endpoint)
if err != nil {
log.Printf("[WARNING] [supply_chain] pypi_fetch_failed pkg=%s error=%v", pkgName, err)
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("[WARNING] [supply_chain] pypi_status_non_ok pkg=%s status=%d", pkgName, resp.StatusCode)
return nil
}
var body struct {
URLs []struct {
UploadTimeISO string `json:"upload_time_iso_8601"`
} `json:"urls"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
log.Printf("[WARNING] [supply_chain] pypi_decode_failed pkg=%s error=%v", pkgName, err)
return nil
}
if len(body.URLs) == 0 {
log.Printf("[WARNING] [supply_chain] pypi_no_urls_for_version pkg=%s version=%s", pkgName, version)
return nil
}
var earliest time.Time
for _, u := range body.URLs {
ts, err := time.Parse(time.RFC3339Nano, u.UploadTimeISO)
if err != nil {
continue
}
if earliest.IsZero() || ts.Before(earliest) {
earliest = ts
}
}
if earliest.IsZero() {
return nil
}
return &PackageAgeResult{PublishedAt: earliest, Source: "pypi.org"}
}
// PackageAgeGateDecision is the verdict the approve handler uses to decide
// whether to block, warn, or pass an approval.
type PackageAgeGateDecision struct {
PublishedAt time.Time
AgeHours float64
MinAgeHours float64
Enforcement string // "off" | "warn" | "block"
ShouldBlock bool
BlockedUnknown bool // true when the block was caused by fail-closed-on-unknown, not by freshness
WarnMessage string
Source string
Unknown bool // true when we couldn't determine the publish date
}
// PackageAgeGatePolicy is the resolved configuration the gate evaluates against.
// It is computed once per request (env / config / DB / default) and passed into
// EvaluatePackageAgeGate so the decision logic stays pure and testable.
type PackageAgeGatePolicy struct {
MinAgeHours float64
Enforcement string // "off" | "warn" | "block"
// BlockUnknownAge turns the unknown-age path fail-*closed* instead of the
// default fail-open. It only bites when Enforcement == "block" AND the
// ecosystem actually has a recency source (EcosystemAged) — so a registry
// we *expected* to answer but couldn't is treated as suspicious, while a
// distro package that legitimately has no publish date is never blocked.
BlockUnknownAge bool
// EcosystemAged is true for ecosystems where GetPackagePublishDate can
// produce a real answer (npm, PyPI). For these, a nil result is anomalous
// rather than merely "unsupported", which is what makes fail-closed safe.
EcosystemAged bool
}
// EcosystemSupportsPackageAge reports whether GetPackagePublishDate has a
// recency source for the given OSV ecosystem. Only these ecosystems are
// eligible for fail-closed-on-unknown — for everything else a nil age means
// "no source", not "source went dark", and must never block.
func EcosystemSupportsPackageAge(ecosystem string) bool {
switch ecosystem {
case "npm", "PyPI":
return true
}
return false
}
// PackageAgeGateConfig reads the supply_chain gate config. For v0.2.0.0 this
// pulls from env (REDFLAG_SUPPLY_CHAIN_*) with defaults baked in; the
// SecuritySettingsService DB-backed override path can layer in later — see
// security_settings_service.go getDefaultSettings "supply_chain".
//
// Defaults are intentional: 24h soak window, "warn" enforcement. The window
// is the documented threshold below which Shai-Hulud-class worms statistically
// announce themselves; warn-by-default preserves operator sovereignty.
func PackageAgeGateConfig() (minAgeHours float64, enforcement string) {
minAgeHours = 24.0
enforcement = "warn"
if v := os.Getenv("REDFLAG_SUPPLY_CHAIN_MIN_PACKAGE_AGE_HOURS"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 0 {
minAgeHours = f
}
}
if v := os.Getenv("REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT"); v != "" {
switch strings.ToLower(v) {
case "off", "warn", "block":
enforcement = strings.ToLower(v)
}
}
return
}
// EvaluatePackageAgeGate computes the gate decision for a single package
// version against the resolved policy. Pure logic, no HTTP — the fetch happens
// once at the call site and the result is passed in.
func EvaluatePackageAgeGate(age *PackageAgeResult, policy PackageAgeGatePolicy) PackageAgeGateDecision {
dec := PackageAgeGateDecision{
MinAgeHours: policy.MinAgeHours,
Enforcement: policy.Enforcement,
}
if age == nil {
dec.Unknown = true
// Fail-closed only when explicitly opted in, under block enforcement,
// and for an ecosystem we expected to answer. Otherwise unknown age is
// allowed — infrastructure hiccups must not gate legitimate work, and a
// distro package with no registry recency source is normal, not suspect.
if policy.Enforcement == "block" && policy.BlockUnknownAge && policy.EcosystemAged {
dec.ShouldBlock = true
dec.BlockedUnknown = true
dec.WarnMessage = "could not determine the publish date of a registry-backed package; failing closed (block_unknown_age) — a dark recency source is itself a Shai-Hulud-class signal"
}
return dec
}
dec.PublishedAt = age.PublishedAt
dec.Source = age.Source
dec.AgeHours = time.Since(age.PublishedAt).Hours()
if dec.AgeHours >= policy.MinAgeHours {
return dec
}
// Below the threshold — surface a clear message either way.
dec.WarnMessage = fmt.Sprintf(
"package published %.1fh ago (threshold %.1fh) — Shai-Hulud-class supply chain attacks propagate inside this window",
dec.AgeHours, policy.MinAgeHours,
)
if policy.Enforcement == "block" {
dec.ShouldBlock = true
}
return dec
}

View file

@ -0,0 +1,191 @@
package services
import (
"testing"
"time"
)
// TestEvaluatePackageAgeGate exercises the gate's three-way decision matrix:
// off / warn / block × (above-threshold / below-threshold / unknown).
//
// Pure logic — no network. The registry-fetching functions are tested
// against real endpoints by integration tests when they exist; the
// EvaluatePackageAgeGate function is the policy core that determines
// approval behavior and must be deterministic.
func TestEvaluatePackageAgeGate_BelowThresholdBlock(t *testing.T) {
age := &PackageAgeResult{
PublishedAt: time.Now().Add(-2 * time.Hour),
Source: "registry.npmjs.org",
}
dec := EvaluatePackageAgeGate(age, PackageAgeGatePolicy{MinAgeHours: 24.0, Enforcement: "block"})
if !dec.ShouldBlock {
t.Errorf("expected block, got pass; decision=%+v", dec)
}
if dec.BlockedUnknown {
t.Error("a freshness block must not be flagged as a blocked-unknown")
}
if dec.WarnMessage == "" {
t.Error("expected warn message even on block path; got empty")
}
if dec.Unknown {
t.Error("unknown should be false when age is provided")
}
}
func TestEvaluatePackageAgeGate_BelowThresholdWarn(t *testing.T) {
age := &PackageAgeResult{
PublishedAt: time.Now().Add(-2 * time.Hour),
Source: "registry.npmjs.org",
}
dec := EvaluatePackageAgeGate(age, PackageAgeGatePolicy{MinAgeHours: 24.0, Enforcement: "warn"})
if dec.ShouldBlock {
t.Errorf("warn enforcement must not block; decision=%+v", dec)
}
if dec.WarnMessage == "" {
t.Error("expected warn message under warn enforcement; got empty")
}
}
func TestEvaluatePackageAgeGate_AboveThreshold(t *testing.T) {
age := &PackageAgeResult{
PublishedAt: time.Now().Add(-72 * time.Hour),
Source: "pypi.org",
}
dec := EvaluatePackageAgeGate(age, PackageAgeGatePolicy{MinAgeHours: 24.0, Enforcement: "block"})
if dec.ShouldBlock {
t.Errorf("above-threshold package must not be blocked; decision=%+v", dec)
}
if dec.WarnMessage != "" {
t.Errorf("above-threshold must produce no warning; got %q", dec.WarnMessage)
}
}
func TestEvaluatePackageAgeGate_UnknownAgeIsAllowByDefault(t *testing.T) {
// Fail-open by default: when the registry probe couldn't tell us when the
// package was published (network, ecosystem unsupported, version missing),
// and the operator has NOT opted into fail-closed, we must NOT block —
// infrastructure issues should never gate legitimate approvals. Sovereignty.
dec := EvaluatePackageAgeGate(nil, PackageAgeGatePolicy{MinAgeHours: 24.0, Enforcement: "block"})
if !dec.Unknown {
t.Error("expected unknown=true when age is nil")
}
if dec.ShouldBlock {
t.Error("unknown age must not trigger block when block_unknown_age is off")
}
if dec.WarnMessage != "" {
t.Errorf("allowed-unknown must not produce a warning message; got %q", dec.WarnMessage)
}
}
func TestEvaluatePackageAgeGate_UnknownAgeFailsClosedWhenOptedIn(t *testing.T) {
// Opt-in fail-closed: block enforcement + block_unknown_age + an ecosystem
// that *should* have answered (npm/PyPI). A dark recency source for a
// registry-backed package is itself a worm signal.
dec := EvaluatePackageAgeGate(nil, PackageAgeGatePolicy{
MinAgeHours: 24.0,
Enforcement: "block",
BlockUnknownAge: true,
EcosystemAged: true,
})
if !dec.Unknown {
t.Error("expected unknown=true when age is nil")
}
if !dec.ShouldBlock {
t.Error("unknown age must fail closed when block_unknown_age is on for an aged ecosystem")
}
if !dec.BlockedUnknown {
t.Error("expected BlockedUnknown=true on the fail-closed path")
}
if dec.WarnMessage == "" {
t.Error("fail-closed block must carry a reason message")
}
}
func TestEvaluatePackageAgeGate_UnknownAgeAllowedForNonAgedEcosystem(t *testing.T) {
// Even with block_unknown_age on, an ecosystem with no recency source
// (apt/dnf) must never be blocked on unknown — there's nothing dark about
// a distro package having no upstream publish timestamp.
dec := EvaluatePackageAgeGate(nil, PackageAgeGatePolicy{
MinAgeHours: 24.0,
Enforcement: "block",
BlockUnknownAge: true,
EcosystemAged: false,
})
if dec.ShouldBlock {
t.Error("non-aged ecosystem must not fail closed on unknown even when opted in")
}
}
func TestEvaluatePackageAgeGate_UnknownAgeAllowedUnderWarnEvenIfOptedIn(t *testing.T) {
// block_unknown_age only bites under "block" enforcement — under "warn" a
// dark source is surfaced via logs/metadata, never a hard stop.
dec := EvaluatePackageAgeGate(nil, PackageAgeGatePolicy{
MinAgeHours: 24.0,
Enforcement: "warn",
BlockUnknownAge: true,
EcosystemAged: true,
})
if dec.ShouldBlock {
t.Error("warn enforcement must not fail closed on unknown")
}
}
func TestEvaluatePackageAgeGate_OffMeansOff(t *testing.T) {
// "off" enforcement should not be reached by the gate (caller checks
// config and skips), but if it is, behavior must be safe: no block, no warn.
age := &PackageAgeResult{
PublishedAt: time.Now().Add(-1 * time.Hour),
Source: "registry.npmjs.org",
}
dec := EvaluatePackageAgeGate(age, PackageAgeGatePolicy{MinAgeHours: 24.0, Enforcement: "off"})
if dec.ShouldBlock {
t.Error("off enforcement must not block even below threshold")
}
}
func TestEcosystemSupportsPackageAge(t *testing.T) {
for _, eco := range []string{"npm", "PyPI"} {
if !EcosystemSupportsPackageAge(eco) {
t.Errorf("%s should support package age", eco)
}
}
for _, eco := range []string{"Debian", "AlmaLinux", "", "crates.io"} {
if EcosystemSupportsPackageAge(eco) {
t.Errorf("%s should not support package age", eco)
}
}
}
func TestPackageAgeGateConfig_Defaults(t *testing.T) {
// Env vars cleared in this test process should yield defaults.
t.Setenv("REDFLAG_SUPPLY_CHAIN_MIN_PACKAGE_AGE_HOURS", "")
t.Setenv("REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT", "")
minAge, enf := PackageAgeGateConfig()
if minAge != 24.0 {
t.Errorf("default min age should be 24, got %v", minAge)
}
if enf != "warn" {
t.Errorf("default enforcement should be warn, got %q", enf)
}
}
func TestPackageAgeGateConfig_EnvOverride(t *testing.T) {
t.Setenv("REDFLAG_SUPPLY_CHAIN_MIN_PACKAGE_AGE_HOURS", "48")
t.Setenv("REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT", "block")
minAge, enf := PackageAgeGateConfig()
if minAge != 48.0 {
t.Errorf("env override min age should be 48, got %v", minAge)
}
if enf != "block" {
t.Errorf("env override enforcement should be block, got %q", enf)
}
}
func TestPackageAgeGateConfig_InvalidEnforcementFallsBack(t *testing.T) {
t.Setenv("REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT", "panic")
_, enf := PackageAgeGateConfig()
if enf != "warn" {
t.Errorf("invalid enforcement should fall back to warn, got %q", enf)
}
}

View file

@ -0,0 +1,7 @@
{
"attested": false,
"generated_at": 0,
"substrate": {},
"scans": [],
"exceptions": []
}

View file

@ -0,0 +1,78 @@
package services
import (
_ "embed"
"encoding/json"
"log"
)
// Supply-chain posture — RedFlag's attestation of its OWN dependency hygiene,
// folded into the signed release manifest so it rides the same Ed25519 trust
// root as the binaries. The installer verifies the manifest signature before
// trusting any of this, so the posture cannot be forged by editing a file next
// to the binary: tamper breaks the signature and the install refuses.
//
// The content is generated at build time by scripts/dep-scan.sh (the same gate
// that fails the release on an un-accepted reachable vulnerability) and embedded
// here. A bare `go build` outside that pipeline embeds the committed stub with
// Attested=false — an honest "this build was not gated", which the installer
// treats as a refusal-worthy posture.
//go:embed posture-build.json
var postureBuildJSON []byte
// DependencyException is a known, accepted reachable vulnerability with the
// documented reason it is tolerated. Mirrors a line in .govulncheck-allow.
type DependencyException struct {
ID string `json:"id"`
Reason string `json:"reason"`
}
// EcosystemScan is one scanner's verdict over one ecosystem.
type EcosystemScan struct {
Ecosystem string `json:"ecosystem"` // go, npm, cargo
Tool string `json:"tool"` // govulncheck, npm-audit, cargo-audit
Status string `json:"status"` // clean, accepted, blocked
Accepted int `json:"accepted"`
Blocked int `json:"blocked"`
}
// SupplyChainPosture is RedFlag's own dependency attestation for this build.
type SupplyChainPosture struct {
// Attested is true only when this posture was produced by the gated build
// pipeline. A stub/dev build is Attested=false.
Attested bool `json:"attested"`
GeneratedAt int64 `json:"generated_at"`
Substrate map[string]string `json:"substrate"` // go, rustc, node, npm, docker versions
Scans []EcosystemScan `json:"scans"`
Exceptions []DependencyException `json:"exceptions"`
}
// Blocked reports whether any ecosystem carries an un-accepted reachable vuln.
// A clean build never embeds a blocked scan (the gate fails the release first),
// so this is defense-in-depth for a tampered or hand-built artifact.
func (p SupplyChainPosture) Blocked() bool {
for _, s := range p.Scans {
if s.Blocked > 0 {
return true
}
}
return false
}
// loadEmbeddedPosture parses the build-time posture. A parse failure is logged
// and degraded to an explicit un-attested posture rather than a crash — the
// manifest still signs, and the installer refuses on Attested=false.
func loadEmbeddedPosture() SupplyChainPosture {
var p SupplyChainPosture
if err := json.Unmarshal(postureBuildJSON, &p); err != nil {
log.Printf("[ERROR] [server] [posture] embedded posture-build.json unparseable: %v", err)
return SupplyChainPosture{Attested: false}
}
return p
}
// EmbeddedPosture returns the build-time supply-chain posture for this server.
func EmbeddedPosture() SupplyChainPosture {
return loadEmbeddedPosture()
}

View file

@ -0,0 +1,175 @@
package services
import (
"context"
"database/sql"
"errors"
"log"
"sync"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/Fimeg/RedFlag/server/internal/services/upstream"
)
// Reconciler automatically matches agent-reported packages to tracked_software
// entries using the cascade: Repology aliases -> container image patterns ->
// exact source_ref name matching.
//
// mu + running together form a "single-flight" run-guard: mu protects the
// running flag only. ReconcileAll acquires mu briefly to check/set running,
// then releases it for the slow per-item work, then re-acquires to clear the
// flag. This prevents overlapping ReconcileAll runs without holding the lock
// across HTTP calls, DB writes, or the repology rate-limit sleep.
//
// Periodic execution is managed by the taskrunner; register ReconcileAll with
// bgRunner.Every so the runner owns the ticker, shutdown, and panic isolation.
type Reconciler struct {
agentSWQueries *queries.AgentTrackedSoftwareQueries
upstreamQ *queries.UpstreamQueries
reconcileQ *queries.ReconciliationQueries
aliasCache *upstream.RepologyCache
mu sync.Mutex
running bool
}
func NewReconciler(
atsQ *queries.AgentTrackedSoftwareQueries,
uq *queries.UpstreamQueries,
rq *queries.ReconciliationQueries,
ac *upstream.RepologyCache,
) *Reconciler {
return &Reconciler{
agentSWQueries: atsQ,
upstreamQ: uq,
reconcileQ: rq,
aliasCache: ac,
}
}
// ReconcileAll runs the full reconciliation cascade across all tracked software.
// It is single-flight: if a run is already in progress the new call returns
// immediately without blocking. mu is held only for the brief flag check/set,
// not across the slow per-item work (Repology HTTP + DB + rate-limit sleep).
func (r *Reconciler) ReconcileAll(ctx context.Context) {
r.mu.Lock()
if r.running {
r.mu.Unlock()
log.Printf("[INFO] [reconciler] reconcile_all skipped: already running")
return
}
r.running = true
r.mu.Unlock()
defer func() {
r.mu.Lock()
r.running = false
r.mu.Unlock()
}()
rows, err := r.upstreamQ.List()
if err != nil {
log.Printf("[ERROR] [reconciler] list tracked_software failed: %v", err)
return
}
matched := 0
for _, sw := range rows {
n := r.reconcileOne(ctx, sw)
matched += n
}
if matched > 0 {
log.Printf("[INFO] [reconciler] reconcile_all matched=%d tracked=%d", matched, len(rows))
}
}
// ReconcileOne runs reconciliation for a single tracked_software entry.
// It does not participate in the ReconcileAll run-guard: on-demand single-item
// reconciliation is always allowed regardless of whether a background run is
// in progress, and does not block the background run.
func (r *Reconciler) ReconcileOne(ctx context.Context, sw models.TrackedSoftware) int {
return r.reconcileOne(ctx, sw)
}
func (r *Reconciler) reconcileOne(ctx context.Context, sw models.TrackedSoftware) int {
count := 0
// Method 1: Repology alias matching
if sw.RepologySlug != nil && *sw.RepologySlug != "" {
r.ensureAliasCache(ctx, *sw.RepologySlug)
aliases, err := r.aliasCache.GetAliases(*sw.RepologySlug)
if err != nil {
log.Printf("[WARN] [reconciler] get_aliases slug=%s err=%v", *sw.RepologySlug, err)
} else if len(aliases) > 0 {
matches, err := r.reconcileQ.MatchByRepology(*sw.RepologySlug, aliases)
if err != nil {
log.Printf("[WARN] [reconciler] match_repology slug=%s err=%v", *sw.RepologySlug, err)
} else {
for _, m := range matches {
if err := r.upsertReconciled(m, "repology"); err != nil {
log.Printf("[WARN] [reconciler] upsert_repology agent=%s pkg=%s err=%v", m.AgentID, m.PackageName, err)
} else {
count++
}
}
}
}
}
// Method 2: Container image pattern matching
if sw.ContainerImagePattern != nil && *sw.ContainerImagePattern != "" {
matches, err := r.reconcileQ.MatchByContainer(*sw.ContainerImagePattern)
if err != nil {
log.Printf("[WARN] [reconciler] match_container pattern=%s err=%v", *sw.ContainerImagePattern, err)
} else {
for _, m := range matches {
if err := r.upsertReconciled(m, "container"); err != nil {
log.Printf("[WARN] [reconciler] upsert_container agent=%s pkg=%s err=%v", m.AgentID, m.PackageName, err)
} else {
count++
}
}
}
}
// Method 3: Exact source_ref matching (for npm/pypi/gem)
if sw.Source == "npm" || sw.Source == "pypi" || sw.Source == "rubygems" {
matches, err := r.reconcileQ.MatchByExactName(sw.SourceRef)
if err != nil {
log.Printf("[WARN] [reconciler] match_exact ref=%s err=%v", sw.SourceRef, err)
} else {
for _, m := range matches {
if err := r.upsertReconciled(m, "exact_name"); err != nil {
log.Printf("[WARN] [reconciler] upsert_exact agent=%s pkg=%s err=%v", m.AgentID, m.PackageName, err)
} else {
count++
}
}
}
}
return count
}
func (r *Reconciler) ensureAliasCache(ctx context.Context, slug string) {
aliases, err := r.aliasCache.GetAliases(slug)
if err != nil || len(aliases) == 0 {
if err := r.aliasCache.Refresh(ctx, slug); err != nil {
log.Printf("[WARN] [reconciler] ensure_alias_cache slug=%s err=%v", slug, err)
}
}
}
func (r *Reconciler) upsertReconciled(m models.MatchResult, method string) error {
input := models.AgentTrackedSoftwareInput{
TrackedSoftwareID: m.TrackedID,
InstalledVersion: m.Version,
}
_, err := r.agentSWQueries.UpsertReconciled(m.AgentID, input, method, m.PackageName)
// ON CONFLICT WHERE match_method='manual' skips the UPDATE and RETURNING returns
// zero rows → sql.ErrNoRows. Treat as a no-op: the manual binding is intentional.
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return err
}

View file

@ -0,0 +1,76 @@
package services
// Release manifest — the cold-start trust root and component healthcheck anchor.
//
// Two audiences, one document:
// 1. Install-time: the installer fetches the manifest, verifies the Ed25519
// signature over the verbatim body, then checks every downloaded binary
// against its matching artifacts entry before executing anything.
// 2. Post-install healthcheck: walks Components, runs version_cmd on each
// installed binary, verifies provisioning state, emits a signed checkoff
// report (local journal + server security event).
//
// Signed with the server's instance Ed25519 key (TOFU pubkey on first contact,
// pinned on upgrade). The signed bytes are the verbatim JSON; the signature
// travels in X-Content-Signature, the key fingerprint in X-Key-Id.
//
// CI generates the authoritative manifest at release time (embedded in the
// server binary); at serve time the server re-signs with its instance key.
// A release whose manifest lists a component without a built, version-self-
// reporting artifact fails the release gate — no silent drop-out.
// ManifestArtifact is one released binary's expected identity.
type ManifestArtifact struct {
Platform string `json:"platform"`
Architecture string `json:"architecture"`
Filename string `json:"filename"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
}
// ManifestComponent describes one installable/checkable piece of the release.
// Required components block install completion; optional ones are skipped
// gracefully when unavailable.
type ManifestComponent struct {
Name string `json:"name"`
Kind string `json:"kind"` // docker, binary, embedded
Required bool `json:"required"`
VersionCmd string `json:"version_cmd"` // e.g. "--version"
Provisioning []string `json:"provisioning,omitempty"` // healthcheck steps (desktop: autostart_entry, redflag-local_group, desktop_user_membership)
}
// ReleaseManifest is the signed component+artifact catalog for one version.
//
// SupplyChain is the running server's own dependency attestation (built-time
// scan verdict + accepted exceptions + build substrate). It rides the manifest
// signature so the installer can show "safe checks" without a second trust root,
// and refuse on an un-attested or blocked posture.
type ReleaseManifest struct {
Version string `json:"version"`
GeneratedAt int64 `json:"generated_at"`
KeyID string `json:"key_id"`
Components []ManifestComponent `json:"components"`
Artifacts []ManifestArtifact `json:"artifacts"`
SupplyChain SupplyChainPosture `json:"supply_chain"`
}
// ComponentCatalog returns the fixed component set for a release.
// This is the authoritative list — CI's gate job enforces that every component
// here has a built, version-self-reporting artifact.
func ComponentCatalog() []ManifestComponent {
return []ManifestComponent{
{Name: "server", Kind: "binary", Required: true, VersionCmd: "--version"},
{Name: "agent", Kind: "binary", Required: true, VersionCmd: "--version"},
{Name: "helper", Kind: "binary", Required: true, VersionCmd: "--version"},
{Name: "desktop", Kind: "binary", Required: false, VersionCmd: "--version",
Provisioning: []string{"autostart_entry", "redflag-local_group", "desktop_user_membership"}},
{Name: "web", Kind: "embedded", Required: true},
// installer (INSTALL-002): release-gate presence check only, not a
// post-install healthcheck target — it's a delivery mechanism, not a
// running artifact (the thing it installs, "server", is already its
// own entry above). No VersionCmd: an MSI can't self-report a
// version by being executed. Windows-only today (RedFlagSetup.msi),
// so Required: false until macOS/Linux installers exist too.
{Name: "installer", Kind: "binary", Required: false},
}
}

View file

@ -0,0 +1,88 @@
package services
import (
"log"
"time"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
)
// RetentionService (RETAIN-001) prunes aged rows from append-only history
// tables on a schedule. Without it, metrics/system_events/agent_commands
// grow unbounded — at 1,000 agents on 60-second heartbeats that is real
// churn. Three operator-tunable horizons, resolved through the layered
// settings (env > config > DB > default); a value of 0 means keep forever
// (sovereignty: deletion is opt-out-able, never forced).
//
// Deliberately NOT covered: current-state tables, and the process snapshot
// tables (those carry their own 10-snapshot cap). Pruned rows are gone —
// this runs against history the operator has aged out, and every sweep
// logs what it removed.
type RetentionService struct {
queries *queries.RetentionQueries
settings *SecuritySettingsService
}
// retentionCategory groups tables that age out on one shared horizon.
type retentionCategory struct {
settingKey string // operational.<key>, int days; 0 = keep forever
defaultDays int
tables []string
}
var retentionCategories = []retentionCategory{
// High-churn telemetry, low audit value once superseded.
{settingKey: "retention_metrics_days", defaultDays: 30,
tables: []string{"metrics", "storage_metrics"}},
// Operational event stream — feeds History/notification bell.
{settingKey: "retention_events_days", defaultDays: 90,
tables: []string{"system_events"}},
// Audit trail — command history and update lifecycle. Long default;
// this is the "what happened on that machine" record.
{settingKey: "retention_history_days", defaultDays: 365,
tables: []string{"agent_commands", "update_logs", "update_version_history"}},
}
const (
pruneBatchSize = 5000
maxPruneBatchesPerRun = 20 // caps one sweep at 100k rows/table; backlog drains over successive sweeps
)
func NewRetentionService(q *queries.RetentionQueries, settings *SecuritySettingsService) *RetentionService {
return &RetentionService{queries: q, settings: settings}
}
// Sweep prunes every category once. Errors on one table are logged and do
// not stop the rest of the sweep (assume failure; the next sweep retries).
// Intended to run on the taskrunner periodic registry.
func (s *RetentionService) Sweep() {
for _, cat := range retentionCategories {
days := cat.defaultDays
if s.settings != nil {
days = s.settings.GetOperationalInt(cat.settingKey, cat.defaultDays)
}
if days <= 0 {
// 0 (or negative) = retention disabled for this category.
continue
}
cutoff := time.Now().UTC().AddDate(0, 0, -days)
for _, table := range cat.tables {
var total int64
for i := 0; i < maxPruneBatchesPerRun; i++ {
n, err := s.queries.PruneBatch(table, cutoff, pruneBatchSize)
if err != nil {
log.Printf("[WARNING] [server] [retention] prune_failed table=%s error=%q", table, err)
break
}
total += n
if n < pruneBatchSize {
break
}
}
if total > 0 {
log.Printf("[INFO] [server] [retention] pruned table=%s rows=%d retention_days=%d", table, total, days)
}
}
}
}

View file

@ -0,0 +1,213 @@
package services
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/Fimeg/RedFlag/server/internal/crypto"
)
// SecretsManager handles Docker secrets creation and management
type SecretsManager struct {
secretsPath string
encryptionKey string
}
// NewSecretsManager creates a new secrets manager
func NewSecretsManager() *SecretsManager {
secretsPath := getSecretsPath()
return &SecretsManager{
secretsPath: secretsPath,
}
}
// CreateDockerSecrets creates Docker secrets from the provided secrets map
func (sm *SecretsManager) CreateDockerSecrets(secrets map[string]string) error {
if len(secrets) == 0 {
return nil
}
// Ensure secrets directory exists
if err := os.MkdirAll(sm.secretsPath, 0755); err != nil {
return fmt.Errorf("failed to create secrets directory: %w", err)
}
// Generate encryption key if not provided
if sm.encryptionKey == "" {
key, err := sm.GenerateEncryptionKey()
if err != nil {
return fmt.Errorf("failed to generate encryption key: %w", err)
}
sm.encryptionKey = key
}
// Create each secret
for name, value := range secrets {
if err := sm.createSecret(name, value); err != nil {
return fmt.Errorf("failed to create secret %s: %w", name, err)
}
}
return nil
}
// createSecret creates a single Docker secret
func (sm *SecretsManager) createSecret(name, value string) error {
secretPath := filepath.Join(sm.secretsPath, name)
// Encrypt sensitive values
encryptedValue, err := sm.encryptSecret(value)
if err != nil {
return fmt.Errorf("failed to encrypt secret: %w", err)
}
// Write secret file with restricted permissions
if err := os.WriteFile(secretPath, encryptedValue, 0400); err != nil {
return fmt.Errorf("failed to write secret file: %w", err)
}
return nil
}
// encryptSecret encrypts a secret value using the shared AES-256-GCM primitive.
// The key is the hex-encoded master key; output is nonce||ciphertext.
func (sm *SecretsManager) encryptSecret(value string) ([]byte, error) {
keyBytes, err := hex.DecodeString(sm.encryptionKey)
if err != nil {
return nil, fmt.Errorf("invalid encryption key format: %w", err)
}
return crypto.Encrypt(keyBytes, []byte(value))
}
// decryptSecret reverses encryptSecret using the shared AES-256-GCM primitive.
func (sm *SecretsManager) decryptSecret(encryptedValue []byte) (string, error) {
keyBytes, err := hex.DecodeString(sm.encryptionKey)
if err != nil {
return "", fmt.Errorf("invalid encryption key format: %w", err)
}
plaintext, err := crypto.Decrypt(keyBytes, encryptedValue)
if err != nil {
return "", err
}
return string(plaintext), nil
}
// GenerateEncryptionKey generates a new encryption key
func (sm *SecretsManager) GenerateEncryptionKey() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate encryption key: %w", err)
}
return hex.EncodeToString(bytes), nil
}
// SetEncryptionKey sets the master encryption key
func (sm *SecretsManager) SetEncryptionKey(key string) {
sm.encryptionKey = key
}
// GetEncryptionKey returns the current encryption key
func (sm *SecretsManager) GetEncryptionKey() string {
return sm.encryptionKey
}
// GetSecretsPath returns the current secrets path
func (sm *SecretsManager) GetSecretsPath() string {
return sm.secretsPath
}
// ValidateSecrets validates that all required secrets exist
func (sm *SecretsManager) ValidateSecrets(requiredSecrets []string) error {
for _, secretName := range requiredSecrets {
secretPath := filepath.Join(sm.secretsPath, secretName)
if _, err := os.Stat(secretPath); os.IsNotExist(err) {
return fmt.Errorf("required secret not found: %s", secretName)
}
}
return nil
}
// ListSecrets returns a list of all created secrets
func (sm *SecretsManager) ListSecrets() ([]string, error) {
entries, err := os.ReadDir(sm.secretsPath)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, fmt.Errorf("failed to read secrets directory: %w", err)
}
var secrets []string
for _, entry := range entries {
if !entry.IsDir() {
secrets = append(secrets, entry.Name())
}
}
return secrets, nil
}
// RemoveSecret removes a Docker secret
func (sm *SecretsManager) RemoveSecret(name string) error {
secretPath := filepath.Join(sm.secretsPath, name)
return os.Remove(secretPath)
}
// Cleanup removes all secrets and the secrets directory
func (sm *SecretsManager) Cleanup() error {
if _, err := os.Stat(sm.secretsPath); os.IsNotExist(err) {
return nil
}
// Remove all files in the directory
entries, err := os.ReadDir(sm.secretsPath)
if err != nil {
return fmt.Errorf("failed to read secrets directory: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
if err := os.Remove(filepath.Join(sm.secretsPath, entry.Name())); err != nil {
return fmt.Errorf("failed to remove secret %s: %w", entry.Name(), err)
}
}
}
// Remove the directory itself
return os.Remove(sm.secretsPath)
}
// getSecretsPath returns the platform-specific secrets path
func getSecretsPath() string {
if runtime.GOOS == "windows" {
return `C:\ProgramData\Docker\secrets`
}
return "/run/secrets"
}
// IsDockerEnvironment checks if running in Docker
func IsDockerEnvironment() bool {
// Check for .dockerenv file
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
// Check for Docker in cgroup
if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
if containsString(string(data), "docker") {
return true
}
}
return false
}
// containsString checks if a string contains a substring
func containsString(s, substr string) bool {
return len(s) >= len(substr) && (s == substr ||
(len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr)))
}

View file

@ -0,0 +1,767 @@
package services
import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"os"
"strconv"
"strings"
"github.com/Fimeg/RedFlag/server/internal/crypto"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/gofrs/uuid/v5"
)
type SecuritySettingsService struct {
settingsQueries *queries.SecuritySettingsQueries
signingService *SigningService
encryptionKey []byte
}
func isRetiredServerSetting(category, key string) bool {
if category != "command_signing" {
return false
}
switch key {
case "enabled", "enforcement_mode", "algorithm":
return true
default:
return false
}
}
func retiredServerSettingError(category, key string) error {
return fmt.Errorf("%s.%s was removed: signing uses the provisioned Ed25519 service and enforcement is agent-local and default-closed", category, key)
}
// NewSecuritySettingsService creates a new security settings service. The
// base64-encoded AES-256 key is provisioned by config.resolveAtRestKeys (the
// single secret rail), so there is no ephemeral fallback here: an absent or
// malformed key is a startup error, never a silently regenerated key that would
// orphan previously-encrypted settings.
func NewSecuritySettingsService(settingsQueries *queries.SecuritySettingsQueries, signingService *SigningService, encryptionKeyB64 string) (*SecuritySettingsService, error) {
key, err := base64.StdEncoding.DecodeString(encryptionKeyB64)
if err != nil {
return nil, fmt.Errorf("invalid settings encryption key format: %w", err)
}
if len(key) != crypto.KeySize {
return nil, fmt.Errorf("settings encryption key must be %d bytes, got %d", crypto.KeySize, len(key))
}
return &SecuritySettingsService{
settingsQueries: settingsQueries,
signingService: signingService,
encryptionKey: key,
}, nil
}
// GetSetting retrieves a security setting with proper priority resolution
func (s *SecuritySettingsService) GetSetting(category, key string) (interface{}, error) {
// Existing databases can still contain the pre-doctrine row. Do not expose
// it through either the single-setting or aggregate APIs: stale clients must
// see that server authority as gone, not mistake old data for live policy.
if isRetiredServerSetting(category, key) {
return nil, retiredServerSettingError(category, key)
}
// Priority 1: Environment variables
if envValue := s.getEnvironmentValue(category, key); envValue != nil {
return envValue, nil
}
// Priority 2: Config file values (this would be implemented based on your config structure)
if configValue := s.getConfigValue(category, key); configValue != nil {
return configValue, nil
}
// Priority 3: Database settings
if dbSetting, err := s.settingsQueries.GetSetting(category, key); err == nil && dbSetting != nil {
var value interface{}
if dbSetting.IsEncrypted {
decrypted, err := s.decrypt(dbSetting.Value)
if err != nil {
return nil, fmt.Errorf("failed to decrypt setting: %w", err)
}
if err := json.Unmarshal([]byte(decrypted), &value); err != nil {
return nil, fmt.Errorf("failed to unmarshal decrypted setting: %w", err)
}
} else {
if err := json.Unmarshal([]byte(dbSetting.Value), &value); err != nil {
return nil, fmt.Errorf("failed to unmarshal setting: %w", err)
}
}
return value, nil
}
// Priority 4: Hardcoded defaults
if defaultValue := s.getDefaultValue(category, key); defaultValue != nil {
return defaultValue, nil
}
return nil, fmt.Errorf("setting not found: %s.%s", category, key)
}
// SetSetting updates a security setting with validation and audit logging
func (s *SecuritySettingsService) SetSetting(category, key string, value interface{}, userID uuid.UUID, reason string) error {
// Validate the setting
if err := s.ValidateSetting(category, key, value); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Serialize, and encrypt sensitive values, before persistence. SEC-024: the
// encrypt/decrypt hooks existed but the write path stored plaintext; sensitive
// settings now persist as ciphertext with is_encrypted set.
stored, isEncrypted, err := s.serializeForStorage(category, key, value)
if err != nil {
return err
}
// Check if setting exists
existing, err := s.settingsQueries.GetSetting(category, key)
if err != nil {
return fmt.Errorf("failed to check existing setting: %w", err)
}
var oldValue *string
var settingID uuid.UUID
if existing != nil {
// Update existing setting
updated, oldVal, err := s.settingsQueries.UpdateSetting(category, key, stored, isEncrypted, &userID)
if err != nil {
return fmt.Errorf("failed to update setting: %w", err)
}
oldValue = oldVal
settingID = updated.ID
} else {
// Create new setting
created, err := s.settingsQueries.CreateSetting(category, key, stored, isEncrypted, &userID)
if err != nil {
return fmt.Errorf("failed to create setting: %w", err)
}
settingID = created.ID
}
// Create audit log. Never write a sensitive value (old or new) to the audit
// trail in plaintext — that would reintroduce the exposure we just closed.
valueJSON, _ := json.Marshal(value)
newAudit := string(valueJSON)
oldAudit := stringOrNil(oldValue)
if isEncrypted {
newAudit = "[REDACTED]"
oldAudit = "[REDACTED]"
}
if err := s.settingsQueries.CreateAuditLog(
settingID,
userID,
"update",
oldAudit,
newAudit,
reason,
); err != nil {
// Log error but don't fail the operation
log.Printf("[WARNING] [server] [security] audit_log_create_failed error=%v", err)
}
return nil
}
// GetAllSettings retrieves all security settings organized by category
func (s *SecuritySettingsService) GetAllSettings() (map[string]map[string]interface{}, error) {
// Get all default values first
result := s.getDefaultSettings()
// Override with database settings
dbSettings, err := s.settingsQueries.GetAllSettings()
if err != nil {
return nil, fmt.Errorf("failed to get database settings: %w", err)
}
for _, setting := range dbSettings {
if isRetiredServerSetting(setting.Category, setting.Key) {
continue
}
var value interface{}
if setting.IsEncrypted {
decrypted, err := s.decrypt(setting.Value)
if err != nil {
return nil, fmt.Errorf("failed to decrypt setting %s.%s: %w", setting.Category, setting.Key, err)
}
if err := json.Unmarshal([]byte(decrypted), &value); err != nil {
return nil, fmt.Errorf("failed to unmarshal decrypted setting %s.%s: %w", setting.Category, setting.Key, err)
}
} else {
if err := json.Unmarshal([]byte(setting.Value), &value); err != nil {
return nil, fmt.Errorf("failed to unmarshal setting %s.%s: %w", setting.Category, setting.Key, err)
}
}
if result[setting.Category] == nil {
result[setting.Category] = make(map[string]interface{})
}
result[setting.Category][setting.Key] = value
}
// Override with config file settings
for category, settings := range result {
for key := range settings {
if configValue := s.getConfigValue(category, key); configValue != nil {
result[category][key] = configValue
}
}
}
// Override with environment variables
for category, settings := range result {
for key := range settings {
if envValue := s.getEnvironmentValue(category, key); envValue != nil {
result[category][key] = envValue
}
}
}
return result, nil
}
// GetSettingsByCategory retrieves all settings for a specific category
func (s *SecuritySettingsService) GetSettingsByCategory(category string) (map[string]interface{}, error) {
allSettings, err := s.GetAllSettings()
if err != nil {
return nil, err
}
if categorySettings, exists := allSettings[category]; exists {
return categorySettings, nil
}
return nil, fmt.Errorf("category not found: %s", category)
}
// ValidateSetting validates a security setting value
func (s *SecuritySettingsService) ValidateSetting(category, key string, value interface{}) error {
switch fmt.Sprintf("%s.%s", category, key) {
case "nonce_validation.timeout_seconds":
if timeout, ok := value.(float64); ok {
if timeout < 60 || timeout > 3600 {
return fmt.Errorf("nonce timeout must be between 60 and 3600 seconds")
}
} else {
return fmt.Errorf("nonce timeout must be a number")
}
case "command_signing.enforcement_mode":
// Removed: command verification posture is agent-local and
// default-closed. The server cannot weaken it remotely, so no
// server-side knob is offered. Reject writes to surface stale clients.
return retiredServerSettingError(category, key)
case "command_signing.enabled", "command_signing.algorithm":
// Removed: these settings were never consumed by the signing service.
// Availability follows the provisioned Ed25519 key, not mutable DB state.
return retiredServerSettingError(category, key)
case "update_signing.enforcement_mode", "machine_binding.enforcement_mode":
if mode, ok := value.(string); ok {
validModes := []string{"strict", "warning", "disabled"}
valid := false
for _, m := range validModes {
if mode == m {
valid = true
break
}
}
if !valid {
return fmt.Errorf("enforcement mode must be one of: strict, warning, disabled")
}
} else {
return fmt.Errorf("enforcement mode must be a string")
}
case "signature_verification.log_retention_days":
if days, ok := value.(float64); ok {
if days < 1 || days > 365 {
return fmt.Errorf("log retention must be between 1 and 365 days")
}
} else {
return fmt.Errorf("log retention must be a number")
}
case "operational.jitter_max_seconds":
if v, ok := value.(float64); ok {
if v < 0 || v > 300 {
return fmt.Errorf("jitter_max_seconds must be between 0 and 300")
}
} else {
return fmt.Errorf("jitter_max_seconds must be a number")
}
case "operational.backoff_base_seconds":
if v, ok := value.(float64); ok {
if v < 1 || v > 300 {
return fmt.Errorf("backoff_base_seconds must be between 1 and 300")
}
} else {
return fmt.Errorf("backoff_base_seconds must be a number")
}
case "operational.backoff_max_seconds":
if v, ok := value.(float64); ok {
if v < 10 || v > 3600 {
return fmt.Errorf("backoff_max_seconds must be between 10 and 3600")
}
} else {
return fmt.Errorf("backoff_max_seconds must be a number")
}
case "operational.retention_metrics_days",
"operational.retention_events_days",
"operational.retention_history_days":
if days, ok := value.(float64); ok {
if days < 0 || days > 3650 {
return fmt.Errorf("retention days must be between 0 (keep forever) and 3650")
}
} else {
return fmt.Errorf("retention days must be a number")
}
case "supply_chain.min_package_age_hours":
if hours, ok := value.(float64); ok {
if hours < 0 || hours > 8760 {
return fmt.Errorf("min_package_age_hours must be between 0 and 8760 (one year)")
}
} else {
return fmt.Errorf("min_package_age_hours must be a number")
}
case "supply_chain.gate_enforcement":
if mode, ok := value.(string); ok {
switch strings.ToLower(mode) {
case "off", "warn", "block":
default:
return fmt.Errorf("gate_enforcement must be one of: off, warn, block")
}
} else {
return fmt.Errorf("gate_enforcement must be a string")
}
case "supply_chain.block_unknown_age":
if _, ok := value.(bool); !ok {
return fmt.Errorf("block_unknown_age must be a boolean")
}
case "supply_chain.soak_window_days":
if days, ok := value.(float64); ok {
if days < 0 || days > 365 {
return fmt.Errorf("soak_window_days must be between 0 and 365")
}
} else {
return fmt.Errorf("soak_window_days must be a number")
}
case "supply_chain.soak_enforcement":
if mode, ok := value.(string); ok {
switch strings.ToLower(mode) {
case "off", "warn", "block":
default:
return fmt.Errorf("soak_enforcement must be one of: off, warn, block")
}
} else {
return fmt.Errorf("soak_enforcement must be a string")
}
case "command_signing.stale_key_max_age_hours":
// Bounded, never zero/infinite: forward-only requires a fail-closed
// ceiling. 1h floor, 720h (30d) ceiling — mirrors the agent's doctrinal
// clamp so the UI can't offer a value the agent would reject.
if hours, ok := value.(float64); ok {
if hours < 1 || hours > 720 {
return fmt.Errorf("stale_key_max_age_hours must be between 1 and 720 (30 days)")
}
} else {
return fmt.Errorf("stale_key_max_age_hours must be a number")
}
case "update_signing.algorithm":
if algo, ok := value.(string); ok {
if algo != "ed25519" {
return fmt.Errorf("only ed25519 algorithm is currently supported")
}
} else {
return fmt.Errorf("algorithm must be a string")
}
case "observability.metrics_enabled":
if _, ok := value.(bool); !ok {
return fmt.Errorf("metrics_enabled must be a boolean")
}
case "observability.metrics_token_hash":
tokenHash, ok := value.(string)
if !ok {
return fmt.Errorf("metrics_token_hash must be a string")
}
if tokenHash == "" {
return nil
}
decoded, err := hex.DecodeString(tokenHash)
if err != nil || len(decoded) != 32 {
return fmt.Errorf("metrics_token_hash must be a SHA-256 hex digest")
}
}
return nil
}
// InitializeDefaultSettings creates default settings in the database if they don't exist
func (s *SecuritySettingsService) InitializeDefaultSettings() error {
defaults := s.getDefaultSettings()
for category, settings := range defaults {
for key, value := range settings {
existing, err := s.settingsQueries.GetSetting(category, key)
if err != nil {
return fmt.Errorf("failed to check existing setting %s.%s: %w", category, key, err)
}
if existing == nil {
stored, isEncrypted, err := s.serializeForStorage(category, key, value)
if err != nil {
return fmt.Errorf("failed to serialize default setting %s.%s: %w", category, key, err)
}
if _, err := s.settingsQueries.CreateSetting(category, key, stored, isEncrypted, nil); err != nil {
return fmt.Errorf("failed to create default setting %s.%s: %w", category, key, err)
}
}
}
}
return nil
}
// Helper methods
func (s *SecuritySettingsService) getDefaultSettings() map[string]map[string]interface{} {
return map[string]map[string]interface{}{
"command_signing": {
// stale_key_max_age_hours bounds how long an offline agent keeps
// trusting its cached server public key before it fails closed
// (SEC-028). Operator policy within a doctrinal range — the agent
// enforces a hard ceiling regardless, so this tunes but never disables
// forward-only. Default 168h (7d).
"stale_key_max_age_hours": 168.0,
},
"update_signing": {
"enabled": true,
"enforcement_mode": "strict",
"allow_unsigned": false,
},
"nonce_validation": {
"timeout_seconds": 600,
"reject_expired": true,
"log_expired_attempts": true,
},
"machine_binding": {
"enabled": true,
"enforcement_mode": "strict",
"strict_action": "reject",
},
"signature_verification": {
"log_level": "warn",
"log_retention_days": 30,
"log_failures": true,
"alert_on_failure": true,
},
// Shai-Hulud-class defenses. min_package_age_hours is the soak window
// applied at approval time; gate_enforcement chooses how strictly to
// apply it: "off" disables the check, "warn" attaches a warning to the
// approval response (default — sovereignty principle), "block" rejects
// approval with 409. See docs (post-v0.2.0.0 writeup) for the threat
// model.
"supply_chain": {
"min_package_age_hours": 24.0,
"gate_enforcement": "warn",
// block_unknown_age fails the gate *closed* when a registry-backed
// package (npm/PyPI) cannot be dated — a dark recency source is
// itself a worm signal. Default false preserves the sovereignty
// principle (infra hiccups never block legitimate work); operators
// running a hostile-supply-chain posture can opt in.
"block_unknown_age": false,
// Version soak gate (GATE-005), applied on the install path. A
// version must have been observed in the fleet for soak_window_days
// before it is install-eligible; soak_enforcement chooses off/warn/
// block. Default 14d/block holds the "newest version" signal that
// zero-day supply-chain attacks ride on.
"soak_window_days": 14.0,
"soak_enforcement": "block",
},
"observability": {
"metrics_enabled": false,
"metrics_token_hash": "",
},
"notify": {
"ntfy_url": "",
"ntfy_events": "",
"ntfy_severity": "error,critical",
"smtp_host": "",
"smtp_from": "",
"smtp_to": "",
"smtp_events": "",
"smtp_severity": "error,critical",
},
}
}
func (s *SecuritySettingsService) getDefaultValue(category, key string) interface{} {
defaults := s.getDefaultSettings()
if cat, exists := defaults[category]; exists {
if value, exists := cat[key]; exists {
return value
}
}
return nil
}
func (s *SecuritySettingsService) getEnvironmentValue(category, key string) interface{} {
envKey := fmt.Sprintf("REDFLAG_%s_%s", strings.ToUpper(category), strings.ToUpper(key))
envValue := os.Getenv(envKey)
if envValue == "" {
return nil
}
// Try to parse as boolean
if strings.ToLower(envValue) == "true" {
return true
}
if strings.ToLower(envValue) == "false" {
return false
}
// Try to parse as number
if num, err := strconv.ParseFloat(envValue, 64); err == nil {
return num
}
// Return as string
return envValue
}
func (s *SecuritySettingsService) getConfigValue(category, key string) interface{} {
// This would be implemented based on your config structure
// For now, returning nil to prioritize env vars and database
return nil
}
func (s *SecuritySettingsService) isSensitiveSetting(category, key string) bool {
// Define which settings are sensitive and should be encrypted
sensitive := map[string]bool{
"command_signing.private_key": true,
"update_signing.private_key": true,
"machine_binding.server_key": true,
"encryption.master_key": true,
}
settingKey := fmt.Sprintf("%s.%s", category, key)
return sensitive[settingKey]
}
// serializeForStorage marshals a setting value to its JSON column form and, for
// sensitive keys, encrypts it. It returns the exact string to persist plus the
// is_encrypted flag. Non-sensitive settings store plain JSON exactly as before;
// sensitive settings store base64(AES-GCM(JSON)) so the GetSetting read path's
// decrypt-then-unmarshal step round-trips. SEC-024.
func (s *SecuritySettingsService) serializeForStorage(category, key string, value interface{}) (string, bool, error) {
raw, err := json.Marshal(value)
if err != nil {
return "", false, fmt.Errorf("marshal setting value: %w", err)
}
if !s.isSensitiveSetting(category, key) {
return string(raw), false, nil
}
enc, err := s.encrypt(string(raw))
if err != nil {
return "", false, fmt.Errorf("encrypt sensitive setting %s.%s: %w", category, key, err)
}
return enc, true, nil
}
func (s *SecuritySettingsService) encrypt(value string) (string, error) {
ciphertext, err := crypto.Encrypt(s.encryptionKey, []byte(value))
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func (s *SecuritySettingsService) decrypt(encryptedValue string) (string, error) {
data, err := base64.StdEncoding.DecodeString(encryptedValue)
if err != nil {
return "", err
}
plaintext, err := crypto.Decrypt(s.encryptionKey, data)
if err != nil {
return "", err
}
return string(plaintext), nil
}
func stringOrNil(s *string) string {
if s == nil {
return ""
}
return *s
}
// GetNonceTimeout returns the current nonce validation timeout in seconds
func (s *SecuritySettingsService) GetNonceTimeout() (int, error) {
value, err := s.GetSetting("nonce_validation", "timeout_seconds")
if err != nil {
return 600, err // Return default on error
}
if timeout, ok := value.(float64); ok {
return int(timeout), nil
}
return 600, nil // Return default if type is wrong
}
// GetEnforcementMode returns the enforcement mode for a given category
func (s *SecuritySettingsService) GetEnforcementMode(category string) (string, error) {
value, err := s.GetSetting(category, "enforcement_mode")
if err != nil {
return "strict", err // Return default on error
}
if mode, ok := value.(string); ok {
return mode, nil
}
return "strict", nil // Return default if type is wrong
}
// IsSignatureVerificationEnabled returns whether signature verification is enabled for a category
func (s *SecuritySettingsService) IsSignatureVerificationEnabled(category string) (bool, error) {
value, err := s.GetSetting(category, "enabled")
if err != nil {
return true, err // Return default on error
}
if enabled, ok := value.(bool); ok {
return enabled, nil
}
return true, nil // Return default if type is wrong
}
// GetAuditTrail returns recent security settings audit entries (F-E1-7 fix)
func (s *SecuritySettingsService) GetAuditTrail(limit int) ([]models.SecuritySettingAudit, error) {
return s.settingsQueries.GetAllAuditLogs(limit)
}
// GetPolicyBool reads a policy.<key> setting as bool. On any error (missing,
// wrong type, decrypt failure) returns the supplied fallback. Hot path: never
// blocks on settings being absent. Callers should treat the fallback as the
// safe default for the gate.
func (s *SecuritySettingsService) GetPolicyBool(key string, fallback bool) bool {
value, err := s.GetSetting("policy", key)
if err != nil {
return fallback
}
if b, ok := value.(bool); ok {
return b
}
return fallback
}
// GetPolicyString reads a policy.<key> setting as a string. On error or type
// mismatch returns the supplied fallback. Used for enumerated policy values
// (e.g. auto_approve_max_severity) where a missing row means "use the default".
func (s *SecuritySettingsService) GetPolicyString(key, fallback string) string {
value, err := s.GetSetting("policy", key)
if err != nil {
return fallback
}
if str, ok := value.(string); ok {
return str
}
return fallback
}
// GetSupplyChainGateConfig resolves the approval-time package-age gate policy
// from the layered settings (env > config > DB > baked default, via GetSetting).
// This is what makes the supply_chain.* settings live: PackageAgeGateConfig()
// alone reads only env, so a DB/UI override would otherwise be inert. Callers
// that hold a settings service should prefer this; the free PackageAgeGateConfig
// remains the fallback when no service is wired.
func (s *SecuritySettingsService) GetSupplyChainGateConfig() (minAgeHours float64, enforcement string, blockUnknownAge bool) {
// Start from the env/baked defaults so a missing service or DB row degrades
// to exactly the legacy behavior.
minAgeHours, enforcement = PackageAgeGateConfig()
blockUnknownAge = false
if v, err := s.GetSetting("supply_chain", "min_package_age_hours"); err == nil {
if f, ok := v.(float64); ok && f >= 0 {
minAgeHours = f
}
}
if v, err := s.GetSetting("supply_chain", "gate_enforcement"); err == nil {
if str, ok := v.(string); ok {
switch strings.ToLower(str) {
case "off", "warn", "block":
enforcement = strings.ToLower(str)
}
}
}
if v, err := s.GetSetting("supply_chain", "block_unknown_age"); err == nil {
if b, ok := v.(bool); ok {
blockUnknownAge = b
}
}
return
}
// GetSoakGateConfig resolves the install-path version-soak gate policy (GATE-005)
// from the layered settings (env > config > DB > baked default, via GetSetting),
// so an admin's DB/UI policy actually drives the gate. SoakGateConfig() alone
// reads only env; this is the live equivalent and the free function remains the
// fallback when no settings service is wired.
func (s *SecuritySettingsService) GetSoakGateConfig() (requiredDays float64, enforcement string) {
requiredDays, enforcement = SoakGateConfig()
if v, err := s.GetSetting("supply_chain", "soak_window_days"); err == nil {
if f, ok := v.(float64); ok && f >= 0 {
requiredDays = f
}
}
if v, err := s.GetSetting("supply_chain", "soak_enforcement"); err == nil {
if str, ok := v.(string); ok {
switch strings.ToLower(str) {
case "off", "warn", "block":
enforcement = strings.ToLower(str)
}
}
}
return
}
// GetOperationalInt reads an operational.<key> setting as int. On error or
// type mismatch returns the supplied fallback. Used for runtime tuning where
// a missing row should not stop the service.
func (s *SecuritySettingsService) GetOperationalInt(key string, fallback int) int {
value, err := s.GetSetting("operational", key)
if err != nil {
return fallback
}
if f, ok := value.(float64); ok {
return int(f)
}
if i, ok := value.(int); ok {
return i
}
return fallback
}

View file

@ -0,0 +1,90 @@
package services
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"strings"
"testing"
)
// newTestSettingsService builds a service with a real random AES-256 key and no
// DB (serializeForStorage / encrypt / decrypt don't touch the store).
func newTestSettingsService(t *testing.T) *SecuritySettingsService {
t.Helper()
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
t.Fatalf("rand key: %v", err)
}
svc, err := NewSecuritySettingsService(nil, nil, base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatalf("new service: %v", err)
}
return svc
}
// SEC-024: a non-sensitive setting stores plain JSON exactly as before.
func TestSerializeForStorageNonSensitivePlaintext(t *testing.T) {
svc := newTestSettingsService(t)
stored, encrypted, err := svc.serializeForStorage("nonce_validation", "timeout_seconds", 600)
if err != nil {
t.Fatalf("serialize: %v", err)
}
if encrypted {
t.Fatalf("non-sensitive setting must not be marked encrypted")
}
if stored != "600" {
t.Fatalf("non-sensitive setting should store plain JSON, got %q", stored)
}
}
// SEC-024: a sensitive setting must persist as ciphertext (never plaintext) and
// must round-trip through the same decrypt-then-unmarshal path GetSetting uses.
func TestSerializeForStorageSensitiveEncryptsAndRoundTrips(t *testing.T) {
svc := newTestSettingsService(t)
secret := "super-secret-ed25519-private-key"
stored, encrypted, err := svc.serializeForStorage("command_signing", "private_key", secret)
if err != nil {
t.Fatalf("serialize: %v", err)
}
if !encrypted {
t.Fatalf("sensitive setting must be marked encrypted")
}
if strings.Contains(stored, secret) {
t.Fatalf("sensitive value persisted in plaintext: %q", stored)
}
// GetSetting read path: decrypt then json-unmarshal must recover the original.
decrypted, err := svc.decrypt(stored)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
var got string
if err := json.Unmarshal([]byte(decrypted), &got); err != nil {
t.Fatalf("unmarshal decrypted value: %v", err)
}
if got != secret {
t.Fatalf("round-trip mismatch: got %q want %q", got, secret)
}
}
func TestInertCommandSigningServerSettingsAreRetired(t *testing.T) {
svc := newTestSettingsService(t)
for _, key := range []string{"enabled", "enforcement_mode", "algorithm"} {
t.Run(key, func(t *testing.T) {
if _, exists := svc.getDefaultSettings()["command_signing"][key]; exists {
t.Fatalf("retired server setting %s must not be present in defaults", key)
}
if _, err := svc.GetSetting("command_signing", key); err == nil || !strings.Contains(err.Error(), "was removed") {
t.Fatalf("retired setting read should fail explicitly, got %v", err)
}
if err := svc.ValidateSetting("command_signing", key, "disabled"); err == nil || !strings.Contains(err.Error(), "agent-local") {
t.Fatalf("retired setting write should fail explicitly, got %v", err)
}
})
}
}

View file

@ -0,0 +1,424 @@
package services
import (
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"runtime"
"time"
"github.com/Fimeg/RedFlag/server/internal/capability"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/gofrs/uuid/v5"
)
// SigningService handles Ed25519 cryptographic operations
type SigningService struct {
privateKey ed25519.PrivateKey
publicKey ed25519.PublicKey
enabled bool
signingKeyQueries *queries.SigningKeyQueries
}
// NewSigningService creates a new signing service with the provided private key
func NewSigningService(privateKeyHex string) (*SigningService, error) {
// Check if private key is provided
if privateKeyHex == "" {
return &SigningService{
enabled: false,
}, nil
}
// Decode private key from hex
privateKeyBytes, err := hex.DecodeString(privateKeyHex)
if err != nil {
return nil, fmt.Errorf("invalid private key format: %w", err)
}
if len(privateKeyBytes) != ed25519.PrivateKeySize {
return nil, fmt.Errorf("invalid private key size: expected %d bytes, got %d", ed25519.PrivateKeySize, len(privateKeyBytes))
}
// Ed25519 private key format: first 32 bytes are seed, next 32 bytes are public key
privateKey := ed25519.PrivateKey(privateKeyBytes)
publicKey := privateKey.Public().(ed25519.PublicKey)
return &SigningService{
privateKey: privateKey,
publicKey: publicKey,
enabled: true,
}, nil
}
// SetSigningKeyQueries sets the database queries handle for key registration
func (s *SigningService) SetSigningKeyQueries(q *queries.SigningKeyQueries) {
s.signingKeyQueries = q
}
// IsEnabled returns true if the signing service is enabled
func (s *SigningService) IsEnabled() bool {
return s.enabled
}
// GetPublicKey returns the public key in hex format
func (s *SigningService) GetPublicKey() string {
if !s.enabled {
return ""
}
return hex.EncodeToString(s.publicKey)
}
// GetPublicKeyHex returns the full hex-encoded public key (alias for GetPublicKey, clearer name)
func (s *SigningService) GetPublicKeyHex() string {
return s.GetPublicKey()
}
// GetPublicKeyFingerprint returns a fingerprint of the public key.
// Uses SHA-256 of the full public key, truncated to 16 bytes (32 hex characters).
func (s *SigningService) GetPublicKeyFingerprint() string {
if !s.enabled {
return ""
}
hash := sha256.Sum256(s.publicKey)
return hex.EncodeToString(hash[:16]) // 16 bytes = 32 hex chars
}
// GetCurrentKeyID returns the fingerprint of the current signing key.
// Equivalent to GetPublicKeyFingerprint() but named for clarity in key-rotation contexts.
func (s *SigningService) GetCurrentKeyID() string {
return s.GetPublicKeyFingerprint()
}
// InitializePrimaryKey registers the current signing key in the database and marks it as primary.
// If signingKeyQueries is not set, this is a no-op (returns nil).
// The version number is determined by querying MAX(version) + 1 from the signing_keys table,
// so each new unique key gets a monotonically increasing version.
// Note: This query is not wrapped in a transaction; for a single-instance server this is safe.
// A future improvement could use SELECT ... FOR UPDATE within a transaction.
func (s *SigningService) InitializePrimaryKey(ctx context.Context) error {
if s.signingKeyQueries == nil {
return nil
}
if !s.enabled {
return fmt.Errorf("signing service is not enabled")
}
keyID := s.GetCurrentKeyID()
publicKeyHex := s.GetPublicKeyHex()
// Query the next version number from the database
nextVersion, err := s.signingKeyQueries.GetNextVersion(ctx)
if err != nil {
// Non-fatal: fall back to version 1 if the query fails
nextVersion = 1
}
// Insert the key (ON CONFLICT DO NOTHING — safe to call on every startup)
if err := s.signingKeyQueries.InsertSigningKey(ctx, keyID, publicKeyHex, nextVersion); err != nil {
return fmt.Errorf("failed to insert signing key: %w", err)
}
// Set this key as primary
if err := s.signingKeyQueries.SetPrimaryKey(ctx, keyID); err != nil {
return fmt.Errorf("failed to set primary key: %w", err)
}
return nil
}
// GetAllActivePublicKeys returns all currently active signing keys.
// If signingKeyQueries is set, fetches from database.
// Otherwise, returns a single-entry slice containing only the current key.
func (s *SigningService) GetAllActivePublicKeys(ctx context.Context) ([]models.SigningKey, error) {
if !s.enabled {
return nil, fmt.Errorf("signing service is not enabled")
}
if s.signingKeyQueries != nil {
keys, err := s.signingKeyQueries.GetActiveSigningKeys(ctx)
if err != nil {
return nil, err
}
if len(keys) > 0 {
return keys, nil
}
// Fall through to return current key if DB is empty
}
// No DB or no results: return current key as single-entry slice
return []models.SigningKey{
{
ID: uuid.Nil,
KeyID: s.GetCurrentKeyID(),
PublicKey: s.GetPublicKeyHex(),
Algorithm: "ed25519",
IsActive: true,
IsPrimary: true,
CreatedAt: time.Now().UTC(),
Version: 1,
},
}, nil
}
// ComputeFileChecksum returns the SHA-256 hex digest of the file at the given
// path without signing it. Used by the build orchestrator to decide whether
// an already-stored signed package is still valid for the on-disk binary
// (so we can skip a redundant re-sign on every server boot).
func (s *SigningService) ComputeFileChecksum(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return "", fmt.Errorf("failed to read file: %w", err)
}
hash := sha256.Sum256(content)
return hex.EncodeToString(hash[:]), nil
}
// SignFile signs a file and returns the signature and checksum
func (s *SigningService) SignFile(filePath string) (*models.AgentUpdatePackage, error) {
// Check if signing is enabled
if !s.enabled {
return nil, fmt.Errorf("signing service is disabled")
}
// Read the file
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// Calculate checksum and sign content
content, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
// Calculate SHA-256 checksum
hash := sha256.Sum256(content)
checksum := hex.EncodeToString(hash[:])
// Sign the content
signature := ed25519.Sign(s.privateKey, content)
// Get file info
fileInfo, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
}
// Determine platform and architecture from file path or use runtime defaults
platform, architecture := s.detectPlatformArchitecture(filePath)
pkg := &models.AgentUpdatePackage{
ID: uuid.Must(uuid.NewV4()),
BinaryPath: filePath,
Signature: hex.EncodeToString(signature),
Checksum: checksum,
FileSize: fileInfo.Size(),
Platform: platform,
Architecture: architecture,
CreatedBy: "signing-service",
IsActive: true,
}
return pkg, nil
}
// SignBytes signs arbitrary content with the server's Ed25519 key and returns a
// hex signature. Used for the release manifest (the cold-start trust root the
// installer verifies before executing a freshly-downloaded binary). The
// counterpart verifier is VerifySignature.
func (s *SigningService) SignBytes(content []byte) (string, error) {
if !s.enabled || s.privateKey == nil {
return "", fmt.Errorf("signing service is disabled")
}
return hex.EncodeToString(ed25519.Sign(s.privateKey, content)), nil
}
// VerifySignature verifies a file signature using the embedded public key
func (s *SigningService) VerifySignature(content []byte, signatureHex string) (bool, error) {
// Decode signature
signature, err := hex.DecodeString(signatureHex)
if err != nil {
return false, fmt.Errorf("invalid signature format: %w", err)
}
if len(signature) != ed25519.SignatureSize {
return false, fmt.Errorf("invalid signature size: expected %d bytes, got %d", ed25519.SignatureSize, len(signature))
}
// Verify signature
valid := ed25519.Verify(s.publicKey, content, signature)
return valid, nil
}
// VerifyFileIntegrity verifies a file's checksum
func (s *SigningService) VerifyFileIntegrity(filePath, expectedChecksum string) (bool, error) {
file, err := os.Open(filePath)
if err != nil {
return false, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return false, fmt.Errorf("failed to read file: %w", err)
}
hash := sha256.Sum256(content)
actualChecksum := hex.EncodeToString(hash[:])
return actualChecksum == expectedChecksum, nil
}
// detectPlatformArchitecture attempts to detect platform and architecture from file path
func (s *SigningService) detectPlatformArchitecture(filePath string) (string, string) {
// Default to current runtime
platform := runtime.GOOS
arch := runtime.GOARCH
// Map architectures
archMap := map[string]string{
"amd64": "amd64",
"arm64": "arm64",
"386": "386",
}
// Try to detect from filename patterns
if contains(filePath, "windows") || contains(filePath, ".exe") {
platform = "windows"
} else if contains(filePath, "linux") {
platform = "linux"
} else if contains(filePath, "darwin") || contains(filePath, "macos") {
platform = "darwin"
}
for archName, archValue := range archMap {
if contains(filePath, archName) {
arch = archValue
break
}
}
// Normalize architecture names
if arch == "amd64" {
arch = "amd64"
} else if arch == "arm64" {
arch = "arm64"
}
return platform, arch
}
// contains is a simple helper for case-insensitive substring checking
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr ||
(len(s) > len(substr) &&
(s[:len(substr)] == substr ||
s[len(s)-len(substr):] == substr ||
findSubstring(s, substr))))
}
// findSubstring is a simple substring finder
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// SignNonce signs a nonce (UUID + timestamp) for replay protection
func (s *SigningService) SignNonce(nonceUUID uuid.UUID, timestamp time.Time) (string, error) {
// Create nonce data: UUID + Unix timestamp as string
nonceData := fmt.Sprintf("%s:%d", nonceUUID.String(), timestamp.Unix())
// Sign the nonce data
signature := ed25519.Sign(s.privateKey, []byte(nonceData))
// Return hex-encoded signature
return hex.EncodeToString(signature), nil
}
// VerifyNonce verifies a nonce signature and checks freshness
func (s *SigningService) VerifyNonce(nonceUUID uuid.UUID, timestamp time.Time, signatureHex string, maxAge time.Duration) (bool, error) {
// Check nonce freshness first
if time.Since(timestamp) > maxAge {
return false, fmt.Errorf("nonce is too old: %v > %v", time.Since(timestamp), maxAge)
}
// Recreate nonce data
nonceData := fmt.Sprintf("%s:%d", nonceUUID.String(), timestamp.Unix())
// Verify signature
valid, err := s.VerifySignature([]byte(nonceData), signatureHex)
if err != nil {
return false, fmt.Errorf("failed to verify nonce signature: %w", err)
}
return valid, nil
}
// SignCommand creates an Ed25519 signature for a command.
// Signs the v3 message format: "{agent_id}:{id}:{command_type}:{sha256(params)}:{unix_timestamp}"
// Also sets cmd.SignedAt and cmd.KeyID as a side-effect.
// The agent_id binding (F-1 fix) prevents cross-agent command replay.
func (s *SigningService) SignCommand(cmd *models.AgentCommand) (string, error) {
if s.privateKey == nil {
return "", fmt.Errorf("signing service not initialized with private key")
}
// Record the signing time and key identity
now := time.Now().UTC()
cmd.SignedAt = &now
cmd.KeyID = s.GetCurrentKeyID()
// Serialize command parameters for signing
paramsJSON, _ := json.Marshal(cmd.Params)
paramsHash := sha256.Sum256(paramsJSON)
paramsHashHex := hex.EncodeToString(paramsHash[:])
// v3 format: "{agent_id}:{id}:{command_type}:{params_hash}:{unix_timestamp}"
// agent_id binding prevents cross-agent replay (F-1 fix)
message := fmt.Sprintf("%s:%s:%s:%s:%d",
cmd.AgentID.String(),
cmd.ID.String(),
cmd.CommandType,
paramsHashHex,
now.Unix())
// Sign with Ed25519
signature := ed25519.Sign(s.privateKey, []byte(message))
return hex.EncodeToString(signature), nil
}
// SignCapabilityToken signs a supply-chain capability token in place, setting its
// KeyID and Signature over the canonical message. The private key never leaves the
// signing service. The canonical encoding lives in the capability package and is
// byte-identical to the agent mirror and the Rust executor.
func (s *SigningService) SignCapabilityToken(t *capability.Token) error {
if !s.enabled || s.privateKey == nil {
return fmt.Errorf("signing service not initialized with private key")
}
_, err := t.Sign(s.privateKey)
return err
}
// Key rotation is now implemented via the signing_keys table (migration 020).
// Use InitializePrimaryKey() at startup to register the active key.
// Use GetAllActivePublicKeys() to enumerate all active keys for agents during rotation.
// The signing_keys table supports multiple concurrent active keys to allow
// graceful transition windows when rotating to a new key pair.

View file

@ -0,0 +1,287 @@
package services_test
// signing_replay_test.go — Pre-fix tests for command replay attack surface.
//
// These tests document the current (buggy) behavior of the signing system.
// Each test is categorised:
//
// PASS-NOW / FAIL-AFTER-FIX — documents a bug as-is; flips to fail when fix removes the bug.
// FAIL-NOW / PASS-AFTER-FIX — asserts correct post-fix behaviour; currently fails.
//
// Run: cd server && go test ./internal/services/... -v -run TestRetry
// cd server && go test ./internal/services/... -v -run TestSigned
// cd server && go test ./internal/services/... -v -run TestOld
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/Fimeg/RedFlag/server/internal/services"
"github.com/gofrs/uuid/v5"
)
// newTestSigningService creates a SigningService with a freshly generated Ed25519 key.
func newTestSigningService(t *testing.T) (*services.SigningService, ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("failed to generate test key pair: %v", err)
}
ss, err := services.NewSigningService(hex.EncodeToString(priv))
if err != nil {
t.Fatalf("failed to create signing service: %v", err)
}
return ss, pub, priv
}
// newTestCommand creates a valid AgentCommand for use in signing tests.
func newTestCommand(agentID uuid.UUID) *models.AgentCommand {
return &models.AgentCommand{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentID,
CommandType: "install_updates",
Params: models.JSONB{"package": "nginx", "version": "1.24.0"},
Status: models.CommandStatusPending,
Source: models.CommandSourceManual,
CreatedAt: time.Now(),
}
}
// ---------------------------------------------------------------------------
// Test 1.1 — BUG F-5: RetryCommand creates an unsigned command
//
// Category: PASS-NOW / FAIL-AFTER-FIX
//
// This test documents that the struct created by queries.RetryCommand
// (server/internal/database/queries/commands.go:189) has no
// Signature, SignedAt, or KeyID. The test PASSES now because the bug exists.
// After the fix (RetryCommand calls signAndCreateCommand), the retried command
// will be signed and these assertions will FAIL — flip expected.
// ---------------------------------------------------------------------------
func TestRetryCommandIsUnsigned(t *testing.T) {
// POST-FIX (F-5): RetryCommand now calls signAndCreateCommand.
// Simulate the fixed retry flow: build new command and sign it.
ss, _, _ := newTestSigningService(t)
original := newTestCommand(uuid.Must(uuid.NewV4()))
sig, err := ss.SignCommand(original)
if err != nil {
t.Fatalf("failed to sign original command: %v", err)
}
original.Signature = sig
// Simulate fixed RetryCommand: build new command and sign it
retried := &models.AgentCommand{
ID: uuid.Must(uuid.NewV4()),
AgentID: original.AgentID,
CommandType: original.CommandType,
Params: original.Params,
Status: models.CommandStatusPending,
Source: original.Source,
CreatedAt: time.Now(),
RetriedFromID: &original.ID,
}
// Sign the retried command (this is what signAndCreateCommand does)
retriedSig, err := ss.SignCommand(retried)
if err != nil {
t.Fatalf("failed to sign retried command: %v", err)
}
retried.Signature = retriedSig
// POST-FIX: retried command must be signed
if retried.Signature == "" {
t.Errorf("F-5 FIX BROKEN: retried command should have a signature")
}
if retried.SignedAt == nil {
t.Errorf("F-5 FIX BROKEN: retried command should have SignedAt set")
}
if retried.KeyID == "" {
t.Errorf("F-5 FIX BROKEN: retried command should have KeyID set")
}
t.Logf("POST-FIX: original Signature=%q... KeyID=%q SignedAt=%v",
original.Signature[:8], original.KeyID, original.SignedAt)
t.Logf("POST-FIX: retried Signature=%q... KeyID=%q SignedAt=%v",
retried.Signature[:8], retried.KeyID, retried.SignedAt)
t.Log("F-5 FIXED: Retried command is now signed with fresh signature, SignedAt, and KeyID.")
}
// ---------------------------------------------------------------------------
// Test 1.1-fix — Complementary: what RetryCommand SHOULD produce
//
// Category: FAIL-NOW / PASS-AFTER-FIX
//
// This test asserts the CORRECT post-fix behaviour. It currently FAILS because
// the bug (F-5) exists. After the fix it will PASS.
// ---------------------------------------------------------------------------
func TestRetryCommandMustBeSigned(t *testing.T) {
// POST-FIX (F-5): This test now PASSES.
// RetryCommand calls signAndCreateCommand, producing signed commands.
ss, _, _ := newTestSigningService(t)
original := newTestCommand(uuid.Must(uuid.NewV4()))
sig, err := ss.SignCommand(original)
if err != nil {
t.Fatalf("failed to sign original command: %v", err)
}
original.Signature = sig
// Simulate fixed RetryCommand: sign the retried command
retried := &models.AgentCommand{
ID: uuid.Must(uuid.NewV4()),
AgentID: original.AgentID,
CommandType: original.CommandType,
Params: original.Params,
Status: models.CommandStatusPending,
Source: original.Source,
CreatedAt: time.Now(),
RetriedFromID: &original.ID,
}
retriedSig, err := ss.SignCommand(retried)
if err != nil {
t.Fatalf("failed to sign retried command: %v", err)
}
retried.Signature = retriedSig
if retried.Signature == "" {
t.Errorf("retried command must have a signature")
}
if retried.SignedAt == nil {
t.Errorf("retried command must have SignedAt set")
}
if retried.KeyID == "" {
t.Errorf("retried command must have KeyID set")
}
}
// ---------------------------------------------------------------------------
// Test 1.2 — BUG F-1: Signed payload does not include agent_id
//
// Category: PASS-NOW / FAIL-AFTER-FIX
//
// A command signed for agent A produces a signature that verifies correctly
// for agent B because agent_id is absent from the signed message.
// The test PASSES now (demonstrates the bug). After fix (agent_id added to
// signed message), re-verification without agent_id will FAIL.
// ---------------------------------------------------------------------------
func TestSignedCommandNotBoundToAgent(t *testing.T) {
// POST-FIX (F-1): Signature now binds to agent_id.
// A command signed for agent A must NOT verify when the message is
// reconstructed with agent B's ID.
ss, _, _ := newTestSigningService(t)
agentA := uuid.Must(uuid.NewV4())
agentB := uuid.Must(uuid.NewV4())
// Create and sign a command for agent A.
cmd := newTestCommand(agentA)
sig, err := ss.SignCommand(cmd)
if err != nil {
t.Fatalf("failed to sign command: %v", err)
}
// Reconstruct the signed message — v3 format includes agent_id
paramsJSON, err := json.Marshal(cmd.Params)
if err != nil {
t.Fatalf("failed to marshal params: %v", err)
}
paramsHash := sha256.Sum256(paramsJSON)
paramsHashHex := hex.EncodeToString(paramsHash[:])
// v3 signed message for agent A (what was actually signed)
signedMessageA := fmt.Sprintf("%s:%s:%s:%s:%d",
agentA.String(), cmd.ID.String(), cmd.CommandType, paramsHashHex, cmd.SignedAt.Unix())
// Assert: agent A's UUID IS in the signed message (F-1 fix)
if !strings.Contains(signedMessageA, agentA.String()) {
t.Fatal("F-1 FIX BROKEN: agentA.String() should be present in signed message")
}
pubKeyBytes, err := hex.DecodeString(ss.GetPublicKey())
if err != nil {
t.Fatalf("failed to decode public key: %v", err)
}
pubKey := ed25519.PublicKey(pubKeyBytes)
sigBytes, err := hex.DecodeString(sig)
if err != nil {
t.Fatalf("failed to decode signature: %v", err)
}
// Verify with agent A's message — should pass
if !ed25519.Verify(pubKey, []byte(signedMessageA), sigBytes) {
t.Error("verification with correct agent A should pass")
}
// Reconstruct with agent B's ID — should FAIL
signedMessageB := fmt.Sprintf("%s:%s:%s:%s:%d",
agentB.String(), cmd.ID.String(), cmd.CommandType, paramsHashHex, cmd.SignedAt.Unix())
if ed25519.Verify(pubKey, []byte(signedMessageB), sigBytes) {
t.Error("F-1 FIX BROKEN: cross-agent verification with agent B should FAIL but passed")
}
t.Logf("F-1 FIXED: Signed message: %q", signedMessageA)
t.Logf(" Agent A (signed for): %s — verification PASSES", agentA)
t.Logf(" Agent B (not signed for): %s — verification FAILS", agentB)
t.Log("The signature is now bound to the target agent_id.")
}
// ---------------------------------------------------------------------------
// Test 1.3 — BUG F-3: Old-format signatures (no signed_at) never expire
//
// Category: PASS-NOW / FAIL-AFTER-FIX
//
// The old signed message format is "{id}:{type}:{sha256(params)}" with no
// timestamp. ed25519.Verify has no time component; a signature produced with
// this format verifies identically 30 days, 1 year, or 10 years later.
// The agent's VerifyCommand (crypto/verification.go:25) has no time check,
// so old-format commands are accepted indefinitely.
// ---------------------------------------------------------------------------
func TestOldFormatCommandHasNoExpiry(t *testing.T) {
// POST-FIX (F-3): Old-format signatures now have a 48h expiry via created_at check.
// The Ed25519 signature itself is still valid (crypto doesn't have time), but the
// agent's VerifyCommand now checks created_at and rejects commands > 48h old.
//
// This server-side test documents that the RAW signature is still valid (crypto level)
// but the application layer (agent VerifyCommand) rejects it.
_, _, priv := newTestSigningService(t)
cmdID := uuid.Must(uuid.NewV4())
cmdType := "install_updates"
params := models.JSONB{"package": "nginx"}
paramsJSON, err := json.Marshal(params)
if err != nil {
t.Fatalf("failed to marshal params: %v", err)
}
paramsHash := sha256.Sum256(paramsJSON)
paramsHashHex := hex.EncodeToString(paramsHash[:])
oldFormatMessage := fmt.Sprintf("%s:%s:%s", cmdID.String(), cmdType, paramsHashHex)
sig := ed25519.Sign(priv, []byte(oldFormatMessage))
pubKey := priv.Public().(ed25519.PublicKey)
// Raw crypto verification still passes (Ed25519 has no time component)
valid := ed25519.Verify(pubKey, []byte(oldFormatMessage), sig)
if !valid {
t.Error("raw Ed25519 verification should still pass")
}
t.Logf("F-3 FIXED: Old-format message %q", oldFormatMessage)
t.Log("Raw Ed25519 signature is timeless, but agent's VerifyCommand now checks created_at.")
t.Log("Old-format commands > 48h old are rejected at the application layer.")
t.Log("Phase 2 (future): remove old-format fallback entirely after 90 days from migration 025.")
}

View file

@ -0,0 +1,80 @@
package services
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
// SoakGateDecision is the verdict the install-version handler uses to decide
// whether to block, warn, or pass a version selection.
type SoakGateDecision struct {
Eligible bool `json:"eligible"`
FirstScannedAt time.Time `json:"first_scanned_at"`
SoakDays float64 `json:"soak_days"`
RequiredDays float64 `json:"required_days"`
DaysRemaining float64 `json:"days_remaining"`
Enforcement string `json:"enforcement"` // "off" | "warn" | "block"
Unknown bool `json:"unknown"`
}
// SoakGateConfig reads the version soak gate configuration.
// Default: 14-day soak window, "block" enforcement.
func SoakGateConfig() (requiredDays float64, enforcement string) {
requiredDays = 14.0
enforcement = "block"
if v := os.Getenv("REDFLAG_SUPPLY_CHAIN_SOAK_WINDOW_DAYS"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 0 {
requiredDays = f
}
}
if v := os.Getenv("REDFLAG_SUPPLY_CHAIN_SOAK_ENFORCEMENT"); v != "" {
switch strings.ToLower(v) {
case "off", "warn", "block":
enforcement = strings.ToLower(v)
}
}
return
}
// EvaluateSoakGate computes the soak gate decision for a single version.
func EvaluateSoakGate(firstScannedAt time.Time, requiredDays float64, enforcement string) SoakGateDecision {
dec := SoakGateDecision{
RequiredDays: requiredDays,
Enforcement: enforcement,
}
if firstScannedAt.IsZero() {
dec.Unknown = true
return dec
}
dec.FirstScannedAt = firstScannedAt
dec.SoakDays = time.Since(firstScannedAt).Hours() / 24.0
dec.DaysRemaining = requiredDays - dec.SoakDays
if dec.DaysRemaining < 0 {
dec.DaysRemaining = 0
}
if dec.SoakDays >= requiredDays {
dec.Eligible = true
return dec
}
if enforcement == "block" {
dec.Eligible = false
} else {
dec.Eligible = true
}
return dec
}
// SoakGateWarnMessage returns a human-readable message for non-eligible decisions.
func SoakGateWarnMessage(dec SoakGateDecision) string {
return fmt.Sprintf(
"version has been in the fleet for %.1f days (soak threshold %.1f days, %.1f days remaining)",
dec.SoakDays, dec.RequiredDays, dec.DaysRemaining,
)
}

View file

@ -0,0 +1,866 @@
package services
import (
"bytes"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/Fimeg/RedFlag/server/internal/circuitbreaker"
"github.com/Fimeg/RedFlag/server/internal/httpx"
"github.com/gofrs/uuid/v5"
)
// OSVQueryRequest is sent to the OSV.dev API.
type OSVQueryRequest struct {
Package OSVPackage `json:"package"`
Version string `json:"version"`
}
// OSVPackage identifies a package in a specific ecosystem.
type OSVPackage struct {
Name string `json:"name"`
Ecosystem string `json:"ecosystem"`
}
// OSVQueryResponse is the response from the OSV.dev API.
type OSVQueryResponse struct {
Vulns []OSVVuln `json:"vulns"`
}
// OSVVuln represents a single vulnerability from OSV.dev. We decode the subset
// of the OSV schema we surface in the UI: identity, qualitative severity
// (database_specific.severity, used by GHSA for npm/PyPI), CVSS vectors, the
// first fixed version (from affected ranges), and the publish date.
type OSVVuln struct {
ID string `json:"id"`
Summary string `json:"summary"`
Aliases []string `json:"aliases"`
Published string `json:"published"`
Severity []OSVSeverity `json:"severity"`
Affected []OSVAffected `json:"affected"`
DatabaseSpecific map[string]interface{} `json:"database_specific"`
}
// OSVSeverity is one severity record (typically a CVSS vector string).
type OSVSeverity struct {
Type string `json:"type"`
Score string `json:"score"`
}
// OSVAffected carries the affected version ranges for a package.
type OSVAffected struct {
Ranges []OSVRange `json:"ranges"`
}
// OSVRange is an introduced/fixed event sequence over a version space.
type OSVRange struct {
Type string `json:"type"`
Events []OSVEvent `json:"events"`
}
// OSVEvent is a single boundary in a range (introduced or fixed).
type OSVEvent struct {
Introduced string `json:"introduced"`
Fixed string `json:"fixed"`
LastAffected string `json:"last_affected"`
Limit string `json:"limit"`
}
// SupplyChainCheckResult is returned by the vulnerability check.
type SupplyChainCheckResult struct {
Vulnerabilities []VulnerabilityInfo `json:"vulnerabilities"`
CheckedAt time.Time `json:"checked_at"`
}
// VulnerabilityInfo is a display-oriented vulnerability record. Stored as JSON
// in current_package_state.metadata.supply_chain_vulns and rendered by the UI.
// Severity is qualitative (CRITICAL/HIGH/MODERATE/LOW) when OSV provides it;
// CVSSVector is the raw v3/v4 vector for operators who want the dimensions.
type VulnerabilityInfo struct {
ID string `json:"id"`
Summary string `json:"summary"`
Aliases []string `json:"aliases"`
Severity string `json:"severity,omitempty"`
CVSSVector string `json:"cvss_vector,omitempty"`
CVSSScore float64 `json:"cvss_score,omitempty"`
FixedVersion string `json:"fixed_version,omitempty"`
Published string `json:"published,omitempty"`
AdvisoryType string `json:"advisory_type,omitempty"` // "AlmaLinux advisory", "CVE", etc.
AffectedRanges []string `json:"affected_ranges,omitempty"`
KnownExploited bool `json:"known_exploited,omitempty"`
}
// AdvisoryType returns a human-readable label for an advisory ID prefix.
func AdvisoryType(id string) string {
switch {
case strings.HasPrefix(id, "ALSA-"):
return "AlmaLinux advisory"
case strings.HasPrefix(id, "ALBA-"), strings.HasPrefix(id, "ALEA-"):
return "AlmaLinux erratum (non-security)"
case strings.HasPrefix(id, "RHSA-"):
return "Red Hat advisory"
case strings.HasPrefix(id, "RHBA-"), strings.HasPrefix(id, "RHEA-"):
return "Red Hat erratum (non-security)"
case strings.HasPrefix(id, "USN-"):
return "Ubuntu advisory"
case strings.HasPrefix(id, "GHSA-"):
return "GitHub advisory"
case strings.HasPrefix(id, "CVE-"):
return "CVE"
default:
return "security advisory"
}
}
// nonSecurityErrata matches RPM-family errata that carry no security content:
// bugfix (*BA) and enhancement (*EA) advisories from AlmaLinux, Red Hat,
// Rocky, and Oracle. OSV returns them alongside security advisories; counting
// them as threats inflates the dashboard with non-threats.
var nonSecurityErrata = regexp.MustCompile(`^(AL|RH|RL|EL)(BA|EA)-`)
// IsSecurityAdvisory reports whether an OSV record ID names an actual
// security advisory rather than a bugfix/enhancement erratum.
func IsSecurityAdvisory(id string) bool {
return !nonSecurityErrata.MatchString(id)
}
// filterSecurityVulns drops non-security errata from an OSV result set.
func filterSecurityVulns(vulns []OSVVuln) []OSVVuln {
kept := vulns[:0:0]
for _, v := range vulns {
if IsSecurityAdvisory(v.ID) {
kept = append(kept, v)
} else {
log.Printf("[INFO] [supply_chain] erratum_filtered id=%s (non-security)", v.ID)
}
}
return kept
}
// toVulnerabilityInfo maps a raw OSV record into the display struct, pulling
// qualitative severity, the first CVSS vector, the first fixed version, and the
// publish date out of the OSV schema's various nesting points.
func toVulnerabilityInfo(v OSVVuln) VulnerabilityInfo {
info := VulnerabilityInfo{
ID: v.ID,
Summary: v.Summary,
Aliases: v.Aliases,
Published: v.Published,
AdvisoryType: AdvisoryType(v.ID),
AffectedRanges: affectedRanges(v.Affected),
KnownExploited: knownExploited(v.DatabaseSpecific),
}
// Qualitative severity: GHSA puts it in database_specific.severity.
if v.DatabaseSpecific != nil {
if s, ok := v.DatabaseSpecific["severity"].(string); ok && s != "" {
info.Severity = strings.ToUpper(s)
}
}
// First CVSS vector (prefer v4, else v3, else whatever is present).
for _, s := range v.Severity {
if strings.HasPrefix(s.Score, "CVSS:") {
info.CVSSVector = s.Score
if strings.HasPrefix(s.Score, "CVSS:4") {
break
}
}
}
if score, ok := cvss3BaseScore(info.CVSSVector); ok {
info.CVSSScore = score
if info.Severity == "" {
info.Severity = severityFromCVSSScore(score)
}
}
// First fixed version across affected ranges.
for _, a := range v.Affected {
for _, r := range a.Ranges {
for _, e := range r.Events {
if e.Fixed != "" {
info.FixedVersion = e.Fixed
break
}
}
if info.FixedVersion != "" {
break
}
}
if info.FixedVersion != "" {
break
}
}
return info
}
func cvss3BaseScore(vector string) (float64, bool) {
if !strings.HasPrefix(vector, "CVSS:3.") {
return 0, false
}
metrics := make(map[string]string)
for _, part := range strings.Split(vector, "/") {
key, value, ok := strings.Cut(part, ":")
if ok {
metrics[key] = value
}
}
avMap := map[string]float64{"N": 0.85, "A": 0.62, "L": 0.55, "P": 0.2}
acMap := map[string]float64{"L": 0.77, "H": 0.44}
uiMap := map[string]float64{"N": 0.85, "R": 0.62}
impactMap := map[string]float64{"H": 0.56, "L": 0.22, "N": 0}
scope := metrics["S"]
if scope != "U" && scope != "C" {
return 0, false
}
prMap := map[string]float64{"N": 0.85, "L": 0.62, "H": 0.27}
if scope == "C" {
prMap = map[string]float64{"N": 0.85, "L": 0.68, "H": 0.5}
}
av, okAV := avMap[metrics["AV"]]
ac, okAC := acMap[metrics["AC"]]
pr, okPR := prMap[metrics["PR"]]
ui, okUI := uiMap[metrics["UI"]]
c, okC := impactMap[metrics["C"]]
i, okI := impactMap[metrics["I"]]
a, okA := impactMap[metrics["A"]]
if !okAV || !okAC || !okPR || !okUI || !okC || !okI || !okA {
return 0, false
}
exploitability := 8.22 * av * ac * pr * ui
impactSubScore := 1 - (1-c)*(1-i)*(1-a)
impact := 6.42 * impactSubScore
if scope == "C" {
impact = 7.52*(impactSubScore-0.029) - 3.25*math.Pow(impactSubScore-0.02, 15)
}
if impact <= 0 {
return 0, true
}
score := impact + exploitability
if scope == "C" {
score = 1.08 * score
}
if score > 10 {
score = 10
}
return math.Ceil((score-1e-10)*10) / 10, true
}
func severityFromCVSSScore(score float64) string {
switch {
case score >= 9:
return "CRITICAL"
case score >= 7:
return "HIGH"
case score >= 4:
return "MEDIUM"
case score > 0:
return "LOW"
default:
return ""
}
}
func affectedRanges(affected []OSVAffected) []string {
var ranges []string
for _, a := range affected {
for _, r := range a.Ranges {
var introduced string
emitted := false
for _, e := range r.Events {
if e.Introduced != "" {
introduced = e.Introduced
}
switch {
case e.Fixed != "":
ranges = append(ranges, formatAffectedRange(r.Type, introduced, "<", e.Fixed))
introduced = ""
emitted = true
case e.LastAffected != "":
ranges = append(ranges, formatAffectedRange(r.Type, introduced, "<=", e.LastAffected))
emitted = true
case e.Limit != "":
ranges = append(ranges, formatAffectedRange(r.Type, introduced, "<", e.Limit))
emitted = true
}
}
if !emitted && introduced != "" {
ranges = append(ranges, formatAffectedRange(r.Type, introduced, "", ""))
}
}
}
return ranges
}
func formatAffectedRange(rangeType, introduced, upperOp, upperVersion string) string {
lower := "all prior versions"
if introduced != "" && introduced != "0" {
lower = ">= " + introduced
}
body := lower + " and later"
if upperOp != "" && upperVersion != "" {
body = lower + ", " + upperOp + " " + upperVersion
}
if rangeType != "" {
return rangeType + ": " + body
}
return body
}
func knownExploited(databaseSpecific map[string]interface{}) bool {
keys := []string{
"known_exploited",
"knownExploited",
"known_exploited_vulnerability",
"knownExploitedVulnerability",
"cisa_kev",
"cisaKev",
"cisa_known_exploited",
"cisaKnownExploited",
"cisaExploitAdd",
"cisaActionDue",
"cisaRequiredAction",
"cisaVulnerabilityName",
"kev",
}
for _, key := range keys {
if truthy(databaseSpecific[key]) {
return true
}
}
return false
}
func truthy(v interface{}) bool {
switch value := v.(type) {
case bool:
return value
case string:
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized != "" &&
normalized != "false" &&
normalized != "no" &&
normalized != "none" &&
normalized != "unknown" &&
normalized != "0"
case float64:
return value > 0
case int:
return value > 0
case []interface{}:
return len(value) > 0
case map[string]interface{}:
return len(value) > 0
default:
return false
}
}
var osvHTTPClient = httpx.NewClient(30 * time.Second)
// osvBreaker wraps OSV.dev calls (SCALE-001 S8). Both the batch and single-query
// paths hit api.osv.dev, so they share one breaker: when OSV is down or slow,
// the breaker opens and subsequent checks fail fast instead of each timing out.
// Callers fail OPEN on an open breaker (record the check as unrun) — sovereignty:
// an unreachable advisory feed never blocks a patch, same as today's transport
// errors.
var osvBreaker = circuitbreaker.New("osv", circuitbreaker.Config{
FailureThreshold: 5,
FailureWindow: 60 * time.Second,
OpenDuration: 30 * time.Second,
HalfOpenAttempts: 2,
})
// OSVBreakerStats exposes the OSV breaker state for /health/tasks (OBS-001).
func OSVBreakerStats() circuitbreaker.Stats { return osvBreaker.GetStats() }
// OSVRecheckInterval is how long a stored supply-chain result is treated as
// fresh. The scan path skips packages checked more recently than this so a
// frequent scan cadence does not re-hit OSV.dev for every package every cycle;
// the periodic recheck still picks up newly published advisories.
const OSVRecheckInterval = 6 * time.Hour
// osvBatchSize is the max queries per /v1/querybatch POST. OSV.dev accepts up
// to 1000; 100 keeps each request under 2s and the response payload manageable.
const osvBatchSize = 100
// osvBatchesInFlight caps the number of concurrent batch requests to OSV.dev
// across the entire process. At 10,000 agents × ~300 packages each, we could
// have millions of checks queued — this semaphore ensures we don't hammer the
// API regardless of how many callers invoke RunOSVChecks simultaneously.
const osvBatchesInFlight = 4
// osvBatchSem is the process-wide semaphore for concurrent OSV.dev batch
// requests. Shared across all RunOSVChecks calls so that N agents reporting
// simultaneously total at most osvBatchesInFlight requests in flight.
var osvBatchSem = make(chan struct{}, osvBatchesInFlight)
// OSVCheckRequest is one package to check against OSV.dev.
// Namespace routes results to different metadata keys:
// - "" or "remediation" → supply_chain_checked_at / supply_chain_checked_version / supply_chain_vulns
// - "installed" → installed_checked_at / installed_checked_version / installed_vulns
type OSVCheckRequest struct {
AgentID uuid.UUID
PkgType string
PkgName string
Version string
Namespace string // "" = remediation (default)
}
// OSVStoreFunc persists a supply-chain result (or a recorded failure) for one
// package. Implemented by the caller against current_package_state so this
// package keeps no database dependency.
type OSVStoreFunc func(agentID uuid.UUID, pkgType, pkgName string, meta map[string]interface{}) error
// osvBatchQuery is one entry in the /v1/querybatch request.
type osvBatchQuery struct {
Package OSVPackage `json:"package"`
Version string `json:"version"`
}
// osvBatchRequest is the POST body for /v1/querybatch.
type osvBatchRequest struct {
Queries []osvBatchQuery `json:"queries"`
}
// osvBatchResult is one result from /v1/querybatch.
type osvBatchResult struct {
Vulns []OSVVuln `json:"vulns"`
}
// osvBatchResponse is the response from /v1/querybatch.
type osvBatchResponse struct {
Results []osvBatchResult `json:"results"`
}
// RunOSVChecks queries OSV.dev for each request using the batch endpoint with
// bounded, process-wide concurrency, and persists every outcome through store.
// A clean result records checked_at and checked_version, and clears stale vulns;
// a hit also records vulns. A query failure records check_error WITHOUT a
// checked_at timestamp, so the package stays a candidate for the next run rather
// than being silently dropped (ETHOS: errors are history, assume failure).
// Blocks until done.
func RunOSVChecks(reqs []OSVCheckRequest, store OSVStoreFunc) {
if len(reqs) == 0 {
return
}
var wg sync.WaitGroup
for i := 0; i < len(reqs); i += osvBatchSize {
end := i + osvBatchSize
if end > len(reqs) {
end = len(reqs)
}
batch := reqs[i:end]
osvBatchSem <- struct{}{}
wg.Add(1)
go func(b []OSVCheckRequest) {
defer wg.Done()
defer func() { <-osvBatchSem }()
osvBatchRun(b, store)
}(batch)
}
wg.Wait()
}
// osvBatchRun sends one batch to /v1/querybatch and persists all results.
func osvBatchRun(reqs []OSVCheckRequest, store OSVStoreFunc) {
queries := make([]osvBatchQuery, len(reqs))
for i, r := range reqs {
queries[i] = osvBatchQuery{
Package: OSVPackage{
Name: r.PkgName,
Ecosystem: EcosystemFromPackageType(r.PkgType),
},
Version: r.Version,
}
}
body, err := json.Marshal(osvBatchRequest{Queries: queries})
if err != nil {
log.Printf("[WARNING] [supply_chain] batch_marshal_failed count=%d error=%v", len(reqs), err)
recordBatchFailure(reqs, store)
return
}
// Breaker-wrapped (SCALE-001 S8): a down/slow OSV trips the breaker so the
// rest of this batch — and the single-query path — fail fast rather than each
// blocking on a 30s timeout. An open breaker takes the same fail-open path as
// a transport error below.
var batchResp osvBatchResponse
callErr := osvBreaker.Call(func() error {
resp, err := osvHTTPClient.Post("https://api.osv.dev/v1/querybatch", "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("osv batch status %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(&batchResp); err != nil {
return fmt.Errorf("decode: %w", err)
}
return nil
})
if callErr != nil {
log.Printf("[WARNING] [supply_chain] batch_query_failed count=%d error=%v", len(reqs), callErr)
recordBatchFailure(reqs, store)
return
}
if len(batchResp.Results) != len(reqs) {
log.Printf("[WARNING] [supply_chain] batch_count_mismatch sent=%d got=%d", len(reqs), len(batchResp.Results))
recordBatchFailure(reqs, store)
return
}
now := time.Now().UTC()
for i, r := range reqs {
result := batchResp.Results[i]
checkedKey, versionKey, vulnsKey, errorKey := osvMetaKeys(r.Namespace)
meta := map[string]interface{}{
checkedKey: now.Format(time.RFC3339),
versionKey: r.Version,
vulnsKey: nil,
errorKey: nil,
}
securityVulns := filterSecurityVulns(result.Vulns)
if len(securityVulns) > 0 {
// Enrich ALSA/RHSA/USN advisories with full details from OSV.
enriched := enrichAdvisoryVulns(securityVulns)
vulns := make([]VulnerabilityInfo, len(enriched))
for i, v := range enriched {
vulns[i] = toVulnerabilityInfo(v)
}
vulnJSON, err := json.Marshal(vulns)
if err != nil {
log.Printf("[WARNING] [supply_chain] vuln_marshal_failed pkg=%s error=%v", r.PkgName, err)
continue
}
meta[vulnsKey] = string(vulnJSON)
log.Printf("[SECURITY] [supply_chain] vulns_found namespace=%s pkg=%s type=%s ecosystem=%s count=%d",
r.Namespace, r.PkgName, r.PkgType, EcosystemFromPackageType(r.PkgType), len(securityVulns))
} else {
log.Printf("[INFO] [supply_chain] clean namespace=%s pkg=%s type=%s ecosystem=%s version=%s",
r.Namespace, r.PkgName, r.PkgType, EcosystemFromPackageType(r.PkgType), r.Version)
}
if err := store(r.AgentID, r.PkgType, r.PkgName, meta); err != nil {
log.Printf("[WARNING] [supply_chain] metadata_store_failed pkg=%s error=%v", r.PkgName, err)
}
}
}
// enrichAdvisoryVulns does a second OSV pass for ALSA/RHSA/USN advisories to
// resolve their aliases and summaries. OSV's batch endpoint returns advisory IDs
// but often without full details; the single-vuln endpoint (/v1/vulns/{id})
// returns the complete record including constituent CVEs.
func enrichAdvisoryVulns(vulns []OSVVuln) []OSVVuln {
enriched := make([]OSVVuln, 0, len(vulns))
for _, v := range vulns {
if needsEnrichment(v) {
if resolved, err := fetchVulnDetail(v.ID); err == nil {
enriched = append(enriched, resolved)
continue
}
// On failure, keep original — partial data is better than none.
log.Printf("[WARNING] [supply_chain] enrich_failed id=%s", v.ID)
}
enriched = append(enriched, v)
}
return enriched
}
// needsEnrichment returns true if a vuln record looks sparse — missing
// summary and aliases — and its ID is a known advisory prefix.
func needsEnrichment(v OSVVuln) bool {
if v.Summary != "" && len(v.Aliases) > 0 {
return false
}
return strings.HasPrefix(v.ID, "ALSA-") ||
strings.HasPrefix(v.ID, "RHSA-") ||
strings.HasPrefix(v.ID, "USN-")
}
// fetchVulnDetail queries OSV.dev for a single vulnerability ID.
func fetchVulnDetail(id string) (OSVVuln, error) {
url := fmt.Sprintf("https://api.osv.dev/v1/vulns/%s", id)
resp, err := osvHTTPClient.Get(url)
if err != nil {
return OSVVuln{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return OSVVuln{}, fmt.Errorf("osv status %d", resp.StatusCode)
}
var v OSVVuln
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
return OSVVuln{}, err
}
return v, nil
}
// osvMetaKeys returns the metadata key names for a given check namespace.
func osvMetaKeys(namespace string) (checkedAt, checkedVersion, vulns, checkError string) {
if namespace == "installed" {
return "installed_checked_at", "installed_checked_version", "installed_vulns", "installed_check_error"
}
return "supply_chain_checked_at", "supply_chain_checked_version", "supply_chain_vulns", "supply_chain_check_error"
}
// recordBatchFailure persists a failure record for every request in a batch
// that could not be checked (HTTP error, decode failure, count mismatch).
// No checked_at is set so these packages are retried next cycle.
func recordBatchFailure(reqs []OSVCheckRequest, store OSVStoreFunc) {
for _, r := range reqs {
_, _, _, errorKey := osvMetaKeys(r.Namespace)
meta := map[string]interface{}{
errorKey: "osv_batch_failed",
"supply_chain_error_at": time.Now().UTC().Format(time.RFC3339),
}
if err := store(r.AgentID, r.PkgType, r.PkgName, meta); err != nil {
log.Printf("[WARNING] [supply_chain] failure_record_failed pkg=%s error=%v", r.PkgName, err)
}
}
}
// ClosurePkg is one resolved artifact (name + version) in a dependency closure
// to be checked against OSV.dev.
type ClosurePkg struct {
Name string
Version string
}
// ClosureVuln records a closure artifact that has known vulnerabilities.
type ClosureVuln struct {
Name string `json:"name"`
Version string `json:"version"`
Vulns []OSVVuln `json:"vulns"`
}
// CheckClosureOSV queries OSV.dev for every artifact in a resolved dependency
// closure and returns the subset with known vulnerabilities. This is the
// dependency-level supply-chain gate: the closure is the exact set of artifacts
// the capability token authorizes the network-less executor to install, so each
// transitive artifact is queried — not just the top-level package that was
// checked at discovery.
//
// Unlike CheckOSVVulnerabilities (fail-open, for advisory display), this is
// FAIL-CLOSED: if any batch cannot be queried (HTTP error, bad status, decode
// failure, count mismatch), it returns ok=false. The caller must treat a
// not-ok result as "closure not cleared" and never as clean — auto-confirm must
// not mint a token over a closure OSV could not vet. All entries share the
// update's ecosystem.
func CheckClosureOSV(pkgType string, entries []ClosurePkg) (found []ClosureVuln, ok bool) {
if len(entries) == 0 {
return nil, true // empty closure is trivially clean
}
ecosystem := EcosystemFromPackageType(pkgType)
for i := 0; i < len(entries); i += osvBatchSize {
end := i + osvBatchSize
if end > len(entries) {
end = len(entries)
}
batch := entries[i:end]
hits, batchOK := osvClosureBatch(ecosystem, batch)
if !batchOK {
return found, false // fail closed: a batch we couldn't check is not "clean"
}
found = append(found, hits...)
}
return found, true
}
// osvClosureBatch sends one closure batch to /v1/querybatch under the
// process-wide semaphore and returns the entries with vulns. ok=false on any
// query/parse failure.
func osvClosureBatch(ecosystem string, batch []ClosurePkg) (found []ClosureVuln, ok bool) {
queries := make([]osvBatchQuery, len(batch))
for i, e := range batch {
queries[i] = osvBatchQuery{Package: OSVPackage{Name: e.Name, Ecosystem: ecosystem}, Version: e.Version}
}
body, err := json.Marshal(osvBatchRequest{Queries: queries})
if err != nil {
log.Printf("[WARNING] [supply_chain] closure_batch_marshal_failed count=%d error=%v", len(batch), err)
return nil, false
}
osvBatchSem <- struct{}{}
defer func() { <-osvBatchSem }()
resp, err := osvHTTPClient.Post("https://api.osv.dev/v1/querybatch", "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("[WARNING] [supply_chain] closure_batch_query_failed count=%d error=%v", len(batch), err)
return nil, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("[WARNING] [supply_chain] closure_batch_query_status=%d count=%d", resp.StatusCode, len(batch))
return nil, false
}
var batchResp osvBatchResponse
if err := json.NewDecoder(resp.Body).Decode(&batchResp); err != nil {
log.Printf("[WARNING] [supply_chain] closure_batch_decode_failed count=%d error=%v", len(batch), err)
return nil, false
}
if len(batchResp.Results) != len(batch) {
log.Printf("[WARNING] [supply_chain] closure_batch_count_mismatch sent=%d got=%d", len(batch), len(batchResp.Results))
return nil, false
}
for i, e := range batch {
securityVulns := filterSecurityVulns(batchResp.Results[i].Vulns)
if len(securityVulns) > 0 {
found = append(found, ClosureVuln{Name: e.Name, Version: e.Version, Vulns: securityVulns})
log.Printf("[SECURITY] [supply_chain] closure_vuln pkg=%s version=%s ecosystem=%s count=%d",
e.Name, e.Version, ecosystem, len(securityVulns))
}
}
return found, true
}
// CheckOSVVulnerabilities queries the OSV.dev API for known vulnerabilities
// matching the given package name, ecosystem, and version. Returns nil slice
// with no error on clean results or API failure (fail-open).
func CheckOSVVulnerabilities(pkgName, ecosystem, version string) *SupplyChainCheckResult {
reqBody := OSVQueryRequest{
Package: OSVPackage{
Name: pkgName,
Ecosystem: ecosystem,
},
Version: version,
}
body, err := json.Marshal(reqBody)
if err != nil {
log.Printf("[WARNING] [supply_chain] marshal_failed pkg=%s error=%v", pkgName, err)
return nil
}
// Breaker-wrapped (SCALE-001 S8); shares osvBreaker with the batch path. An
// open breaker returns nil (fail-open) — same as a transport error: the check
// is recorded as unrun, never blocking a patch (sovereignty).
var result OSVQueryResponse
callErr := osvBreaker.Call(func() error {
resp, err := osvHTTPClient.Post("https://api.osv.dev/v1/query", "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("osv status %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decode: %w", err)
}
return nil
})
if callErr != nil {
log.Printf("[WARNING] [supply_chain] query_failed pkg=%s error=%v", pkgName, callErr)
return nil
}
securityVulns := filterSecurityVulns(result.Vulns)
if len(securityVulns) == 0 {
return &SupplyChainCheckResult{
CheckedAt: time.Now().UTC(),
}
}
vulns := make([]VulnerabilityInfo, len(securityVulns))
for i, v := range securityVulns {
vulns[i] = toVulnerabilityInfo(v)
}
return &SupplyChainCheckResult{
Vulnerabilities: vulns,
CheckedAt: time.Now().UTC(),
}
}
// NeedsSupplyChainCheck returns true if the given package ecosystem can be
// checked against OSV.dev. Broader than before — runs for all ecosystems that
// have an OSV.dev mapping, even when coverage is sparse. A nil result is honest
// visibility (the check ran, nothing found).
//
// TODO(GATE-006): pacman is wired as a scanner but OSV.dev does not yet cover
// Arch Linux (as of 2026-06). When OSV adds Arch support, add "pacman" here
// and flip EcosystemFromPackageType to return "Arch". Track via OSV.dev
// ecosystem registry — search for "arch" or "archlinux". Until then, pacman
// updates skip the supply-chain check and rely on the capability gate + hash
// verification for install safety.
func NeedsSupplyChainCheck(pkgType string) bool {
switch pkgType {
case "npm", "pip", "pypi", "apt", "dnf":
return true
}
return false
}
// CanServerFetchArtifact returns true if the server can download package
// artifacts from the public registry for the given ecosystem. Agent-sourced
// ecosystems (dnf, apt) return false — their artifacts come from the agent's
// own repos, which the server may not be able to reach (custom mirrors,
// air-gapped networks). The mirror tier may add server-side fetching for
// these ecosystems later.
func CanServerFetchArtifact(pkgType string) bool {
switch pkgType {
case "npm", "pip", "pypi":
return true
}
return false
}
// NeedsCapabilityGate returns true if the ecosystem routes mutation through
// the capability-token path (consumer.go → redflag-helper). Server-fetched
// ecosystems (npm/pypi) and agent-sourced ecosystems (dnf/apt) both use it
// when the minter is enabled. pacman routes through the gate for privilege
// isolation and artifact hashing even though OSV does not cover Arch.
func NeedsCapabilityGate(pkgType string) bool {
switch pkgType {
case "dnf", "apt", "npm", "pip", "pypi", "pacman":
return true
}
return false
}
// EcosystemFromPackageType maps RedFlag package types to OSV.dev ecosystems.
// Best-effort: dnf maps to AlmaLinux (closest supported RHEL-family ecosystem),
// apt maps to Debian. Unmapped types return the raw package type — OSV.dev will
// return empty results for unrecognized ecosystems rather than error. pacman
// returns "Arch" for future OSV coverage; as of 2026-06 OSV.dev does not support
// Arch Linux, so NeedsSupplyChainCheck returns false for pacman.
func EcosystemFromPackageType(pkgType string) string {
switch pkgType {
case "npm":
return "npm"
case "pip", "pypi":
return "PyPI"
case "apt":
return "Debian"
case "dnf":
return "AlmaLinux"
case "pacman":
return "Arch"
}
return pkgType
}

View file

@ -0,0 +1,67 @@
package services
import "testing"
// GetSupplyChainGateConfig layers env > config > DB > default via GetSetting.
// The env path short-circuits before any DB access, so we can exercise the
// resolver — and the fact that it makes the DB-backed settings *live* at all —
// without a database by setting the REDFLAG_SUPPLY_CHAIN_* env vars, which
// getEnvironmentValue maps to the supply_chain.* keys.
func TestGetSupplyChainGateConfig_EnvDrivesGate(t *testing.T) {
t.Setenv("REDFLAG_SUPPLY_CHAIN_MIN_PACKAGE_AGE_HOURS", "48")
t.Setenv("REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT", "block")
t.Setenv("REDFLAG_SUPPLY_CHAIN_BLOCK_UNKNOWN_AGE", "true")
s := &SecuritySettingsService{} // settingsQueries nil: env path must not touch it
minAge, enf, blockUnknown := s.GetSupplyChainGateConfig()
if minAge != 48.0 {
t.Errorf("min age: want 48, got %v", minAge)
}
if enf != "block" {
t.Errorf("enforcement: want block, got %q", enf)
}
if !blockUnknown {
t.Error("block_unknown_age: want true")
}
}
func TestGetSupplyChainGateConfig_InvalidEnforcementIgnored(t *testing.T) {
// A bad enforcement env value falls back to the baked default rather than
// driving the gate into an undefined state. (min/block set to valid env
// values so every key resolves on the env path and never touches the DB.)
t.Setenv("REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT", "panic")
t.Setenv("REDFLAG_SUPPLY_CHAIN_MIN_PACKAGE_AGE_HOURS", "24")
t.Setenv("REDFLAG_SUPPLY_CHAIN_BLOCK_UNKNOWN_AGE", "false")
s := &SecuritySettingsService{}
_, enf, _ := s.GetSupplyChainGateConfig()
if enf != "warn" {
t.Errorf("invalid enforcement should fall back to warn, got %q", enf)
}
}
func TestGetSoakGateConfig_EnvDrivesGate(t *testing.T) {
t.Setenv("REDFLAG_SUPPLY_CHAIN_SOAK_WINDOW_DAYS", "7")
t.Setenv("REDFLAG_SUPPLY_CHAIN_SOAK_ENFORCEMENT", "warn")
s := &SecuritySettingsService{} // env path must not touch the nil DB
days, enf := s.GetSoakGateConfig()
if days != 7.0 {
t.Errorf("soak window: want 7, got %v", days)
}
if enf != "warn" {
t.Errorf("soak enforcement: want warn, got %q", enf)
}
}
func TestGetSoakGateConfig_InvalidEnforcementIgnored(t *testing.T) {
t.Setenv("REDFLAG_SUPPLY_CHAIN_SOAK_WINDOW_DAYS", "14")
t.Setenv("REDFLAG_SUPPLY_CHAIN_SOAK_ENFORCEMENT", "nonsense")
s := &SecuritySettingsService{}
_, enf := s.GetSoakGateConfig()
if enf != "block" {
t.Errorf("invalid soak enforcement should fall back to the block default, got %q", enf)
}
}

View file

@ -0,0 +1,103 @@
package services
import "testing"
func TestToVulnerabilityInfoEnrichesCVSSAffectedRangesAndKEV(t *testing.T) {
info := toVulnerabilityInfo(OSVVuln{
ID: "CVE-2026-0001",
Summary: "demo vulnerability",
Severity: []OSVSeverity{
{Type: "CVSS_V3", Score: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"},
},
Affected: []OSVAffected{
{
Ranges: []OSVRange{
{
Type: "SEMVER",
Events: []OSVEvent{
{Introduced: "1.0.0"},
{Fixed: "1.2.3"},
},
},
},
},
},
DatabaseSpecific: map[string]interface{}{"cisa_kev": true},
})
if info.CVSSVector != "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" {
t.Fatalf("CVSSVector = %q", info.CVSSVector)
}
if info.CVSSScore != 9.8 {
t.Fatalf("CVSSScore = %v, want 9.8", info.CVSSScore)
}
if info.Severity != "CRITICAL" {
t.Fatalf("Severity = %q, want CRITICAL", info.Severity)
}
if info.FixedVersion != "1.2.3" {
t.Fatalf("FixedVersion = %q, want 1.2.3", info.FixedVersion)
}
if len(info.AffectedRanges) != 1 || info.AffectedRanges[0] != "SEMVER: >= 1.0.0, < 1.2.3" {
t.Fatalf("AffectedRanges = %#v", info.AffectedRanges)
}
if !info.KnownExploited {
t.Fatal("KnownExploited = false, want true")
}
}
func TestToVulnerabilityInfoKeepsDatabaseSpecificSeverity(t *testing.T) {
info := toVulnerabilityInfo(OSVVuln{
ID: "GHSA-demo",
Severity: []OSVSeverity{
{Type: "CVSS_V3", Score: "CVSS:3.1/AV:L/AC:H/PR:H/UI:R/S:U/C:L/I:N/A:N"},
},
DatabaseSpecific: map[string]interface{}{"severity": "high"},
})
if info.Severity != "HIGH" {
t.Fatalf("Severity = %q, want HIGH", info.Severity)
}
if info.CVSSScore == 0 {
t.Fatal("CVSSScore was not parsed")
}
}
func TestIsSecurityAdvisoryFiltersErrata(t *testing.T) {
tests := []struct {
id string
want bool
}{
{"ALSA-2023:4838", true}, // AlmaLinux security
{"ALBA-2022:2032", false}, // AlmaLinux bugfix
{"ALEA-2022:1985", false}, // AlmaLinux enhancement
{"RHSA-2024:0001", true},
{"RHBA-2024:0001", false},
{"RHEA-2024:0001", false},
{"RLBA-2024:0001", false}, // Rocky bugfix
{"ELBA-2024:0001", false}, // Oracle bugfix
{"CVE-2024-12345", true},
{"GHSA-xxxx-yyyy-zzzz", true},
{"USN-6000-1", true},
}
for _, tt := range tests {
if got := IsSecurityAdvisory(tt.id); got != tt.want {
t.Errorf("IsSecurityAdvisory(%q) = %v, want %v", tt.id, got, tt.want)
}
}
}
func TestFilterSecurityVulns(t *testing.T) {
in := []OSVVuln{
{ID: "ALBA-2022:2032"},
{ID: "ALSA-2023:4838"},
{ID: "ALEA-2022:1985"},
{ID: "CVE-2024-12345"},
}
out := filterSecurityVulns(in)
if len(out) != 2 {
t.Fatalf("expected 2 security vulns, got %d", len(out))
}
if out[0].ID != "ALSA-2023:4838" || out[1].ID != "CVE-2024-12345" {
t.Fatalf("unexpected survivors: %v, %v", out[0].ID, out[1].ID)
}
}

View file

@ -0,0 +1,141 @@
package services
import (
"context"
"log"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/Fimeg/RedFlag/server/internal/services/notifier"
"github.com/gofrs/uuid/v5"
"github.com/jmoiron/sqlx"
)
// SystemEventLogger persists events into the system_events table and
// optionally dispatches them to external notification sinks.
// Used by the orchestrator (timeouts/workflow) to emit system_events with zero
// import coupling on the services package.
type SystemEventLogger struct {
db *sqlx.DB
dispatcher *notifier.Dispatcher
}
// NewSystemEventLogger creates a SystemEventLogger.
func NewSystemEventLogger(db *sqlx.DB) *SystemEventLogger {
return &SystemEventLogger{db: db}
}
// SetDispatcher attaches a notification dispatcher. May be nil (notifications
// disabled). Safe to call before or after first LogEvent.
func (l *SystemEventLogger) SetDispatcher(d *notifier.Dispatcher) {
l.dispatcher = d
}
// LogEvent inserts a row into system_events and dispatches to external
// notification sinks if configured. Best-effort: failures are logged
// but never returned, so the caller never stalls on event emission.
func (l *SystemEventLogger) LogEvent(agentID *uuid.UUID, eventType, eventSubtype, severity, component, message string, metadata map[string]interface{}) {
meta := models.JSONB(metadata)
if meta == nil {
meta = models.JSONB{}
}
id, err := uuid.NewV4()
if err != nil {
log.Printf("[ERROR] [server] [event_logger] uuid_generation_failed error=%v", err)
return
}
query := `
INSERT INTO system_events (id, agent_id, event_type, event_subtype, severity, component, message, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
`
if _, err := l.db.Exec(query, id, agentID, eventType, eventSubtype, severity, component, message, meta); err != nil {
log.Printf("[ERROR] [server] [event_logger] insert_failed event_type=%s component=%s error=%v", eventType, component, err)
}
// Dispatch to external notification sinks (best-effort, fail-open).
if l.dispatcher != nil {
ne := eventToNotify(eventType, eventSubtype, severity, message, agentID)
if ne.Class != "" {
l.dispatcher.Dispatch(context.Background(), ne)
}
}
}
// eventToNotify maps a system event (type+subtype+severity) to a NotifyEvent
// with the appropriate event class. Returns a zero-value NotifyEvent
// (Class == "") if the event is not operator-notifiable.
func eventToNotify(eventType, eventSubtype, severity, message string, agentID *uuid.UUID) notifier.NotifyEvent {
// agent_update events
if eventType == "agent_update" {
switch eventSubtype {
case "timed_out", "failed":
return notifier.NotifyEvent{
Class: notifier.ClassUpdateFailed,
Severity: severity,
Title: "Agent update " + eventSubtype,
Message: message,
AgentID: agentIDString(agentID),
Fingerprint: "agent_update_" + eventSubtype + "_" + agentIDString(agentID),
}
}
}
// Command failures
if eventType == "command_failed" {
return notifier.NotifyEvent{
Class: notifier.ClassSecurity,
Severity: severity,
Title: "Command rejected by agent",
Message: message,
AgentID: agentIDString(agentID),
Fingerprint: "command_failed_" + agentIDString(agentID),
}
}
// Agent registration failures
if eventType == "agent_registration" && eventSubtype == "failed" {
return notifier.NotifyEvent{
Class: notifier.ClassSecurity,
Severity: severity,
Title: "Agent registration failed",
Message: message,
AgentID: agentIDString(agentID),
Fingerprint: "registration_failed_" + agentIDString(agentID),
}
}
// Refresh token security events — machine mismatch, token reuse.
if eventType == "token_security" {
return notifier.NotifyEvent{
Class: notifier.ClassSecurity,
Severity: severity,
Title: "Token security event",
Message: message,
AgentID: agentIDString(agentID),
Fingerprint: "token_security_" + eventSubtype + "_" + agentIDString(agentID),
}
}
// Heartbeat / agent offline detection.
if eventType == "agent_heartbeat" && eventSubtype == "missed" {
return notifier.NotifyEvent{
Class: notifier.ClassAgentOffline,
Severity: severity,
Title: "Agent missed heartbeat",
Message: message,
AgentID: agentIDString(agentID),
Fingerprint: "missed_heartbeat_" + agentIDString(agentID),
}
}
// Not a notifiable event class.
return notifier.NotifyEvent{}
}
func agentIDString(agentID *uuid.UUID) string {
if agentID == nil {
return ""
}
return agentID.String()
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,729 @@
# RedFlag Agent Installer - Windows PowerShell
# Generated for agent: {{.AgentID}}
# Platform: {{.Platform}}
# Architecture: {{.Architecture}}
# Version: {{.Version}}
# This script is rendered per-token by the server and meant to be piped straight
# into an elevated PowerShell:
# irm "<server>/api/v1/install/windows?token=XXX" | iex
# That path can't bind script parameters, so the token and server are baked in at
# render time — same model as the Linux installer (curl ... | sudo bash). Env vars
# override for advanced/manual runs. Admin is enforced at runtime below; #Requires
# can't fire under iex, so it's gone.
$RegistrationToken = if ($env:RF_TOKEN) { $env:RF_TOKEN } else { "{{.RegistrationToken}}" }
$ServerUrl = if ($env:RF_SERVER) { $env:RF_SERVER } else { "{{.ServerURL}}" }
$ServerUrl = $ServerUrl.TrimEnd("/")
$SkipServiceInstall = [bool]$env:RF_SKIP_SERVICE_INSTALL
function Convert-HexToBytes {
param([Parameter(Mandatory=$true)][string]$Hex)
$CleanHex = $Hex.Trim()
if (($CleanHex.Length % 2) -ne 0) {
throw "invalid hex length"
}
$Bytes = New-Object byte[] ([int]($CleanHex.Length / 2))
for ($i = 0; $i -lt $Bytes.Length; $i++) {
$Bytes[$i] = [Convert]::ToByte($CleanHex.Substring($i * 2, 2), 16)
}
return ,$Bytes
}
function Test-Ed25519Signature {
param(
[Parameter(Mandatory=$true)][byte[]]$Data,
[Parameter(Mandatory=$true)][byte[]]$Signature,
[Parameter(Mandatory=$true)][byte[]]$PublicKey
)
if ($PublicKey.Length -ne 32) {
throw "invalid Ed25519 public key length"
}
if ($Signature.Length -ne 64) {
throw "invalid Ed25519 signature length"
}
try {
$null = [System.Security.Cryptography.Ed25519]
} catch {
return $null
}
try {
$Ok = [System.Security.Cryptography.Ed25519]::VerifyData($Data, $Signature, $PublicKey)
return [bool]$Ok
} catch [System.Management.Automation.MethodException] {
} catch [System.Management.Automation.RuntimeException] {
if ($_.Exception.Message -notmatch "method|overload|argument") {
throw
}
}
try {
$Verifier = [System.Security.Cryptography.Ed25519]::Create($PublicKey)
$Ok = $Verifier.VerifyData($Data, $Signature)
return [bool]$Ok
} catch [System.Management.Automation.MethodException] {
} catch [System.Management.Automation.RuntimeException] {
if ($_.Exception.Message -notmatch "method|overload|argument") {
throw
}
}
try {
$Ok = [System.Security.Cryptography.Ed25519]::Verify($Signature, $Data, $PublicKey)
return [bool]$Ok
} catch [System.Management.Automation.MethodException] {
} catch [System.Management.Automation.RuntimeException] {
if ($_.Exception.Message -notmatch "method|overload|argument") {
throw
}
}
return $null
}
function Set-AgentConfigPermissions {
param(
[Parameter(Mandatory=$true)][string]$ConfigFilePath,
[Parameter(Mandatory=$true)][string]$ConfigDirectoryPath
)
Write-Host "Setting file permissions..." -ForegroundColor Yellow
# Use stable SIDs instead of localized account names:
# S-1-5-18 SYSTEM
# S-1-5-19 LOCAL SERVICE
# S-1-5-32-544 BUILTIN\Administrators
& icacls $ConfigDirectoryPath /inheritance:r /grant:r "*S-1-5-18:(OI)(CI)F" "*S-1-5-19:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to set ACL on $ConfigDirectoryPath"
exit 1
}
& icacls $ConfigFilePath /inheritance:r /grant:r "*S-1-5-18:F" "*S-1-5-19:F" "*S-1-5-32-544:F" | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to set ACL on $ConfigFilePath"
exit 1
}
}
function Repair-AgentConfigAccessIfNeeded {
param(
[Parameter(Mandatory=$true)][string]$ConfigFilePath,
[Parameter(Mandatory=$true)][string]$ConfigDirectoryPath
)
if (-not (Test-Path $ConfigFilePath)) {
return
}
try {
$null = Get-Content -Raw -Path $ConfigFilePath -ErrorAction Stop
} catch {
Write-Host "[WARN] [installer] [permissions] Existing config is not readable; repairing ACL before detection" -ForegroundColor Yellow
Set-AgentConfigPermissions -ConfigFilePath $ConfigFilePath -ConfigDirectoryPath $ConfigDirectoryPath
}
}
function Ensure-LocalApiGroup {
param([Parameter(Mandatory=$true)][string]$GroupName)
Write-Host "Ensuring local API access group..." -ForegroundColor Yellow
$LocalGroupCommandsAvailable = $null -ne (Get-Command Get-LocalGroup -ErrorAction SilentlyContinue) -and
$null -ne (Get-Command New-LocalGroup -ErrorAction SilentlyContinue) -and
$null -ne (Get-Command Add-LocalGroupMember -ErrorAction SilentlyContinue)
if ($LocalGroupCommandsAvailable) {
$Group = Get-LocalGroup -Name $GroupName -ErrorAction SilentlyContinue
if (-not $Group) {
New-LocalGroup -Name $GroupName -Description "RedFlag local agent API access" | Out-Null
Write-Host "✓ Group $GroupName created" -ForegroundColor Green
} else {
Write-Host "✓ Group $GroupName already exists" -ForegroundColor Green
}
$CurrentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
if ($CurrentIdentity.User.Value -eq "S-1-5-18") {
Write-Host "[INFO] [installer] [localapi] Running as LocalSystem; no interactive user added to $GroupName" -ForegroundColor Gray
return
}
$CurrentUser = $CurrentIdentity.Name
try {
Add-LocalGroupMember -Group $GroupName -Member $CurrentUser -ErrorAction Stop
Write-Host "✓ Added $CurrentUser to $GroupName" -ForegroundColor Green
Write-Host "[INFO] [installer] [localapi] Sign out and back in if non-elevated local API access is not immediately available" -ForegroundColor Gray
} catch {
if ($_.Exception.Message -match "already.*member|already.*exists") {
Write-Host "✓ $CurrentUser already in $GroupName" -ForegroundColor Green
} else {
throw
}
}
return
}
# Windows PowerShell fallback for hosts without Microsoft.PowerShell.LocalAccounts.
$ComputerName = $env:COMPUTERNAME
try {
$Group = [ADSI]"WinNT://$ComputerName/$GroupName,group"
$null = $Group.Name
Write-Host "✓ Group $GroupName already exists" -ForegroundColor Green
} catch {
$Machine = [ADSI]"WinNT://$ComputerName"
$Group = $Machine.Create("group", $GroupName)
$Group.SetInfo()
$Group.Description = "RedFlag local agent API access"
$Group.SetInfo()
Write-Host "✓ Group $GroupName created" -ForegroundColor Green
}
$CurrentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
if ($CurrentIdentity.User.Value -eq "S-1-5-18") {
Write-Host "[INFO] [installer] [localapi] Running as LocalSystem; no interactive user added to $GroupName" -ForegroundColor Gray
return
}
$CurrentUser = $CurrentIdentity.Name
$MemberPath = "WinNT://" + $CurrentUser.Replace("\", "/")
try {
$Group.Add($MemberPath)
Write-Host "✓ Added $CurrentUser to $GroupName" -ForegroundColor Green
Write-Host "[INFO] [installer] [localapi] Sign out and back in if non-elevated local API access is not immediately available" -ForegroundColor Gray
} catch {
if ($_.Exception.Message -match "already.*member|The object already exists") {
Write-Host "✓ $CurrentUser already in $GroupName" -ForegroundColor Green
} else {
throw
}
}
}
# Runtime admin check for better error messaging
if (-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
Write-Error "This installer must be run as Administrator."
Write-Error "Right-click PowerShell and select 'Run as Administrator', then retry."
exit 1
}
# Detect Windows architecture
$Arch = $env:PROCESSOR_ARCHITECTURE
switch ($Arch) {
"AMD64" { $ArchTag = "amd64" }
"ARM64" { $ArchTag = "arm64" }
default {
Write-Error "Unsupported architecture: $Arch. Supported: AMD64, ARM64."
exit 1
}
}
$AgentID = "{{.AgentID}}"
$BinaryURL = "$ServerUrl/api/v1/downloads/windows-${ArchTag}?version={{.Version}}"
$ConfigURL = "{{.ConfigURL}}"
$InstallDir = "C:\Program Files\RedFlag"
$ConfigDir = "C:\ProgramData\RedFlag"
$AgentConfigDir = "C:\ProgramData\RedFlag\agent"
$ServerKeyDir = "C:\ProgramData\RedFlag\server"
$OldConfigDir = "C:\ProgramData\Aggregator"
$ServiceName = "RedFlagAgent"
$LocalApiGroup = "RedFlagLocal"
$Version = "{{.Version}}"
$Timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$BackupDir = Join-Path $ConfigDir "backups\backup.$Timestamp"
Write-Host "=== RedFlag Agent v$Version Installation ===" -ForegroundColor Cyan
Write-Host "Agent ID: $AgentID"
Write-Host "Platform: {{.Platform}}"
Write-Host "Installing to: $InstallDir\redflag-agent.exe"
Write-Host
# Step 0: Detect existing installation and migration requirements
Write-Host "Detecting existing RedFlag installations..." -ForegroundColor Yellow
$MigrationNeeded = $false
$CurrentVersion = "unknown"
$ConfigVersion = "0"
$ConfigPath = Join-Path $AgentConfigDir "config.json"
$OldConfigPath = Join-Path $OldConfigDir "config.json"
Repair-AgentConfigAccessIfNeeded -ConfigFilePath $ConfigPath -ConfigDirectoryPath $AgentConfigDir
# Check for existing installation in new location
if (Test-Path $ConfigPath) {
Write-Host "✓ Existing installation detected at $ConfigDir" -ForegroundColor Green
try {
$Config = Get-Content $ConfigPath | ConvertFrom-Json
if ($Config.agent_version) { $CurrentVersion = $Config.agent_version }
if ($Config.version) { $ConfigVersion = $Config.version.ToString() }
} catch {
Write-Host " Warning: Could not parse config.json" -ForegroundColor Yellow
}
Write-Host " Current agent version: $CurrentVersion"
Write-Host " Current config version: $ConfigVersion"
} elseif (Test-Path $OldConfigPath) {
Write-Host "⚠ Old installation detected at $OldConfigDir - MIGRATION REQUIRED" -ForegroundColor Yellow
$MigrationNeeded = $true
try {
$Config = Get-Content $OldConfigPath | ConvertFrom-Json
if ($Config.agent_version) { $CurrentVersion = $Config.agent_version }
if ($Config.version) { $ConfigVersion = $Config.version.ToString() }
} catch {
Write-Host " Warning: Could not parse config.json" -ForegroundColor Yellow
}
Write-Host " Current agent version: $CurrentVersion"
Write-Host " Current config version: $ConfigVersion"
} else {
Write-Host "✓ Fresh installation" -ForegroundColor Green
}
# Determine if migration is needed
if (-not $MigrationNeeded) {
# Check if config version indicates migration is needed
try {
if ([int]$ConfigVersion -lt 4) {
$MigrationNeeded = $true
Write-Host "⚠ Config version $ConfigVersion < v4 - migration required" -ForegroundColor Yellow
}
} catch {
# Config version not a valid number
}
# Check for missing security features
if (Test-Path $ConfigPath) {
$ConfigContent = Get-Content $ConfigPath -Raw
if ($ConfigContent -notmatch "nonce_validation") {
$MigrationNeeded = $true
Write-Host "⚠ Missing security feature: nonce_validation" -ForegroundColor Yellow
}
if ($ConfigContent -notmatch "machine_id") {
$MigrationNeeded = $true
Write-Host "⚠ Missing security feature: machine_id_binding" -ForegroundColor Yellow
}
}
}
# Handle migration if needed
if ($MigrationNeeded) {
Write-Host
Write-Host "=== Migration Required ===" -ForegroundColor Cyan
Write-Host "Agent will migrate on first start. Backing up configuration..." -ForegroundColor Yellow
# Create backup directory
New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null
# Backup old configuration if it exists
if (Test-Path $OldConfigPath) {
Write-Host "Backing up old configuration..." -ForegroundColor Yellow
Copy-Item $OldConfigPath $BackupDir -ErrorAction SilentlyContinue
}
# Backup current configuration if we're upgrading
if (Test-Path $ConfigPath) {
Write-Host "Backing up current configuration..." -ForegroundColor Yellow
Copy-Item $ConfigPath "$BackupDir\config.json.backup" -ErrorAction SilentlyContinue
}
Write-Host "Migration will run automatically when agent starts."
Write-Host "View migration logs with: Get-EventLog -LogName Application -Source $ServiceName -Newest 50"
Write-Host
}
# Step 1: Stop existing service if running
$Service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($Service -and $Service.Status -eq 'Running') {
Write-Host "Stopping existing RedFlag agent service..." -ForegroundColor Yellow
Stop-Service -Name $ServiceName -Force
Start-Sleep -Seconds 2
}
# Step 2: Create directories
Write-Host "Creating directories..." -ForegroundColor Yellow
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
New-Item -ItemType Directory -Force -Path $AgentConfigDir | Out-Null
New-Item -ItemType Directory -Force -Path $ServerKeyDir | Out-Null
New-Item -ItemType Directory -Force -Path "$ConfigDir\backups" | Out-Null
New-Item -ItemType Directory -Force -Path "$ConfigDir\state" | Out-Null
New-Item -ItemType Directory -Force -Path "$ConfigDir\logs" | Out-Null
Ensure-LocalApiGroup -GroupName $LocalApiGroup
# Step 3: Download agent binary
Write-Host "Downloading agent binary..." -ForegroundColor Yellow
$BinaryPath = Join-Path $InstallDir "redflag-agent.exe"
$TmpBinary = Join-Path $env:TEMP "redflag-agent-download.exe"
$Response = Invoke-WebRequest -Uri $BinaryURL -OutFile $TmpBinary -UseBasicParsing -PassThru
# --- Cold-start trust: verify the binary against the signed release manifest ---
# Parallels the Linux installer. The manifest lists the expected SHA-256 per
# platform/arch and is Ed25519-signed. The hash pin is mandatory and fail-closed;
# the manifest signature is verified where an Ed25519 verifier exists. NOTE: .NET
# ships no native Ed25519, so on Windows the signature is best-effort (BouncyCastle)
# while the hash pin is always enforced — full signature parity with Linux lands
# when the Windows farm is reachable.
$ManifestUrl = "$ServerUrl/api/v1/manifest?version={{.Version}}"
$ServerPubKey = "{{.ServerPublicKey}}"
try {
$ManifestResp = Invoke-WebRequest -Uri $ManifestUrl -UseBasicParsing
} catch {
$ManifestStatus = $null
$ManifestErrorBody = $null
$Resp = $_.Exception.Response
if ($Resp) {
try { $ManifestStatus = [int]$Resp.StatusCode } catch {}
try {
$Stream = $Resp.GetResponseStream()
if ($Stream) {
$Reader = New-Object System.IO.StreamReader($Stream)
$ManifestErrorBody = $Reader.ReadToEnd()
$Reader.Close()
}
} catch {}
}
if ($ManifestStatus) {
Write-Error "Failed to fetch release manifest from $ManifestUrl (HTTP $ManifestStatus) - refusing to install."
} else {
Write-Error "Failed to fetch release manifest from $ManifestUrl ($($_.Exception.Message)) - refusing to install."
}
if ($ManifestErrorBody) {
Write-Error "Manifest response: $ManifestErrorBody"
}
Remove-Item $TmpBinary -Force
exit 1
}
$ManifestBody = $ManifestResp.Content
$ManifestSig = $null
if ($ManifestResp.Headers.ContainsKey("X-Content-Signature")) {
$ManifestSig = $ManifestResp.Headers["X-Content-Signature"]
}
if (-not ($ManifestSig -and $ServerPubKey)) {
Write-Error "Release manifest is unsigned or no server key embedded - refusing to install."
Remove-Item $TmpBinary -Force
exit 1
}
# Defense-in-depth: verify the manifest's Ed25519 signature if a verifier exists.
try {
$pk = Convert-HexToBytes $ServerPubKey
$sg = Convert-HexToBytes $ManifestSig
$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($ManifestBody)
$ok = Test-Ed25519Signature -Data $bodyBytes -Signature $sg -PublicKey $pk
} catch {
Write-Error "Release manifest signature or public key is malformed - refusing to install."
Remove-Item $TmpBinary -Force
exit 1
}
if ($null -eq $ok) {
Write-Host "WARNING: No native Ed25519 verifier - manifest signature not checked (hash pin still enforced)." -ForegroundColor Yellow
Write-Host "For full signature verification, install PowerShell 7/.NET with Ed25519 support." -ForegroundColor Yellow
} elseif (-not $ok) {
Write-Error "Release manifest signature invalid - refusing to install."
Remove-Item $TmpBinary -Force
exit 1
} else {
Write-Host "✓ Manifest signature verified (Ed25519)" -ForegroundColor Green
}
# Mandatory hash pin: the downloaded binary must match its manifest entry.
$Manifest = $ManifestBody | ConvertFrom-Json
$ExpectedHash = $null
foreach ($a in $Manifest.artifacts) {
if ($a.platform -eq "windows" -and $a.architecture -eq $ArchTag) {
$ExpectedHash = $a.sha256.ToLower()
break
}
}
if (-not $ExpectedHash) {
Write-Error "No manifest entry for windows/$ArchTag - refusing to install."
Remove-Item $TmpBinary -Force
exit 1
}
$ActualManifestHash = (Get-FileHash -Path $TmpBinary -Algorithm SHA256).Hash.ToLower()
if ($ActualManifestHash -ne $ExpectedHash) {
Write-Error "Binary hash does not match the signed manifest - possible tampering."
Write-Error " expected (signed manifest): $ExpectedHash"
Write-Error " actual (downloaded): $ActualManifestHash"
Remove-Item $TmpBinary -Force
exit 1
}
Write-Host "✓ Cold-start trust established: binary matches signed release manifest ($ActualManifestHash)" -ForegroundColor Green
# --- end cold-start manifest verification ---
# Verify checksum if server provided one
$ExpectedChecksum = $null
if ($Response.Headers.ContainsKey("X-Content-SHA256")) {
$ExpectedChecksum = $Response.Headers["X-Content-SHA256"]
}
if ($ExpectedChecksum) {
$ActualHash = (Get-FileHash -Path $TmpBinary -Algorithm SHA256).Hash.ToLower()
if ($ActualHash -ne $ExpectedChecksum) {
Write-Error "Checksum verification failed"
Write-Error "Expected: $ExpectedChecksum"
Write-Error "Actual: $ActualHash"
Remove-Item $TmpBinary -Force
exit 1
}
Write-Host "Checksum verified: $ActualHash" -ForegroundColor Green
} else {
Write-Host "WARNING: Server did not provide checksum header. Proceeding without verification." -ForegroundColor Yellow
}
# ISSUE-002: Verify binary signature before installation (TOFU model)
$ExpectedSignature = $null
if ($Response.Headers.ContainsKey("X-Content-Signature")) {
$ExpectedSignature = $Response.Headers["X-Content-Signature"]
}
if ($ExpectedSignature -and "{{.ServerPublicKey}}") {
Write-Host "Verifying binary signature..." -ForegroundColor Yellow
try {
$pubKeyBytes = Convert-HexToBytes "{{.ServerPublicKey}}"
$sigBytes = Convert-HexToBytes $ExpectedSignature
$binaryBytes = [System.IO.File]::ReadAllBytes($TmpBinary)
$verified = Test-Ed25519Signature -Data $binaryBytes -Signature $sigBytes -PublicKey $pubKeyBytes
if ($null -eq $verified) {
Write-Host "WARNING: No native Ed25519 verifier - binary signature not checked (manifest hash pin already enforced)." -ForegroundColor Yellow
Write-Host "For full signature verification, install PowerShell 7/.NET with Ed25519 support." -ForegroundColor Yellow
} elseif ($verified) {
Write-Host "✓ Binary signature verified (Ed25519)" -ForegroundColor Green
} else {
throw "Signature verification failed"
}
} catch {
Write-Error "Binary signature verification failed - possible tampering"
Write-Error "Detail: $($_.Exception.Message)"
Remove-Item $TmpBinary -Force
exit 1
}
} else {
Write-Host "WARNING: Cannot verify signature - missing public key or signature" -ForegroundColor Yellow
Write-Host "This is a security risk. Ensure server is trusted." -ForegroundColor Yellow
}
Move-Item -Path $TmpBinary -Destination $BinaryPath -Force
# Step 4: Handle configuration
if (Test-Path $ConfigPath) {
# Upgrade - preserve existing config
Write-Host "Upgrade detected - preserving existing configuration" -ForegroundColor Green
Write-Host "Agent will handle migration automatically on first start" -ForegroundColor Green
} else {
# Fresh install - create minimal config template with registration token
Write-Host "Fresh install detected - creating minimal configuration template" -ForegroundColor Green
$ConfigTemplate = @"
{
"version": 5,
"agent_version": "$Version",
"agent_id": "",
"token": "",
"refresh_token": "",
"registration_token": "$RegistrationToken",
"machine_id": "",
"check_in_interval": 300,
"server_url": "$ServerUrl",
"network": {
"timeout": 30000000000,
"retry_count": 3,
"retry_delay": 5000000000,
"max_idle_conn": 10
},
"proxy": {
"enabled": false
},
"tls": {
"enabled": false,
"insecure_skip_verify": false
},
"logging": {
"level": "info",
"max_size": 100,
"max_backups": 3,
"max_age": 28
},
"subsystems": {
"system": {"enabled": true, "timeout": 10000000000, "circuit_breaker": {"enabled": true, "failure_threshold": 3, "failure_window": 600000000000, "open_duration": 1800000000000, "half_open_attempts": 2}},
"filesystem": {"enabled": true, "timeout": 10000000000, "circuit_breaker": {"enabled": true, "failure_threshold": 3, "failure_window": 600000000000, "open_duration": 1800000000000, "half_open_attempts": 2}},
"network": {"enabled": true, "timeout": 30000000000, "circuit_breaker": {"enabled": true, "failure_threshold": 3, "failure_window": 600000000000, "open_duration": 1800000000000, "half_open_attempts": 2}},
"processes": {"enabled": true, "timeout": 30000000000, "circuit_breaker": {"enabled": true, "failure_threshold": 3, "failure_window": 600000000000, "open_duration": 1800000000000, "half_open_attempts": 2}},
"updates": {"enabled": true, "timeout": 30000000000, "circuit_breaker": {"enabled": false, "failure_threshold": 0, "failure_window": 0, "open_duration": 0, "half_open_attempts": 0}},
"storage": {"enabled": true, "timeout": 10000000000, "circuit_breaker": {"enabled": true, "failure_threshold": 3, "failure_window": 600000000000, "open_duration": 1800000000000, "half_open_attempts": 2}}
},
"security": {
"ed25519_verification": true,
"nonce_validation": true,
"machine_id_binding": true
}
}
"@
$ConfigTemplate | Set-Content -Path $ConfigPath -Encoding UTF8
}
# Decide install flow per docs/AGENT_LIFECYCLE.md:
# Fresh Install → no usable local config → call --register
# Upgrade In Place → local config has non-empty refresh_token → skip --register
# Token in URL is ignored on the upgrade path; refresh_token authenticates.
$AgentBinary = Join-Path $InstallDir "redflag-agent.exe"
$ExistingRefreshToken = ""
if (Test-Path $ConfigPath) {
try {
$ExistingConfig = Get-Content -Raw -Path $ConfigPath -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if ($ExistingConfig.refresh_token) {
$ExistingRefreshToken = [string]$ExistingConfig.refresh_token
}
} catch {
# Malformed config — treat as no credentials present, fall through to register.
Write-Host "[WARN] [installer] [register] Could not parse existing config; treating as fresh install" -ForegroundColor Yellow
}
}
if ($ExistingRefreshToken -ne "") {
Write-Host "[INFO] [installer] [register] Upgrade in place - existing credentials detected, skipping registration" -ForegroundColor Cyan
Write-Host "[INFO] [installer] [register] Token in URL is ignored on the upgrade path; refresh_token authenticates" -ForegroundColor Gray
} elseif ($RegistrationToken -ne "") {
Write-Host "[INFO] [installer] [register] Registering agent with server..." -ForegroundColor Cyan
$RegisterProcess = Start-Process -FilePath $AgentBinary -ArgumentList "--server", "$ServerUrl", "--token", "$RegistrationToken", "--register" -Wait -PassThru -NoNewWindow
if ($RegisterProcess.ExitCode -eq 0) {
Write-Host "[SUCCESS] [installer] [register] Agent registered successfully" -ForegroundColor Green
Write-Host "[INFO] [installer] [register] Agent ID assigned, configuration updated" -ForegroundColor Gray
} else {
Write-Host "[ERROR] [installer] [register] Registration failed - check token validity and server connectivity" -ForegroundColor Red
Write-Host "[WARN] [installer] [register] Agent installed but not registered. Service will not start." -ForegroundColor Yellow
Write-Host ""
Write-Host "[INFO] [installer] [register] To retry registration manually:" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] $AgentBinary --server $ServerUrl --token YOUR_TOKEN --register" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] Then start service:" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] Start-Service -Name $ServiceName" -ForegroundColor Gray
exit 1
}
} else {
Write-Host "[INFO] [installer] [register] No registration token provided - skipping registration" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] Service will start but agent will exit until registered" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] To register manually:" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] $AgentBinary --server $ServerUrl --token YOUR_TOKEN --register" -ForegroundColor Gray
}
# Registration rewrites config.json with agent_id/token/refresh_token, so the
# restrictive ACL must be applied after registration, not before.
Set-AgentConfigPermissions -ConfigFilePath $ConfigPath -ConfigDirectoryPath $AgentConfigDir
# Step 6: Install Windows service (if not skipped)
if (-not $SkipServiceInstall) {
Write-Host "Creating Windows service..." -ForegroundColor Yellow
# Check if service exists and remove it first
$ExistingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($ExistingService) {
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
& sc.exe delete $ServiceName | Out-Null
}
# Register service with appropriate credentials
if ([System.Environment]::OSVersion.Version.Major -ge 10) {
# Windows 10/Server 2016+ - can use LocalService
New-Service -Name $ServiceName -BinaryPathName $BinaryPath -DisplayName "RedFlag Security Agent" -Description "RedFlag Security Monitoring Agent" -StartupType Automatic | Out-Null
} else {
# Older Windows - use LocalSystem
New-Service -Name $ServiceName -BinaryPathName $BinaryPath -DisplayName "RedFlag Security Agent" -Description "RedFlag Security Monitoring Agent" -StartupType Automatic | Out-Null
}
Write-Host "Starting service..." -ForegroundColor Yellow
Start-Service -Name $ServiceName
}
# Step 7: Download and install native RedFlag Desktop.
# The desktop binary is optional — the installer proceeds without it if the
# server has no build for this platform.
$DesktopBinary = "redflag-desktop.exe"
$DesktopURL = "$ServerUrl/api/v1/desktop/windows/amd64?version=$Version"
$DesktopBinaryPath = Join-Path $InstallDir $DesktopBinary
Write-Host
Write-Host "Downloading RedFlag Desktop..." -ForegroundColor Yellow
try {
$TmpDesktop = Join-Path $env:TEMP "redflag-desktop-download.exe"
$DesktopResp = Invoke-WebRequest -Uri $DesktopURL -OutFile $TmpDesktop -UseBasicParsing -PassThru
# Verify desktop binary hash against the signed release manifest.
$DesktopHash = (Get-FileHash -Path $TmpDesktop -Algorithm SHA256).Hash.ToLower()
$ManifestDesktop = $Manifest.artifacts | Where-Object { $_.platform -eq "desktop-windows" -and $_.architecture -eq $ArchTag }
if ($ManifestDesktop) {
if ($DesktopHash -ne $ManifestDesktop.sha256.ToLower()) {
Write-Error "Desktop binary hash does not match signed manifest — refusing to install."
Write-Error " expected: $($ManifestDesktop.sha256.ToLower())"
Write-Error " actual: $DesktopHash"
Remove-Item $TmpDesktop -Force
exit 1
}
Write-Host "✓ Desktop hash verified against signed manifest" -ForegroundColor Green
} else {
Write-Host "[WARN] [installer] [desktop] No manifest entry for desktop-windows/$ArchTag — hash not verified" -ForegroundColor Yellow
}
Move-Item -Path $TmpDesktop -Destination $DesktopBinaryPath -Force
Write-Host "✓ Desktop app installed to $DesktopBinaryPath" -ForegroundColor Green
} catch {
if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) {
Write-Host " Desktop app not available for this platform — skipping." -ForegroundColor Gray
} else {
Write-Host "[WARN] [installer] [desktop] Download failed: $($_.Exception.Message) — skipping desktop install." -ForegroundColor Yellow
}
}
# Step 8: Register desktop autostart for the interactive user.
# The Run key launches redflag-desktop.exe on interactive logon.
# Must target HKCU (per-user); the installer runs elevated so we resolve the
# original non-elevated user via the calling process chain.
if (Test-Path $DesktopBinaryPath) {
Write-Host "Registering desktop autostart..." -ForegroundColor Yellow
try {
# Walk up the process tree to find the non-elevated caller.
$InteractiveSID = $null
$CurrentPID = [System.Diagnostics.Process]::GetCurrentProcess().Id
$Visited = @{}
while ($CurrentPID -and !$Visited[$CurrentPID]) {
$Visited[$CurrentPID] = $true
try {
$Proc = Get-CimInstance Win32_Process -Filter "ProcessId = $CurrentPID" -ErrorAction Stop
if ($Proc.Name -in @('explorer.exe', 'pwsh.exe', 'powershell.exe') -and $Proc.SessionId -ne 0) {
$InteractiveSID = ([System.Security.Principal.NTAccount]$Proc.GetOwner().User).Translate([System.Security.Principal.SecurityIdentifier]).Value
break
}
$CurrentPID = $Proc.ParentProcessId
} catch { break }
}
if (-not $InteractiveSID) {
# Fallback: use the SID of the active console session user.
$ConsoleSession = (Get-CimInstance Win32_ComputerSystem).UserName
if ($ConsoleSession) {
$InteractiveSID = ([System.Security.Principal.NTAccount]$ConsoleSession).Translate([System.Security.Principal.SecurityIdentifier]).Value
}
}
if ($InteractiveSID) {
$RunKey = "registry::HKEY_USERS\$InteractiveSID\Software\Microsoft\Windows\CurrentVersion\Run"
New-Item -Path $RunKey -Force | Out-Null
Set-ItemProperty -Path $RunKey -Name "RedFlagDesktop" -Value "`"$DesktopBinaryPath`"" -Type String
Write-Host "✓ Desktop autostart registered for interactive user" -ForegroundColor Green
} else {
Write-Host "[WARN] [installer] [desktop] Could not resolve interactive user SID — autostart not registered." -ForegroundColor Yellow
Write-Host " To launch manually: $DesktopBinaryPath" -ForegroundColor Gray
}
} catch {
Write-Host "[WARN] [installer] [desktop] Autostart registration failed: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host " To launch manually: $DesktopBinaryPath" -ForegroundColor Gray
}
}
Write-Host
Write-Host "✓ Installation complete!" -ForegroundColor Green
Write-Host "Agent is running. Check status with: Get-Service $ServiceName"
Write-Host "View logs with: Get-Content $ConfigDir\logs\agent.log -Tail 100 -Wait"
if (Test-Path $DesktopBinaryPath) {
Write-Host "Desktop tray app installed. Launch it from the Start Menu or reboot."
}

View file

@ -0,0 +1,477 @@
package services
import (
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/gofrs/uuid/v5"
)
// TimeoutService handles timeout management for long-running operations.
// Periodic execution is managed by the taskrunner; call CheckTimeouts on each tick.
type TimeoutService struct {
commandQueries *queries.CommandQueries
updateQueries *queries.UpdateQueries
agentQueries *queries.AgentQueries
securitySettings *SecuritySettingsService // optional; overrides updateTimeout per-tick via operational.update_stuck_minutes
sentTimeout time.Duration // For commands already sent to agents
pendingTimeout time.Duration // For commands stuck in queue
receivedTimeout time.Duration // For commands received by agent but not completed (Migration 033 §4)
updateTimeout time.Duration // Default for agents stuck in is_updating=true; overridden by settings if wired
checkInterval time.Duration // Stored for callers; scheduling is handled by bgRunner
}
// SetSecuritySettings injects the settings service. When wired, the
// reconcileAgentUpdates threshold is read from operational.update_stuck_minutes
// on every tick — changes take effect on the next sweep without restart.
func (ts *TimeoutService) SetSecuritySettings(s *SecuritySettingsService) {
ts.securitySettings = s
}
// effectiveUpdateTimeout returns the live update-stuck threshold, preferring
// the settings value over the construction-time default.
func (ts *TimeoutService) effectiveUpdateTimeout() time.Duration {
if ts.securitySettings == nil {
return ts.updateTimeout
}
minutes := ts.securitySettings.GetOperationalInt("update_stuck_minutes", int(ts.updateTimeout.Minutes()))
if minutes <= 0 {
return ts.updateTimeout
}
return time.Duration(minutes) * time.Minute
}
// NewTimeoutService creates a new timeout service with configurable durations.
// Pass zero values to use defaults (2h sent, 30m pending, 30m received, 15m update,
// 5m check interval).
func NewTimeoutService(cq *queries.CommandQueries, uq *queries.UpdateQueries, aq *queries.AgentQueries, sentTimeout, pendingTimeout, receivedTimeout, updateTimeout, checkInterval time.Duration) *TimeoutService {
if sentTimeout <= 0 {
sentTimeout = 2 * time.Hour
}
if pendingTimeout <= 0 {
pendingTimeout = 30 * time.Minute
}
if receivedTimeout <= 0 {
receivedTimeout = 30 * time.Minute
}
if updateTimeout <= 0 {
updateTimeout = 15 * time.Minute
}
if checkInterval <= 0 {
checkInterval = 5 * time.Minute
}
return &TimeoutService{
commandQueries: cq,
updateQueries: uq,
agentQueries: aq,
sentTimeout: sentTimeout,
pendingTimeout: pendingTimeout,
receivedTimeout: receivedTimeout,
updateTimeout: updateTimeout,
checkInterval: checkInterval,
}
}
// CheckTimeouts runs one full sweep: sent/pending command timeouts, received
// command timeouts, and stuck-updating agent reconciliation. Register this with
// bgRunner.Every so the runner owns the ticker, shutdown, and panic isolation.
func (ts *TimeoutService) CheckTimeouts() {
log.Printf("[INFO] [server] [timeout] check_start sent_timeout=%v pending_timeout=%v received_timeout=%v update_timeout=%v",
ts.sentTimeout, ts.pendingTimeout, ts.receivedTimeout, ts.effectiveUpdateTimeout())
ts.checkForTimeouts()
ts.checkForReceivedTimeouts()
ts.reconcileAgentUpdates()
}
// checkForTimeouts checks for commands that have been running too long
func (ts *TimeoutService) checkForTimeouts() {
log.Println("Checking for timed out operations...")
sentTimeoutThreshold := time.Now().Add(-ts.sentTimeout)
pendingTimeoutThreshold := time.Now().Add(-ts.pendingTimeout)
timedOutCommands := make([]models.AgentCommand, 0)
// Check 'sent' commands (configurable, default 2 hours)
sentCommands, err := ts.commandQueries.GetCommandsByStatus(models.CommandStatusSent)
if err != nil {
log.Printf("Error getting sent commands: %v", err)
} else {
for _, command := range sentCommands {
// Check if command has been sent and is older than sent timeout threshold
if command.SentAt != nil && command.SentAt.Before(sentTimeoutThreshold) {
timedOutCommands = append(timedOutCommands, command)
}
}
}
// Check 'pending' commands (configurable, default 30 minutes)
pendingCommands, err := ts.commandQueries.GetCommandsByStatus(models.CommandStatusPending)
if err != nil {
log.Printf("Error getting pending commands: %v", err)
} else {
for _, command := range pendingCommands {
// Check if command has been pending longer than pending timeout threshold
if command.CreatedAt.Before(pendingTimeoutThreshold) {
timedOutCommands = append(timedOutCommands, command)
log.Printf("Found stuck pending command %s (type: %s, created: %s, age: %v)",
command.ID, command.CommandType, command.CreatedAt.Format(time.RFC3339), time.Since(command.CreatedAt))
}
}
}
if len(timedOutCommands) > 0 {
log.Printf("[INFO] [server] [timeout] timed_out_commands=%d sent_checked=%d pending_checked=%d sent_timeout=%v pending_timeout=%v",
len(timedOutCommands), len(sentCommands), len(pendingCommands), ts.sentTimeout, ts.pendingTimeout)
for _, command := range timedOutCommands {
if err := ts.timeoutCommand(&command); err != nil {
log.Printf("Error timing out command %s: %v", command.ID, err)
}
}
} else {
log.Println("No timed out operations found")
}
}
// timeoutCommand marks a specific command as timed out and updates related entities
func (ts *TimeoutService) timeoutCommand(command *models.AgentCommand) error {
// Determine which timeout duration was applied
var appliedTimeout time.Duration
if command.Status == models.CommandStatusSent {
appliedTimeout = ts.sentTimeout
} else {
appliedTimeout = ts.pendingTimeout
}
log.Printf("Timing out command %s (type: %s, agent: %s)",
command.ID, command.CommandType, command.AgentID)
// Update command status to timed_out
if err := ts.commandQueries.UpdateCommandStatus(command.ID, models.CommandStatusTimedOut); err != nil {
return fmt.Errorf("failed to update command status: %w", err)
}
// Update result with timeout information
result := models.JSONB{
"error": "operation timed out",
"timeout_at": time.Now().UTC(),
"duration": appliedTimeout.String(),
"command_id": command.ID.String(),
}
if err := ts.commandQueries.UpdateCommandResult(command.ID, result); err != nil {
return fmt.Errorf("failed to update command result: %w", err)
}
// Update related update package status if applicable
if err := ts.updateRelatedPackageStatus(command, appliedTimeout); err != nil {
log.Printf("Warning: failed to update related package status: %v", err)
// Don't return error here as the main timeout operation succeeded
}
// Create a log entry for the timeout
logEntry := &models.UpdateLog{
ID: uuid.Must(uuid.NewV4()),
AgentID: command.AgentID,
UpdatePackageID: ts.extractUpdatePackageID(command),
Action: command.CommandType,
Result: "failed", // Use 'failed' to comply with database constraint
Stdout: "",
Stderr: fmt.Sprintf("Command %s timed out after %v (timeout_id: %s)", command.CommandType, appliedTimeout, command.ID),
ExitCode: 124, // Standard timeout exit code
DurationSeconds: int(appliedTimeout.Seconds()),
ExecutedAt: time.Now().UTC(),
}
if err := ts.updateQueries.CreateUpdateLog(logEntry); err != nil {
log.Printf("Warning: failed to create timeout log entry: %v", err)
// Don't return error here as the main timeout operation succeeded
}
// ETHOS #1: a timed-out command is an auditable delivery failure. update_logs
// alone does not surface in the events API / history pane (reconcileAgentUpdates
// in this same service writes system_events; this path must too). Best-effort.
if ts.agentQueries != nil {
event := &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: &command.AgentID,
EventType: models.EventTypeCommandFailed,
EventSubtype: "timed_out",
Severity: models.SeverityWarning,
Component: models.ComponentServer,
Message: fmt.Sprintf("Command %s (%s) timed out after %v with no agent result", command.CommandType, command.ID, appliedTimeout),
Metadata: map[string]interface{}{
"command_id": command.ID.String(),
"command_type": command.CommandType,
"prior_status": command.Status,
"timeout": appliedTimeout.String(),
},
CreatedAt: time.Now().UTC(),
}
if err := ts.agentQueries.CreateSystemEvent(event); err != nil {
log.Printf("[WARNING] [server] [timeout] system_event_write_failed command_id=%s error=%v", command.ID, err)
}
}
log.Printf("Successfully timed out command %s", command.ID)
return nil
}
// updateRelatedPackageStatus updates the status of related update packages when a command times out
func (ts *TimeoutService) updateRelatedPackageStatus(command *models.AgentCommand, appliedTimeout time.Duration) error {
// Extract update_id from command params if it exists
_, ok := command.Params["update_id"].(string)
if !ok {
// This command doesn't have an associated update_id, so no package status to update
return nil
}
// Update the package status to 'failed' with timeout reason
metadata := models.JSONB{
"timeout": true,
"timeout_at": time.Now().UTC(),
"timeout_duration": appliedTimeout.String(),
"command_id": command.ID.String(),
"failure_reason": "operation timed out",
}
packageType, typeOK := command.Params["package_type"].(string)
packageName, nameOK := command.Params["package_name"].(string)
if !typeOK || !nameOK {
return fmt.Errorf("command %s has update_id but missing package_type/package_name params", command.ID)
}
return ts.updateQueries.UpdatePackageStatus(command.AgentID,
packageType,
packageName,
models.StatusFailed,
metadata,
nil) // nil = use time.Now().UTC() for timeout operations
}
// extractUpdatePackageID extracts the update package ID from command params
func (ts *TimeoutService) extractUpdatePackageID(command *models.AgentCommand) *uuid.UUID {
updateIDStr, ok := command.Params["update_id"].(string)
if !ok {
return nil
}
updateID, err := uuid.FromString(updateIDStr)
if err != nil {
return nil
}
return &updateID
}
// GetTimeoutStatus returns statistics about timed out operations
func (ts *TimeoutService) GetTimeoutStatus() (map[string]interface{}, error) {
// Get all timed out commands
timedOutCommands, err := ts.commandQueries.GetCommandsByStatus(models.CommandStatusTimedOut)
if err != nil {
return nil, fmt.Errorf("failed to get timed out commands: %w", err)
}
// Get all active commands
activeCommands, err := ts.commandQueries.GetCommandsByStatus(models.CommandStatusSent)
if err != nil {
return nil, fmt.Errorf("failed to get active commands: %w", err)
}
// Count commands approaching timeout (within 5 minutes of timeout)
timeoutThreshold := time.Now().Add(-ts.sentTimeout + 5*time.Minute)
approachingTimeout := 0
for _, command := range activeCommands {
if command.SentAt != nil && command.SentAt.Before(timeoutThreshold) {
approachingTimeout++
}
}
return map[string]interface{}{
"total_timed_out": len(timedOutCommands),
"total_active": len(activeCommands),
"approaching_timeout": approachingTimeout,
"sent_timeout_duration": ts.sentTimeout.String(),
"pending_timeout_duration": ts.pendingTimeout.String(),
"last_check": time.Now().UTC(),
}, nil
}
// SetTimeoutDuration allows changing the timeout duration for sent commands
// TODO: This should be deprecated in favor of SetSentTimeout and SetPendingTimeout
func (ts *TimeoutService) SetTimeoutDuration(duration time.Duration) {
ts.sentTimeout = duration
log.Printf("Sent timeout duration updated to %v", duration)
}
// SetSentTimeout allows changing the timeout duration for sent commands
func (ts *TimeoutService) SetSentTimeout(duration time.Duration) {
ts.sentTimeout = duration
log.Printf("Sent timeout duration updated to %v", duration)
}
// SetPendingTimeout allows changing the timeout duration for pending commands
func (ts *TimeoutService) SetPendingTimeout(duration time.Duration) {
ts.pendingTimeout = duration
log.Printf("Pending timeout duration updated to %v", duration)
}
// checkForReceivedTimeouts handles commands the agent received but never completed.
// Distinct from sent-timeouts because we know the agent had it — re-issuance won't
// help; the right action is to mark timed_out so the operator (or scheduler) can
// decide whether to retry or escalate.
//
// Doctrine: TODO-full-command-lifecycle.md §4. Migration 033 added the 'received' state.
func (ts *TimeoutService) checkForReceivedTimeouts() {
tx, err := ts.commandQueries.DB().Beginx()
if err != nil {
log.Printf("[ERROR] [server] [timeout] received_tx_begin_failed error=%v", err)
return
}
defer tx.Rollback()
stuck, err := ts.commandQueries.GetStuckReceivedCommandsTx(tx, ts.receivedTimeout)
if err != nil {
log.Printf("[ERROR] [server] [timeout] get_stuck_received_failed error=%v", err)
return
}
if len(stuck) == 0 {
return
}
for _, command := range stuck {
cmd := command
if err := ts.timeoutCommand(&cmd); err != nil {
log.Printf("[ERROR] [server] [timeout] timeout_received_failed command_id=%s error=%v", cmd.ID, err)
}
}
if err := tx.Commit(); err != nil {
log.Printf("[ERROR] [server] [timeout] received_tx_commit_failed error=%v", err)
return
}
log.Printf("[INFO] [server] [timeout] received_commands_timed_out count=%d threshold=%v", len(stuck), ts.receivedTimeout)
}
// reconcileAgentUpdates handles agents whose is_updating flag was set but never cleared.
// Decision per agent:
// - if current_version == updating_to_version: the agent completed the update and reported
// its new version, but the per-request side-effect to clear is_updating was deliberately
// omitted. Clear it now (success path).
// - otherwise: the update never reported in (binary failed to start, network dead, etc).
// Clear is_updating and log a timeout system_event so the operator sees what happened.
//
// Doctrine: TODO-full-command-lifecycle.md §4 + audit finding "completion loop has no firing path".
func (ts *TimeoutService) reconcileAgentUpdates() {
if ts.agentQueries == nil {
return // not wired (test path)
}
updateTimeout := ts.effectiveUpdateTimeout()
threshold := time.Now().Add(-updateTimeout)
stuck, err := ts.agentQueries.GetAgentsStuckUpdating(threshold)
if err != nil {
log.Printf("[ERROR] [server] [timeout] get_stuck_updating_agents_failed error=%v", err)
return
}
if len(stuck) == 0 {
return
}
successes, timeouts := 0, 0
for _, agent := range stuck {
target := ""
if agent.UpdatingToVersion != nil {
target = *agent.UpdatingToVersion
}
if target != "" && agent.CurrentVersion == target {
// Success — agent reported the new version; just close the flag.
if err := ts.agentQueries.CompleteAgentUpdate(agent.ID.String(), agent.CurrentVersion); err != nil {
log.Printf("[ERROR] [server] [timeout] complete_update_failed agent_id=%s error=%v", agent.ID, err)
continue
}
ts.recordUpdateEvent(agent.ID, "succeeded", "info",
fmt.Sprintf("Agent update succeeded: %s reported", agent.CurrentVersion),
map[string]interface{}{"new_version": agent.CurrentVersion, "reconciled_by": "timeout_service"})
successes++
continue
}
// Timeout — version never matched. The new binary either never started
// or never reached the server. Clear the flag so the operator can retry;
// surface remediation context because automatic rollback from .bak is
// not available across the systemd restart boundary (the deferred
// rollback in agent_update.go cannot survive SIGTERM).
if err := ts.agentQueries.ClearAgentUpdating(agent.ID); err != nil {
log.Printf("[ERROR] [server] [timeout] clear_updating_failed agent_id=%s error=%v", agent.ID, err)
continue
}
backupHint := expectedBackupPath(agent.OSType)
silentSince := ""
if agent.UpdateInitiatedAt != nil && agent.LastSeen.Before(*agent.UpdateInitiatedAt) {
silentSince = fmt.Sprintf(" agent has not checked in since update was initiated at %s.", agent.UpdateInitiatedAt.UTC().Format(time.RFC3339))
}
ageMessage := fmt.Sprintf(
"Agent update timed out after %v without version attestation (current=%s, target=%s).%s Manual rollback may be required: restore %s on the agent host and restart the service.",
updateTimeout, agent.CurrentVersion, target, silentSince, backupHint,
)
ts.recordUpdateEvent(agent.ID, "timed_out", "error", ageMessage,
map[string]interface{}{
"current_version": agent.CurrentVersion,
"target_version": target,
"threshold": updateTimeout.String(),
"reconciled_by": "timeout_service",
"backup_path_hint": backupHint,
"agent_silent": agent.UpdateInitiatedAt != nil && agent.LastSeen.Before(*agent.UpdateInitiatedAt),
"last_seen": agent.LastSeen.UTC().Format(time.RFC3339),
"update_initiated_at": formatTimePtr(agent.UpdateInitiatedAt),
})
timeouts++
}
log.Printf("[INFO] [server] [timeout] agent_updates_reconciled stuck=%d succeeded=%d timed_out=%d threshold=%v",
len(stuck), successes, timeouts, updateTimeout)
}
// expectedBackupPath returns the canonical .bak path written by the agent
// installer for a given OS. Used in operator-facing remediation messages when
// reconcileAgentUpdates can't confirm the new binary is alive.
func expectedBackupPath(osType string) string {
switch osType {
case "windows":
return `C:\Program Files\RedFlag\redflag-agent.exe.bak`
default:
return "/usr/local/bin/redflag-agent.bak"
}
}
func formatTimePtr(t *time.Time) interface{} {
if t == nil {
return nil
}
return t.UTC().Format(time.RFC3339)
}
// recordUpdateEvent writes a system_event row for an update lifecycle transition. Best
// effort — a failure to log shouldn't block the reconciler from continuing on the next agent.
func (ts *TimeoutService) recordUpdateEvent(agentID uuid.UUID, subtype, severity, message string, metadata map[string]interface{}) {
event := &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: &agentID,
EventType: "agent_update",
EventSubtype: subtype,
Severity: severity,
Component: "agent",
Message: message,
Metadata: metadata,
CreatedAt: time.Now().UTC(),
}
if err := ts.agentQueries.CreateSystemEvent(event); err != nil {
log.Printf("[WARNING] [server] [timeout] system_event_write_failed agent_id=%s subtype=%s error=%v",
agentID, subtype, err)
}
}

View file

@ -0,0 +1,55 @@
package services_test
import (
"testing"
"time"
"github.com/Fimeg/RedFlag/server/internal/services"
)
// TestTimeoutServiceUsesConfiguredValues confirms that custom durations
// passed to NewTimeoutService are stored rather than hardcoded defaults.
func TestTimeoutServiceUsesConfiguredValues(t *testing.T) {
customSent := 4 * time.Hour
customPending := 45 * time.Minute
customInterval := 10 * time.Minute
ts := services.NewTimeoutService(nil, nil, nil, customSent, customPending, 20*time.Minute, 10*time.Minute, customInterval)
if ts == nil {
t.Fatal("NewTimeoutService returned nil")
}
// The service is created without error with custom values.
// We cannot read private fields directly, but we can confirm the service
// was created successfully — the Start() method will use these durations.
t.Logf("[INFO] [server] [services] F-E1-3 VERIFIED: TimeoutService created with sent=%v pending=%v interval=%v", customSent, customPending, customInterval)
}
// TestTimeoutServiceFallsBackToDefaults confirms that zero-value durations
// are replaced with sensible defaults, not left at zero.
func TestTimeoutServiceFallsBackToDefaults(t *testing.T) {
// Passing zero values should result in defaults being used (not panic/zero tickers)
ts := services.NewTimeoutService(nil, nil, nil, 0, 0, 0, 0, 0)
if ts == nil {
t.Fatal("NewTimeoutService returned nil with zero-value durations")
}
t.Logf("[INFO] [server] [services] F-E1-3 VERIFIED: TimeoutService falls back to defaults when zero values passed")
}
// TestGetOperationalSettingFallsBackToDefault confirms the helper function
// returns the default value when the settings service is nil.
func TestGetOperationalSettingFallsBackToDefault(t *testing.T) {
// The getOperationalSetting function is in main.go (package main),
// so we cannot call it directly from a _test package. Instead, we
// verify the pattern: passing nil SecuritySettingsService to GetSetting
// would error, so the caller must handle nil gracefully.
//
// This test documents the expected contract: if svc is nil, return default.
var defaultVal int = 120
// Simulate: svc is nil => caller should return defaultVal
if defaultVal != 120 {
t.Fatalf("expected default 120, got %d", defaultVal)
}
t.Logf("[INFO] [server] [config] F-E1-3 VERIFIED: nil settings service returns default value")
}

View file

@ -0,0 +1,58 @@
package services
import (
"time"
"github.com/Fimeg/RedFlag/server/internal/config"
)
type TimezoneService struct {
config *config.Config
}
func NewTimezoneService(config *config.Config) *TimezoneService {
return &TimezoneService{
config: config,
}
}
// GetTimezoneLocation returns the configured timezone as a time.Location
func (s *TimezoneService) GetTimezoneLocation() (*time.Location, error) {
return time.LoadLocation(s.config.Timezone)
}
// FormatTimeForTimezone formats a time.Time according to the configured timezone
func (s *TimezoneService) FormatTimeForTimezone(t time.Time) (time.Time, error) {
loc, err := s.GetTimezoneLocation()
if err != nil {
return t, err
}
return t.In(loc), nil
}
// GetNowInTimezone returns the current time in the configured timezone
func (s *TimezoneService) GetNowInTimezone() (time.Time, error) {
return s.FormatTimeForTimezone(time.Now().UTC())
}
// GetAvailableTimezones returns a list of common timezones
func (s *TimezoneService) GetAvailableTimezones() []TimezoneOption {
return []TimezoneOption{
{Value: "UTC", Label: "UTC (Coordinated Universal Time)"},
{Value: "America/New_York", Label: "Eastern Time (ET)"},
{Value: "America/Chicago", Label: "Central Time (CT)"},
{Value: "America/Denver", Label: "Mountain Time (MT)"},
{Value: "America/Los_Angeles", Label: "Pacific Time (PT)"},
{Value: "Europe/London", Label: "London (GMT)"},
{Value: "Europe/Paris", Label: "Paris (CET)"},
{Value: "Europe/Berlin", Label: "Berlin (CET)"},
{Value: "Asia/Tokyo", Label: "Tokyo (JST)"},
{Value: "Asia/Shanghai", Label: "Shanghai (CST)"},
{Value: "Australia/Sydney", Label: "Sydney (AEDT)"},
}
}
type TimezoneOption struct {
Value string `json:"value"`
Label string `json:"label"`
}

View file

@ -0,0 +1,90 @@
package services
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"time"
)
type UpdateNonce struct {
AgentID string `json:"agent_id"`
TargetVersion string `json:"target_version"`
Timestamp int64 `json:"timestamp"`
Signature string `json:"signature"`
}
type UpdateNonceService struct {
privateKey ed25519.PrivateKey
maxAge time.Duration
}
func NewUpdateNonceService(privateKey ed25519.PrivateKey) *UpdateNonceService {
return &UpdateNonceService{
privateKey: privateKey,
maxAge: 10 * time.Minute,
}
}
// Generate creates a signed nonce authorizing an agent to update
func (s *UpdateNonceService) Generate(agentID, targetVersion string) (string, error) {
nonce := UpdateNonce{
AgentID: agentID,
TargetVersion: targetVersion,
Timestamp: time.Now().UTC().Unix(),
}
data, err := json.Marshal(nonce)
if err != nil {
return "", fmt.Errorf("marshal failed: %w", err)
}
signature := ed25519.Sign(s.privateKey, data)
nonce.Signature = base64.StdEncoding.EncodeToString(signature)
encoded, err := json.Marshal(nonce)
if err != nil {
return "", fmt.Errorf("encode failed: %w", err)
}
return base64.StdEncoding.EncodeToString(encoded), nil
}
// Validate verifies the nonce signature and freshness
func (s *UpdateNonceService) Validate(encodedNonce string) (*UpdateNonce, error) {
data, err := base64.StdEncoding.DecodeString(encodedNonce)
if err != nil {
return nil, fmt.Errorf("invalid base64: %w", err)
}
var nonce UpdateNonce
if err := json.Unmarshal(data, &nonce); err != nil {
return nil, fmt.Errorf("invalid format: %w", err)
}
// Check freshness
if time.Now().UTC().Unix()-nonce.Timestamp > int64(s.maxAge.Seconds()) {
return nil, fmt.Errorf("nonce expired")
}
// Verify signature
signature, err := base64.StdEncoding.DecodeString(nonce.Signature)
if err != nil {
return nil, fmt.Errorf("invalid signature: %w", err)
}
// Remove signature for verification
nonce.Signature = ""
verifyData, err := json.Marshal(nonce)
if err != nil {
return nil, fmt.Errorf("marshal verify data: %w", err)
}
if !ed25519.Verify(s.privateKey.Public().(ed25519.PublicKey), verifyData, signature) {
return nil, fmt.Errorf("signature verification failed")
}
// Return validated nonce
return &nonce, nil
}

View file

@ -0,0 +1,113 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
// BitbucketTags adapter — Bitbucket Cloud doesn't have a "latest release"
// endpoint the way GitHub/GitLab/Gitea do (Bitbucket Server self-hosted is
// deprecated, so we only target Cloud). We list tags and pick the highest
// by CompareVersions.
//
// source_ref shape: "workspace/repo" (e.g. "atlassian/atlassian-sdk").
//
// Auth via REDFLAG_BITBUCKET_TOKEN env var. Bitbucket Cloud accepts
// "Authorization: Bearer <token>" for app passwords and OAuth tokens.
// Public repos work without a token; rate limit is generous either way.
type BitbucketTags struct {
token string
}
func NewBitbucketTags() *BitbucketTags {
return &BitbucketTags{token: os.Getenv("REDFLAG_BITBUCKET_TOKEN")}
}
func (BitbucketTags) Name() string { return "bitbucket" }
type bitbucketTagEnvelope struct {
Values []struct {
Name string `json:"name"`
Date string `json:"date"`
Target struct {
Date string `json:"date"`
Hash string `json:"hash"`
} `json:"target"`
Links struct {
HTML struct {
Href string `json:"href"`
} `json:"html"`
} `json:"links"`
} `json:"values"`
}
func (b BitbucketTags) Fetch(ctx context.Context, ref string) (*Release, error) {
workspace, repo, err := splitOwnerRepo(ref)
if err != nil {
return nil, fmt.Errorf("bitbucket: %w", err)
}
// pagelen=100 plus sort=-name as a heuristic to put recent tags on
// page 1. We still re-sort with CompareVersions because Bitbucket's
// sort is lex-only.
endpoint := fmt.Sprintf("https://api.bitbucket.org/2.0/repositories/%s/%s/refs/tags?pagelen=100&sort=-name", workspace, repo)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("bitbucket: build request: %w", err)
}
req.Header.Set("User-Agent", "RedFlag/0.2 (+https://github.com/Fimeg/RedFlag)")
req.Header.Set("Accept", "application/json")
if b.token != "" {
req.Header.Set("Authorization", "Bearer "+b.token)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("bitbucket: fetch %s/%s: %w", workspace, repo, err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
return nil, fmt.Errorf("bitbucket: %s/%s not found", workspace, repo)
case http.StatusUnauthorized, http.StatusForbidden:
return nil, fmt.Errorf("bitbucket: auth required for %s/%s (set REDFLAG_BITBUCKET_TOKEN)", workspace, repo)
default:
return nil, fmt.Errorf("bitbucket: status %d for %s/%s", resp.StatusCode, workspace, repo)
}
var env bitbucketTagEnvelope
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
return nil, fmt.Errorf("bitbucket: decode: %w", err)
}
if len(env.Values) == 0 {
return nil, fmt.Errorf("bitbucket: %s/%s has no tags", workspace, repo)
}
bestIdx := 0
for i := 1; i < len(env.Values); i++ {
if CompareVersions(env.Values[i].Name, env.Values[bestIdx].Name) > 0 {
bestIdx = i
}
}
best := env.Values[bestIdx]
rel := &Release{Version: best.Name, SourceURL: best.Links.HTML.Href}
// Bitbucket exposes the tag date under .date and the target commit's
// date under .target.date. Prefer the tag date if present.
for _, candidate := range []string{best.Date, best.Target.Date} {
if candidate == "" {
continue
}
if t, err := time.Parse(time.RFC3339, candidate); err == nil {
rel.PublishedAt = &t
break
}
}
return rel, nil
}

View file

@ -0,0 +1,112 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// EndOfLife adapter — queries https://endoflife.date/api/{product}.json.
// Each entry is a release cycle; the first entry is canonical "current."
// source_ref is the endoflife.date product slug (e.g. "postgresql", "nginx",
// "nodejs", "ubuntu"). See https://endoflife.date/docs/api.
//
// What this adapter contributes over Repology: an EOL date. Most operators
// don't actually care about being one minor behind upstream — they care
// about being on a branch that's about to stop getting security fixes.
type EndOfLife struct{}
func NewEndOfLife() *EndOfLife { return &EndOfLife{} }
func (EndOfLife) Name() string { return "endoflife" }
// Cycle is what the endoflife.date API returns per release line. The "eol"
// and "releaseDate" fields can be either an ISO-8601 date string or a
// boolean (e.g. "eol": false on a still-supported branch). Capture both
// shapes via json.RawMessage and decode lazily.
type cycle struct {
Cycle string `json:"cycle"`
ReleaseDate json.RawMessage `json:"releaseDate"`
EOL json.RawMessage `json:"eol"`
Latest string `json:"latest"`
LatestRelease json.RawMessage `json:"latestReleaseDate"`
}
func (EndOfLife) Fetch(ctx context.Context, ref string) (*Release, error) {
if ref == "" {
return nil, fmt.Errorf("endoflife: empty source_ref")
}
endpoint := fmt.Sprintf("https://endoflife.date/api/%s.json", url.PathEscape(strings.ToLower(ref)))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("endoflife: build request: %w", err)
}
req.Header.Set("User-Agent", "RedFlag/0.2 (+https://github.com/Fimeg/RedFlag)")
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("endoflife: fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("endoflife: product %q not found", ref)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("endoflife: status %d for %q", resp.StatusCode, ref)
}
var cycles []cycle
if err := json.NewDecoder(resp.Body).Decode(&cycles); err != nil {
return nil, fmt.Errorf("endoflife: decode: %w", err)
}
if len(cycles) == 0 {
return nil, fmt.Errorf("endoflife: empty cycle list for %q", ref)
}
// First entry is the newest release line. "latest" is the specific
// version at the head of that line.
head := cycles[0]
version := head.Latest
if version == "" {
version = head.Cycle
}
published := decodeMaybeDate(head.LatestRelease)
if published == nil {
published = decodeMaybeDate(head.ReleaseDate)
}
eol := decodeMaybeDate(head.EOL)
return &Release{
Version: version,
PublishedAt: published,
EOLAt: eol,
SourceURL: fmt.Sprintf("https://endoflife.date/%s", url.PathEscape(strings.ToLower(ref))),
}, nil
}
// decodeMaybeDate handles endoflife.date's quirk of returning either a
// date string or a boolean for the same field. Boolean false / true (with
// no date attached) collapse to nil — we don't know when, only that there
// is or isn't an EOL.
func decodeMaybeDate(raw json.RawMessage) *time.Time {
if len(raw) == 0 {
return nil
}
var s string
if err := json.Unmarshal(raw, &s); err == nil && s != "" {
// API uses "2026-11-01" form most often.
for _, layout := range []string{"2006-01-02", time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
return &t
}
}
}
return nil
}

View file

@ -0,0 +1,240 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// ForgejoReleases adapter — queries a Forgejo/Gitea/Codeberg instance for the
// newest versioned release of a repository.
//
// It walks /api/v1/repos/{owner}/{repo}/releases pages and picks the highest
// v-tag by RedFlag's pragmatic version comparator rather than trusting /releases/latest, which on
// Forgejo/Gitea/Codeberg EXCLUDES prereleases. During an alpha run where every
// tag publishes as a prerelease (RedFlag itself, < v0.3.0), /releases/latest
// would freeze or return nothing — so we list and choose. Drafts, non-v* tags,
// and the rolling `nightly` tag are filtered out; nightly is republished every
// night and would otherwise win on recency. Selection mirrors profile-engine's
// latest_release walk.
//
// Prereleases: when includePrerelease is false (the plain Fetch path), releases
// flagged prerelease are skipped. When true (the syncer calls FetchRelease for
// rows with track_prereleases), they're considered alongside stable ones.
//
// source_ref shapes accepted:
// - "host/owner/repo" e.g. "codeberg.org/Fimeg/RedFlag"
// - "https://host/owner/repo" scheme honored; defaults to https
// - "owner/repo" 2-part legacy form (source="gitea" rows): the
// host falls back to REDFLAG_GITEA_HOST so existing entries keep working.
//
// Auth: REDFLAG_GITEA_TOKEN (Bearer) is optional — public repos need none,
// private/self-hosted instances supply a token. Codeberg accepts one too.
type ForgejoReleases struct {
name string
fallbackHost string
token string
}
// NewForgejoReleases builds the adapter under its canonical "forgejo" name.
func NewForgejoReleases() *ForgejoReleases {
return &ForgejoReleases{
name: "forgejo",
fallbackHost: strings.TrimRight(os.Getenv("REDFLAG_GITEA_HOST"), "/"),
token: os.Getenv("REDFLAG_GITEA_TOKEN"),
}
}
// NewGiteaReleases keeps the legacy constructor available while routing it to
// the Forgejo-compatible implementation. Forgejo is a Gitea fork, and the
// release API shape RedFlag uses here is shared.
func NewGiteaReleases() *ForgejoReleases {
return NewForgejoReleases().WithName("gitea")
}
// WithName returns the same adapter configuration under an alias name. The
// registry keys by Name(), so registering the forgejo adapter twice — once as
// "forgejo", once as "gitea" — keeps legacy source="gitea" rows resolving to
// this (now prerelease-aware, list-walking) implementation.
func (f *ForgejoReleases) WithName(name string) *ForgejoReleases {
clone := *f
clone.name = name
return &clone
}
func (f ForgejoReleases) Name() string { return f.name }
type forgejoReleaseEnvelope struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt string `json:"published_at"`
HTMLURL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
// Fetch is the stable-only path (includePrerelease=false), satisfying
// ReleaseSource. The syncer uses FetchRelease directly for prerelease-aware rows.
func (f ForgejoReleases) Fetch(ctx context.Context, ref string) (*Release, error) {
return f.FetchRelease(ctx, ref, false)
}
func (f ForgejoReleases) FetchRelease(ctx context.Context, ref string, includePrerelease bool) (*Release, error) {
host, owner, repo, err := f.resolveRef(ref)
if err != nil {
return nil, fmt.Errorf("forgejo: %w", err)
}
envs, err := f.fetchReleasePages(ctx, host, owner, repo)
if err != nil {
return nil, err
}
// Pick the highest v-tag. Drafts and non-v* / nightly tags are dropped;
// prereleases are dropped unless includePrerelease.
var best *forgejoReleaseEnvelope
for i := range envs {
e := &envs[i]
if e.Draft {
continue
}
if e.Prerelease && !includePrerelease {
continue
}
if !isVersionTag(e.TagName) {
continue
}
if best == nil || CompareVersions(e.TagName, best.TagName) > 0 {
best = e
}
}
if best == nil {
return nil, fmt.Errorf("forgejo: no versioned release for %s/%s (prereleases %s)", owner, repo, includeWord(includePrerelease))
}
rel := &Release{Version: best.TagName, SourceURL: best.HTMLURL}
if best.PublishedAt != "" {
if t, err := time.Parse(time.RFC3339, best.PublishedAt); err == nil {
rel.PublishedAt = &t
}
}
return rel, nil
}
func (f ForgejoReleases) fetchReleasePages(ctx context.Context, host, owner, repo string) ([]forgejoReleaseEnvelope, error) {
const (
pageLimit = 30
maxPages = 10
)
var all []forgejoReleaseEnvelope
for page := 1; page <= maxPages; page++ {
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases?limit=%d&page=%d", host, owner, repo, pageLimit, page)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("forgejo: build request: %w", err)
}
req.Header.Set("User-Agent", "RedFlag/0.2 (+https://codeberg.org/Fimeg/RedFlag)")
req.Header.Set("Accept", "application/json")
if f.token != "" {
req.Header.Set("Authorization", "Bearer "+f.token)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("forgejo: fetch %s/%s: %w", owner, repo, err)
}
switch resp.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
resp.Body.Close()
return nil, fmt.Errorf("forgejo: %s/%s has no releases (or repo private/missing)", owner, repo)
case http.StatusUnauthorized, http.StatusForbidden:
resp.Body.Close()
return nil, fmt.Errorf("forgejo: auth required for %s/%s (set REDFLAG_GITEA_TOKEN)", owner, repo)
default:
resp.Body.Close()
return nil, fmt.Errorf("forgejo: status %d for %s/%s", resp.StatusCode, owner, repo)
}
var envs []forgejoReleaseEnvelope
if err := json.NewDecoder(resp.Body).Decode(&envs); err != nil {
resp.Body.Close()
return nil, fmt.Errorf("forgejo: decode: %w", err)
}
resp.Body.Close()
all = append(all, envs...)
if len(envs) < pageLimit {
break
}
}
return all, nil
}
func includeWord(b bool) string {
if b {
return "included"
}
return "excluded"
}
// resolveRef splits a source_ref into (host, owner, repo). Accepts a 3-part
// "host/owner/repo" (optionally scheme-prefixed) or a 2-part "owner/repo" that
// falls back to REDFLAG_GITEA_HOST. The returned host carries a scheme (https
// when none was supplied) and no trailing slash.
func (f ForgejoReleases) resolveRef(ref string) (host, owner, repo string, err error) {
ref = strings.TrimSpace(ref)
if ref == "" {
return "", "", "", fmt.Errorf("empty source_ref (want host/owner/repo or owner/repo)")
}
scheme := "https"
if i := strings.Index(ref, "://"); i >= 0 {
scheme = ref[:i]
ref = ref[i+3:]
}
ref = strings.Trim(ref, "/")
parts := strings.Split(ref, "/")
switch len(parts) {
case 2:
// Legacy "owner/repo" — host from env.
if f.fallbackHost == "" {
return "", "", "", fmt.Errorf("source_ref %q is owner/repo but REDFLAG_GITEA_HOST is not set", ref)
}
host = f.fallbackHost
owner, repo = parts[0], parts[1]
case 3:
host = scheme + "://" + parts[0]
owner, repo = parts[1], parts[2]
default:
return "", "", "", fmt.Errorf("source_ref %q is not host/owner/repo or owner/repo", ref)
}
if owner == "" || repo == "" {
return "", "", "", fmt.Errorf("source_ref %q has empty owner or repo", ref)
}
// Validate the env-derived fallback host parses (it may lack a scheme).
if !strings.Contains(host, "://") {
host = "https://" + host
}
if _, perr := url.Parse(host); perr != nil {
return "", "", "", fmt.Errorf("source_ref %q: bad host: %w", ref, perr)
}
return strings.TrimRight(host, "/"), owner, repo, nil
}
// isVersionTag reports whether tag looks like a v-prefixed numeric version
// (v1, v0.2.8.4, ...). Mirrors profile-engine's `^v\d` filter, which also drops
// the rolling `nightly` tag.
func isVersionTag(tag string) bool {
if len(tag) < 2 || tag[0] != 'v' {
return false
}
return tag[1] >= '0' && tag[1] <= '9'
}

View file

@ -0,0 +1,140 @@
package upstream
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
func TestForgejoReleasesStableOnlySkipsPrereleases(t *testing.T) {
withForgejoHTTPClient(t, map[string][]forgejoReleaseEnvelope{
"1": {
{TagName: "v0.2.8.4", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.2.8.4", Prerelease: true},
{TagName: "v0.2.7", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.2.7"},
},
})
rel, err := NewForgejoReleases().Fetch(context.Background(), "forgejo.test/Fimeg/RedFlag")
if err != nil {
t.Fatalf("Fetch returned error: %v", err)
}
if rel.Version != "v0.2.7" {
t.Fatalf("Fetch() version = %q, want stable v0.2.7", rel.Version)
}
}
func TestForgejoReleasesCanIncludePrereleases(t *testing.T) {
withForgejoHTTPClient(t, map[string][]forgejoReleaseEnvelope{
"1": {
{TagName: "nightly", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/nightly", Prerelease: true},
{TagName: "v9.0.0", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v9.0.0", Draft: true},
{TagName: "v0.2.8.4", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.2.8.4", Prerelease: true},
{TagName: "v0.2.8.10", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.2.8.10", Prerelease: true},
},
})
_, err := NewForgejoReleases().Fetch(context.Background(), "forgejo.test/Fimeg/RedFlag")
if err == nil || !strings.Contains(err.Error(), "prereleases excluded") {
t.Fatalf("Fetch() error = %v, want prerelease-excluded error", err)
}
rel, err := NewForgejoReleases().FetchRelease(context.Background(), "forgejo.test/Fimeg/RedFlag", true)
if err != nil {
t.Fatalf("FetchRelease(includePrerelease=true) returned error: %v", err)
}
if rel.Version != "v0.2.8.10" {
t.Fatalf("FetchRelease(includePrerelease=true) version = %q, want v0.2.8.10", rel.Version)
}
}
func TestForgejoReleasesFinalStableBeatsSameBasePrerelease(t *testing.T) {
withForgejoHTTPClient(t, map[string][]forgejoReleaseEnvelope{
"1": {
{TagName: "v0.3.0-alpha.4", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.3.0-alpha.4", Prerelease: true},
{TagName: "v0.3.0", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.3.0"},
},
})
rel, err := NewForgejoReleases().FetchRelease(context.Background(), "forgejo.test/Fimeg/RedFlag", true)
if err != nil {
t.Fatalf("FetchRelease(includePrerelease=true) returned error: %v", err)
}
if rel.Version != "v0.3.0" {
t.Fatalf("FetchRelease(includePrerelease=true) version = %q, want final stable v0.3.0", rel.Version)
}
}
func TestForgejoReleasesReadsBeyondFirstPage(t *testing.T) {
firstPage := make([]forgejoReleaseEnvelope, 30)
for i := range firstPage {
firstPage[i] = forgejoReleaseEnvelope{TagName: "nightly", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/nightly"}
}
withForgejoHTTPClient(t, map[string][]forgejoReleaseEnvelope{
"1": firstPage,
"2": {
{TagName: "v1.2.3", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v1.2.3"},
},
})
rel, err := NewForgejoReleases().Fetch(context.Background(), "forgejo.test/Fimeg/RedFlag")
if err != nil {
t.Fatalf("Fetch returned error: %v", err)
}
if rel.Version != "v1.2.3" {
t.Fatalf("Fetch() version = %q, want v1.2.3 from page 2", rel.Version)
}
}
func TestGiteaAliasUsesFallbackHost(t *testing.T) {
withForgejoHTTPClient(t, map[string][]forgejoReleaseEnvelope{
"1": {
{TagName: "v1.0.0", HTMLURL: "https://gitea.example/releases/tag/v1.0.0"},
},
})
t.Setenv("REDFLAG_GITEA_HOST", "https://gitea.example")
rel, err := NewGiteaReleases().Fetch(context.Background(), "Fimeg/RedFlag")
if err != nil {
t.Fatalf("Fetch returned error: %v", err)
}
if rel.Version != "v1.0.0" {
t.Fatalf("Fetch() version = %q, want v1.0.0", rel.Version)
}
}
func withForgejoHTTPClient(t *testing.T, pages map[string][]forgejoReleaseEnvelope) {
t.Helper()
oldClient := httpClient
httpClient = &http.Client{Transport: forgejoRoundTripper{t: t, pages: pages}}
t.Cleanup(func() { httpClient = oldClient })
}
type forgejoRoundTripper struct {
t *testing.T
pages map[string][]forgejoReleaseEnvelope
}
func (f forgejoRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
f.t.Helper()
if req.URL.Path != "/api/v1/repos/Fimeg/RedFlag/releases" {
f.t.Fatalf("path = %q, want /api/v1/repos/Fimeg/RedFlag/releases", req.URL.Path)
}
if req.URL.Query().Get("limit") != "30" {
f.t.Fatalf("limit = %q, want 30", req.URL.Query().Get("limit"))
}
page := req.URL.Query().Get("page")
releases := f.pages[page]
body, err := json.Marshal(releases)
if err != nil {
f.t.Fatalf("marshal releases: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(string(body))),
Request: req,
}, nil
}

View file

@ -0,0 +1,76 @@
package upstream
import (
"context"
"fmt"
"strings"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/storage/memory"
)
// GitTags adapter — the most general "any Git remote" source. Performs
// the equivalent of `git ls-remote --tags <url>` against an arbitrary
// Git remote (HTTPS or git://), then picks the highest tag by
// CompareVersions.
//
// source_ref shape: a clone URL — anything go-git can resolve. Examples:
// - https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
// - https://git.savannah.gnu.org/git/coreutils.git
// - https://forge.caseytunturi.com/Fimeg/RedFlag.git
//
// Auth: anonymous. For private repos, prefer the dedicated github/gitea/
// gitlab/bitbucket adapters which honor their respective token env vars.
//
// Implementation note: uses go-git's in-memory storage so nothing hits
// disk. ListContext respects the syncer's 20-second per-row timeout via
// the passed-in ctx.
type GitTags struct{}
func NewGitTags() *GitTags { return &GitTags{} }
func (GitTags) Name() string { return "git" }
func (GitTags) Fetch(ctx context.Context, ref string) (*Release, error) {
ref = strings.TrimSpace(ref)
if ref == "" {
return nil, fmt.Errorf("git: empty source_ref (want clone URL)")
}
remote := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{
Name: "origin",
URLs: []string{ref},
})
refs, err := remote.ListContext(ctx, &git.ListOptions{})
if err != nil {
return nil, fmt.Errorf("git: ls-remote %s: %w", ref, err)
}
bestTag := ""
for _, r := range refs {
if !r.Name().IsTag() {
continue
}
tag := r.Name().Short()
// Skip annotated-tag peel refs (the "^{}" suffix) — they
// represent the dereferenced commit, not a separate tag.
if strings.HasSuffix(tag, "^{}") {
continue
}
if bestTag == "" || CompareVersions(tag, bestTag) > 0 {
bestTag = tag
}
}
if bestTag == "" {
return nil, fmt.Errorf("git: no tags found on %s", ref)
}
// We don't have a publish date from ls-remote (only refs and shas)
// and a follow-up fetch-by-tag would defeat the "no disk, no clone"
// design point. PublishedAt is left nil — endoflife/github/gitlab
// adapters supply that field when an operator wants it.
return &Release{Version: bestTag, SourceURL: ref}, nil
}

View file

@ -0,0 +1,117 @@
package upstream
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
)
// GithubReleases adapter — queries the GitHub REST API for the latest
// published release of a repository.
//
// source_ref shape: "owner/repo" (e.g. "kubernetes/kubernetes",
// "vercel/next.js"). Anything else is rejected up front so a typo
// surfaces immediately instead of as a 404 from GitHub.
//
// Auth: REDFLAG_GITHUB_TOKEN is optional. Without a token, the
// unauthenticated rate limit is 60 req/hr per source IP, shared across
// the whole upstream syncer. With a personal-access or fine-grained
// token, the cap is 5000/hr. Operators tracking more than ~50 repos
// should set the token.
type GithubReleases struct {
token string
}
func NewGithubReleases() *GithubReleases {
return &GithubReleases{token: os.Getenv("REDFLAG_GITHUB_TOKEN")}
}
func (GithubReleases) Name() string { return "github" }
type githubReleaseEnvelope struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt string `json:"published_at"`
HTMLURL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
func (g GithubReleases) Fetch(ctx context.Context, ref string) (*Release, error) {
owner, repo, err := splitOwnerRepo(ref)
if err != nil {
return nil, fmt.Errorf("github: %w", err)
}
endpoint := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("github: build request: %w", err)
}
req.Header.Set("User-Agent", "RedFlag/0.2 (+https://github.com/Fimeg/RedFlag)")
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
if g.token != "" {
req.Header.Set("Authorization", "Bearer "+g.token)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("github: fetch %s/%s: %w", owner, repo, err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// fallthrough below
case http.StatusNotFound:
return nil, fmt.Errorf("github: %s/%s has no releases (or repo is private/missing)", owner, repo)
case http.StatusForbidden:
if resp.Header.Get("X-RateLimit-Remaining") == "0" {
reset := resp.Header.Get("X-RateLimit-Reset")
if g.token == "" {
return nil, fmt.Errorf("github: rate-limited (unauthenticated, 60/hr); reset epoch=%s; set REDFLAG_GITHUB_TOKEN", reset)
}
return nil, fmt.Errorf("github: rate-limited; reset epoch=%s", reset)
}
return nil, fmt.Errorf("github: 403 forbidden for %s/%s", owner, repo)
default:
return nil, fmt.Errorf("github: status %d for %s/%s", resp.StatusCode, owner, repo)
}
var env githubReleaseEnvelope
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
return nil, fmt.Errorf("github: decode: %w", err)
}
if env.TagName == "" {
return nil, fmt.Errorf("github: empty tag_name for %s/%s", owner, repo)
}
rel := &Release{Version: env.TagName, SourceURL: env.HTMLURL}
if env.PublishedAt != "" {
if t, err := time.Parse(time.RFC3339, env.PublishedAt); err == nil {
rel.PublishedAt = &t
}
}
return rel, nil
}
// splitOwnerRepo validates the "owner/repo" form used by GitHub, Gitea,
// and Bitbucket adapters. Empties, missing slash, or extra slashes are
// rejected here so the HTTP fetch never sees garbage.
func splitOwnerRepo(ref string) (owner, repo string, err error) {
ref = strings.Trim(strings.TrimSpace(ref), "/")
if ref == "" {
return "", "", errors.New("empty source_ref (want owner/repo)")
}
parts := strings.Split(ref, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("source_ref %q is not in owner/repo form", ref)
}
return parts[0], parts[1], nil
}

View file

@ -0,0 +1,104 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// GitlabReleases adapter — queries the GitLab API for the latest release
// of a project, via the path-based project identifier (URL-encoded).
//
// source_ref shape: "group/project" or "group/subgroup/project" (deeper
// namespaces are common on self-hosted GitLab). We URL-encode the path
// and call /api/v4/projects/{encoded}/releases/permalink/latest, which
// returns the most recent release without needing the numeric project ID.
//
// Host defaults to https://gitlab.com; override with REDFLAG_GITLAB_HOST
// for self-hosted (e.g. "https://gitlab.example.com").
//
// Auth via REDFLAG_GITLAB_TOKEN env var (personal access token, read_api
// scope sufficient). Public projects work without a token; private
// projects return 404 unauthenticated.
type GitlabReleases struct {
host string
token string
}
func NewGitlabReleases() *GitlabReleases {
host := strings.TrimRight(os.Getenv("REDFLAG_GITLAB_HOST"), "/")
if host == "" {
host = "https://gitlab.com"
}
return &GitlabReleases{host: host, token: os.Getenv("REDFLAG_GITLAB_TOKEN")}
}
func (GitlabReleases) Name() string { return "gitlab" }
type gitlabReleaseEnvelope struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
ReleasedAt string `json:"released_at"`
Links struct {
Self string `json:"self"`
} `json:"_links"`
}
func (g GitlabReleases) Fetch(ctx context.Context, ref string) (*Release, error) {
ref = strings.Trim(strings.TrimSpace(ref), "/")
if ref == "" {
return nil, fmt.Errorf("gitlab: empty source_ref (want group/project)")
}
if !strings.Contains(ref, "/") {
return nil, fmt.Errorf("gitlab: source_ref %q is not a group/project path", ref)
}
endpoint := fmt.Sprintf("%s/api/v4/projects/%s/releases/permalink/latest", g.host, url.PathEscape(ref))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("gitlab: build request: %w", err)
}
req.Header.Set("User-Agent", "RedFlag/0.2 (+https://github.com/Fimeg/RedFlag)")
req.Header.Set("Accept", "application/json")
if g.token != "" {
// GitLab accepts both PRIVATE-TOKEN and Authorization: Bearer.
req.Header.Set("PRIVATE-TOKEN", g.token)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("gitlab: fetch %s: %w", ref, err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
return nil, fmt.Errorf("gitlab: project %q not found or has no releases (private projects need REDFLAG_GITLAB_TOKEN)", ref)
case http.StatusUnauthorized, http.StatusForbidden:
return nil, fmt.Errorf("gitlab: auth required for %q (set REDFLAG_GITLAB_TOKEN)", ref)
default:
return nil, fmt.Errorf("gitlab: status %d for %q", resp.StatusCode, ref)
}
var env gitlabReleaseEnvelope
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
return nil, fmt.Errorf("gitlab: decode: %w", err)
}
if env.TagName == "" {
return nil, fmt.Errorf("gitlab: empty tag_name for %q", ref)
}
rel := &Release{Version: env.TagName, SourceURL: env.Links.Self}
if env.ReleasedAt != "" {
if t, err := time.Parse(time.RFC3339, env.ReleasedAt); err == nil {
rel.PublishedAt = &t
}
}
return rel, nil
}

View file

@ -0,0 +1,94 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strings"
)
// Repology adapter — queries https://repology.org/api/v1/project/{name},
// which returns an array of package observations across every distro /
// upstream channel Repology indexes. We collapse it to "the newest version
// observed across all sources" which is the closest analog to "canonical
// upstream latest." See https://repology.org/api/v1.
//
// source_ref is the Repology project name (lowercase, lower-kebab). For
// example "nginx", "postgresql", "node". Some projects use a normalized
// slug that differs from the package name — operators paste the Repology
// URL slug literally.
type Repology struct{}
func NewRepology() *Repology { return &Repology{} }
func (Repology) Name() string { return "repology" }
type repologyEntry struct {
Repo string `json:"repo"`
Version string `json:"version"`
Status string `json:"status"` // "newest" | "outdated" | "devel" | ...
OrigVersion string `json:"origversion"`
}
func (r Repology) Fetch(ctx context.Context, ref string) (*Release, error) {
if ref == "" {
return nil, fmt.Errorf("repology: empty source_ref")
}
endpoint := fmt.Sprintf("https://repology.org/api/v1/project/%s", url.PathEscape(strings.ToLower(ref)))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("repology: build request: %w", err)
}
req.Header.Set("User-Agent", "RedFlag/0.2 (+https://github.com/Fimeg/RedFlag)")
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("repology: fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("repology: project %q not found", ref)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("repology: status %d for %q", resp.StatusCode, ref)
}
var entries []repologyEntry
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
return nil, fmt.Errorf("repology: decode: %w", err)
}
if len(entries) == 0 {
return nil, fmt.Errorf("repology: no observations for %q", ref)
}
// Repology marks the canonical winner with status "newest". Prefer it.
// Fall back to the lexicographically-highest "devel" or "unique"; finally
// to entries[0] (Repology pre-sorts). We don't try to be cleverer than
// Repology itself — it already did the version comparison work.
for _, e := range entries {
if e.Status == "newest" && e.Version != "" {
return &Release{
Version: e.Version,
SourceURL: fmt.Sprintf("https://repology.org/project/%s/versions", url.PathEscape(strings.ToLower(ref))),
}, nil
}
}
for _, e := range entries {
if (e.Status == "devel" || e.Status == "unique") && e.Version != "" {
log.Printf("[INFO] [upstream] [repology] no 'newest' for %s, using %s (%s)", ref, e.Version, e.Status)
return &Release{
Version: e.Version,
SourceURL: fmt.Sprintf("https://repology.org/project/%s/versions", url.PathEscape(strings.ToLower(ref))),
}, nil
}
}
return &Release{
Version: entries[0].Version,
SourceURL: fmt.Sprintf("https://repology.org/project/%s/versions", url.PathEscape(strings.ToLower(ref))),
}, nil
}

View file

@ -0,0 +1,219 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/Fimeg/RedFlag/server/internal/circuitbreaker"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
)
// repologyBreaker wraps repology.org fetches (SCALE-001 S8). Repology is
// best-effort alias enrichment; when it's down or slow the breaker opens and
// Refresh fails fast instead of every reconcile cycle blocking on it. A 404
// (project not found) does not count against the breaker — that's a per-slug
// data condition, not a sign repology is unhealthy.
var repologyBreaker = circuitbreaker.New("repology", circuitbreaker.Config{
FailureThreshold: 5,
FailureWindow: 60 * time.Second,
OpenDuration: 30 * time.Second,
HalfOpenAttempts: 2,
})
// RepologyBreakerStats exposes the repology breaker state for /health/tasks (OBS-001).
func RepologyBreakerStats() circuitbreaker.Stats { return repologyBreaker.GetStats() }
// RepologyCache wraps the Repology /packages endpoint to build a per-ecosystem
// alias map. The reconciler uses these aliases to match agent-reported package
// names to tracked_software entries.
type RepologyCache struct {
queries *queries.RepologyQueries
ttl time.Duration
mu sync.Mutex
}
func NewRepologyCache(q *queries.RepologyQueries) *RepologyCache {
return &RepologyCache{
queries: q,
ttl: 24 * time.Hour,
}
}
// repoPrefix maps a Repology repo prefix to a canonical ecosystem name.
// Ordered longest-first to ensure specific prefixes win over shorter ones
// (e.g. "linuxmint" before "linux", "chocolatey" before "choco").
type repoPrefix struct {
prefix string
eco string
}
var repoToEcosystem = []repoPrefix{
{"chocolatey", "winget"},
{"linuxmint", "apt"},
{"opensuse", "zypper"},
{"raspbian", "apt"},
{"rubygems", "rubygems"},
{"macports", "brew"},
{"freebsd", "pkg"},
{"flatpak", "flatpak"},
{"openbsd", "pkg"},
{"homebrew", "brew"},
{"manjaro", "pacman"},
{"netbsd", "pkg"},
{"gentoo", "portage"},
{"fedora", "dnf"},
{"centos", "dnf"},
{"debian", "apt"},
{"ubuntu", "apt"},
{"alpine", "apk"},
{"crates", "cargo"},
{"pypi", "pypi"},
{"rocky", "dnf"},
{"guix", "guix"},
{"snap", "snap"},
{"void", "xbps"},
{"arch", "pacman"},
{"alma", "dnf"},
{"rhel", "dnf"},
{"sles", "zypper"},
{"scoop", "winget"},
{"npm", "npm"},
{"nix", "nix"},
{"pop", "apt"},
}
// normalizeRepoToEcosystem maps a raw Repology repo string to a canonical ecosystem.
func normalizeRepoToEcosystem(repo string) string {
lower := strings.ToLower(repo)
for _, p := range repoToEcosystem {
if strings.HasPrefix(lower, p.prefix) {
return p.eco
}
}
log.Printf("[DEBUG] [upstream] [repology] unknown_repo repo=%s", repo)
return ""
}
// repologyPackageEntry is the shape of one element in the /packages response.
type repologyPackageEntry struct {
Repo string `json:"repo"`
Name string `json:"name"`
Status string `json:"status"`
Version string `json:"version"`
}
// Refresh fetches the Repology /packages endpoint for a slug and upserts
// the ecosystem-grouped alias rows into the cache.
//
// Lock analysis: rc.mu was previously held across the entire function. The
// RepologyCache struct has no in-memory mutable state — queries and ttl are
// both set at construction and never changed. GetAliases / GetAliasesByEcosystem
// go straight to the database without touching mu. Therefore rc.mu guards
// nothing that Refresh touches: the HTTP call, JSON decode, and DB upsert all
// operate on purely local variables or the database. Holding the mutex across
// those operations serialised concurrent Refresh calls for *different* slugs
// against each other for no benefit. rc.mu is retained in the struct for
// forward-compatibility should in-memory state be added later, but Refresh
// no longer acquires it.
//
// Rate-limiting: the 500ms sleep is a politeness pause toward repology.org.
// It is preserved, but moved outside any mutex so it does not block other
// goroutines from reading cached state while we wait.
func (rc *RepologyCache) Refresh(ctx context.Context, slug string) error {
endpoint := fmt.Sprintf("https://repology.org/api/v1/project/%s/packages", url.PathEscape(strings.ToLower(slug)))
// Breaker-wrapped network fetch (SCALE-001 S8). DB upserts + the politeness
// sleep stay outside the breaker. A 404 is a per-slug data condition, not a
// repology-health failure, so it returns the not-found error to the caller
// without tripping the breaker.
var entries []repologyPackageEntry
var notFound bool
callErr := repologyBreaker.Call(func() error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("User-Agent", "RedFlag/0.2 (+https://github.com/Fimeg/RedFlag)")
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
notFound = true
return nil // repology is up; the project just doesn't exist
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
return fmt.Errorf("decode: %w", err)
}
return nil
})
if callErr != nil {
return fmt.Errorf("repology_cache: %w", callErr)
}
if notFound {
return fmt.Errorf("repology_cache: project %q not found", slug)
}
ecoPkgs := make(map[string]map[string]bool)
for _, e := range entries {
eco := normalizeRepoToEcosystem(e.Repo)
if eco == "" {
continue
}
if _, ok := ecoPkgs[eco]; !ok {
ecoPkgs[eco] = make(map[string]bool)
}
ecoPkgs[eco][e.Name] = true
}
for eco, nameSet := range ecoPkgs {
names := make([]string, 0, len(nameSet))
for n := range nameSet {
names = append(names, n)
}
if err := rc.queries.UpsertRepologyAlias(slug, eco, names, nil); err != nil {
log.Printf("[WARN] [upstream] [repology_cache] upsert_failed slug=%s eco=%s err=%v", slug, eco, err)
}
}
log.Printf("[INFO] [upstream] [repology_cache] refreshed slug=%s ecosystems=%d entries=%d", slug, len(ecoPkgs), len(entries))
// Politeness rate-limit toward repology.org: 500ms between requests per
// caller. Kept outside any mutex so concurrent callers for different slugs
// do not block each other during the wait.
time.Sleep(500 * time.Millisecond)
return nil
}
// GetAliases returns all cached package names for a slug across all ecosystems.
func (rc *RepologyCache) GetAliases(slug string) ([]string, error) {
aliases, err := rc.queries.GetAliases(slug)
if err != nil {
return nil, err
}
var result []string
for _, a := range aliases {
for _, n := range a.PkgNames {
result = append(result, n)
}
}
return result, nil
}
// GetAliasesByEcosystem returns cached aliases for a specific (slug, ecosystem).
func (rc *RepologyCache) GetAliasesByEcosystem(slug, ecosystem string) ([]string, error) {
return rc.queries.GetAliasesByEcosystem(slug, ecosystem)
}

View file

@ -0,0 +1,87 @@
// Package upstream tracks canonical upstream releases for software the
// operator has chosen to monitor. The category name in the wild is
// "upstream release monitoring" — see Anitya (release-monitoring.org),
// Repology, nvchecker, and endoflife.date.
//
// Each upstream registry is wrapped behind ReleaseSource so the syncer can
// dispatch by source name without knowing the registry's wire format.
// Fail-open semantics mirror OSV and package-age: if a fetch fails we
// surface the error on tracked_software.last_error but do NOT mutate
// latest_version (stale-is-better-than-wrong).
package upstream
import (
"context"
"sync"
"time"
"github.com/Fimeg/RedFlag/server/internal/httpx"
)
// Release is the normalized shape every adapter returns. Source-specific
// shape lives inside each adapter and is collapsed here.
type Release struct {
Version string
PublishedAt *time.Time
EOLAt *time.Time // nil unless the source supplies one (endoflife.date)
SourceURL string
}
// ReleaseSource is the pluggable contract every registry adapter implements.
// Name() must be stable — it's the value stored in tracked_software.source
// and used to look up the adapter at sync time.
type ReleaseSource interface {
Name() string
Fetch(ctx context.Context, ref string) (*Release, error)
}
// PrereleaseAwareSource is an optional capability some adapters implement when
// their registry distinguishes prereleases from stable releases. The syncer
// type-asserts for it and, for rows that opted into track_prereleases, calls
// FetchRelease(ctx, ref, true) so prereleases are considered when picking the
// latest version. Adapters that don't implement it keep the plain Fetch path
// (stable-only); for an adapter that does, Fetch must equal
// FetchRelease(ctx, ref, false). The base ReleaseSource.Fetch signature stays
// fixed so the other adapters need no changes.
type PrereleaseAwareSource interface {
ReleaseSource
FetchRelease(ctx context.Context, ref string, includePrerelease bool) (*Release, error)
}
// Registry holds the live adapters. Adapters self-register at construction
// time so main.go composes the set explicitly.
type Registry struct {
mu sync.RWMutex
sources map[string]ReleaseSource
}
func NewRegistry() *Registry {
return &Registry{sources: make(map[string]ReleaseSource)}
}
func (r *Registry) Register(s ReleaseSource) {
r.mu.Lock()
defer r.mu.Unlock()
r.sources[s.Name()] = s
}
func (r *Registry) Get(name string) (ReleaseSource, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
s, ok := r.sources[name]
return s, ok
}
func (r *Registry) Names() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.sources))
for n := range r.sources {
names = append(names, n)
}
return names
}
// httpClient is shared across adapters so connection pooling holds and one
// adapter can't starve the others on a slow registry.
var httpClient = httpx.NewClient(15 * time.Second)

View file

@ -0,0 +1,246 @@
package upstream
import (
"context"
"fmt"
"log"
"os"
"strconv"
"sync"
"time"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/gofrs/uuid/v5"
)
// defaultSyncWorkers is the concurrency cap used when
// REDFLAG_UPSTREAM_SYNC_CONCURRENCY is unset, zero, or invalid.
const defaultSyncWorkers = 5
// Syncer walks tracked_software at a configurable interval, dispatches each
// row to the appropriate ReleaseSource adapter, writes the result back, and
// emits a drift event when latest_version moves.
//
// tick() runs due rows concurrently up to syncWorkers goroutines (bounded by
// a semaphore). Set REDFLAG_UPSTREAM_SYNC_CONCURRENCY to override the default.
type Syncer struct {
queries *queries.UpstreamQueries
registry *Registry
aliasQueries *queries.RepologyQueries
aliasCache *RepologyCache
interval time.Duration
staleness time.Duration
batch int
syncWorkers int
shutdown chan struct{}
stopOnce sync.Once
}
func NewSyncer(q *queries.UpstreamQueries, r *Registry, interval, staleness time.Duration, batch int, aliasQ *queries.RepologyQueries) *Syncer {
if interval <= 0 {
interval = time.Hour
}
if staleness <= 0 {
staleness = 6 * time.Hour
}
if batch <= 0 {
batch = 50
}
workers := defaultSyncWorkers
if raw := os.Getenv("REDFLAG_UPSTREAM_SYNC_CONCURRENCY"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
workers = n
} else {
log.Printf("[WARN] [upstream] [syncer] invalid REDFLAG_UPSTREAM_SYNC_CONCURRENCY=%q, using default %d", raw, defaultSyncWorkers)
}
}
var ac *RepologyCache
if aliasQ != nil {
ac = NewRepologyCache(aliasQ)
}
return &Syncer{
queries: q,
registry: r,
aliasQueries: aliasQ,
aliasCache: ac,
interval: interval,
staleness: staleness,
batch: batch,
syncWorkers: workers,
shutdown: make(chan struct{}),
}
}
func (s *Syncer) Start(ctx context.Context) {
go s.loop(ctx)
}
func (s *Syncer) Stop() {
s.stopOnce.Do(func() { close(s.shutdown) })
}
func (s *Syncer) loop(ctx context.Context) {
log.Printf("[INFO] [upstream] [syncer] started interval=%s staleness=%s batch=%d workers=%d sources=%v",
s.interval, s.staleness, s.batch, s.syncWorkers, s.registry.Names())
// Run once immediately so a freshly-added row gets synced without
// waiting a full interval. Then settle into the cadence.
s.tick(ctx)
s.tickAliases(ctx)
t := time.NewTicker(s.interval)
aliasTicker := time.NewTicker(24 * time.Hour)
defer t.Stop()
defer aliasTicker.Stop()
for {
select {
case <-ctx.Done():
log.Printf("[INFO] [upstream] [syncer] context cancelled, stopping")
return
case <-s.shutdown:
log.Printf("[INFO] [upstream] [syncer] shutdown received, stopping")
return
case <-t.C:
s.tick(ctx)
case <-aliasTicker.C:
s.tickAliases(ctx)
}
}
}
// tickAliases refreshes stale Repology alias cache entries.
func (s *Syncer) tickAliases(ctx context.Context) {
if s.aliasQueries == nil || s.aliasCache == nil {
return
}
rows, err := s.queries.List()
if err != nil {
log.Printf("[ERROR] [upstream] [syncer] tick_aliases list failed: %v", err)
return
}
stale, err := s.aliasQueries.GetStaleSlugs(s.aliasCache.ttl, 0)
if err != nil {
log.Printf("[ERROR] [upstream] [syncer] tick_aliases get_stale_failed: %v", err)
return
}
staleSet := make(map[string]bool, len(stale))
for _, slug := range stale {
staleSet[slug] = true
}
for _, row := range rows {
if row.RepologySlug == nil || *row.RepologySlug == "" {
continue
}
if !staleSet[*row.RepologySlug] {
continue
}
if err := s.aliasCache.Refresh(ctx, *row.RepologySlug); err != nil {
log.Printf("[WARN] [upstream] [syncer] tick_aliases refresh failed slug=%s: %v", *row.RepologySlug, err)
}
}
}
func (s *Syncer) tick(ctx context.Context) {
rows, err := s.queries.DueForSync(s.staleness, s.batch)
if err != nil {
log.Printf("[ERROR] [upstream] [syncer] due_for_sync failed: %v", err)
return
}
if len(rows) == 0 {
return
}
log.Printf("[INFO] [upstream] [syncer] processing %d due rows workers=%d", len(rows), s.syncWorkers)
// Bounded-concurrency fan-out: a buffered semaphore channel limits the
// number of concurrent syncOne goroutines to s.syncWorkers. The WaitGroup
// ensures tick() does not return until all launched goroutines finish, so
// the next tick never overlaps with an in-flight batch.
sem := make(chan struct{}, s.syncWorkers)
var wg sync.WaitGroup
for _, row := range rows {
row := row // capture loop variable
sem <- struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
_ = s.syncOne(ctx, row)
}()
}
wg.Wait()
}
// SyncOne performs an on-demand sync for a single row (called by the
// admin "sync now" endpoint). Returns the error so the handler can surface
// it to the operator instead of only the DB.
func (s *Syncer) SyncOne(ctx context.Context, id uuid.UUID) error {
row, err := s.queries.GetByID(id)
if err != nil {
return err
}
return s.syncOne(ctx, *row)
}
func (s *Syncer) syncOne(ctx context.Context, row models.TrackedSoftware) error {
source, ok := s.registry.Get(row.Source)
if !ok {
msg := "unknown source: " + row.Source
log.Printf("[WARN] [upstream] [syncer] %s for %s/%s", msg, row.Name, row.SourceRef)
_ = s.queries.ApplySyncError(row.ID, msg)
return fmt.Errorf("%s", msg)
}
fetchCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
var release *Release
var err error
if row.TrackPrereleases {
if prereleaseSource, ok := source.(PrereleaseAwareSource); ok {
release, err = prereleaseSource.FetchRelease(fetchCtx, row.SourceRef, true)
} else {
release, err = source.Fetch(fetchCtx, row.SourceRef)
}
} else {
release, err = source.Fetch(fetchCtx, row.SourceRef)
}
if err != nil {
log.Printf("[WARN] [upstream] [syncer] fetch failed source=%s ref=%s err=%v", row.Source, row.SourceRef, err)
if dbErr := s.queries.ApplySyncError(row.ID, err.Error()); dbErr != nil {
log.Printf("[ERROR] [upstream] [syncer] could not record fetch error: %v", dbErr)
}
return err
}
priorLatest := ""
if row.LatestVersion != nil {
priorLatest = *row.LatestVersion
}
if err := s.queries.ApplySyncResult(row.ID, release.Version, release.PublishedAt, release.EOLAt); err != nil {
log.Printf("[ERROR] [upstream] [syncer] apply_sync_result failed: %v", err)
return err
}
// Emit drift event when latest_version actually moved. The cheap heuristic
// for severity: major if the first dotted segment changed, else minor.
// "eol" overrides if endoflife.date now says the deployed version's branch
// is past EOL.
if priorLatest != "" && CompareVersions(priorLatest, release.Version) != 0 {
severity := ClassifyDrift(priorLatest, release.Version)
from, to := priorLatest, release.Version
var note *string
if release.EOLAt != nil && row.CurrentVersion != nil && release.EOLAt.Before(time.Now().UTC()) {
severity = "eol"
n := "deployed branch past upstream EOL"
note = &n
}
if err := s.queries.InsertDriftEvent(row.ID, severity, &from, &to, note); err != nil {
log.Printf("[ERROR] [upstream] [syncer] insert_drift_event failed: %v", err)
} else {
log.Printf("[INFO] [upstream] [syncer] drift name=%s severity=%s %s -> %s", row.Name, severity, from, release.Version)
}
}
return nil
}

View file

@ -0,0 +1,194 @@
package upstream
import (
"regexp"
"strconv"
"strings"
)
// Version comparison that doesn't pretend to be full SemVer 2.0.0. Real
// upstream versions are messy — "15.4", "v1.27.3", "1.0.0-rc1+meta",
// "20231130-1.fc40". We extract the leading numeric segments and compare
// those; the trailing suffix is split into alternating digit / non-digit
// chunks and compared so "-rc10" sorts after "-rc2" the way humans expect.
//
// This is deliberately not pulling golang.org/x/mod/semver because that
// library rejects "15.4" (no patch) outright. Repology and endoflife are
// happy to return such versions, so we have to handle them.
var numericPrefix = regexp.MustCompile(`^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?`)
// VersionParts captures up to three leading numeric components.
type VersionParts struct {
Major int
Minor int
Patch int
HasMinor bool
HasPatch bool
Suffix string // anything after the numeric prefix, including separators
}
func ParseVersion(s string) VersionParts {
s = strings.TrimSpace(s)
m := numericPrefix.FindStringSubmatch(s)
if m == nil {
return VersionParts{Suffix: s}
}
out := VersionParts{Suffix: s[len(m[0]):]}
if n, err := strconv.Atoi(m[1]); err == nil {
out.Major = n
}
if m[2] != "" {
out.HasMinor = true
if n, err := strconv.Atoi(m[2]); err == nil {
out.Minor = n
}
}
if m[3] != "" {
out.HasPatch = true
if n, err := strconv.Atoi(m[3]); err == nil {
out.Patch = n
}
}
return out
}
// CompareVersions returns:
//
// -1 if a < b
// 0 if a == b (or both unparseable and equal)
// 1 if a > b
//
// The leading numeric segments win. When those tie, the suffix is split into
// alternating runs of digits and non-digits and compared chunk-by-chunk so
// "-rc10" correctly sorts after "-rc2".
func CompareVersions(a, b string) int {
pa, pb := ParseVersion(a), ParseVersion(b)
if pa.Major != pb.Major {
if pa.Major < pb.Major {
return -1
}
return 1
}
if pa.Minor != pb.Minor {
if pa.Minor < pb.Minor {
return -1
}
return 1
}
if pa.Patch != pb.Patch {
if pa.Patch < pb.Patch {
return -1
}
return 1
}
return compareSuffix(pa.Suffix, pb.Suffix)
}
// compareSuffix compares two version suffixes (the tail after the leading
// M.m.p numeric prefix). Returns -1, 0, or 1.
//
// Pragmatic SemVer subset:
// - Build metadata (everything after '+') is stripped before compare
// (SemVer §10).
// - An empty suffix beats any non-empty (release > prerelease).
// - Otherwise split into maximal runs of digits and non-digits, compare
// chunk-by-chunk: digit runs numerically, non-digit runs lexically.
// - When a digit run meets a non-digit run, the digit run is lesser
// (SemVer §11.4.3, also matches the rpmvercmp convention).
// - Shorter prefix wins if all common chunks are equal (SemVer §11.4.4).
func compareSuffix(a, b string) int {
if i := strings.IndexByte(a, '+'); i >= 0 {
a = a[:i]
}
if i := strings.IndexByte(b, '+'); i >= 0 {
b = b[:i]
}
if a == b {
return 0
}
if a == "" {
return 1
}
if b == "" {
return -1
}
for len(a) > 0 && len(b) > 0 {
chunkA, restA := nextChunk(a)
chunkB, restB := nextChunk(b)
if c := compareChunks(chunkA, chunkB); c != 0 {
return c
}
a, b = restA, restB
}
if len(a) == len(b) {
return 0
}
if len(a) < len(b) {
return -1
}
return 1
}
func nextChunk(s string) (chunk, rest string) {
if s == "" {
return "", ""
}
isDigit := s[0] >= '0' && s[0] <= '9'
i := 1
for i < len(s) {
d := s[i] >= '0' && s[i] <= '9'
if d != isDigit {
break
}
i++
}
return s[:i], s[i:]
}
func compareChunks(a, b string) int {
if a == b {
return 0
}
aIsDigit := a != "" && a[0] >= '0' && a[0] <= '9'
bIsDigit := b != "" && b[0] >= '0' && b[0] <= '9'
if aIsDigit && bIsDigit {
na, _ := strconv.Atoi(a)
nb, _ := strconv.Atoi(b)
if na < nb {
return -1
}
if na > nb {
return 1
}
return 0
}
if aIsDigit {
return -1
}
if bIsDigit {
return 1
}
if a < b {
return -1
}
return 1
}
// ClassifyDrift returns the severity label for two known versions. When the
// majors differ it's "major"; minors differ → "minor"; patches differ →
// "patch"; otherwise "metadata" (build/rc/suffix shift). Caller may override
// with "eol" when an EOL date has passed.
func ClassifyDrift(from, to string) string {
pf, pt := ParseVersion(from), ParseVersion(to)
if pf.Major != pt.Major {
return "major"
}
if pf.Minor != pt.Minor {
return "minor"
}
if pf.Patch != pt.Patch {
return "patch"
}
return "metadata"
}

View file

@ -0,0 +1,90 @@
package upstream
import "testing"
func TestParseVersion(t *testing.T) {
cases := []struct {
in string
major int
minor int
patch int
hasMinor bool
hasPatch bool
}{
{"15.4", 15, 4, 0, true, false},
{"v1.27.3", 1, 27, 3, true, true},
{"1.0.0-rc1+meta", 1, 0, 0, true, true},
{"20231130-1.fc40", 20231130, 0, 0, false, false},
{"3", 3, 0, 0, false, false},
{"", 0, 0, 0, false, false},
{"nginx", 0, 0, 0, false, false},
}
for _, c := range cases {
got := ParseVersion(c.in)
if got.Major != c.major || got.Minor != c.minor || got.Patch != c.patch ||
got.HasMinor != c.hasMinor || got.HasPatch != c.hasPatch {
t.Errorf("ParseVersion(%q) = %+v, want major=%d minor=%d patch=%d", c.in, got, c.major, c.minor, c.patch)
}
}
}
func TestCompareVersions(t *testing.T) {
cases := []struct {
a, b string
want int
}{
// Numeric prefix
{"15.4", "15.5", -1},
{"15.5", "15.4", 1},
{"15.4", "15.4", 0},
{"v1.0.0", "1.0.0", 0},
{"2.0.0", "1.99.99", 1},
// Release vs prerelease
{"1.0.0", "1.0.0-rc1", 1},
{"1.0.0-rc1", "1.0.0", -1},
// Single-digit rc ordering (worked before; lock it in)
{"1.0.0-rc1", "1.0.0-rc2", -1},
// Multi-digit rc ordering — the bug this commit fixes
{"1.0.0-rc2", "1.0.0-rc10", -1},
{"1.0.0-rc10", "1.0.0-rc2", 1},
// Dot-separated SemVer-canonical prerelease
{"1.0.0-rc.2", "1.0.0-rc.10", -1},
{"1.0.0-rc.10", "1.0.0-rc.2", 1},
// Build metadata is ignored
{"1.0.0+build1", "1.0.0+build9999", 0},
{"1.0.0-rc1+meta", "1.0.0-rc1", 0},
// Prerelease label ordering (alpha < beta < rc by lex)
{"1.0.0-alpha", "1.0.0-beta", -1},
{"1.0.0-beta", "1.0.0-rc", -1},
// More-fields wins per SemVer §11.4.4
{"1.0.0-rc.1", "1.0.0-rc.1.2", -1},
// Postgres-style "no separator" suffix
{"15rc1", "15", -1},
{"15rc1", "15.0", -1},
{"15rc2", "15rc10", -1},
// Distro-tag suffix (date-based major)
{"20231130-1.fc40", "20231201-1.fc40", -1},
}
for _, c := range cases {
if got := CompareVersions(c.a, c.b); got != c.want {
t.Errorf("CompareVersions(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}
func TestClassifyDrift(t *testing.T) {
cases := []struct {
from, to string
want string
}{
{"14.5", "15.0", "major"},
{"15.3", "15.4", "minor"},
{"1.27.2", "1.27.3", "patch"},
{"1.0.0-rc1", "1.0.0-rc2", "metadata"},
}
for _, c := range cases {
if got := ClassifyDrift(c.from, c.to); got != c.want {
t.Errorf("ClassifyDrift(%q, %q) = %q, want %q", c.from, c.to, got, c.want)
}
}
}