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:
parent
177b1e3b02
commit
0d908ba512
22 changed files with 935 additions and 450 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Reference in a new issue