feat: bump to v0.1.23 with security metrics and UI improvements

- Bump agent and server versions to 0.1.23
- Implement security metrics collection (bound agents, command processing, version compliance)
- Add dismiss button for timed out commands in agent status
- Add config sync endpoint for server->agent configuration updates
- Add ignored updates workflow in AgentUpdatesEnhanced (approve/reject workflow)
- Swap AgentScanners layout (subsystems top, security bottom)
- Replace placeholder security data with database metrics
- Add backpressure detection based on pending command ratios
This commit is contained in:
Fimeg
2025-11-04 09:41:27 -05:00
parent 38894f64d3
commit 95f70bd9bb
12 changed files with 511 additions and 244 deletions

View File

@@ -1150,7 +1150,7 @@ func (h *AgentHandler) GetAgentConfig(c *gin.Context) {
}
// Verify agent exists
agent, err := h.agentQueries.GetAgentByID(agentID)
_, err = h.agentQueries.GetAgentByID(agentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return

View File

@@ -194,17 +194,24 @@ func (h *DockerReportsHandler) GetAgentDockerInfo(c *gin.Context) {
dockerInfo := make([]models.DockerImageInfo, 0, len(result.Images))
for _, image := range result.Images {
info := models.DockerImageInfo{
Name: image.PackageName,
Tag: extractTag(image.PackageName),
ImageID: image.CurrentVersion,
Size: parseImageSize(image.Metadata),
CreatedAt: image.CreatedAt,
Registry: image.RepositorySource,
HasUpdate: image.AvailableVersion != image.CurrentVersion,
LatestImageID: image.AvailableVersion,
Severity: image.Severity,
Labels: extractLabels(image.Metadata),
LastScanned: image.CreatedAt,
ID: image.ID.String(),
AgentID: image.AgentID.String(),
ImageName: extractName(image.PackageName),
ImageTag: extractTag(image.PackageName),
ImageID: image.CurrentVersion,
RepositorySource: image.RepositorySource,
SizeBytes: parseImageSize(image.Metadata),
CreatedAt: image.CreatedAt.Format(time.RFC3339),
HasUpdate: image.AvailableVersion != image.CurrentVersion,
LatestImageID: image.AvailableVersion,
Severity: image.Severity,
Labels: extractLabels(image.Metadata),
Metadata: convertInterfaceMapToJSONB(image.Metadata),
PackageType: image.PackageType,
CurrentVersion: image.CurrentVersion,
AvailableVersion: image.AvailableVersion,
EventType: image.EventType,
CreatedAtTime: image.CreatedAt,
}
dockerInfo = append(dockerInfo, info)
}
@@ -217,6 +224,16 @@ func (h *DockerReportsHandler) GetAgentDockerInfo(c *gin.Context) {
})
}
// Helper function to extract name from image name
func extractName(imageName string) string {
// Simple implementation - split by ":" and return everything except last part
parts := strings.Split(imageName, ":")
if len(parts) > 1 {
return strings.Join(parts[:len(parts)-1], ":")
}
return imageName
}
// Helper function to extract tag from image name
func extractTag(imageName string) string {
// Simple implementation - split by ":" and return last part
@@ -285,4 +302,14 @@ func convertToJSONB(data map[string]interface{}) models.JSONB {
result[k] = v
}
return models.JSONB(result)
}
}
// Helper function to convert map[string]interface{} to models.JSONB
func convertInterfaceMapToJSONB(data models.JSONB) models.JSONB {
result := make(map[string]interface{})
for k, v := range data {
result[k] = v
}
return models.JSONB(result)
}

View File

@@ -2,7 +2,6 @@ package handlers
import (
"fmt"
"log"
"net/http"
"strconv"
"time"
@@ -74,7 +73,7 @@ func (h *MetricsHandler) ReportMetrics(c *gin.Context) {
AvailableVersion: item.AvailableVersion,
Severity: item.Severity,
RepositorySource: item.RepositorySource,
Metadata: models.JSONB(item.Metadata),
Metadata: convertStringMapToJSONB(item.Metadata),
EventType: "discovered",
CreatedAt: req.Timestamp,
}
@@ -123,6 +122,8 @@ func (h *MetricsHandler) GetAgentMetrics(c *gin.Context) {
pageSize = 50
}
offset := (page - 1) * pageSize
packageType := c.Query("package_type")
severity := c.Query("severity")
@@ -132,7 +133,7 @@ func (h *MetricsHandler) GetAgentMetrics(c *gin.Context) {
PackageType: nil,
Severity: nil,
Limit: &pageSize,
Offset: &((page - 1) * pageSize),
Offset: &offset,
}
if packageType != "" {
@@ -286,4 +287,13 @@ func aggregateSystemMetrics(metrics []models.StoredMetric) *models.SystemMetrics
LoadAverage: []float64{0, 0, 0},
LastUpdated: metrics[0].CreatedAt,
}
}
// Helper function to convert map[string]string to models.JSONB
func convertStringMapToJSONB(data map[string]string) models.JSONB {
result := make(map[string]interface{})
for k, v := range data {
result[k] = v
}
return models.JSONB(result)
}

View File

@@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"net/http"
"time"
@@ -112,21 +113,38 @@ func (h *SecurityHandler) CommandValidationStatus(c *gin.Context) {
},
}
// Get command metrics
// Get real command metrics
if h.commandQueries != nil {
// TODO: Add methods to CommandQueries for aggregate metrics
// For now, we can provide basic status
response["metrics"].(map[string]interface{})["total_pending_commands"] = "N/A"
response["metrics"].(map[string]interface{})["agents_with_pending"] = "N/A"
if totalPending, err := h.commandQueries.GetTotalPendingCommands(); err == nil {
response["metrics"].(map[string]interface{})["total_pending_commands"] = totalPending
}
if agentsWithPending, err := h.commandQueries.GetAgentsWithPendingCommands(); err == nil {
response["metrics"].(map[string]interface{})["agents_with_pending"] = agentsWithPending
}
if commandsLastHour, err := h.commandQueries.GetCommandsInTimeRange(1); err == nil {
response["metrics"].(map[string]interface{})["commands_last_hour"] = commandsLastHour
}
if commandsLast24h, err := h.commandQueries.GetCommandsInTimeRange(24); err == nil {
response["metrics"].(map[string]interface{})["commands_last_24h"] = commandsLast24h
}
}
// Get agent metrics for responsiveness
if h.agentQueries != nil {
// TODO: Add method to count online vs offline agents
// This would help identify if agents are responsive to commands
if activeAgents, err := h.agentQueries.GetActiveAgentCount(); err == nil {
response["checks"].(map[string]interface{})["agent_responsive"] = fmt.Sprintf("%d online", activeAgents)
}
}
response["status"] = "healthy"
// Determine if backpressure is active (5+ pending commands per agent threshold)
if totalPending, ok := response["metrics"].(map[string]interface{})["total_pending_commands"].(int); ok {
if agentsWithPending, ok := response["metrics"].(map[string]interface{})["agents_with_pending"].(int); ok && agentsWithPending > 0 {
avgPerAgent := float64(totalPending) / float64(agentsWithPending)
response["checks"].(map[string]interface{})["backpressure_active"] = avgPerAgent >= 5.0
}
}
response["status"] = "operational"
response["checks"].(map[string]interface{})["command_processing"] = "operational"
c.JSON(http.StatusOK, response)
@@ -143,6 +161,8 @@ func (h *SecurityHandler) MachineBindingStatus(c *gin.Context) {
"min_agent_version": "v0.1.22",
"fingerprint_required": true,
"recent_violations": 0,
"bound_agents": 0,
"version_compliance": 0,
},
"details": map[string]interface{}{
"enforcement_method": "hardware_fingerprint",
@@ -151,8 +171,30 @@ func (h *SecurityHandler) MachineBindingStatus(c *gin.Context) {
},
}
// TODO: Add metrics for machine binding violations
// This would require logging when machine binding middleware rejects requests
// Get real machine binding metrics
if h.agentQueries != nil {
// Get total agents with machine binding
if boundAgents, err := h.agentQueries.GetAgentsWithMachineBinding(); err == nil {
response["checks"].(map[string]interface{})["bound_agents"] = boundAgents
}
// Get total agents for comparison
if totalAgents, err := h.agentQueries.GetTotalAgentCount(); err == nil {
// Calculate version compliance (agents meeting minimum version requirement)
if compliantAgents, err := h.agentQueries.GetAgentCountByVersion("0.1.22"); err == nil {
response["checks"].(map[string]interface{})["version_compliance"] = compliantAgents
}
// Set recent violations based on version compliance gap
boundAgents := response["checks"].(map[string]interface{})["bound_agents"].(int)
versionCompliance := response["checks"].(map[string]interface{})["version_compliance"].(int)
violations := boundAgents - versionCompliance
if violations < 0 {
violations = 0
}
response["checks"].(map[string]interface{})["recent_violations"] = violations
}
}
response["status"] = "enforced"
response["checks"].(map[string]interface{})["binding_enforced"] = true
@@ -192,6 +234,14 @@ func (h *SecurityHandler) SecurityOverview(c *gin.Context) {
// Check Ed25519 signing
if h.signingService != nil && h.signingService.GetPublicKey() != "" {
overview["subsystems"].(map[string]interface{})["ed25519_signing"].(map[string]interface{})["status"] = "healthy"
// Add Ed25519 details
overview["subsystems"].(map[string]interface{})["ed25519_signing"].(map[string]interface{})["checks"] = map[string]interface{}{
"service_initialized": true,
"public_key_available": true,
"signing_operational": true,
"public_key_fingerprint": h.signingService.GetPublicKeyFingerprint(),
"algorithm": "ed25519",
}
} else {
overview["subsystems"].(map[string]interface{})["ed25519_signing"].(map[string]interface{})["status"] = "unavailable"
overview["alerts"] = append(overview["alerts"].([]string), "Ed25519 signing service not configured")
@@ -200,12 +250,74 @@ func (h *SecurityHandler) SecurityOverview(c *gin.Context) {
// Check nonce validation
overview["subsystems"].(map[string]interface{})["nonce_validation"].(map[string]interface{})["status"] = "healthy"
overview["subsystems"].(map[string]interface{})["nonce_validation"].(map[string]interface{})["checks"] = map[string]interface{}{
"validation_enabled": true,
"max_age_minutes": 5,
"validation_failures": 0, // TODO: Implement nonce validation failure tracking
}
overview["subsystems"].(map[string]interface{})["nonce_validation"].(map[string]interface{})["details"] = map[string]interface{}{
"nonce_format": "UUID:UnixTimestamp",
"signature_algorithm": "ed25519",
"replay_protection": "active",
}
// Check machine binding
overview["subsystems"].(map[string]interface{})["machine_binding"].(map[string]interface{})["status"] = "enforced"
// Get real machine binding metrics
if h.agentQueries != nil {
boundAgents, _ := h.agentQueries.GetAgentsWithMachineBinding()
compliantAgents, _ := h.agentQueries.GetAgentCountByVersion("0.1.22")
violations := boundAgents - compliantAgents
if violations < 0 {
violations = 0
}
// Check command validation
overview["subsystems"].(map[string]interface{})["command_validation"].(map[string]interface{})["status"] = "operational"
overview["subsystems"].(map[string]interface{})["machine_binding"].(map[string]interface{})["status"] = "enforced"
overview["subsystems"].(map[string]interface{})["machine_binding"].(map[string]interface{})["checks"] = map[string]interface{}{
"binding_enforced": true,
"min_agent_version": "v0.1.22",
"recent_violations": violations,
"bound_agents": boundAgents,
"version_compliance": compliantAgents,
}
overview["subsystems"].(map[string]interface{})["machine_binding"].(map[string]interface{})["details"] = map[string]interface{}{
"enforcement_method": "hardware_fingerprint",
"binding_scope": "machine_id + cpu + memory + system_uuid",
"violation_action": "command_rejection",
}
}
// Get real command validation metrics
if h.commandQueries != nil {
totalPending, _ := h.commandQueries.GetTotalPendingCommands()
agentsWithPending, _ := h.commandQueries.GetAgentsWithPendingCommands()
commandsLastHour, _ := h.commandQueries.GetCommandsInTimeRange(1)
commandsLast24h, _ := h.commandQueries.GetCommandsInTimeRange(24)
// Calculate backpressure
backpressureActive := false
if agentsWithPending > 0 {
avgPerAgent := float64(totalPending) / float64(agentsWithPending)
backpressureActive = avgPerAgent >= 5.0
}
overview["subsystems"].(map[string]interface{})["command_validation"].(map[string]interface{})["status"] = "operational"
overview["subsystems"].(map[string]interface{})["command_validation"].(map[string]interface{})["metrics"] = map[string]interface{}{
"total_pending_commands": totalPending,
"agents_with_pending": agentsWithPending,
"commands_last_hour": commandsLastHour,
"commands_last_24h": commandsLast24h,
}
overview["subsystems"].(map[string]interface{})["command_validation"].(map[string]interface{})["checks"] = map[string]interface{}{
"command_processing": "operational",
"backpressure_active": backpressureActive,
}
// Add agent responsiveness info
if h.agentQueries != nil {
if activeAgents, err := h.agentQueries.GetActiveAgentCount(); err == nil {
overview["subsystems"].(map[string]interface{})["command_validation"].(map[string]interface{})["checks"].(map[string]interface{})["agent_responsive"] = fmt.Sprintf("%d online", activeAgents)
}
}
}
// Determine overall status
healthyCount := 0

View File

@@ -86,7 +86,7 @@ func Load() (*Config, error) {
cfg.CheckInInterval = checkInInterval
cfg.OfflineThreshold = offlineThreshold
cfg.Timezone = getEnv("TIMEZONE", "UTC")
cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", "0.1.22")
cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", "0.1.23")
cfg.MinAgentVersion = getEnv("MIN_AGENT_VERSION", "0.1.22")
cfg.SigningPrivateKey = getEnv("REDFLAG_SIGNING_PRIVATE_KEY", "")

View File

@@ -226,6 +226,30 @@ func (q *AgentQueries) GetActiveAgentCount() (int, error) {
return count, err
}
// GetTotalAgentCount returns the total count of registered agents
func (q *AgentQueries) GetTotalAgentCount() (int, error) {
var count int
query := `SELECT COUNT(*) FROM agents`
err := q.db.Get(&count, query)
return count, err
}
// GetAgentCountByVersion returns the count of agents by version (for version compliance)
func (q *AgentQueries) GetAgentCountByVersion(minVersion string) (int, error) {
var count int
query := `SELECT COUNT(*) FROM agents WHERE current_version >= $1`
err := q.db.Get(&count, query, minVersion)
return count, err
}
// GetAgentsWithMachineBinding returns count of agents that have machine IDs set
func (q *AgentQueries) GetAgentsWithMachineBinding() (int, error) {
var count int
query := `SELECT COUNT(*) FROM agents WHERE machine_id IS NOT NULL AND machine_id != ''`
err := q.db.Get(&count, query)
return count, err
}
// UpdateAgentRebootStatus updates the reboot status for an agent
func (q *AgentQueries) UpdateAgentRebootStatus(id uuid.UUID, required bool, reason string) error {
query := `

View File

@@ -370,6 +370,38 @@ func (q *CommandQueries) CountPendingCommandsForAgent(agentID uuid.UUID) (int, e
return count, err
}
// GetTotalPendingCommands returns total pending commands across all agents
func (q *CommandQueries) GetTotalPendingCommands() (int, error) {
var count int
query := `SELECT COUNT(*) FROM agent_commands WHERE status = 'pending'`
err := q.db.Get(&count, query)
return count, err
}
// GetAgentsWithPendingCommands returns count of agents with pending commands
func (q *CommandQueries) GetAgentsWithPendingCommands() (int, error) {
var count int
query := `
SELECT COUNT(DISTINCT agent_id)
FROM agent_commands
WHERE status = 'pending'
`
err := q.db.Get(&count, query)
return count, err
}
// GetCommandsInTimeRange returns count of commands processed in a time range
func (q *CommandQueries) GetCommandsInTimeRange(hours int) (int, error) {
var count int
query := `
SELECT COUNT(*)
FROM agent_commands
WHERE created_at >= $1 AND status IN ('completed', 'failed', 'timed_out')
`
err := q.db.Get(&count, query, time.Now().Add(-time.Duration(hours)*time.Hour))
return count, err
}
// VerifyCommandsCompleted checks which command IDs from the provided list have been completed or failed
// Returns the list of command IDs that have been successfully recorded (completed or failed status)
func (q *CommandQueries) VerifyCommandsCompleted(commandIDs []string) ([]string, error) {