projection: begin the exact-path public epoch

The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally.

Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
Fimeg 2026-09-08 21:59:33 -04:00
commit 67e26be2d9
838 changed files with 157302 additions and 0 deletions

View file

@ -0,0 +1,303 @@
package supplychain
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/constants"
)
// Staging paths are derived from constants.GetAgentStagingPath so they are
// platform-aware. The cross-language contract with the Rust helper
// (helper/src/main.rs DEFAULT_* constants) uses the same suffix values.
var (
agentSelfStagingPath = constants.GetAgentStagingPath("pending-upgrade.bin")
helperSelfStagingPath = constants.GetAgentStagingPath("pending-helper.bin")
desktopSelfStagingPath = constants.GetAgentStagingPath("pending-desktop.bin")
)
const (
AgentSelfPackageType = "agent-self"
HelperSelfPackageType = "helper-self"
DesktopSelfPackageType = "desktop-self"
selfUpdateOperation = "upgrade"
maxSelfUpdateBinarySize = 500 * 1024 * 1024
)
func (c *Consumer) processAgentSelfToken(ctx context.Context, token *capability.Token) (*PolicyResult, error) {
if token.Operation != selfUpdateOperation {
return nil, fmt.Errorf("agent-self operation %q not supported", token.Operation)
}
agentEntry, err := findClosureEntry(token, "redflag-agent")
if err != nil {
return nil, err
}
stagedAgent, err := c.stageClosureArtifact(agentEntry, agentSelfStagingPath)
if err != nil {
return nil, fmt.Errorf("stage agent-self artifact: %w", err)
}
defer os.Remove(stagedAgent)
var helperArgs []string
if helperEntry, err := findClosureEntry(token, "redflag-helper"); err == nil {
stagedHelper, err := c.stageClosureArtifact(helperEntry, helperSelfStagingPath)
if err != nil {
return nil, fmt.Errorf("stage agent-self helper artifact: %w", err)
}
defer os.Remove(stagedHelper)
helperArgs = []string{"--helper-file", stagedHelper}
}
return c.executor.Execute(ctx, token, helperArgs...)
}
func (c *Consumer) processHelperSelfToken(ctx context.Context, token *capability.Token) (*PolicyResult, error) {
if token.Operation != selfUpdateOperation {
return nil, fmt.Errorf("helper-self operation %q not supported", token.Operation)
}
entry, err := findClosureEntry(token, "redflag-helper")
if err != nil {
return nil, err
}
staged, err := c.stageClosureArtifact(entry, helperSelfStagingPath)
if err != nil {
return nil, fmt.Errorf("stage helper-self artifact: %w", err)
}
defer os.Remove(staged)
return c.executor.Execute(ctx, token)
}
// processDesktopSelfToken stages the new desktop binary where the helper reads
// it, then hands the token to the privileged executor — the same path agent-self
// and helper-self take. The helper verifies the signature and hash, consumes the
// replay slot (record-before-install), and atomically installs the binary. The
// agent no longer mutates the binary or keeps its own replay file (GATE-004): the
// desktop app stays a pure status surface, and the one hardened replay guard
// lives in the helper. The token, closure, and signed message are unchanged.
func (c *Consumer) processDesktopSelfToken(ctx context.Context, token *capability.Token) (*PolicyResult, error) {
if token.Operation != selfUpdateOperation {
return nil, fmt.Errorf("desktop-self operation %q not supported", token.Operation)
}
entry, err := findClosureEntry(token, "redflag-desktop")
if err != nil {
return nil, err
}
staged, err := c.stageClosureArtifact(entry, desktopSelfStagingPath)
if err != nil {
return nil, fmt.Errorf("stage desktop-self artifact: %w", err)
}
defer os.Remove(staged)
result, err := c.executor.Execute(ctx, token)
if err != nil {
return result, err
}
// Best-effort: nudge the running desktop app to relaunch on the new binary.
// This is not a privileged or trust-sensitive step — just a signal — so it
// stays agent-side. A failure here does not undo a successful install.
if result != nil && result.Executed {
if target, perr := desktopBinaryPath(); perr == nil {
if sigErr := signalDesktopRestart(target); sigErr != nil {
log.Printf("[WARNING] [agent] [supplychain] desktop_restart_signal_failed token_id=%s error=%v", token.TokenID, sigErr)
}
}
}
return result, nil
}
func findClosureEntry(token *capability.Token, name string) (*capability.ClosureEntry, error) {
for i := range token.Closure {
if token.Closure[i].Name == name {
return &token.Closure[i], nil
}
}
return nil, fmt.Errorf("closure missing %s entry", name)
}
func (c *Consumer) stageClosureArtifact(entry *capability.ClosureEntry, dstPath string) (string, error) {
if entry.ArtifactPath == "" {
return "", fmt.Errorf("closure entry %s missing artifact_path", entry.Name)
}
if strings.TrimSpace(entry.SHA256) == "" {
return "", fmt.Errorf("closure entry %s missing sha256", entry.Name)
}
dstDir := filepath.Dir(dstPath)
if err := os.MkdirAll(dstDir, 0o700); err != nil {
return "", fmt.Errorf("create staging dir: %w", err)
}
tmp, err := os.CreateTemp(dstDir, filepath.Base(dstPath)+".")
if err != nil {
return "", fmt.Errorf("create staging temp: %w", err)
}
tmpPath := tmp.Name()
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("close staging temp: %w", err)
}
cleanup := true
defer func() {
if cleanup {
os.Remove(tmpPath)
}
}()
if err := c.fetchArtifactToFile(entry.ArtifactPath, tmpPath); err != nil {
return "", err
}
actual, err := computeFileSHA256Hex(tmpPath)
if err != nil {
return "", err
}
if !strings.EqualFold(actual, entry.SHA256) {
return "", fmt.Errorf("hash mismatch for %s: expected=%s actual=%s", entry.Name, strings.ToLower(entry.SHA256), actual)
}
if err := os.Chmod(tmpPath, 0o644); err != nil {
return "", fmt.Errorf("chmod staged artifact: %w", err)
}
if err := os.Rename(tmpPath, dstPath); err != nil {
return "", fmt.Errorf("commit staged artifact: %w", err)
}
cleanup = false
return dstPath, nil
}
func (c *Consumer) fetchArtifactToFile(ref, dstPath string) error {
// Download refs (full URLs and server-relative /api/ paths) never touch the
// local-path branch — "/api/v1/..." would otherwise be mistaken for a
// missing on-disk staging path.
if isDownloadRef(ref) {
if c.downloader == nil {
return fmt.Errorf("artifact downloader unavailable for %s", ref)
}
if _, err := c.downloader.DownloadAuthenticatedToFile(ref, dstPath, maxSelfUpdateBinarySize); err != nil {
return fmt.Errorf("download artifact %s: %w", ref, err)
}
return nil
}
if info, err := os.Stat(ref); err == nil {
if info.IsDir() {
return fmt.Errorf("artifact path is a directory: %s", ref)
}
return copyRegularFile(ref, dstPath)
} else if !os.IsNotExist(err) {
// Stat failed for a reason other than "not found" (e.g. permission denied).
return fmt.Errorf("stat artifact %s: %w", ref, err)
}
// Local absolute path that doesn't exist — don't fall through to the
// downloader; the file was expected on disk and is missing.
if strings.HasPrefix(ref, "/") {
return fmt.Errorf("artifact not staged at %s", ref)
}
return copyRegularFile(ref, dstPath)
}
// isDownloadRef reports whether ref is fetched over the network: a full URL or
// a server-relative API path (resolved against the configured server with the
// agent's credentials by DownloadAuthenticatedToFile).
func isDownloadRef(ref string) bool {
return strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") || strings.HasPrefix(ref, "/api/")
}
func desktopBinaryPath() (string, error) {
if p := os.Getenv("REDFLAG_DESKTOP_BINARY"); p != "" {
return p, nil
}
execPath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("determine agent executable path: %w", err)
}
name := "redflag-desktop"
if runtime.GOOS == "windows" {
name += ".exe"
}
return filepath.Join(filepath.Dir(execPath), name), nil
}
func signalDesktopRestart(target string) error {
name := filepath.Base(target)
var cmd *exec.Cmd
switch {
case runtime.GOOS == "linux":
cmd = exec.Command("pkill", "-x", name)
case runtime.GOOS == "windows":
cmd = exec.Command("taskkill", "/F", "/IM", name)
default:
return nil
}
out, err := cmd.CombinedOutput()
if err == nil {
log.Printf("[INFO] [agent] [supplychain] desktop_restart_signal_sent binary=%s", name)
return nil
}
// pkill exit 1 = no process matched; taskkill exit 128 = process not found.
// Both are non-errors — Desktop wasn't running.
if exitErr, ok := err.(*exec.ExitError); ok {
if (runtime.GOOS == "linux" && exitErr.ExitCode() == 1) ||
(runtime.GOOS == "windows" && exitErr.ExitCode() == 128) {
log.Printf("[INFO] [agent] [supplychain] desktop_restart_signal_skipped binary=%s reason=not_running", name)
return nil
}
}
return fmt.Errorf("signal restart %s failed: %w output=%s", name, err, strings.TrimSpace(string(out)))
}
func copyRegularFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer in.Close()
if info, err := in.Stat(); err != nil {
return fmt.Errorf("stat source: %w", err)
} else if info.IsDir() {
return fmt.Errorf("source is a directory: %s", src)
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("open destination: %w", err)
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return fmt.Errorf("copy file: %w", err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("close destination: %w", err)
}
return nil
}
func computeFileSHA256Hex(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("open for hash: %w", err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("hash file: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}

View file

@ -0,0 +1,352 @@
// Package supplychain is the unprivileged agent-side consumer of capability
// tokens. It fetches tokens minted by the server authority, confirms each is
// bound to this host, and hands it to the privileged executor (helper/) which
// independently verifies the signature and artifact hashes before performing the
// one authorized operation. The consumer holds no signing key and makes no
// allow/deny decision of its own — that authority lives in the token and the
// executor.
//
// This retires the rs-helper socket-decision daemon: its package-manager
// allowlist is salvaged here as defense-in-depth, but its runtime allow/deny RPC
// role is gone — authorization is the signed token.
package supplychain
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/gofrs/uuid/v5"
)
// tokenDir and resultDir are subdirectories under DataDir where the agent writes
// the token file for the helper and reads back the result. The directories are
// agent-writable and readable; the token file is 0600 agent-owned, the result
// file is written 0644 by the root helper so the agent can read it.
const (
tokenDir = "tokens"
resultDir = "results"
)
// executorTimeout bounds a single executor invocation. Package operations can be
// slow; this is a guard against a hung child, not a tight latency target.
const executorTimeout = 30 * time.Minute
// AllowedPackageManagers is salvaged from rs-helper. Defense-in-depth only: the
// signed token is the authorization. A token for a package_type outside this set
// and outside the self-update set is refused before any executor or local binary
// swap is attempted.
var AllowedPackageManagers = map[string]bool{
"apt": true,
"dnf": true,
"npm": true,
"bun": true,
"pip": true,
"docker": true,
"winget": true,
}
// AllowedSelfUpdatePackageTypes are capability-token package types for RedFlag's
// own binaries. They are not package managers and therefore stay out of
// AllowedPackageManagers, but the consumer accepts them as first-class capability
// operations.
var AllowedSelfUpdatePackageTypes = map[string]bool{
AgentSelfPackageType: true,
HelperSelfPackageType: true,
DesktopSelfPackageType: true,
}
func allowedCapabilityPackageType(packageType string) bool {
return AllowedPackageManagers[packageType] || AllowedSelfUpdatePackageTypes[packageType]
}
// PolicyResult mirrors the executor's stdout contract (helper/src/main.rs). Field
// names must stay aligned with the Rust PolicyResult serialization.
type PolicyResult struct {
TokenID string `json:"token_id"`
AgentID string `json:"agent_id"`
PackageType string `json:"package_type"`
Operation string `json:"operation"`
Decision string `json:"decision"` // executed | denied | failed
Reason string `json:"reason"`
Executed bool `json:"executed"`
VerifiedArtifacts int `json:"verified_artifacts"`
ExitCode int `json:"exit_code"`
Error string `json:"error,omitempty"`
Timestamp int64 `json:"timestamp"`
}
// TokenProcessSummary is count-only processing state safe for local status
// surfaces. It intentionally excludes token IDs and token payloads.
type TokenProcessSummary struct {
Total int
Processed int
Failed int
}
// safeTokenFilename validates that a token ID is safe to use as a filename.
// It rejects any ID containing path separators or traversal components, which
// prevents a malicious server from writing files outside the intended directory.
func safeTokenFilename(id string) (string, error) {
// filepath.Base strips directory components; reject if it changed the value.
base := filepath.Base(id)
if base != id || base == "." || base == ".." || strings.ContainsAny(id, "/\\") {
return "", fmt.Errorf("unsafe token_id: %q", id)
}
if len(base) > 128 {
return "", fmt.Errorf("token_id too long: %d chars", len(base))
}
return base + ".json", nil
}
// Executor invokes the privileged helper binary, piping a token to its stdin and
// parsing the structured result from stdout.
type Executor struct {
BinaryPath string
}
// NewExecutor returns an Executor for the given binary path, defaulting when empty.
func NewExecutor(binaryPath string) *Executor {
if binaryPath == "" {
binaryPath = constants.GetHelperBinaryPath()
}
return &Executor{BinaryPath: binaryPath}
}
// Execute runs the executor for one token. It returns the parsed PolicyResult.
// A non-zero executor exit is reflected in result.ExitCode/Decision, not as a Go
// error; a Go error is returned only when the executor could not be run or its
// output could not be parsed.
//
// The token is delivered to the helper via a file under the agent's data dir
// rather than --pipe fd-passing over dbus. dbus-broker 37 on Fedora 43 drops
// SCM_RIGHTS for new-connection handshake messages (MSG_CTRUNC), which causes
// --pipe to fail with "Connection reset by peer" at StartTransientUnit. Writing
// the token to a temp file and passing --token-file avoids the fd-transfer
// entirely while preserving the same mount-namespace escape.
func (e *Executor) Execute(ctx context.Context, token *capability.Token, extraArgs ...string) (*PolicyResult, error) {
payload, err := json.Marshal(token)
if err != nil {
return nil, fmt.Errorf("marshal token: %w", err)
}
runCtx, cancel := context.WithTimeout(ctx, executorTimeout)
defer cancel()
agentDir := filepath.Join(constants.GetBaseDir(), constants.AgentDir)
tokDir := filepath.Join(agentDir, tokenDir)
resDir := filepath.Join(agentDir, resultDir)
if err := os.MkdirAll(tokDir, 0o700); err != nil {
return nil, fmt.Errorf("create token dir: %w", err)
}
if err := os.MkdirAll(resDir, 0o700); err != nil {
return nil, fmt.Errorf("create result dir: %w", err)
}
fname, err := safeTokenFilename(token.TokenID)
if err != nil {
return nil, err
}
tokPath := filepath.Join(tokDir, fname)
resPath := filepath.Join(resDir, fname)
if err := os.Remove(resPath); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("clear stale result file: %w", err)
}
if err := os.WriteFile(tokPath, payload, 0o600); err != nil {
return nil, fmt.Errorf("write token file: %w", err)
}
defer os.Remove(tokPath)
defer os.Remove(resPath) // best-effort removal of stale results
// Invoke the helper. On Linux this goes through sudo systemd-run --wait
// so the helper escapes the agent's ProtectSystem=strict sandbox. On
// Windows the helper runs as a direct child process (both run as SYSTEM).
// Without --pipe, no SCM_RIGHTS fd-passing crosses dbus, working around
// dbus-broker 37's MSG_CTRUNC bug.
var cmd *exec.Cmd
if runtime.GOOS == "linux" {
args := []string{"systemd-run", "--wait",
"--property=ProtectSystem=no",
"--", e.BinaryPath, "--token-file", tokPath, "--result-file", resPath}
args = append(args, extraArgs...)
cmd = exec.CommandContext(runCtx, "sudo", args...)
} else {
args := []string{"--token-file", tokPath, "--result-file", resPath}
args = append(args, extraArgs...)
cmd = exec.CommandContext(runCtx, e.BinaryPath, args...)
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
runErr := cmd.Run()
// Forward the executor's structured stderr logs to the agent log stream.
// Without --pipe, stderr from systemd-run itself (unit summary, errors) is
// captured here; the helper's own stderr goes to journald via the transient
// unit and is not blocked.
if stderr.Len() > 0 {
for _, line := range bytes.Split(bytes.TrimRight(stderr.Bytes(), "\n"), []byte("\n")) {
log.Printf("[INFO] [agent] [supplychain] executor_log %s", string(line))
}
}
resultBytes, err := os.ReadFile(resPath)
if err != nil {
if runErr != nil {
return nil, fmt.Errorf("helper failed and result file not found: %w (exec error: %v)", err, runErr)
}
return nil, fmt.Errorf("helper succeeded but result file not found: %w", err)
}
var result PolicyResult
if err := json.Unmarshal(bytes.TrimSpace(resultBytes), &result); err != nil {
return nil, fmt.Errorf("parse executor result: %w", err)
}
// TOCTOU sanity check: the result's token_id must match what we sent.
// Catches accidental mismatches; a deliberate forgery would need write
// access to the results directory (requires agent-user compromise).
if result.TokenID != "" && result.TokenID != token.TokenID {
return nil, fmt.Errorf("result token_id mismatch: expected %s, got %s", token.TokenID, result.TokenID)
}
return &result, nil
}
// Reporter delivers a token result back to the server for audit (optional).
type Reporter interface {
ReportCapabilityResult(agentID uuid.UUID, tokenID string, decision, reason string, exitCode int) error
}
// ArtifactDownloader is implemented by the authenticated API client. Self-update
// tokens may point closure entries at relative or absolute binary URLs.
type ArtifactDownloader interface {
DownloadAuthenticatedToFile(rawURL, dstPath string, maxBytes int64) (int64, error)
}
// Consumer ties together fetching, bind-checking, executing, and reporting.
type Consumer struct {
agentID uuid.UUID
executor *Executor
reporter Reporter // optional
downloader ArtifactDownloader // optional, inferred from reporter when available
// mu serializes ProcessToken. The helper's replay-state guard is check-then-act
// (read-scan-rewrite) and not safe under concurrent processing of the same token;
// GATE-004 B removed the agent-side desktop-self replay file, so the helper guard
// is now the only one. The instance lock is process-level only; today's single
// caller (loop.go) never overlaps, but a future second caller — local-API trigger,
// retry worker — would race without this. One token at a time is the design
// invariant; this enforces it.
mu sync.Mutex
}
// NewConsumer builds a consumer bound to this host's agent identity.
func NewConsumer(agentID uuid.UUID, executor *Executor, reporter Reporter) *Consumer {
var downloader ArtifactDownloader
if d, ok := reporter.(ArtifactDownloader); ok {
downloader = d
}
return &Consumer{agentID: agentID, executor: executor, reporter: reporter, downloader: downloader}
}
// ProcessToken runs the full path for one token. It refuses, before invoking the
// executor, any token not bound to this host or for an unsupported package type;
// the executor enforces the same checks again as the privileged authority.
func (c *Consumer) ProcessToken(ctx context.Context, token *capability.Token) (*PolicyResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if token.AgentID != c.agentID.String() {
log.Printf("[SECURITY] [agent] [supplychain] bind_check_failed token_id=%s token_agent_id=%s host_agent_id=%s",
token.TokenID, token.AgentID, c.agentID)
err := fmt.Errorf("token not bound to this host")
c.reportTokenProcessFailure(token, err)
return nil, err
}
if !allowedCapabilityPackageType(token.PackageType) {
log.Printf("[SECURITY] [agent] [supplychain] package_type_refused token_id=%s package_type=%s",
token.TokenID, token.PackageType)
err := fmt.Errorf("package_type %q not in allowlist", token.PackageType)
c.reportTokenProcessFailure(token, err)
return nil, err
}
var (
result *PolicyResult
err error
)
switch token.PackageType {
case AgentSelfPackageType:
result, err = c.processAgentSelfToken(ctx, token)
case HelperSelfPackageType:
result, err = c.processHelperSelfToken(ctx, token)
case DesktopSelfPackageType:
result, err = c.processDesktopSelfToken(ctx, token)
default:
result, err = c.executor.Execute(ctx, token)
}
if err != nil {
log.Printf("[ERROR] [agent] [supplychain] token_process_failed token_id=%s package_type=%s error=%v",
token.TokenID, token.PackageType, err)
c.reportTokenProcessFailure(token, err)
return nil, err
}
log.Printf("[SECURITY] [agent] [supplychain] token_processed token_id=%s decision=%s reason=%s exit=%d verified_artifacts=%d",
token.TokenID, result.Decision, result.Reason, result.ExitCode, result.VerifiedArtifacts)
if c.reporter != nil {
if err := c.reporter.ReportCapabilityResult(c.agentID, token.TokenID, result.Decision, result.Reason, result.ExitCode); err != nil {
log.Printf("[WARNING] [agent] [supplychain] result_report_failed token_id=%s error=%v", token.TokenID, err)
}
}
return result, nil
}
func (c *Consumer) reportTokenProcessFailure(token *capability.Token, cause error) {
if c.reporter == nil || token == nil {
return
}
reason := "token_process_failed"
if cause != nil {
reason = cause.Error()
}
if err := c.reporter.ReportCapabilityResult(c.agentID, token.TokenID, "failed", reason, 1); err != nil {
log.Printf("[WARNING] [agent] [supplychain] failure_result_report_failed token_id=%s error=%v", token.TokenID, err)
}
}
// ProcessTokens runs ProcessToken for each token, continuing past individual
// failures so one bad token does not strand the rest.
func (c *Consumer) ProcessTokens(ctx context.Context, tokens []*capability.Token) {
_ = c.ProcessTokensWithSummary(ctx, tokens)
}
// ProcessTokensWithSummary runs ProcessToken for each token and returns count-only
// outcome state for local observability.
func (c *Consumer) ProcessTokensWithSummary(ctx context.Context, tokens []*capability.Token) TokenProcessSummary {
summary := TokenProcessSummary{Total: len(tokens)}
for _, token := range tokens {
if _, err := c.ProcessToken(ctx, token); err != nil {
summary.Failed++
log.Printf("[WARNING] [agent] [supplychain] token_skipped token_id=%s error=%v", token.TokenID, err)
continue
}
summary.Processed++
}
return summary
}

View file

@ -0,0 +1,218 @@
package supplychain
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/gofrs/uuid/v5"
)
type recordingReporter struct {
agentID uuid.UUID
tokenID string
decision string
reason string
exitCode int
calls int
}
func (r *recordingReporter) ReportCapabilityResult(agentID uuid.UUID, tokenID string, decision, reason string, exitCode int) error {
r.agentID = agentID
r.tokenID = tokenID
r.decision = decision
r.reason = reason
r.exitCode = exitCode
r.calls++
return nil
}
func TestSafeTokenFilename(t *testing.T) {
tests := []struct {
name string
id string
wantErr bool
}{
{"valid uuid", "550e8400-e29b-41d4-a716-446655440000", false},
{"simple alphanumeric", "tok-123", false},
{"path traversal dot-dot", "../etc/passwd", true},
{"path traversal slash", "tok/../../secret", true},
{"path traversal backslash", "tok\\..\\secret", true},
{"single dot", ".", true},
{"double dot", "..", true},
{"empty string", "", true},
{"too long", string(make([]byte, 200)), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := safeTokenFilename(tt.id)
if tt.wantErr {
if err == nil {
t.Errorf("safeTokenFilename(%q) = %q, want error", tt.id, got)
}
return
}
if err != nil {
t.Errorf("safeTokenFilename(%q) error: %v", tt.id, err)
return
}
if got != tt.id+".json" {
t.Errorf("safeTokenFilename(%q) = %q, want %q", tt.id, got, tt.id+".json")
}
})
}
}
func TestAllowedPackageManagers(t *testing.T) {
allowed := []string{"apt", "dnf", "npm", "bun", "pip", "docker", "winget"}
for _, pm := range allowed {
if !AllowedPackageManagers[pm] {
t.Errorf("expected %q to be in AllowedPackageManagers", pm)
}
}
blocked := []string{"yum", "pacman", "brew", "cargo", ""}
for _, pm := range blocked {
if AllowedPackageManagers[pm] {
t.Errorf("expected %q to NOT be in AllowedPackageManagers", pm)
}
}
}
func TestAllowedSelfUpdatePackageTypes(t *testing.T) {
allowed := []string{AgentSelfPackageType, HelperSelfPackageType, DesktopSelfPackageType}
for _, packageType := range allowed {
if !AllowedSelfUpdatePackageTypes[packageType] {
t.Errorf("expected %q to be in AllowedSelfUpdatePackageTypes", packageType)
}
if AllowedPackageManagers[packageType] {
t.Errorf("expected %q to stay out of AllowedPackageManagers", packageType)
}
if !allowedCapabilityPackageType(packageType) {
t.Errorf("expected %q to be accepted by allowedCapabilityPackageType", packageType)
}
}
if allowedCapabilityPackageType("cargo") {
t.Fatal("expected cargo to remain disallowed")
}
}
func TestStageClosureArtifactFromLocalFile(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "redflag-desktop.src")
dst := filepath.Join(dir, "redflag-desktop.staged")
body := []byte("desktop-binary")
if err := os.WriteFile(src, body, 0o600); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(body)
consumer := NewConsumer(uuid.Must(uuid.NewV4()), NewExecutor("/nonexistent/helper"), nil)
entry := &capability.ClosureEntry{
Name: "redflag-desktop",
Version: "test",
SHA256: hex.EncodeToString(sum[:]),
Source: "desktop-self",
ArtifactPath: src,
}
staged, err := consumer.stageClosureArtifact(entry, dst)
if err != nil {
t.Fatalf("stageClosureArtifact returned error: %v", err)
}
if staged != dst {
t.Fatalf("staged path = %q, want %q", staged, dst)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(got) != string(body) {
t.Fatalf("staged body = %q, want %q", string(got), string(body))
}
}
func TestConsumerProcessTokenRejectsWrongAgentID(t *testing.T) {
agentID := uuid.Must(uuid.NewV4())
executor := NewExecutor("/nonexistent/helper")
reporter := &recordingReporter{}
consumer := NewConsumer(agentID, executor, reporter)
wrongID := uuid.Must(uuid.NewV4())
tok := &capability.Token{
TokenID: "tok-1",
AgentID: wrongID.String(),
PackageType: "npm",
Operation: "install",
}
_, err := consumer.ProcessToken(t.Context(), tok)
if err == nil {
t.Fatal("expected error for wrong agent ID, got nil")
}
if got := err.Error(); got != "token not bound to this host" {
t.Errorf("unexpected error: %v", got)
}
if reporter.calls != 1 {
t.Fatalf("reporter calls = %d, want 1", reporter.calls)
}
if reporter.agentID != agentID || reporter.tokenID != "tok-1" || reporter.decision != "failed" || reporter.exitCode != 1 {
t.Fatalf("reported result = %#v", reporter)
}
if reporter.reason != "token not bound to this host" {
t.Fatalf("reported reason = %q", reporter.reason)
}
}
func TestConsumerProcessTokenRejectsDisallowedPackageType(t *testing.T) {
agentID := uuid.Must(uuid.NewV4())
executor := NewExecutor("/nonexistent/helper")
reporter := &recordingReporter{}
consumer := NewConsumer(agentID, executor, reporter)
tok := &capability.Token{
TokenID: "tok-2",
AgentID: agentID.String(),
PackageType: "cargo", // not in allowlist
Operation: "install",
}
_, err := consumer.ProcessToken(t.Context(), tok)
if err == nil {
t.Fatal("expected error for disallowed package type, got nil")
}
if reporter.calls != 1 {
t.Fatalf("reporter calls = %d, want 1", reporter.calls)
}
if reporter.tokenID != "tok-2" || reporter.decision != "failed" || reporter.reason != "package_type \"cargo\" not in allowlist" {
t.Fatalf("reported result = %#v", reporter)
}
}
func TestConsumerProcessTokensSummary(t *testing.T) {
agentID := uuid.Must(uuid.NewV4())
executor := NewExecutor("/nonexistent/helper")
consumer := NewConsumer(agentID, executor, nil)
// All three tokens should fail (wrong agent ID), so summary counts failures.
tokens := []*capability.Token{
{TokenID: "t1", AgentID: "wrong", PackageType: "npm"},
{TokenID: "t2", AgentID: "wrong", PackageType: "dnf"},
{TokenID: "t3", AgentID: "wrong", PackageType: "apt"},
}
summary := consumer.ProcessTokensWithSummary(t.Context(), tokens)
if summary.Total != 3 {
t.Errorf("Total = %d, want 3", summary.Total)
}
if summary.Failed != 3 {
t.Errorf("Failed = %d, want 3", summary.Failed)
}
if summary.Processed != 0 {
t.Errorf("Processed = %d, want 0", summary.Processed)
}
}

View file

@ -0,0 +1,158 @@
package supplychain
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/gofrs/uuid/v5"
)
// Standalone local authority — mint invocation (FEAT-003, design of record
// RAF/security/06-standalone-authority.md). The unprivileged agent assembles a
// mint request (closure + gate evidence) and asks the privileged helper to
// sign a capability token with the root-owned local authority key. The helper
// re-validates everything; this file is transport, not judgment.
const (
mintRequestDir = "mint"
mintTimeout = 60 * time.Second
)
// Helper mint-mode exit codes (helper/src/main.rs deny taxonomy).
const (
mintExitGate = 22
mintExitStale = 23
mintExitKey = 24
mintExitDuplicate = 25
)
// Typed mint failures so the local API can map them to honest HTTP codes.
var (
ErrMintGateRefused = errors.New("mint refused by gate verdict")
ErrMintStale = errors.New("mint refused: gate evidence stale")
ErrMintNoAuthority = errors.New("mint refused: no local authority key (fleet mode or unprovisioned)")
ErrMintDuplicate = errors.New("mint refused: request already minted")
)
// GateEvidence is the agent's gate verdict bundle. JSON contract with the
// helper's MintRequest parsing — keep aligned with helper/src/main.rs.
type GateEvidence struct {
ResolvedAt int64 `json:"resolved_at"`
OSVCheckedAt int64 `json:"osv_checked_at"`
OSVStatus string `json:"osv_status"`
OSVVulnCount int `json:"osv_vuln_count"`
AgeGate string `json:"age_gate"`
SoakGate string `json:"soak_gate"`
Operator string `json:"operator"`
OverrideReason string `json:"override_reason"`
}
// MintRequest is the file handed to `redflag-helper mint --request-file`.
type MintRequest struct {
Version int `json:"version"`
RequestID string `json:"request_id"`
AgentID string `json:"agent_id"`
PackageType string `json:"package_type"`
Operation string `json:"operation"`
Closure []capability.ClosureEntry `json:"closure"`
GateEvidence GateEvidence `json:"gate_evidence"`
}
// Mint invokes the privileged helper's mint mode and returns the signed token.
// The request and token travel as files under the agent data dir, mirroring the
// execute path's --token-file pattern (and the same sudoers pinning style).
func (e *Executor) Mint(ctx context.Context, req *MintRequest) (*capability.Token, error) {
if req.RequestID == "" {
id, err := uuid.NewV4()
if err != nil {
return nil, fmt.Errorf("generate request id: %w", err)
}
req.RequestID = id.String()
}
req.Version = capability.Version
payload, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal mint request: %w", err)
}
agentDir := filepath.Join(constants.GetBaseDir(), constants.AgentDir)
reqDir := filepath.Join(agentDir, mintRequestDir)
tokDir := filepath.Join(agentDir, tokenDir)
if err := os.MkdirAll(reqDir, 0o750); err != nil {
return nil, fmt.Errorf("create mint request dir: %w", err)
}
if err := os.MkdirAll(tokDir, 0o700); err != nil {
return nil, fmt.Errorf("create token dir: %w", err)
}
fname, err := safeTokenFilename(req.RequestID)
if err != nil {
return nil, err
}
reqPath := filepath.Join(reqDir, fname)
tokPath := filepath.Join(tokDir, fname)
if err := os.Remove(tokPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("clear stale minted token: %w", err)
}
if err := os.WriteFile(reqPath, payload, 0o640); err != nil {
return nil, fmt.Errorf("write mint request: %w", err)
}
defer os.Remove(reqPath)
defer os.Remove(tokPath)
runCtx, cancel := context.WithTimeout(ctx, mintTimeout)
defer cancel()
args := []string{"systemd-run", "--wait",
"--property=ProtectSystem=no",
"--", e.BinaryPath, "mint", "--request-file", reqPath, "--token-out", tokPath}
cmd := exec.CommandContext(runCtx, "sudo", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
runErr := cmd.Run()
if runErr != nil {
exitCode := -1
var exitErr *exec.ExitError
if errors.As(runErr, &exitErr) {
exitCode = exitErr.ExitCode()
}
log.Printf("[SECURITY] [agent] [supplychain] mint_denied request_id=%s exit=%d stderr=%s",
req.RequestID, exitCode, stderr.String())
switch exitCode {
case mintExitGate:
return nil, fmt.Errorf("%w (request_id=%s)", ErrMintGateRefused, req.RequestID)
case mintExitStale:
return nil, fmt.Errorf("%w (request_id=%s)", ErrMintStale, req.RequestID)
case mintExitKey:
return nil, fmt.Errorf("%w (request_id=%s)", ErrMintNoAuthority, req.RequestID)
case mintExitDuplicate:
return nil, fmt.Errorf("%w (request_id=%s)", ErrMintDuplicate, req.RequestID)
default:
return nil, fmt.Errorf("mint failed exit=%d request_id=%s: %w", exitCode, req.RequestID, runErr)
}
}
raw, err := os.ReadFile(tokPath)
if err != nil {
return nil, fmt.Errorf("read minted token: %w", err)
}
var token capability.Token
if err := json.Unmarshal(raw, &token); err != nil {
return nil, fmt.Errorf("parse minted token: %w", err)
}
log.Printf("[SECURITY] [agent] [supplychain] mint_succeeded request_id=%s token_id=%s package_type=%s closure_size=%d",
req.RequestID, token.TokenID, token.PackageType, len(token.Closure))
return &token, nil
}

View file

@ -0,0 +1,218 @@
package supplychain
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/gofrs/uuid/v5"
)
const (
mutationRequestDir = "mutation-requests"
mutationEnvelopeDir = "mutation-envelopes"
mutationReceiptDir = "mutation-receipts"
mutationMintTimeout = 2 * time.Minute
)
// EnvelopeMintRequest is the unprivileged request handed to the standalone
// root authority. The helper re-resolves trust from the manifest bytes and
// gate evidence before it signs anything.
type EnvelopeMintRequest struct {
Version int `json:"version"`
RequestID string `json:"request_id"`
Manifest capability.MutationManifest `json:"manifest"`
GateEvidence GateEvidence `json:"gate_evidence"`
}
func runMutationHelper(
ctx context.Context,
timeout time.Duration,
binaryPath string,
args ...string,
) (int, string, error) {
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
unitArgs := []string{
"systemd-run", "--wait", "--property=ProtectSystem=no", "--", binaryPath,
}
unitArgs = append(unitArgs, args...)
cmd := exec.CommandContext(runCtx, "sudo", unitArgs...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
err := cmd.Run()
exitCode := 0
if err != nil {
exitCode = -1
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
}
}
return exitCode, stderr.String(), err
}
// MintEnvelope asks the root-owned standalone authority to sign one immutable
// mutation manifest. It returns the mint request ID for the durable audit join.
func (e *Executor) MintEnvelope(
ctx context.Context,
manifest capability.MutationManifest,
gateEvidence GateEvidence,
) (*capability.MutationEnvelope, string, error) {
requestID, err := uuid.NewV4()
if err != nil {
return nil, "", fmt.Errorf("generate envelope request id: %w", err)
}
request := EnvelopeMintRequest{
Version: capability.MutationProtocolVersion, RequestID: requestID.String(),
Manifest: manifest, GateEvidence: gateEvidence,
}
if err := request.Manifest.Validate(); err != nil {
return nil, request.RequestID, err
}
payload, err := json.Marshal(&request)
if err != nil {
return nil, request.RequestID, fmt.Errorf("marshal envelope request: %w", err)
}
agentDir := filepath.Join(constants.GetBaseDir(), constants.AgentDir)
requestDir := filepath.Join(agentDir, mutationRequestDir)
envelopeDir := filepath.Join(agentDir, mutationEnvelopeDir)
if err := os.MkdirAll(requestDir, 0o700); err != nil {
return nil, request.RequestID, fmt.Errorf("create mutation request dir: %w", err)
}
if err := os.MkdirAll(envelopeDir, 0o700); err != nil {
return nil, request.RequestID, fmt.Errorf("create mutation envelope dir: %w", err)
}
filename, err := safeTokenFilename(request.RequestID)
if err != nil {
return nil, request.RequestID, err
}
requestPath := filepath.Join(requestDir, filename)
envelopePath := filepath.Join(envelopeDir, filename)
if err := os.Remove(envelopePath); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, request.RequestID, fmt.Errorf("clear stale mutation envelope: %w", err)
}
if err := os.WriteFile(requestPath, payload, 0o600); err != nil {
return nil, request.RequestID, fmt.Errorf("write mutation request: %w", err)
}
defer os.Remove(requestPath)
defer os.Remove(envelopePath)
exitCode, stderr, runErr := runMutationHelper(
ctx, mutationMintTimeout, e.BinaryPath,
"mint-envelope", "--request-file", requestPath, "--envelope-out", envelopePath,
)
if runErr != nil {
log.Printf("[SECURITY] [agent] [supplychain] envelope_mint_denied request_id=%s exit=%d stderr=%s",
request.RequestID, exitCode, stderr)
switch exitCode {
case mintExitGate:
return nil, request.RequestID, fmt.Errorf("%w (request_id=%s)", ErrMintGateRefused, request.RequestID)
case mintExitStale:
return nil, request.RequestID, fmt.Errorf("%w (request_id=%s)", ErrMintStale, request.RequestID)
case mintExitKey:
return nil, request.RequestID, fmt.Errorf("%w (request_id=%s)", ErrMintNoAuthority, request.RequestID)
case mintExitDuplicate:
return nil, request.RequestID, fmt.Errorf("%w (request_id=%s)", ErrMintDuplicate, request.RequestID)
default:
return nil, request.RequestID, fmt.Errorf("mint envelope failed exit=%d request_id=%s: %w", exitCode, request.RequestID, runErr)
}
}
raw, err := os.ReadFile(envelopePath)
if err != nil {
return nil, request.RequestID, fmt.Errorf("read minted envelope: %w", err)
}
var envelope capability.MutationEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, request.RequestID, fmt.Errorf("parse minted envelope: %w", err)
}
if envelope.Manifest.Hash() != manifest.Hash() {
return nil, request.RequestID, fmt.Errorf("minted envelope manifest mismatch")
}
log.Printf("[SECURITY] [agent] [supplychain] envelope_mint_succeeded request_id=%s authorization_id=%s actions=%d",
request.RequestID, envelope.Authorization.AuthorizationID, len(envelope.Manifest.ResolvedActions))
return &envelope, request.RequestID, nil
}
// ExecuteEnvelope hands one signed envelope to the privileged verifier and
// returns its joined receipt, including denials and failed package execution.
func (e *Executor) ExecuteEnvelope(
ctx context.Context,
envelope *capability.MutationEnvelope,
) (*capability.MutationReceipt, error) {
if envelope == nil {
return nil, fmt.Errorf("mutation envelope is nil")
}
payload, err := json.Marshal(envelope)
if err != nil {
return nil, fmt.Errorf("marshal mutation envelope: %w", err)
}
agentDir := filepath.Join(constants.GetBaseDir(), constants.AgentDir)
envelopeDir := filepath.Join(agentDir, mutationEnvelopeDir)
receiptDir := filepath.Join(agentDir, mutationReceiptDir)
if err := os.MkdirAll(envelopeDir, 0o700); err != nil {
return nil, fmt.Errorf("create mutation envelope dir: %w", err)
}
if err := os.MkdirAll(receiptDir, 0o700); err != nil {
return nil, fmt.Errorf("create mutation receipt dir: %w", err)
}
filename, err := safeTokenFilename(envelope.Authorization.AuthorizationID)
if err != nil {
return nil, err
}
envelopePath := filepath.Join(envelopeDir, filename)
receiptPath := filepath.Join(receiptDir, filename)
if err := os.Remove(receiptPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("clear stale mutation receipt: %w", err)
}
if err := os.WriteFile(envelopePath, payload, 0o600); err != nil {
return nil, fmt.Errorf("write mutation envelope: %w", err)
}
defer os.Remove(envelopePath)
defer os.Remove(receiptPath)
exitCode, stderr, runErr := runMutationHelper(
ctx, executorTimeout, e.BinaryPath,
"execute-envelope", "--envelope-file", envelopePath, "--receipt-file", receiptPath,
)
if stderr != "" {
log.Printf("[INFO] [agent] [supplychain] envelope_executor_log %s", stderr)
}
raw, err := os.ReadFile(receiptPath)
if err != nil {
if runErr != nil {
return nil, fmt.Errorf("helper failed without mutation receipt exit=%d: %w", exitCode, runErr)
}
return nil, fmt.Errorf("helper returned without mutation receipt: %w", err)
}
var receipt capability.MutationReceipt
if err := json.Unmarshal(bytes.TrimSpace(raw), &receipt); err != nil {
return nil, fmt.Errorf("parse mutation receipt: %w", err)
}
if receipt.AuthorizationID != envelope.Authorization.AuthorizationID ||
receipt.OperationID != envelope.Manifest.OperationID ||
receipt.ManifestHash != envelope.Manifest.Hash() ||
receipt.TargetID != envelope.Manifest.TargetID ||
receipt.ProtocolVersion != capability.MutationProtocolVersion ||
receipt.Backend != envelope.Manifest.Backend ||
receipt.Operation != envelope.Manifest.Operation {
return nil, fmt.Errorf("mutation receipt audit join mismatch")
}
if receipt.VerifiedActions < 0 || receipt.VerifiedActions > len(envelope.Manifest.ResolvedActions) {
return nil, fmt.Errorf("mutation receipt verified action count is outside the signed manifest")
}
if receipt.Executed != (receipt.Decision == "executed" && receipt.ExitCode == 0) {
return nil, fmt.Errorf("mutation receipt outcome is internally inconsistent")
}
return &receipt, nil
}

View file

@ -0,0 +1,228 @@
package supplychain
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
)
// Standalone-mode OSV check (FEAT-003). In fleet mode OSV runs server-side at
// detection; a standalone host has no server, so the unprivileged agent queries
// OSV.dev directly before requesting a mint. Best-effort with an honest verdict
// (Casey, 2026-06-10): vulnerable is a full stop, unreachable is surfaced as
// "unverified" and mints only over an explicit operator override reason. The
// verdict strings below are the gate_evidence contract with the helper's mint
// mode — keep them aligned with helper/src/main.rs.
const (
OSVStatusClear = "clear"
OSVStatusVulnerable = "vulnerable"
OSVStatusUnreachable = "unreachable"
OSVStatusUnsupported = "unsupported"
)
// osvBatchLimit mirrors the server's batch sizing for /v1/querybatch.
const osvBatchLimit = 100
var osvHTTPClient = &http.Client{Timeout: 15 * time.Second}
// OSV resilience (SEC-029, ETHOS #3 "assume failure; circuit-break fragile
// scanners"). A single OSV.dev blip used to force the operator into an override
// reason or an abandoned install. We now retry transient transport/5xx/429 with
// exponential backoff, and a process-wide breaker fast-fails after a run of
// failures so a sustained outage stops hammering the endpoint. The gate stays
// fail-CLOSED throughout: an exhausted retry or an open breaker surfaces as
// OSVStatusUnreachable, never a silent clear.
var osvBackoff = []time.Duration{500 * time.Millisecond, 2 * time.Second, 8 * time.Second}
const (
osvBreakerTrip = 5 // consecutive failed batches before the breaker opens
osvBreakerCooldown = 60 * time.Second
)
var osvBreaker struct {
mu sync.Mutex
consecutive int
openUntil time.Time
}
func osvBreakerOpen() bool {
osvBreaker.mu.Lock()
defer osvBreaker.mu.Unlock()
return time.Now().Before(osvBreaker.openUntil)
}
func osvBreakerRecord(success bool) {
osvBreaker.mu.Lock()
defer osvBreaker.mu.Unlock()
if success {
osvBreaker.consecutive = 0
osvBreaker.openUntil = time.Time{}
return
}
osvBreaker.consecutive++
if osvBreaker.consecutive >= osvBreakerTrip {
osvBreaker.openUntil = time.Now().Add(osvBreakerCooldown)
log.Printf("[WARNING] [agent] [supplychain] osv_breaker_open consecutive=%d cooldown=%s",
osvBreaker.consecutive, osvBreakerCooldown)
}
}
// osvReadBatch decodes a querybatch response. retryable is true only for
// transient server-side conditions (5xx, 429) so the caller backs off; a hard
// 4xx or a decode failure is not retryable.
func osvReadBatch(resp *http.Response) (batchResp *osvBatchResponse, retryable bool, err error) {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
retryable = resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests
return nil, retryable, fmt.Errorf("osv status %d", resp.StatusCode)
}
var decoded osvBatchResponse
if decodeErr := json.NewDecoder(resp.Body).Decode(&decoded); decodeErr != nil {
return nil, false, decodeErr
}
return &decoded, false, nil
}
// osvQueryBatch posts one querybatch payload with retry + breaker, returning the
// decoded response or an error the caller surfaces as unreachable.
func osvQueryBatch(ctx context.Context, body []byte) (*osvBatchResponse, error) {
if osvBreakerOpen() {
return nil, fmt.Errorf("osv breaker open")
}
var lastErr error
for attempt := 0; attempt <= len(osvBackoff); attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(osvBackoff[attempt-1]):
}
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.osv.dev/v1/querybatch", bytes.NewReader(body))
if err != nil {
return nil, err // deterministic construction error; not retryable
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := osvHTTPClient.Do(httpReq)
if err != nil {
lastErr = err
log.Printf("[WARNING] [agent] [supplychain] osv_attempt_failed attempt=%d/%d error=%v",
attempt+1, len(osvBackoff)+1, err)
continue
}
batchResp, retryable, readErr := osvReadBatch(resp)
if readErr == nil {
osvBreakerRecord(true)
return batchResp, nil
}
lastErr = readErr
if !retryable {
osvBreakerRecord(false)
return nil, readErr
}
log.Printf("[WARNING] [agent] [supplychain] osv_attempt_failed attempt=%d/%d error=%v",
attempt+1, len(osvBackoff)+1, readErr)
}
osvBreakerRecord(false)
return nil, lastErr
}
// PkgVersion is the minimal identity OSV needs.
type PkgVersion struct {
Name string
Version string
}
// OSVEcosystem maps RedFlag package types to OSV.dev ecosystem names. Mirrors
// services.EcosystemFromPackageType on the server — keep in sync.
func OSVEcosystem(pkgType string) string {
switch pkgType {
case "npm":
return "npm"
case "pypi", "pip":
return "PyPI"
case "apt":
return "Debian"
case "dnf":
return "AlmaLinux"
}
return pkgType
}
type osvQuery struct {
Package struct {
Name string `json:"name"`
Ecosystem string `json:"ecosystem"`
} `json:"package"`
Version string `json:"version"`
}
type osvBatchRequest struct {
Queries []osvQuery `json:"queries"`
}
type osvBatchResponse struct {
Results []struct {
Vulns []struct {
ID string `json:"id"`
} `json:"vulns"`
} `json:"results"`
}
// CheckClosureOSV queries OSV.dev for every package in the closure. Returns the
// gate-evidence verdict and the total vulnerability count. Any transport or
// decode failure returns OSVStatusUnreachable — never a silent clear.
func CheckClosureOSV(ctx context.Context, pkgType string, pkgs []PkgVersion) (string, int) {
ecosystem := OSVEcosystem(pkgType)
vulnCount := 0
for start := 0; start < len(pkgs); start += osvBatchLimit {
end := start + osvBatchLimit
if end > len(pkgs) {
end = len(pkgs)
}
batch := pkgs[start:end]
req := osvBatchRequest{Queries: make([]osvQuery, len(batch))}
for i, p := range batch {
req.Queries[i].Package.Name = p.Name
req.Queries[i].Package.Ecosystem = ecosystem
req.Queries[i].Version = p.Version
}
body, err := json.Marshal(req)
if err != nil {
log.Printf("[ERROR] [agent] [supplychain] osv_marshal_failed error=%v", err)
return OSVStatusUnreachable, 0
}
batchResp, err := osvQueryBatch(ctx, body)
if err != nil {
log.Printf("[WARNING] [agent] [supplychain] osv_unreachable error=%v", err)
return OSVStatusUnreachable, 0
}
for i, r := range batchResp.Results {
if len(r.Vulns) > 0 {
vulnCount += len(r.Vulns)
log.Printf("[SECURITY] [agent] [supplychain] osv_vulns_found pkg=%s version=%s ecosystem=%s count=%d",
batch[i].Name, batch[i].Version, ecosystem, len(r.Vulns))
}
}
}
if vulnCount > 0 {
return OSVStatusVulnerable, vulnCount
}
log.Printf("[INFO] [agent] [supplychain] osv_closure_clear pkg_type=%s packages=%d", pkgType, len(pkgs))
return OSVStatusClear, 0
}

View file

@ -0,0 +1,158 @@
package supplychain
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestOSVEcosystem(t *testing.T) {
tests := []struct {
pkgType string
want string
}{
{"npm", "npm"},
{"pypi", "PyPI"},
{"pip", "PyPI"},
{"apt", "Debian"},
{"dnf", "AlmaLinux"},
{"unknown", "unknown"},
}
for _, tt := range tests {
if got := OSVEcosystem(tt.pkgType); got != tt.want {
t.Errorf("OSVEcosystem(%q) = %q, want %q", tt.pkgType, got, tt.want)
}
}
}
func TestCheckClosureOSVUnreachable(t *testing.T) {
// Mock HTTP client that returns 503.
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer ts.Close()
orig := osvHTTPClient
osvHTTPClient = ts.Client()
defer func() { osvHTTPClient = orig }()
// We can't easily redirect the hardcoded URL, but we can test with a
// real unreachable host by pointing the client at the test server and
// using a context cancel to simulate transport failure.
ctx, cancel := context.WithCancel(context.Background())
cancel() // immediately cancelled — simulates unreachable
status, vulns := CheckClosureOSV(ctx, "npm", []PkgVersion{
{Name: "left-pad", Version: "1.3.0"},
})
if status != OSVStatusUnreachable {
t.Errorf("expected unreachable, got %q", status)
}
if vulns != 0 {
t.Errorf("expected 0 vulns on unreachable, got %d", vulns)
}
}
func TestCheckClosureOSVEmpty(t *testing.T) {
// Empty closure should return clear without making any HTTP call.
status, vulns := CheckClosureOSV(context.Background(), "npm", nil)
if status != OSVStatusClear {
t.Errorf("expected clear for empty closure, got %q", status)
}
if vulns != 0 {
t.Errorf("expected 0 vulns for empty closure, got %d", vulns)
}
}
func TestCheckClosureOSVCleanResponse(t *testing.T) {
// Mock OSV batch endpoint returning no vulnerabilities.
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req osvBatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("failed to decode request: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
resp := osvBatchResponse{
Results: make([]struct {
Vulns []struct {
ID string `json:"id"`
} `json:"vulns"`
}, len(req.Queries)),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer ts.Close()
// Override the package-level client and URL via a custom transport.
orig := osvHTTPClient
osvHTTPClient = &http.Client{
Transport: rewriteTransport{ts.URL},
}
defer func() { osvHTTPClient = orig }()
status, vulns := CheckClosureOSV(context.Background(), "npm", []PkgVersion{
{Name: "left-pad", Version: "1.3.0"},
{Name: "is-odd", Version: "2.0.0"},
})
if status != OSVStatusClear {
t.Errorf("expected clear, got %q", status)
}
if vulns != 0 {
t.Errorf("expected 0 vulns, got %d", vulns)
}
}
func TestCheckClosureOSVVulnerableResponse(t *testing.T) {
// Mock OSV batch endpoint returning vulnerabilities.
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := osvBatchResponse{
Results: []struct {
Vulns []struct {
ID string `json:"id"`
} `json:"vulns"`
}{
{Vulns: []struct {
ID string `json:"id"`
}{{ID: "CVE-2024-12345"}, {ID: "CVE-2024-99999"}}},
{Vulns: nil}, // clean
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer ts.Close()
orig := osvHTTPClient
osvHTTPClient = &http.Client{
Transport: rewriteTransport{ts.URL},
}
defer func() { osvHTTPClient = orig }()
status, vulns := CheckClosureOSV(context.Background(), "npm", []PkgVersion{
{Name: "vulnerable-pkg", Version: "1.0.0"},
{Name: "clean-pkg", Version: "2.0.0"},
})
if status != OSVStatusVulnerable {
t.Errorf("expected vulnerable, got %q", status)
}
if vulns != 2 {
t.Errorf("expected 2 vulns, got %d", vulns)
}
}
// rewriteTransport rewrites all requests to a test server URL, preserving the
// path but changing the host. This lets us test the hardcoded osv.dev URL.
type rewriteTransport struct {
testServerURL string
}
func (rt rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Rewrite to test server.
req.URL.Scheme = "http"
req.URL.Host = rt.testServerURL[len("http://"):]
return http.DefaultTransport.RoundTrip(req)
}