Watch
1
0
Fork
You've already forked RedFlag
0

feat: zero-sudo agent, helper self-upgrade, OSV expansion, docker handler rewrite, staging UI

Agent privilege reduction:
- apt discovery unprivileged (sandbox opts like dnf)
- docker commands use group membership, no sudo
- agent self-upgrade delegated to helper via agent-self capability token
- sudoers template stripped to single systemd-run helper line

Helper (Rust):
- agent-self package type: stage, hash-verify, backup, install, chmod, restart
- TOCTOU-safe: copy-then-hash, never hash a path re-read later
- --no-block restart so helper finishes before agent SIGTERM

Server:
- mintAgentSelfToken signs agent-self tokens for manual and bulk update paths
- OSV.dev checks at discovery time (async, deduped) + startup backfill
- apt/dnf added to OSV ecosystem mapping
- docker handler rewritten against DockerQueries (proper image/container split)
- status filter server-side on aggregated packages (HAVING clause)
- dead code removed: UpdatePackage model, UpsertUpdate, ListUpdates

Frontend:
- LiveOperations -> Staging with staged-packages section
- Docker severity from data, not hardcoded
- Updates status filter delegated to server
This commit is contained in:
Fimeg 2026-05-30 12:56:40 -04:00
commit 0d908ba512
22 changed files with 935 additions and 450 deletions

View file

@ -124,6 +124,7 @@ func RunAgentLoop(cfg *config.Config) error {
// Start the main loop
return RunPollingLoop(&LoopContext{
Ctx: context.Background(),
Cfg: cfg,
APIClient: apiClient,
AckTracker: ackTracker,
@ -196,13 +197,11 @@ func RunPollingLoop(loopCtx *LoopContext) error {
}
}
// Calculate jitter
pollingInterval := time.Duration(ctx.Cfg.CheckInInterval) * time.Second
if ctx.Cfg.RapidPollingEnabled && time.Now().Before(ctx.Cfg.RapidPollingUntil) {
pollingInterval = 5 * time.Second
}
maxJitter := pollingInterval / 2
// Calculate jitter — always use the base check-in interval for
// pre-fetch jitter; rapid-polling acceleration applies to the
// post-processing sleep (recalculated after commands are handled).
baseInterval := time.Duration(ctx.Cfg.CheckInInterval) * time.Second
maxJitter := baseInterval / 2
jitterCap := time.Duration(resolveJitterMaxSeconds(ctx.Cfg)) * time.Second
if maxJitter > jitterCap {
maxJitter = jitterCap
@ -397,6 +396,13 @@ func RunPollingLoop(loopCtx *LoopContext) error {
// executor. A gate that is not enabled server-side returns no tokens.
processCapabilityTokens(ctx)
// Recalculate polling interval AFTER processing commands — a freshly
// enabled heartbeat takes effect this cycle, not next time.
pollingInterval := time.Duration(ctx.Cfg.CheckInInterval) * time.Second
if ctx.Cfg.RapidPollingEnabled && time.Now().Before(ctx.Cfg.RapidPollingUntil) {
pollingInterval = 5 * time.Second
}
// Sleep until next poll (or stop signal)
if ctx.StopCh != nil {
select {

View file

@ -1,9 +1,11 @@
package handlers
import (
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
@ -13,9 +15,11 @@ import (
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/Fimeg/RedFlag/agent/internal/supplychain"
)
// HandleUpdateAgent handles agent update commands with signature verification
@ -123,6 +127,14 @@ func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker
}
log.Printf("[INFO] [agent] [upgrade] ed25519_signature_verified")
// Linux: the privileged binary swap is performed by the root helper, not the
// agent's own sudo. The server signed an agent-self capability token over this
// binary's hash; the helper re-verifies and installs. Other platforms keep the
// legacy direct path below until a platform executor exists.
if runtime.GOOS == "linux" {
return installAgentViaHelper(tempBinaryPath, params, updateStartTime)
}
currentBinaryPath, err := getCurrentBinaryPath()
if err != nil {
return fmt.Errorf("failed to determine current binary path: %w", err)
@ -177,6 +189,69 @@ func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker
return nil
}
// installAgentViaHelper performs the Linux agent self-upgrade through the
// privileged helper. The agent stages the verified binary on the real filesystem
// (the helper's transient-unit mount namespace cannot see the agent's PrivateTmp)
// and hands the helper the server-signed agent-self token. The helper verifies the
// token signature and the staged binary's hash, backs up, installs, chmods, and
// restarts the agent — the agent holds no sudo for cp/chmod/systemctl.
func installAgentViaHelper(tempBinaryPath string, params map[string]interface{}, startTime time.Time) error {
// Must match the helper's DEFAULT_AGENT_UPGRADE_SOURCE.
const upgradeStagingPath = "/var/lib/redflag/agent/pending-upgrade.bin"
if err := copyFile(tempBinaryPath, upgradeStagingPath); err != nil {
return fmt.Errorf("failed to stage new binary for helper: %w", err)
}
tokenJSON, ok := params["capability_token"].(string)
if !ok || tokenJSON == "" {
return fmt.Errorf("missing capability_token — server did not authorize the helper swap")
}
var token capability.Token
if err := json.Unmarshal([]byte(tokenJSON), &token); err != nil {
return fmt.Errorf("failed to parse capability_token: %w", err)
}
log.Printf("[INFO] [agent] [upgrade] invoking_helper token_id=%s", token.TokenID)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
result, err := supplychain.NewExecutor("").Execute(ctx, &token)
if err != nil {
// On success the helper restarts this agent, which can SIGTERM us before
// Execute returns. The new binary reporting its version on next check-in is
// the authoritative success signal (server reconcileAgentUpdates closes the
// command), so a transport error here is not necessarily a failed upgrade.
return fmt.Errorf("helper invocation failed (or agent restarted mid-swap): %w", err)
}
if result.Decision != "executed" {
return fmt.Errorf("helper refused agent upgrade: decision=%s reason=%s exit=%d",
result.Decision, result.Reason, result.ExitCode)
}
log.Printf("[INFO] [agent] [upgrade] helper_swap_complete token_id=%s duration_seconds=%d",
token.TokenID, int(time.Since(startTime).Seconds()))
return nil
}
// copyFile copies src to dst (truncating dst). No privilege required — used to
// place the verified binary on the real filesystem where the root helper can read
// it (the agent's own /var/lib/redflag/agent is writable and outside PrivateTmp).
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
// --- Helper functions ---
// downloadUpdatePackage streams the new agent binary into a temp file using the

View file

@ -43,8 +43,22 @@ var ecosystemRegistry = map[string]EcosystemConfig{
},
},
"apt": {
Binary: "apt",
SudoForDiscovery: true,
Binary: "apt",
// Discovery is unprivileged, same model as dnf. SandboxOpts redirect apt's
// lists/cache/state/log into an agent-writable temp dir so `apt update`,
// `apt list --upgradable`, and `apt install --dry-run` run without root.
// Mutation is the helper's job; the agent holds no apt sudo. Cost: each
// scan re-fetches repo metadata into the ephemeral dir (perf, not
// correctness), mirroring dnf's tradeoff.
SudoForDiscovery: false,
SandboxOpts: func(tmpDir string) []string {
return []string{
"-o", "Dir::State::Lists=" + tmpDir + "/lists",
"-o", "Dir::Cache=" + tmpDir + "/cache",
"-o", "Dir::State=" + tmpDir + "/state",
"-o", "Dir::Log=" + tmpDir,
}
},
},
"docker": {
Binary: "docker",

View file

@ -33,7 +33,7 @@ func (i *DockerInstaller) Update(imageName, targetVersion string) (*InstallResul
// Pull the new image
fmt.Printf("Pulling Docker image: %s...\n", imageName)
pullCmd := exec.Command("sudo", "docker", "pull", imageName)
pullCmd := exec.Command("docker", "pull", imageName)
output, err := pullCmd.CombinedOutput()
if err != nil {
return &InstallResult{
@ -87,7 +87,7 @@ func (i *DockerInstaller) InstallMultiple(imageNames []string) (*InstallResult,
for _, imageName := range imageNames {
fmt.Printf("Pulling Docker image: %s...\n", imageName)
pullCmd := exec.Command("sudo", "docker", "pull", imageName)
pullCmd := exec.Command("docker", "pull", imageName)
output, err := pullCmd.CombinedOutput()
allOutput.WriteString(string(output))
@ -139,7 +139,7 @@ func (i *DockerInstaller) DryRun(imageName string) (*InstallResult, error) {
startTime := time.Now()
// Check if image exists locally
inspectCmd := exec.Command("sudo", "docker", "image", "inspect", imageName)
inspectCmd := exec.Command("docker", "image", "inspect", imageName)
output, err := inspectCmd.CombinedOutput()
if err == nil {
@ -159,7 +159,7 @@ func (i *DockerInstaller) DryRun(imageName string) (*InstallResult, error) {
// Image doesn't exist locally, check if it exists in registry
// Use docker manifest command to check remote availability
manifestCmd := exec.Command("sudo", "docker", "manifest", "inspect", imageName)
manifestCmd := exec.Command("docker", "manifest", "inspect", imageName)
manifestOutput, manifestErr := manifestCmd.CombinedOutput()
duration := int(time.Since(startTime).Seconds())

View file

@ -13,6 +13,7 @@
use std::collections::BTreeSet;
use std::fs;
use std::io::Read;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
@ -43,6 +44,18 @@ const DEFAULT_KEYRING_DIR: &str = "/etc/redflag/trusted-keys";
const DEFAULT_STATE_FILE: &str = "/var/lib/redflag/helper/consumed-tokens";
const AGENT_ID_FILES: &[&str] = &["/etc/redflag/agent_id", "/var/lib/redflag/agent_id"];
// Agent self-upgrade (package_type "agent-self"). The agent drops the downloaded
// binary at AGENT_UPGRADE_SOURCE — an agent-writable path on the real filesystem,
// not PrivateTmp, which this transient unit's own mount namespace cannot see. The
// helper copies it into HELPER_STAGING (root-only) before hashing and installing,
// so the agent cannot swap the bytes between verification and install. AGENT_BINARY
// is where the running agent lives and is always a helper constant, never taken
// from the token.
const DEFAULT_AGENT_BINARY: &str = "/usr/local/bin/redflag-agent";
const DEFAULT_AGENT_UPGRADE_SOURCE: &str = "/var/lib/redflag/agent/pending-upgrade.bin";
const DEFAULT_HELPER_STAGING: &str = "/var/lib/redflag/helper/upgrade-staging.bin";
const AGENT_SELF_PACKAGE_TYPE: &str = "agent-self";
#[derive(Debug, Deserialize)]
struct ClosureEntry {
name: String,
@ -458,6 +471,97 @@ fn emit_result(result: &PolicyResult) {
}
}
// stage_and_verify_agent_binary copies the agent-written upgrade source into a
// root-only staging file and verifies its SHA-256 against the single signed
// closure entry. Returns the staging path. Run BEFORE the replay slot is consumed
// so a tampered binary does not burn the token.
fn stage_and_verify_agent_binary(token: &CapabilityToken) -> Result<String, Denial> {
if token.closure.len() != 1 {
return Err(Denial::new(
EXIT_BAD_TOKEN,
"agent_self_bad_closure",
format!("expected exactly 1 closure entry, got {}", token.closure.len()),
));
}
let expected = token.closure[0].sha256.trim().to_lowercase();
if expected.is_empty() {
return Err(Denial::new(EXIT_ARTIFACT, "agent_self_empty_hash", "closure sha256 is empty"));
}
let source = env_or("REDFLAG_AGENT_UPGRADE_SOURCE", DEFAULT_AGENT_UPGRADE_SOURCE);
let staging = env_or("REDFLAG_HELPER_STAGING", DEFAULT_HELPER_STAGING);
if let Some(parent) = Path::new(&staging).parent() {
fs::create_dir_all(parent).map_err(|e| {
Denial::new(EXIT_INTERNAL, "agent_self_staging_dir", format!("{}: {}", parent.display(), e))
})?;
}
// Copy first, then hash the copy — never hash a path we will later re-read,
// or the agent could swap the bytes in between.
fs::copy(&source, &staging).map_err(|e| {
Denial::new(EXIT_ARTIFACT, "agent_self_stage_copy", format!("{} -> {}: {}", source, staging, e))
})?;
let actual = match compute_file_sha256(Path::new(&staging)) {
Ok(h) => h.to_lowercase(),
Err(e) => {
let _ = fs::remove_file(&staging);
return Err(Denial::new(EXIT_ARTIFACT, "agent_self_hash_failed", format!("{}: {}", staging, e)));
}
};
if actual != expected {
let _ = fs::remove_file(&staging);
return Err(Denial::new(
EXIT_ARTIFACT,
"agent_self_hash_mismatch",
format!("expected={} actual={}", expected, actual),
));
}
Ok(staging)
}
// install_staged_agent_binary backs up the live agent binary, installs the
// verified staged copy in its place, fixes the mode, and restarts the agent. The
// install path is a helper constant, never taken from the token. Runs only after
// the replay slot is consumed (these side effects are irreversible).
fn install_staged_agent_binary(staged: &str) -> Result<(), Denial> {
let install = env_or("REDFLAG_AGENT_BINARY", DEFAULT_AGENT_BINARY);
let backup = format!("{}.bak", install);
if Path::new(&install).exists() {
fs::copy(&install, &backup).map_err(|e| {
Denial::new(EXIT_EXEC_FAILED, "agent_self_backup", format!("{} -> {}: {}", install, backup, e))
})?;
}
fs::copy(staged, &install).map_err(|e| {
Denial::new(EXIT_EXEC_FAILED, "agent_self_install", format!("{} -> {}: {}", staged, install, e))
})?;
let _ = fs::remove_file(staged);
fs::set_permissions(&install, fs::Permissions::from_mode(0o755)).map_err(|e| {
Denial::new(EXIT_EXEC_FAILED, "agent_self_chmod", format!("{}: {}", install, e))
})?;
// Enqueue the restart with --no-block and return. The agent process is the
// consumer blocked on this helper's stdout pipe; a synchronous restart would
// SIGTERM it before we emit our result (broken pipe). --no-block lets this
// helper finish and report first, then systemd performs the restart. Boot
// verification is server-side (the new binary reports its version on check-in).
let status = Command::new("systemctl")
.args(["restart", "--no-block", "redflag-agent"])
.env_clear()
.env("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
.status()
.map_err(|e| Denial::new(EXIT_EXEC_FAILED, "agent_self_restart_spawn", e.to_string()))?;
if !status.success() {
return Err(Denial::new(
EXIT_EXEC_FAILED,
"agent_self_restart_failed",
format!("systemctl restart redflag-agent exit={:?}", status.code()),
));
}
Ok(())
}
fn run() -> Result<PolicyResult, (Option<CapabilityToken>, Denial)> {
let token = read_token_from_stdin().map_err(|d| (None, d))?;
@ -505,6 +609,42 @@ fn run() -> Result<PolicyResult, (Option<CapabilityToken>, Denial)> {
return Err((Some(token), d));
}
// Agent self-upgrade is a privileged binary swap, not a package install. The
// signature is verified above; here we verify the new binary's hash and replace
// the agent binary, bypassing the package-manager plan path entirely.
if token.package_type == AGENT_SELF_PACKAGE_TYPE {
// Stage + hash-verify before consuming the replay slot so a tampered binary
// cannot burn the token.
let staged = match stage_and_verify_agent_binary(&token) {
Ok(p) => p,
Err(d) => return Err((Some(token), d)),
};
let state_path = PathBuf::from(env_or("REDFLAG_HELPER_STATE", DEFAULT_STATE_FILE));
if let Err(d) = replay_check_and_record(&token.token_id, &state_path) {
return Err((Some(token), d));
}
log_security(&format!(
"authorized agent_self_upgrade token_id={} agent_id={}",
token.token_id, token.agent_id
));
if let Err(d) = install_staged_agent_binary(&staged) {
return Err((Some(token), d));
}
return Ok(PolicyResult {
token_id: token.token_id.clone(),
agent_id: token.agent_id.clone(),
package_type: token.package_type.clone(),
operation: token.operation.clone(),
decision: "executed".to_string(),
reason: "agent_self_upgraded".to_string(),
executed: true,
verified_artifacts: 1,
exit_code: EXIT_OK,
error: None,
timestamp: now_unix(),
});
}
// Verify artifact hashes the executor can reach.
let verified = match verify_artifacts(&token) {
Ok(v) => v,

View file

@ -4,6 +4,7 @@ import (
"context"
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"log"
@ -375,7 +376,7 @@ func main() {
authHandler := handlers.NewAuthHandler(cfg.Admin.JWTSecret, adminQueries)
statsHandler := handlers.NewStatsHandler(agentQueries, updateQueries)
settingsHandler := handlers.NewSettingsHandler(timezoneService)
dockerHandler := handlers.NewDockerHandler(updateQueries, agentQueries, commandQueries, signingService, securityLogger)
dockerHandler := handlers.NewDockerHandler(dockerQueries, updateQueries, agentQueries, commandQueries, signingService, securityLogger)
registrationTokenHandler := handlers.NewRegistrationTokenHandler(registrationTokenQueries, agentQueries, cfg)
maintenanceWindowHandler := handlers.NewMaintenanceWindowHandler(maintenanceWindowQueries)
@ -818,6 +819,10 @@ func main() {
log.Printf("Warning: Failed to start scheduler: %v", err)
}
// Backfill OSV.dev checks for packages discovered before the supply-chain
// check was wired at discovery time. Runs once at startup, async, best-effort.
go backfillOSVChecks(updateQueries)
// Add scheduler stats endpoint (after scheduler is initialized)
// F-A3-10 fix: use WebAuthMiddleware (admin only), not AuthMiddleware (agent JWT)
router.GET("/api/v1/scheduler/stats", authHandler.WebAuthMiddleware(), func(c *gin.Context) {
@ -868,3 +873,47 @@ func getOperationalSetting(svc *services.SecuritySettingsService, key string, de
}
return defaultVal
}
// backfillOSVChecks queries current_package_state for packages that have never
// had a supply-chain check and enqueues OSV.dev queries for each unique
// (package_type, package_name, version). Runs once at startup, async, best-effort.
func backfillOSVChecks(updateQueries *queries.UpdateQueries) {
log.Printf("[INFO] [server] [supply_chain] backfill_start")
rows, err := updateQueries.GetUncheckedPackages()
if err != nil {
log.Printf("[WARNING] [server] [supply_chain] backfill_query_failed error=%v", err)
return
}
log.Printf("[INFO] [server] [supply_chain] backfill_candidates count=%d", len(rows))
for _, r := range rows {
if !services.NeedsSupplyChainCheck(r.PackageType) {
continue
}
pkgType, pkgName, version, agentID := r.PackageType, r.PackageName, r.Version, r.AgentID
go func() {
ecosystem := services.EcosystemFromPackageType(pkgType)
result := services.CheckOSVVulnerabilities(pkgName, ecosystem, version)
if result == nil {
return
}
meta := map[string]interface{}{
"supply_chain_checked_at": result.CheckedAt.UTC().Format(time.RFC3339),
}
if len(result.Vulnerabilities) > 0 {
vulnJSON, err := json.Marshal(result.Vulnerabilities)
if err != nil {
log.Printf("[WARNING] [supply_chain] backfill_marshal_failed pkg=%s error=%v", pkgName, err)
return
}
meta["supply_chain_vulns"] = string(vulnJSON)
log.Printf("[SECURITY] [supply_chain] backfill_vulns_found pkg=%s type=%s ecosystem=%s count=%d",
pkgName, pkgType, ecosystem, len(result.Vulnerabilities))
}
if err := updateQueries.StoreSupplyChainMetadata(agentID, pkgType, pkgName, meta); err != nil {
log.Printf("[WARNING] [supply_chain] backfill_store_failed pkg=%s error=%v", pkgName, err)
}
}()
}
log.Printf("[INFO] [server] [supply_chain] backfill_enqueued")
}

View file

@ -202,39 +202,29 @@ func (h *AgentTrackedSoftwareHandler) CreateUpdateFromDrift(c *gin.Context) {
continue
}
// Build UpdatePackage
update := models.UpdatePackage{
ID: uuid.New(),
AgentID: agentID,
PackageType: binding.Ecosystem,
PackageName: binding.Name,
PackageDescription: "",
CurrentVersion: binding.InstalledVersion,
AvailableVersion: *binding.LatestVersion,
Severity: event.DriftSeverity,
CVEList: []string{},
KBID: "",
RepositorySource: binding.Source + "://" + binding.SourceRef,
SizeBytes: 0, // Will be unknown for source-derived updates
Status: "pending",
DiscoveredAt: time.Now().UTC(),
ApprovedBy: "",
ApprovedAt: nil,
ScheduledFor: nil,
InstalledAt: nil,
ErrorMessage: "",
// Build UpdateEvent and write to current_package_state
updateEvent := models.UpdateEvent{
ID: uuid.New(),
AgentID: agentID,
PackageType: binding.Ecosystem,
PackageName: binding.Name,
VersionFrom: binding.InstalledVersion,
VersionTo: *binding.LatestVersion,
Severity: event.DriftSeverity,
RepositorySource: binding.Source + "://" + binding.SourceRef,
EventType: "drift",
CreatedAt: time.Now().UTC(),
Metadata: models.JSONB{
"source": binding.Source,
"source_ref": binding.SourceRef,
"installed": binding.InstalledVersion,
"latest": *binding.LatestVersion,
"drift_event": event.ID.String(),
"install_type": "gitea_download", // B5: will download and run
"install_type": "gitea_download",
},
}
// Upsert the update
if err := h.updateQueries.UpsertUpdate(&update); err != nil {
if err := h.updateQueries.UpsertCurrentState(&updateEvent); err != nil {
log.Printf("[ERROR] [server] [agent_tracked_software] create_update_from_drift agent=%s software=%s err=%v",
agentID, binding.Name, err)
c.JSON(http.StatusInternalServerError, gin.H{

View file

@ -1,6 +1,7 @@
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
@ -9,6 +10,7 @@ import (
"strings"
"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/Fimeg/RedFlag/server/internal/services"
@ -47,6 +49,40 @@ func (h *AgentUpdateHandler) SetSecuritySettings(s *services.SecuritySettingsSer
h.securitySettings = s
}
// mintAgentSelfToken signs an agent-self capability token authorizing the
// privileged helper to swap the agent binary to the given version/checksum. The
// helper verifies this signature and the new binary's hash before installing.
// Single signing chokepoint for both the manual and bulk update paths; reuses the
// existing signing key (no new key). Returns the JSON to embed in the update_agent
// command params.
func (h *AgentUpdateHandler) mintAgentSelfToken(agentID uuid.UUID, version, checksum string) (string, error) {
now := time.Now().UTC()
token := &capability.Token{
Version: capability.Version,
TokenID: uuid.New().String(),
AgentID: agentID.String(),
PackageType: "agent-self",
Operation: "upgrade",
Closure: []capability.ClosureEntry{{
Name: "redflag-agent",
Version: version,
SHA256: checksum,
Source: "agent-self",
}},
IssuedAt: now.Unix(),
NotBefore: now.Unix(),
ExpiresAt: now.Add(time.Hour).Unix(),
}
if err := h.signingService.SignCapabilityToken(token); err != nil {
return "", fmt.Errorf("sign agent-self token: %w", err)
}
tokenJSON, err := json.Marshal(token)
if err != nil {
return "", fmt.Errorf("marshal agent-self token: %w", err)
}
return string(tokenJSON), nil
}
// UpdateAgent handles POST /api/v1/agents/:id/update (manual agent update)
func (h *AgentUpdateHandler) UpdateAgent(c *gin.Context) {
// Extract agent ID from URL path
@ -230,6 +266,18 @@ func (h *AgentUpdateHandler) UpdateAgent(c *gin.Context) {
}
}
// Mint an agent-self capability token so the privileged helper — not the
// agent's own sudo — performs the binary swap. Fail-closed: a signed package is
// already a precondition for getting here, so a signing failure refuses the
// update rather than falling back to an unprivileged path the agent no longer has.
selfTokenJSON, err := h.mintAgentSelfToken(agentIDUUID, req.Version, pkg.Checksum)
if err != nil {
h.agentUpdateQueries.UpdateAgentUpdatingStatus(agentIDUUID, false, nil)
log.Printf("[ERROR] [server] [agent_update] self_token_failed agent_id=%s error=%v", agentIDUUID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to authorize agent upgrade"})
return
}
// Create update command for agent
commandType := "update_agent"
commandParams := map[string]interface{}{
@ -242,6 +290,7 @@ func (h *AgentUpdateHandler) UpdateAgent(c *gin.Context) {
"nonce_uuid": nonceUUID.String(),
"nonce_timestamp": nonceTimestamp.Format(time.RFC3339),
"nonce_signature": nonceSignature,
"capability_token": selfTokenJSON,
}
// Schedule the update if requested
@ -386,6 +435,14 @@ func (h *AgentUpdateHandler) BulkUpdateAgents(c *gin.Context) {
}
}
// Mint the agent-self token so the helper performs the swap (no agent sudo).
selfTokenJSON, err := h.mintAgentSelfToken(agentID, req.Version, pkg.Checksum)
if err != nil {
h.agentUpdateQueries.UpdateAgentUpdatingStatus(agentID, false, nil)
errors = append(errors, fmt.Sprintf("Agent %s: failed to authorize agent upgrade", agentID))
continue
}
// Create update command
command := &models.AgentCommand{
ID: uuid.New(),
@ -401,6 +458,7 @@ func (h *AgentUpdateHandler) BulkUpdateAgents(c *gin.Context) {
"nonce_uuid": nonceUUID.String(),
"nonce_timestamp": nonceTimestamp.Format(time.RFC3339),
"nonce_signature": nonceSignature,
"capability_token": selfTokenJSON,
},
Status: models.CommandStatusPending,
Source: "manual",

View file

@ -5,6 +5,7 @@ import (
"log"
"net/http"
"strconv"
"strings"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
@ -15,15 +16,17 @@ import (
)
type DockerHandler struct {
updateQueries *queries.UpdateQueries
dockerQueries *queries.DockerQueries
agentQueries *queries.AgentQueries
commandQueries *queries.CommandQueries
updateQueries *queries.UpdateQueries // kept for ApproveUpdate/RejectUpdate which read from current_package_state
signingService *services.SigningService
securityLogger *logging.SecurityLogger
}
func NewDockerHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries, signingService *services.SigningService, securityLogger *logging.SecurityLogger) *DockerHandler {
func NewDockerHandler(dq *queries.DockerQueries, uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries, signingService *services.SigningService, securityLogger *logging.SecurityLogger) *DockerHandler {
return &DockerHandler{
dockerQueries: dq,
updateQueries: uq,
agentQueries: aq,
commandQueries: cq,
@ -73,124 +76,145 @@ func (h *DockerHandler) signAndCreateCommand(cmd *models.AgentCommand) error {
// GetContainers returns Docker containers and images across all agents
func (h *DockerHandler) GetContainers(c *gin.Context) {
// Parse query parameters
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
agentID := c.Query("agent")
search := c.Query("search")
status := c.Query("status")
filters := &models.UpdateFilters{
PackageType: "docker_image",
Page: page,
PageSize: pageSize,
Status: status,
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 50
}
offset := (page - 1) * pageSize
filter := &models.DockerFilter{
Limit: &pageSize,
Offset: &offset,
}
// Parse agent_id if provided
if agentID != "" {
if parsedID, err := uuid.Parse(agentID); err == nil {
filters.AgentID = parsedID
filter.AgentID = &parsedID
}
}
if search != "" {
filter.ImageName = &search
}
if status != "" {
filter.Severity = &status // reused; "status" in docker page means severity or update-state
}
// Get Docker updates (which represent container images)
updates, total, err := h.updateQueries.ListUpdatesFromState(filters)
result, err := h.dockerQueries.GetDockerImages(filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch Docker containers"})
return
}
// Get agent information for better display
agentMap := make(map[uuid.UUID]models.Agent)
for _, update := range updates {
if _, exists := agentMap[update.AgentID]; !exists {
if agent, err := h.agentQueries.GetAgentByID(update.AgentID); err == nil {
agentMap[update.AgentID] = *agent
containers := make([]models.DockerContainer, 0, len(result.Images))
images := make([]models.DockerImage, 0, len(result.Images))
for _, img := range result.Images {
if _, exists := agentMap[img.AgentID]; !exists {
if agent, err := h.agentQueries.GetAgentByID(img.AgentID); err == nil {
agentMap[img.AgentID] = *agent
}
}
}
agentInfo := agentMap[img.AgentID]
// Transform updates into Docker container format
containers := make([]models.DockerContainer, 0, len(updates))
uniqueImages := make(map[string]bool)
imageName, imageTag := splitImageName(img.PackageName)
repo := img.RepositorySource
if repo == "" {
repo = imageName
}
for _, update := range updates {
// Extract container info from update metadata
containerName := update.PackageName
containerName := imageName
var ports []models.DockerPort
if update.Metadata != nil {
if name, exists := update.Metadata["container_name"]; exists {
if nameStr, ok := name.(string); ok {
containerName = nameStr
if img.Metadata != nil {
if cn, ok := img.Metadata["container_name"].(string); ok && cn != "" {
containerName = cn
}
if cNames, ok := img.Metadata["container_names"].([]interface{}); ok && len(cNames) > 0 {
if first, ok := cNames[0].(string); ok {
containerName = strings.TrimPrefix(first, "/")
}
}
// Extract port information from metadata
if portsData, exists := update.Metadata["ports"]; exists {
if portsArray, ok := portsData.([]interface{}); ok {
for _, portData := range portsArray {
if portMap, ok := portData.(map[string]interface{}); ok {
port := models.DockerPort{}
if cp, ok := portMap["container_port"].(float64); ok {
port.ContainerPort = int(cp)
}
if hp, ok := portMap["host_port"].(float64); ok {
hostPort := int(hp)
port.HostPort = &hostPort
}
if proto, ok := portMap["protocol"].(string); ok {
port.Protocol = proto
}
if ip, ok := portMap["host_ip"].(string); ok {
port.HostIP = ip
} else {
port.HostIP = "0.0.0.0"
}
ports = append(ports, port)
if portsData, ok := img.Metadata["ports"].([]interface{}); ok {
for _, pd := range portsData {
if pm, ok := pd.(map[string]interface{}); ok {
p := models.DockerPort{HostIP: "0.0.0.0"}
if cp, ok := pm["container_port"].(float64); ok {
p.ContainerPort = int(cp)
}
if hp, ok := pm["host_port"].(float64); ok {
v := int(hp)
p.HostPort = &v
}
if proto, ok := pm["protocol"].(string); ok {
p.Protocol = proto
}
if ip, ok := pm["host_ip"].(string); ok {
p.HostIP = ip
}
ports = append(ports, p)
}
}
}
}
// Get agent information
agentInfo := agentMap[update.AgentID]
// Create container representation
hasUpdate := img.CurrentVersion != img.AvailableVersion && img.AvailableVersion != ""
container := models.DockerContainer{
ID: update.ID.String(),
ID: img.ID.String(),
ContainerID: containerName,
Image: update.PackageName,
Tag: update.AvailableVersion, // Available version becomes the tag
AgentID: update.AgentID.String(),
Image: imageName,
Tag: imageTag,
AgentID: img.AgentID.String(),
AgentName: agentInfo.Hostname,
AgentHostname: agentInfo.Hostname,
Status: update.Status,
State: "", // Could be extracted from metadata if available
Status: img.EventType,
Severity: img.Severity,
State: "",
Ports: ports,
CreatedAt: update.LastDiscoveredAt,
UpdatedAt: update.LastUpdatedAt,
UpdateAvailable: update.Status != "installed",
CurrentVersion: update.CurrentVersion,
AvailableVersion: update.AvailableVersion,
CreatedAt: img.CreatedAt,
UpdatedAt: img.CreatedAt,
UpdateAvailable: hasUpdate,
CurrentVersion: img.CurrentVersion,
AvailableVersion: img.AvailableVersion,
}
containers = append(containers, container)
sizeBytes := int64(0)
if sb, ok := img.Metadata["size_bytes"].(float64); ok {
sizeBytes = int64(sb)
}
// Add image to unique set
imageKey := update.PackageName + ":" + update.AvailableVersion
uniqueImages[imageKey] = true
containers = append(containers, container)
image := models.DockerImage{
ID: img.ID.String(),
Repository: repo,
Tag: imageTag,
Size: sizeBytes,
CreatedAt: img.CreatedAt,
UpdatedAt: img.CreatedAt,
AgentID: img.AgentID.String(),
AgentName: agentInfo.Hostname,
UpdateAvailable: hasUpdate,
CurrentVersion: img.CurrentVersion,
AvailableVersion: img.AvailableVersion,
}
images = append(images, image)
}
response := models.DockerContainerListResponse{
Containers: containers,
Images: containers, // Alias for containers to match frontend expectation
TotalImages: len(uniqueImages),
Total: len(containers),
Page: page,
PageSize: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Containers: containers,
Images: images,
TotalImages: len(images),
Total: result.Total,
Page: page,
PageSize: pageSize,
TotalPages: (result.Total + pageSize - 1) / pageSize,
}
c.JSON(http.StatusOK, response)
@ -205,109 +229,70 @@ func (h *DockerHandler) GetAgentContainers(c *gin.Context) {
return
}
// Parse query parameters
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
status := c.Query("status")
filters := &models.UpdateFilters{
AgentID: agentID,
PackageType: "docker_image",
Page: page,
PageSize: pageSize,
Status: status,
}
// Get Docker updates for specific agent
updates, total, err := h.updateQueries.ListUpdatesFromState(filters)
images, err := h.dockerQueries.GetDockerImagesByAgentID(agentID, 100)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch Docker containers for agent"})
return
}
// Get agent information
agentInfo, err := h.agentQueries.GetAgentByID(agentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
// Transform updates into Docker container format
containers := make([]models.DockerContainer, 0, len(updates))
uniqueImages := make(map[string]bool)
containers := make([]models.DockerContainer, 0, len(images))
dockerImages := make([]models.DockerImage, 0, len(images))
for _, update := range updates {
// Extract container info from update metadata
containerName := update.PackageName
var ports []models.DockerPort
if update.Metadata != nil {
if name, exists := update.Metadata["container_name"]; exists {
if nameStr, ok := name.(string); ok {
containerName = nameStr
}
}
// Extract port information from metadata
if portsData, exists := update.Metadata["ports"]; exists {
if portsArray, ok := portsData.([]interface{}); ok {
for _, portData := range portsArray {
if portMap, ok := portData.(map[string]interface{}); ok {
port := models.DockerPort{}
if cp, ok := portMap["container_port"].(float64); ok {
port.ContainerPort = int(cp)
}
if hp, ok := portMap["host_port"].(float64); ok {
hostPort := int(hp)
port.HostPort = &hostPort
}
if proto, ok := portMap["protocol"].(string); ok {
port.Protocol = proto
}
if ip, ok := portMap["host_ip"].(string); ok {
port.HostIP = ip
} else {
port.HostIP = "0.0.0.0"
}
ports = append(ports, port)
}
}
}
}
}
for _, img := range images {
imageName, imageTag := splitImageName(img.PackageName)
hasUpdate := img.CurrentVersion != img.AvailableVersion && img.AvailableVersion != ""
container := models.DockerContainer{
ID: update.ID.String(),
ContainerID: containerName,
Image: update.PackageName,
Tag: update.AvailableVersion,
AgentID: update.AgentID.String(),
ID: img.ID.String(),
Image: imageName,
Tag: imageTag,
AgentID: img.AgentID.String(),
AgentName: agentInfo.Hostname,
AgentHostname: agentInfo.Hostname,
Status: update.Status,
State: "", // Could be extracted from metadata if available
Ports: ports,
CreatedAt: update.LastDiscoveredAt,
UpdatedAt: update.LastUpdatedAt,
UpdateAvailable: update.Status != "installed",
CurrentVersion: update.CurrentVersion,
AvailableVersion: update.AvailableVersion,
Status: img.EventType,
Severity: img.Severity,
CreatedAt: img.CreatedAt,
UpdatedAt: img.CreatedAt,
UpdateAvailable: hasUpdate,
CurrentVersion: img.CurrentVersion,
AvailableVersion: img.AvailableVersion,
}
imageKey := update.PackageName + ":" + update.AvailableVersion
uniqueImages[imageKey] = true
containers = append(containers, container)
sizeBytes := int64(0)
if sb, ok := img.Metadata["size_bytes"].(float64); ok {
sizeBytes = int64(sb)
}
di := models.DockerImage{
ID: img.ID.String(),
Repository: img.RepositorySource,
Tag: imageTag,
Size: sizeBytes,
CreatedAt: img.CreatedAt,
UpdatedAt: img.CreatedAt,
AgentID: img.AgentID.String(),
AgentName: agentInfo.Hostname,
UpdateAvailable: hasUpdate,
CurrentVersion: img.CurrentVersion,
AvailableVersion: img.AvailableVersion,
}
dockerImages = append(dockerImages, di)
}
response := models.DockerContainerListResponse{
Containers: containers,
Images: containers, // Alias for containers to match frontend expectation
TotalImages: len(uniqueImages),
Total: len(containers),
Page: page,
PageSize: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Containers: containers,
Images: dockerImages,
TotalImages: len(dockerImages),
Total: len(containers),
Page: 1,
PageSize: 100,
TotalPages: 1,
}
c.JSON(http.StatusOK, response)
@ -315,59 +300,22 @@ func (h *DockerHandler) GetAgentContainers(c *gin.Context) {
// GetStats returns Docker statistics across all agents
func (h *DockerHandler) GetStats(c *gin.Context) {
// Get all Docker updates
filters := &models.UpdateFilters{
PackageType: "docker_image",
Page: 1,
PageSize: 10000, // Get all for stats
}
updates, _, err := h.updateQueries.ListUpdatesFromState(filters)
stats, err := h.dockerQueries.GetDockerStats()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch Docker stats"})
return
}
stats := models.DockerStats{
TotalContainers: len(updates),
TotalImages: 0,
UpdatesAvailable: 0,
PendingApproval: 0,
CriticalUpdates: 0,
response := models.DockerStats{
TotalContainers: 0, // not tracked in docker_images
TotalImages: stats.TotalImages,
UpdatesAvailable: stats.UpdatesAvailable,
PendingApproval: 0, // not applicable for Docker flow
CriticalUpdates: stats.CriticalUpdates,
AgentsWithContainers: stats.AgentsWithContainers,
}
// Calculate stats
uniqueImages := make(map[string]bool)
agentsWithContainers := make(map[uuid.UUID]bool)
for _, update := range updates {
// Count unique images
imageKey := update.PackageName + ":" + update.AvailableVersion
uniqueImages[imageKey] = true
// Count agents with containers
agentsWithContainers[update.AgentID] = true
// Count updates available
if update.Status != "installed" {
stats.UpdatesAvailable++
}
// Count pending approval
if update.Status == "pending_approval" {
stats.PendingApproval++
}
// Count critical updates
if update.Severity == "critical" {
stats.CriticalUpdates++
}
}
stats.TotalImages = len(uniqueImages)
stats.AgentsWithContainers = len(agentsWithContainers)
c.JSON(http.StatusOK, stats)
c.JSON(http.StatusOK, response)
}
// ApproveUpdate approves a Docker image update
@ -487,4 +435,14 @@ func (h *DockerHandler) InstallUpdate(c *gin.Context) {
"image_id": imageID,
"command_id": command.ID,
})
}
// splitImageName splits "image:tag" into name and tag parts.
// If no colon, the whole string is the name and tag defaults to "latest".
func splitImageName(full string) (name, tag string) {
idx := strings.LastIndex(full, ":")
if idx == -1 {
return full, "latest"
}
return full[:idx], full[idx+1:]
}

View file

@ -65,13 +65,41 @@ func (h *DockerReportsHandler) ReportDockerImages(c *gin.Context) {
// Convert Docker images to events
events := make([]models.StoredDockerImage, 0, len(req.Images))
for _, item := range req.Images {
// Extract image_name and image_tag from the package_name (format: "name:tag" or just "name")
imageName := item.PackageName
imageTag := "latest"
if idx := strings.LastIndex(item.PackageName, ":"); idx >= 0 {
imageName = item.PackageName[:idx]
imageTag = item.PackageName[idx+1:]
}
// Metadata also carries image_name/image_tag from the agent; use those
// as the authoritative source when present.
if metaName, ok := item.Metadata["image_name"].(string); ok && metaName != "" {
imageName = metaName
}
if metaTag, ok := item.Metadata["image_tag"].(string); ok && metaTag != "" {
imageTag = metaTag
}
// Extract ports from container metadata
if ports, ok := item.Metadata["ports"]; ok {
if portsList, ok := ports.([]interface{}); ok {
item.Metadata["port_count"] = len(portsList)
}
}
if containerNames, ok := item.Metadata["container_names"]; ok {
if names, ok := containerNames.([]interface{}); ok && len(names) > 0 {
item.Metadata["container_name"] = names[0]
}
}
event := models.StoredDockerImage{
ID: uuid.New(),
AgentID: agentID,
PackageType: "docker_image",
PackageName: item.ImageName + ":" + item.ImageTag,
CurrentVersion: item.ImageID,
AvailableVersion: item.LatestImageID,
PackageType: item.PackageType,
PackageName: imageName + ":" + imageTag,
CurrentVersion: item.CurrentVersion,
AvailableVersion: item.AvailableVersion,
Severity: item.Severity,
RepositorySource: item.RepositorySource,
Metadata: convertToJSONB(item.Metadata),
@ -201,16 +229,33 @@ func (h *DockerReportsHandler) GetAgentDockerInfo(c *gin.Context) {
// Convert to detailed format
dockerInfo := make([]models.DockerImageInfo, 0, len(result.Images))
for _, image := range result.Images {
imageName := extractName(image.PackageName)
imageTag := extractTag(image.PackageName)
// Fallback to metadata for legacy rows where PackageName was stored as ":"
if imageName == "" || imageName == ":" {
if metaName, ok := image.Metadata["image_name"].(string); ok && metaName != "" {
imageName = metaName
}
}
if imageTag == "" || imageTag == "latest" {
if metaTag, ok := image.Metadata["image_tag"].(string); ok && metaTag != "" {
imageTag = metaTag
}
}
// Extract ports from metadata
ports := extractPorts(image.Metadata)
info := models.DockerImageInfo{
ID: image.ID.String(),
AgentID: image.AgentID.String(),
ImageName: extractName(image.PackageName),
ImageTag: extractTag(image.PackageName),
ImageName: imageName,
ImageTag: imageTag,
ImageID: image.CurrentVersion,
RepositorySource: image.RepositorySource,
SizeBytes: parseImageSize(image.Metadata),
CreatedAt: image.CreatedAt.Format(time.RFC3339),
HasUpdate: image.AvailableVersion != image.CurrentVersion,
HasUpdate: image.AvailableVersion != image.CurrentVersion && image.AvailableVersion != "",
LatestImageID: image.AvailableVersion,
Severity: image.Severity,
Labels: extractLabels(image.Metadata),
@ -220,6 +265,7 @@ func (h *DockerReportsHandler) GetAgentDockerInfo(c *gin.Context) {
AvailableVersion: image.AvailableVersion,
EventType: image.EventType,
CreatedAtTime: image.CreatedAt,
Ports: ports,
}
dockerInfo = append(dockerInfo, info)
}
@ -232,6 +278,47 @@ func (h *DockerReportsHandler) GetAgentDockerInfo(c *gin.Context) {
})
}
// Helper function to extract ports from metadata
func extractPorts(metadata models.JSONB) []models.DockerPort {
if portsRaw, ok := metadata["ports"]; ok {
if portsList, ok := portsRaw.([]interface{}); ok {
ports := make([]models.DockerPort, 0, len(portsList))
for _, p := range portsList {
if portMap, ok := p.(map[string]interface{}); ok {
port := models.DockerPort{}
if ip, ok := portMap["ip"].(string); ok {
port.HostIP = ip
}
if pub, ok := portMap["public_port"]; ok {
switch v := pub.(type) {
case float64:
hp := int(v)
port.HostPort = &hp
case int:
hp := v
port.HostPort = &hp
}
}
if priv, ok := portMap["private_port"]; ok {
switch v := priv.(type) {
case float64:
port.ContainerPort = int(v)
case int:
port.ContainerPort = v
}
}
if ptype, ok := portMap["type"].(string); ok {
port.Protocol = ptype
}
ports = append(ports, port)
}
}
return ports
}
}
return nil
}
// Helper function to extract name from image name
func extractName(imageName string) string {
// Simple implementation - split by ":" and return everything except last part

View file

@ -12,6 +12,7 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/Fimeg/RedFlag/server/internal/capability"
@ -23,12 +24,16 @@ import (
"github.com/google/uuid"
)
// osvDedup avoids re-querying OSV.dev for the same package+version across
// multiple scan cycles. Key format: "pkgType:pkgName:version".
var osvDedup sync.Map
// isValidResult checks if the result value complies with the database constraint
func isValidResult(result string) bool {
validResults := map[string]bool{
"updated": true,
"success": true,
"failed": true,
"rollback": true,
"partial": true,
}
return validResults[result]
}
@ -157,6 +162,13 @@ func (h *UpdateHandler) ReportUpdates(c *gin.Context) {
}
}
// Best-effort OSV.dev check at discovery time — runs async so it never
// blocks the agent report. Deduped: same pkg+version is only queried once
// per server lifetime. Results (vulns or clean) are written back to the
// current_package_state metadata so the UI's "Known Vulnerabilities" card
// shows them before the operator approves.
go enqueueOSVChecks(events, h.updateQueries)
c.JSON(http.StatusOK, gin.H{
"message": "update events recorded",
"count": len(events),
@ -164,6 +176,49 @@ func (h *UpdateHandler) ReportUpdates(c *gin.Context) {
})
}
// enqueueOSVChecks fires an OSV.dev query for each unique package+version in the
// batch. Already-checked combos are skipped via osvDedup. Results are stored in
// current_package_state.metadata for UI visibility.
func enqueueOSVChecks(events []models.UpdateEvent, q *queries.UpdateQueries) {
for _, e := range events {
if !services.NeedsSupplyChainCheck(e.PackageType) {
continue
}
key := fmt.Sprintf("%s:%s:%s", e.PackageType, e.PackageName, e.VersionTo)
if _, seen := osvDedup.LoadOrStore(key, true); seen {
continue
}
// Capture loop variables for the goroutine
pkgType, pkgName, version, agentID := e.PackageType, e.PackageName, e.VersionTo, e.AgentID
go func() {
ecosystem := services.EcosystemFromPackageType(pkgType)
result := services.CheckOSVVulnerabilities(pkgName, ecosystem, version)
if result == nil {
return
}
meta := models.JSONB{
"supply_chain_checked_at": result.CheckedAt.UTC().Format(time.RFC3339),
}
if len(result.Vulnerabilities) > 0 {
vulnJSON, err := json.Marshal(result.Vulnerabilities)
if err != nil {
log.Printf("[WARNING] [supply_chain] vuln_marshal_failed pkg=%s error=%v", pkgName, err)
return
}
meta["supply_chain_vulns"] = string(vulnJSON)
log.Printf("[SECURITY] [supply_chain] vulns_found pkg=%s type=%s ecosystem=%s count=%d",
pkgName, pkgType, ecosystem, len(result.Vulnerabilities))
} else {
log.Printf("[INFO] [supply_chain] clean pkg=%s type=%s ecosystem=%s version=%s",
pkgName, pkgType, ecosystem, version)
}
if err := q.StoreSupplyChainMetadata(agentID, pkgType, pkgName, meta); err != nil {
log.Printf("[WARNING] [supply_chain] metadata_store_failed pkg=%s error=%v", pkgName, err)
}
}()
}
}
// ListUpdates retrieves updates with filtering using the new state table
func (h *UpdateHandler) ListUpdates(c *gin.Context) {
filters := &models.UpdateFilters{
@ -235,12 +290,13 @@ func (h *UpdateHandler) ListPackages(c *gin.Context) {
if packageType == "" {
packageType = c.Query("package_type")
}
status := c.Query("status")
sortBy := c.Query("sort_by")
sortOrder := c.Query("sort_order")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100"))
rows, total, err := h.updateQueries.ListAggregatedPackages(search, packageType, sortBy, sortOrder, page, pageSize)
rows, total, err := h.updateQueries.ListAggregatedPackages(search, packageType, status, sortBy, sortOrder, page, pageSize)
if err != nil {
log.Printf("[ERROR] [server] [updates] list_packages_failed error=%v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"})
@ -630,9 +686,9 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
if validResult == "timed_out" || validResult == "timeout" || validResult == "cancelled" {
validResult = "failed"
} else if validResult == "updated" || validResult == "success" {
validResult = "updated"
validResult = "success"
} else if validResult == "rollback" {
validResult = "rollback"
validResult = "success"
} else {
validResult = "failed" // Default to failed for any unknown status
}

View file

@ -23,92 +23,6 @@ func NewUpdateQueries(db *sqlx.DB) *UpdateQueries {
return &UpdateQueries{db: db}
}
// UpsertUpdate inserts or updates an update package
func (q *UpdateQueries) UpsertUpdate(update *models.UpdatePackage) error {
query := `
INSERT INTO update_packages (
id, agent_id, package_type, package_name, package_description,
current_version, available_version, severity, cve_list, kb_id,
repository_source, size_bytes, status, metadata
) VALUES (
:id, :agent_id, :package_type, :package_name, :package_description,
:current_version, :available_version, :severity, :cve_list, :kb_id,
:repository_source, :size_bytes, :status, :metadata
)
ON CONFLICT (agent_id, package_type, package_name, available_version)
DO UPDATE SET
package_description = EXCLUDED.package_description,
current_version = EXCLUDED.current_version,
severity = EXCLUDED.severity,
cve_list = EXCLUDED.cve_list,
kb_id = EXCLUDED.kb_id,
repository_source = EXCLUDED.repository_source,
size_bytes = EXCLUDED.size_bytes,
metadata = EXCLUDED.metadata,
discovered_at = NOW()
`
_, err := q.db.NamedExec(query, update)
return err
}
// ListUpdates retrieves updates with filtering (legacy method for update_packages table)
func (q *UpdateQueries) ListUpdates(filters *models.UpdateFilters) ([]models.UpdatePackage, int, error) {
var updates []models.UpdatePackage
whereClause := []string{"1=1"}
args := []interface{}{}
argIdx := 1
if filters.AgentID != uuid.Nil {
whereClause = append(whereClause, fmt.Sprintf("agent_id = $%d", argIdx))
args = append(args, filters.AgentID)
argIdx++
}
if filters.Status != "" {
whereClause = append(whereClause, fmt.Sprintf("status = $%d", argIdx))
args = append(args, filters.Status)
argIdx++
}
if filters.Severity != "" {
whereClause = append(whereClause, fmt.Sprintf("severity = $%d", argIdx))
args = append(args, filters.Severity)
argIdx++
}
if filters.PackageType != "" {
whereClause = append(whereClause, fmt.Sprintf("package_type = $%d", argIdx))
args = append(args, filters.PackageType)
argIdx++
}
// Get total count
countQuery := "SELECT COUNT(*) FROM update_packages WHERE " + strings.Join(whereClause, " AND ")
var total int
err := q.db.Get(&total, countQuery, args...)
if err != nil {
return nil, 0, err
}
// Get paginated results
query := fmt.Sprintf(`
SELECT * FROM update_packages
WHERE %s
ORDER BY discovered_at DESC
LIMIT $%d OFFSET $%d
`, strings.Join(whereClause, " AND "), argIdx, argIdx+1)
limit := filters.PageSize
if limit == 0 {
limit = 50
}
offset := (filters.Page - 1) * limit
if offset < 0 {
offset = 0
}
args = append(args, limit, offset)
err = q.db.Select(&updates, query, args...)
return updates, total, err
}
// GetUpdateByID retrieves a single update by ID from the new state table
func (q *UpdateQueries) GetUpdateByID(id uuid.UUID) (*models.UpdateState, error) {
var update models.UpdateState
@ -277,7 +191,7 @@ type AggregatedPackage struct {
// the package-centric Updates view. Supports a name search and a type filter; returns
// the rows for the page and the total distinct-package count. Severity is ranked in
// SQL (critical=4 … low=1) and mapped back to a label by the caller.
func (q *UpdateQueries) ListAggregatedPackages(search, packageType, sortBy, sortOrder string, page, pageSize int) ([]AggregatedPackage, int, error) {
func (q *UpdateQueries) ListAggregatedPackages(search, packageType, status, sortBy, sortOrder string, page, pageSize int) ([]AggregatedPackage, int, error) {
where := []string{}
args := []interface{}{}
i := 1
@ -296,6 +210,16 @@ func (q *UpdateQueries) ListAggregatedPackages(search, packageType, sortBy, sort
whereClause = "WHERE " + strings.Join(where, " AND ")
}
// Post-aggregation status filter — filters to packages that have at least one
// row in the requested status. Keeps the per-status counts accurate (the HAVING
// only gates which groups are returned, not the aggregate values themselves).
havingClause := ""
if status != "" {
havingClause = fmt.Sprintf("HAVING COUNT(*) FILTER (WHERE status = $%d) > 0", i)
args = append(args, status)
i++
}
severityRank := `MAX(CASE LOWER(severity)
WHEN 'critical' THEN 4 WHEN 'high' THEN 3
WHEN 'medium' THEN 2 WHEN 'moderate' THEN 2
@ -320,8 +244,8 @@ func (q *UpdateQueries) ListAggregatedPackages(search, packageType, sortBy, sort
var total int
countQuery := fmt.Sprintf(`SELECT COUNT(*) FROM (
SELECT 1 FROM current_package_state %s GROUP BY package_type, package_name
) t`, whereClause)
SELECT 1 FROM current_package_state %s GROUP BY package_type, package_name %s
) t`, whereClause, havingClause)
if err := q.db.Get(&total, countQuery, args...); err != nil {
return nil, 0, err
}
@ -356,9 +280,10 @@ func (q *UpdateQueries) ListAggregatedPackages(search, packageType, sortBy, sort
FROM current_package_state
%s
GROUP BY package_type, package_name
%s
ORDER BY %s %s, package_name ASC
LIMIT %d OFFSET %d`,
severityRank, whereClause, orderCol, dir, pageSize, offset)
severityRank, whereClause, havingClause, orderCol, dir, pageSize, offset)
var rows []AggregatedPackage
if err := q.db.Select(&rows, query, args...); err != nil {
@ -639,7 +564,7 @@ func (q *UpdateQueries) CreateUpdateEventsBatch(events []models.UpdateEvent) err
processedCount++
// Update current state
if err := q.updateCurrentStateInTx(tx, &event); err != nil {
if err := q.UpdateCurrentStateInTx(tx, &event); err != nil {
// Log error but don't fail the entire batch
log.Printf("[WARNING] [server] [database] update_state_failed package=%s error=%v", event.PackageName, err)
}
@ -667,7 +592,7 @@ func (q *UpdateQueries) CreateUpdateEventsBatch(events []models.UpdateEvent) err
}
// updateCurrentStateInTx updates the current_package_state table within a transaction
func (q *UpdateQueries) updateCurrentStateInTx(tx *sqlx.Tx, event *models.UpdateEvent) error {
func (q *UpdateQueries) UpdateCurrentStateInTx(tx *sqlx.Tx, event *models.UpdateEvent) error {
query := `
INSERT INTO current_package_state (
agent_id, package_type, package_name, current_version, available_version,
@ -699,6 +624,22 @@ func (q *UpdateQueries) updateCurrentStateInTx(tx *sqlx.Tx, event *models.Update
return err
}
// UpsertCurrentState inserts or updates a row in current_package_state from an
// UpdateEvent. Opens its own transaction — use for single-row writes outside a batch.
func (q *UpdateQueries) UpsertCurrentState(event *models.UpdateEvent) error {
tx, err := q.db.Beginx()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
if err := q.UpdateCurrentStateInTx(tx, event); err != nil {
return err
}
return tx.Commit()
}
// ListUpdatesFromState returns paginated updates from current state with filtering
func (q *UpdateQueries) ListUpdatesFromState(filters *models.UpdateFilters) ([]models.UpdateState, int, error) {
var updates []models.UpdateState
@ -1213,3 +1154,46 @@ func (q *UpdateQueries) GetSubsystemStats(agentID uuid.UUID) (map[string]int64,
return stats, nil
}
// StoreSupplyChainMetadata merges OSV.dev vulnerability results into the
// current_package_state metadata for a given agent + package. Uses jsonb
// concatenation so existing metadata keys are preserved.
func (q *UpdateQueries) StoreSupplyChainMetadata(agentID uuid.UUID, pkgType, pkgName string, meta models.JSONB) error {
metaJSON, err := json.Marshal(meta)
if err != nil {
return fmt.Errorf("marshal supply chain metadata: %w", err)
}
query := `
UPDATE current_package_state
SET metadata = COALESCE(metadata, '{}'::jsonb) || $4::jsonb,
last_updated_at = NOW()
WHERE agent_id = $1 AND package_type = $2 AND package_name = $3`
_, err = q.db.Exec(query, agentID, pkgType, pkgName, string(metaJSON))
return err
}
// GetUncheckedPackages returns distinct (package_type, package_name, version_to,
// agent_id) tuples from current_package_state that have never had a supply-chain
// check. Used by the startup backfill.
func (q *UpdateQueries) GetUncheckedPackages() ([]OSVBackfillRow, error) {
query := `
SELECT DISTINCT ON (package_type, package_name, available_version, agent_id)
package_type, package_name, available_version, agent_id
FROM current_package_state
WHERE (metadata IS NULL OR NOT metadata ? 'supply_chain_checked_at')
AND available_version IS NOT NULL AND available_version != ''
ORDER BY package_type, package_name, available_version, agent_id`
var rows []OSVBackfillRow
if err := q.db.Select(&rows, query); err != nil {
return nil, err
}
return rows, nil
}
// OSVBackfillRow is a minimal row for the OSV backfill query.
type OSVBackfillRow struct {
PackageType string `db:"package_type"`
PackageName string `db:"package_name"`
Version string `db:"available_version"`
AgentID uuid.UUID `db:"agent_id"`
}

View file

@ -23,6 +23,7 @@ type DockerContainer struct {
AgentName string `json:"agent_name,omitempty"`
AgentHostname string `json:"agent_hostname,omitempty"`
Status string `json:"status"`
Severity string `json:"severity,omitempty"`
State string `json:"state,omitempty"`
Ports []DockerPort `json:"ports,omitempty"`
CreatedAt time.Time `json:"created_at"`
@ -34,28 +35,29 @@ type DockerContainer struct {
// DockerContainerListResponse represents the response for container listing
type DockerContainerListResponse struct {
Containers []DockerContainer `json:"containers"`
Images []DockerContainer `json:"images"` // Alias for containers to match frontend expectation
TotalImages int `json:"total_images"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
Containers []DockerContainer `json:"containers"`
Images []DockerImage `json:"images"`
TotalImages int `json:"total_images"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// DockerImage represents a Docker image
type DockerImage struct {
ID string `json:"id"`
Repository string `json:"repository"`
Tag string `json:"tag"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
AgentID string `json:"agent_id"`
AgentName string `json:"agent_name,omitempty"`
UpdateAvailable bool `json:"update_available"`
CurrentVersion string `json:"current_version"`
AvailableVersion string `json:"available_version"`
ID string `json:"id"`
Repository string `json:"repository"`
Tag string `json:"tag"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
AgentID string `json:"agent_id"`
AgentName string `json:"agent_name,omitempty"`
Severity string `json:"severity,omitempty"`
UpdateAvailable bool `json:"update_available"`
CurrentVersion string `json:"current_version"`
AvailableVersion string `json:"available_version"`
}
// DockerStats represents Docker statistics across all agents
@ -84,19 +86,17 @@ type BulkDockerUpdateRequest struct {
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
}
// AgentDockerImage represents a Docker image as sent by the agent
// AgentDockerImage represents a Docker image as sent by the agent.
// Fields match the agent's client.DockerReportItem JSON tags so the
// payload deserializes correctly.
type AgentDockerImage struct {
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
ImageID string `json:"image_id"`
RepositorySource string `json:"repository_source"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
HasUpdate bool `json:"has_update"`
LatestImageID string `json:"latest_image_id"`
Severity string `json:"severity"`
Labels map[string]string `json:"labels"`
Metadata map[string]interface{} `json:"metadata"`
PackageType string `json:"package_type"`
PackageName string `json:"package_name"`
CurrentVersion string `json:"current_version"`
AvailableVersion string `json:"available_version"`
Severity string `json:"severity"`
RepositorySource string `json:"repository_source"`
Metadata map[string]interface{} `json:"metadata"`
}
// DockerReportRequest is sent by agents when reporting Docker image updates
@ -126,6 +126,8 @@ type DockerImageInfo struct {
AvailableVersion string `json:"available_version"`
EventType string `json:"event_type"`
CreatedAtTime time.Time `json:"created_at_time"`
Ports []DockerPort `json:"ports,omitempty"`
ContainerName string `json:"container_name,omitempty"`
}
// DockerImageUpdate represents a Docker image update from agent scans

View file

@ -6,29 +6,6 @@ import (
"github.com/google/uuid"
)
// UpdatePackage represents a single update available for installation
type UpdatePackage struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
PackageType string `json:"package_type" db:"package_type"`
PackageName string `json:"package_name" db:"package_name"`
PackageDescription string `json:"package_description" db:"package_description"`
CurrentVersion string `json:"current_version" db:"current_version"`
AvailableVersion string `json:"available_version" db:"available_version"`
Severity string `json:"severity" db:"severity"`
CVEList StringArray `json:"cve_list" db:"cve_list"`
KBID string `json:"kb_id" db:"kb_id"`
RepositorySource string `json:"repository_source" db:"repository_source"`
SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
Status string `json:"status" db:"status"`
DiscoveredAt time.Time `json:"discovered_at" db:"discovered_at"`
ApprovedBy string `json:"approved_by,omitempty" db:"approved_by"`
ApprovedAt *time.Time `json:"approved_at,omitempty" db:"approved_at"`
ScheduledFor *time.Time `json:"scheduled_for,omitempty" db:"scheduled_for"`
InstalledAt *time.Time `json:"installed_at,omitempty" db:"installed_at"`
ErrorMessage string `json:"error_message,omitempty" db:"error_message"`
Metadata JSONB `json:"metadata" db:"metadata"`
}
// UpdateReportRequest is sent by agents when reporting discovered updates
type UpdateReportRequest struct {

View file

@ -99,12 +99,13 @@ func CheckOSVVulnerabilities(pkgName, ecosystem, version string) *SupplyChainChe
}
}
// NeedsSupplyChainCheck returns true if the given package ecosystem is one
// we can check against OSV.dev. This is the OSV gate only — not the same as
// capability-gate eligibility or server-fetchability.
// 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).
func NeedsSupplyChainCheck(pkgType string) bool {
switch pkgType {
case "npm", "pypi":
case "npm", "pypi", "apt", "dnf":
return true
}
return false
@ -137,12 +138,19 @@ func NeedsCapabilityGate(pkgType string) bool {
}
// 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.
func EcosystemFromPackageType(pkgType string) string {
switch pkgType {
case "npm":
return "npm"
case "pypi":
return "PyPI"
case "apt":
return "Debian"
case "dnf":
return "AlmaLinux"
}
return pkgType
}

View file

@ -168,9 +168,8 @@ case "$PM" in
apt)
cat <<'EOF' | sudo tee "$SUDOERS_FILE" > /dev/null
# RedFlag Agent minimal sudo permissions - APT
# Discovery — non-mutating (read-only)
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/apt update
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/apt install --dry-run --yes *
# apt discovery needs no sudo — it runs unprivileged with lists/cache/state
# redirected to an agent-writable temp dir (installer/discovery.go). No apt grants.
# Mutation — ONLY through the capability-token executor
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/systemd-run --pipe --property=ProtectSystem=no -- /usr/local/bin/redflag-helper
@ -199,10 +198,8 @@ EOF
*)
cat <<'EOF' | sudo tee "$SUDOERS_FILE" > /dev/null
# RedFlag Agent minimal sudo permissions - Generic (APT and DNF)
# Discovery — non-mutating (read-only). DNF discovery needs no sudo (runs
# unprivileged with log/cache redirected to a temp dir); only apt update does.
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/apt update
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/apt install --dry-run --yes *
# Both apt and dnf discovery run unprivileged (lists/log/cache redirected to an
# agent-writable temp dir, installer/discovery.go). No discovery grants.
# Mutation — ONLY through the capability-token executor
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/systemd-run --pipe --property=ProtectSystem=no -- /usr/local/bin/redflag-helper
@ -210,22 +207,11 @@ EOF
;;
esac
# Add Docker commands
cat <<'DOCKER_EOF' | sudo tee -a "$SUDOERS_FILE" > /dev/null
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/docker pull *
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/docker image inspect *
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/docker manifest inspect *
DOCKER_EOF
# Add self-update commands
cat <<'UPDATE_EOF' | sudo tee -a "$SUDOERS_FILE" > /dev/null
# RedFlag Agent self-update permissions
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/cp /tmp/redflag-update-*.bin /usr/local/bin/redflag-agent
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/cp /usr/local/bin/redflag-agent /usr/local/bin/redflag-agent.bak
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/cp /usr/local/bin/redflag-agent.bak /usr/local/bin/redflag-agent
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/chmod 755 /usr/local/bin/redflag-agent
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/systemctl restart redflag-agent
UPDATE_EOF
# No Docker grants: the agent reaches the docker socket via the docker group
# (added above), so docker discovery/pull need no sudo.
# No self-update grants: the binary swap (cp/.bak/chmod/systemctl restart) is
# performed by the privileged helper under an agent-self capability token, not by
# the agent. The single systemd-run helper line above is the agent's only sudo.
sudo chmod 440 "$SUDOERS_FILE"
if visudo -c -f "$SUDOERS_FILE" &>/dev/null; then

View file

@ -137,7 +137,8 @@ const App: React.FC = () => {
<Route path="/updates" element={<Updates />} />
<Route path="/updates/:id" element={<Updates />} />
<Route path="/docker" element={<Docker />} />
<Route path="/live" element={<LiveOperations />} />
<Route path="/live" element={<Navigate to="/staging" replace />} />
<Route path="/staging" element={<LiveOperations />} />
<Route path="/history" element={<History />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/general" element={<General />} />

View file

@ -59,10 +59,10 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
current: location.pathname.startsWith('/docker'),
},
{
name: 'Live Operations',
href: '/live',
name: 'Staging',
href: '/staging',
icon: Activity,
current: location.pathname === '/live',
current: location.pathname === '/staging',
},
{
name: 'History',

View file

@ -376,9 +376,8 @@ const Docker: React.FC = () => {
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={cn('inline-flex px-2 py-1 text-xs font-semibold rounded-full', getSeverityColor('medium'))}>
{/* Docker updates are typically medium or low severity by default */}
{'medium'}
<span className={cn('inline-flex px-2 py-1 text-xs font-semibold rounded-full', getSeverityColor(container.severity || 'medium'))}>
{container.severity || 'medium'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">

View file

@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Activity,
Clock,
@ -17,9 +18,13 @@ import {
RotateCcw,
X,
Archive,
GitBranch,
Layers,
ArrowRight,
} from 'lucide-react';
import { useAgents } from '@/hooks/useAgents';
import { useActiveCommands, useRetryCommand, useCancelCommand, useClearFailedCommands } from '@/hooks/useCommands';
import { useUpdates } from '@/hooks/useUpdates';
import { getStatusColor, formatRelativeTime } from '@/lib/utils';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
@ -59,7 +64,7 @@ const LiveOperations: React.FC = () => {
});
// Fetch active commands from API
const { data: activeCommandsData, refetch: refetchCommands } = useActiveCommands(autoRefresh);
const { data: activeCommandsData, isLoading: commandsLoading, isError: commandsError, refetch: refetchCommands } = useActiveCommands(autoRefresh);
// Retry, cancel, and cleanup mutations
const retryMutation = useRetryCommand();
@ -70,6 +75,12 @@ const LiveOperations: React.FC = () => {
const { data: agentsData } = useAgents();
const agents = agentsData?.agents || [];
// Fetch staged packages — updates awaiting dependency review
const { data: stagedData } = useUpdates({ status: 'pending_dependencies', page_size: 50 });
const stagedUpdates = stagedData?.updates || [];
const navigate = useNavigate();
// Transform API data to LiveOperation format
const activeOperations: LiveOperation[] = React.useMemo(() => {
if (!activeCommandsData?.commands) {
@ -251,10 +262,10 @@ const LiveOperations: React.FC = () => {
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center space-x-2">
<Activity className="h-6 w-6" />
<span>Live Operations</span>
<span>Staging</span>
</h1>
<p className="mt-1 text-sm text-gray-600">
Real-time monitoring of ongoing update operations
Assembling updates dependencies, approvals, and installs in flight
</p>
</div>
<div className="flex items-center space-x-4">
@ -392,8 +403,98 @@ const LiveOperations: React.FC = () => {
)}
</div>
{/* Staged packages — awaiting dependency review */}
{stagedUpdates.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-3 inline-flex items-center gap-2">
<Layers className="h-4 w-4 text-amber-600" />
Staged for Review
<span className="text-xs text-gray-500 font-normal normal-case tracking-normal">({stagedUpdates.length})</span>
</h3>
<div className="space-y-3">
{stagedUpdates.map((update) => {
const deps: string[] = Array.isArray(update.metadata?.dependencies)
? update.metadata.dependencies
: [];
const agent = agents.find(a => a.id === update.agent_id);
return (
<div
key={update.id}
className="bg-white rounded-lg border border-amber-200 shadow-sm overflow-hidden cursor-pointer hover:border-amber-300 transition-colors"
onClick={() => navigate(`/updates/${update.id}`)}
>
<div className="p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-amber-600" />
<div>
<span className="font-mono text-sm font-medium text-gray-900">{update.package_name}</span>
<span className="text-xs text-gray-500 ml-2">{update.package_type}</span>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Computer className="h-3 w-3" />
{agent?.hostname || update.agent_id?.slice(0, 8)}
</div>
</div>
{/* Dependency tree */}
{deps.length > 0 && (
<div className="ml-8 pl-4 border-l-2 border-amber-200 space-y-1">
{deps.map((dep, i) => (
<div key={i} className="flex items-center gap-2 text-sm text-gray-600">
<GitBranch className="h-3 w-3 text-gray-400 flex-shrink-0" />
<span className="font-mono text-xs">{dep}</span>
<span className="text-xs text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">pending review</span>
</div>
))}
</div>
)}
{deps.length === 0 && (
<div className="ml-8 text-xs text-gray-400">No additional dependencies detected</div>
)}
<div className="mt-3 flex items-center gap-2">
<AlertTriangle className="h-3 w-3 text-amber-500" />
<span className="text-xs text-amber-700">Awaiting dependency confirmation</span>
<ArrowRight className="h-3 w-3 text-amber-400 ml-auto" />
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* In-progress operations */}
{filteredOperations.length > 0 && (
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-3 inline-flex items-center gap-2">
<Activity className="h-4 w-4 text-blue-600" />
In Progress
<span className="text-xs text-gray-500 font-normal normal-case tracking-normal">({filteredOperations.length})</span>
</h3>
)}
{/* Operations list */}
{filteredOperations.length === 0 ? (
{commandsLoading ? (
<div className="text-center py-12">
<Loader2 className="mx-auto h-12 w-12 text-blue-500 animate-spin" />
<h3 className="mt-2 text-sm font-medium text-gray-900">Loading operations</h3>
<p className="mt-1 text-sm text-gray-500">Fetching active commands...</p>
</div>
) : commandsError ? (
<div className="text-center py-12">
<AlertTriangle className="mx-auto h-12 w-12 text-amber-500" />
<h3 className="mt-2 text-sm font-medium text-gray-900">Failed to load operations</h3>
<p className="mt-1 text-sm text-gray-500">The server may be unavailable.</p>
<button
onClick={() => refetchCommands()}
className="mt-3 inline-flex items-center px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100"
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Retry
</button>
</div>
) : filteredOperations.length === 0 ? (
<div className="text-center py-12">
<Activity className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No active operations</h3>

View file

@ -104,11 +104,12 @@ const Updates: React.FC = () => {
page_size: pageSize,
});
// Fetch the package-centric list (one row per package across the fleet). Search
// and type filter server-side; severity/status chips filter the loaded rows below.
// Fetch the package-centric list (one row per package across the fleet).
// Search, type, and status are server-side; severity filters client-side below.
const { data: packagesData, isPending: packagesPending, error: packagesError } = usePackages({
search: debouncedSearchQuery || undefined,
type: typeFilter || undefined,
status: statusFilter || undefined,
sort_by: sortBy || undefined,
sort_order: sortOrder || undefined,
page: currentPage,
@ -146,15 +147,7 @@ const Updates: React.FC = () => {
const packageTotal = packagesData?.total || 0;
const displayedPackages = packages.filter((p) => {
if (severityFilter && p.max_severity !== severityFilter) return false;
switch (statusFilter) {
case 'pending': return p.pending_count > 0;
case 'approved': return p.approved_count > 0;
case 'installing': return p.installing_count > 0;
case 'installed': return p.installed_count > 0;
case 'failed': return p.failed_count > 0;
case 'pending_dependencies': return p.pending_dependencies_count > 0;
default: return true;
}
return true;
});
const packageTotalPages = Math.ceil(packageTotal / pageSize);
const packageHasNext = currentPage < packageTotalPages;

View file

@ -100,6 +100,7 @@ export interface DockerContainer {
image: string;
tag: string;
status: 'running' | 'stopped' | 'paused' | 'restarting' | 'removing' | 'exited' | 'dead';
severity?: 'low' | 'medium' | 'high' | 'critical';
created_at: string;
started_at: string | null;
ports: DockerPort[];