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:
commit
67e26be2d9
838 changed files with 157302 additions and 0 deletions
303
agent/internal/supplychain/binary_update.go
Normal file
303
agent/internal/supplychain/binary_update.go
Normal 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
|
||||
}
|
||||
352
agent/internal/supplychain/consumer.go
Normal file
352
agent/internal/supplychain/consumer.go
Normal 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
|
||||
}
|
||||
218
agent/internal/supplychain/consumer_test.go
Normal file
218
agent/internal/supplychain/consumer_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
158
agent/internal/supplychain/mint.go
Normal file
158
agent/internal/supplychain/mint.go
Normal 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
|
||||
}
|
||||
218
agent/internal/supplychain/mutation.go
Normal file
218
agent/internal/supplychain/mutation.go
Normal 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
|
||||
}
|
||||
228
agent/internal/supplychain/osv.go
Normal file
228
agent/internal/supplychain/osv.go
Normal 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
|
||||
}
|
||||
158
agent/internal/supplychain/osv_test.go
Normal file
158
agent/internal/supplychain/osv_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue