Complete RedFlag codebase with two major security audit implementations.
== A-1: Ed25519 Key Rotation Support ==
Server:
- SignCommand sets SignedAt timestamp and KeyID on every signature
- signing_keys database table (migration 020) for multi-key rotation
- InitializePrimaryKey registers active key at startup
- /api/v1/public-keys endpoint for rotation-aware agents
- SigningKeyQueries for key lifecycle management
Agent:
- Key-ID-aware verification via CheckKeyRotation
- FetchAndCacheAllActiveKeys for rotation pre-caching
- Cache metadata with TTL and staleness fallback
- SecurityLogger events for key rotation and command signing
== A-2: Replay Attack Fixes (F-1 through F-7) ==
F-5 CRITICAL - RetryCommand now signs via signAndCreateCommand
F-1 HIGH - v3 format: "{agent_id}:{cmd_id}:{type}:{hash}:{ts}"
F-7 HIGH - Migration 026: expires_at column with partial index
F-6 HIGH - GetPendingCommands/GetStuckCommands filter by expires_at
F-2 HIGH - Agent-side executedIDs dedup map with cleanup
F-4 HIGH - commandMaxAge reduced from 24h to 4h
F-3 CRITICAL - Old-format commands rejected after 48h via CreatedAt
Verification fixes: migration idempotency (ETHOS #4), log format
compliance (ETHOS #1), stale comments updated.
All 24 tests passing. Docker --no-cache build verified.
See docs/ for full audit reports and deviation log (DEV-001 to DEV-019).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
382 lines
10 KiB
Go
382 lines
10 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
)
|
|
|
|
// AgentBuilder handles generating embedded agent configurations
|
|
// Deprecated: Configuration logic should use services.ConfigService
|
|
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: Delegate config generation to services.ConfigService
|
|
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(),
|
|
}, 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
|
|
} |