standalone: carry pacman through local authority
Give a standalone Agent a durable identity and one signed pacman envelope path through the helper. Every archive and detached signature crosses root custody before mutation. Fleet credentials still refuse the local minter.
This commit is contained in:
parent
776405b618
commit
7472d21c42
21 changed files with 2672 additions and 397 deletions
|
|
@ -18,6 +18,7 @@ type CLI struct {
|
|||
Scan bool
|
||||
Status bool
|
||||
LocalStatus bool
|
||||
InitStandalone bool
|
||||
ListUpdates bool
|
||||
Version bool
|
||||
ServerURL string
|
||||
|
|
@ -48,6 +49,7 @@ func ParseFlags() *CLI {
|
|||
flag.BoolVar(&cli.Scan, "scan", false, "Scan for updates and display locally")
|
||||
flag.BoolVar(&cli.Status, "status", false, "Show agent status")
|
||||
flag.BoolVar(&cli.LocalStatus, "local-status", false, "Show live local agent status over local IPC")
|
||||
flag.BoolVar(&cli.InitStandalone, "init-standalone", false, "Create or print this host's standalone Agent identity")
|
||||
flag.BoolVar(&cli.ListUpdates, "list-updates", false, "List detailed update information")
|
||||
flag.BoolVar(&cli.Version, "version", false, "Show version information")
|
||||
flag.StringVar(&cli.ServerURL, "server", "", "Server URL")
|
||||
|
|
|
|||
|
|
@ -78,8 +78,22 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
if cli.InitStandalone {
|
||||
if err := cfg.InitializeStandalone(); err != nil {
|
||||
log.Fatal("Standalone initialization failed: ", err)
|
||||
}
|
||||
if err := cfg.Save(configPath); err != nil {
|
||||
log.Fatal("Standalone configuration save failed: ", err)
|
||||
}
|
||||
fmt.Println(cfg.AgentID.String())
|
||||
return
|
||||
}
|
||||
|
||||
// Handle registration command
|
||||
if cli.Register {
|
||||
if cfg.IsStandalone() {
|
||||
log.Fatal("Registration refused: standalone fleet join is not implemented; do not add fleet credentials beside local authority")
|
||||
}
|
||||
if err := handleRegistration(cfg, cli.ServerURL); err != nil {
|
||||
log.Fatal("Registration failed:", err)
|
||||
}
|
||||
|
|
@ -120,8 +134,8 @@ func main() {
|
|||
defer unlock()
|
||||
|
||||
// Check if registered
|
||||
if !cfg.IsRegistered() {
|
||||
log.Fatal("Agent not registered. Run with -register flag first.")
|
||||
if !cfg.IsRegistered() && !cfg.IsStandalone() {
|
||||
log.Fatal("Agent has no complete identity. Register with a fleet or run the standalone provisioning script.")
|
||||
}
|
||||
|
||||
// Check if running as Windows service
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import (
|
|||
"github.com/Fimeg/RedFlag/agent/internal/supplychain"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/system"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/version"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
// newCircuitBreaker creates a circuit breaker from config
|
||||
|
|
@ -54,8 +55,12 @@ func RunAgentLoop(cfg *config.Config) error {
|
|||
defer recovery.Recover("agent_main_loop")
|
||||
|
||||
log.Printf("RedFlag Agent v%s starting...", version.Version)
|
||||
log.Printf("Agent ID: %s Server: %s Interval: %ds",
|
||||
cfg.AgentID, cfg.ServerURL, cfg.CheckInInterval)
|
||||
mode := "fleet"
|
||||
if cfg.IsStandalone() {
|
||||
mode = "standalone"
|
||||
}
|
||||
log.Printf("Agent ID: %s Mode: %s Server: %s Interval: %ds",
|
||||
cfg.AgentID, mode, cfg.ServerURL, cfg.CheckInInterval)
|
||||
|
||||
loopCtx, err := NewLoopContext(cfg, LoopContextOptions{
|
||||
Ctx: context.Background(),
|
||||
|
|
@ -67,7 +72,9 @@ func RunAgentLoop(cfg *config.Config) error {
|
|||
|
||||
// Post-upgrade attestation and healthcheck run after the canonical context
|
||||
// exists so any warning path can use the operational event channel.
|
||||
handlers.RunUpgradeAttestation(loopCtx.APIClient, cfg, loopCtx.AckTracker)
|
||||
if cfg.IsRegistered() {
|
||||
handlers.RunUpgradeAttestation(loopCtx.APIClient, cfg, loopCtx.AckTracker)
|
||||
}
|
||||
if gaps := handlers.RunPostUpgradeHealthcheck(cfg); gaps > 0 {
|
||||
log.Printf("[INFO] [agent] [healthcheck] gaps=%d — upgrade may be incomplete; re-run install script to reconcile", gaps)
|
||||
}
|
||||
|
|
@ -309,6 +316,33 @@ type LoopContext struct {
|
|||
StopCh <-chan struct{} // non-nil causes loop to exit cleanly when closed
|
||||
}
|
||||
|
||||
func runStandaloneLoop(ctx *LoopContext, triggerScan func(string) error) error {
|
||||
recordLocalAgentStatus(ctx.Cfg, "standalone", false)
|
||||
log.Printf("[INFO] [agent] [standalone] local_mode_started agent_id=%s", ctx.Cfg.AgentID)
|
||||
if err := triggerScan("standalone-startup"); err != nil {
|
||||
ctx.TeeLogger.Warning("agent", "standalone", "scan", "startup_scan_not_started", map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
|
||||
interval := time.Duration(ctx.Cfg.CheckInInterval) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Minute
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Ctx.Done():
|
||||
return nil
|
||||
case <-ctx.StopCh:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
if err := triggerScan("standalone-interval"); err != nil && !errors.Is(err, localapi.ErrScanInFlight) {
|
||||
ctx.TeeLogger.Warning("agent", "standalone", "scan", "periodic_scan_not_started", map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunPollingLoop runs the main agent polling loop.
|
||||
// It is called by RunAgentLoop and may also be called by the Windows service
|
||||
// with a stop channel. When stopCh is non-nil, the loop selects on it and exits cleanly.
|
||||
|
|
@ -362,6 +396,8 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
return nil, fmt.Errorf("%w: %v", localapi.ErrApprovalConflict, err)
|
||||
case errors.Is(err, supplychain.ErrMintNoAuthority):
|
||||
return nil, fmt.Errorf("%w: %v", localapi.ErrApprovalUnavailable, err)
|
||||
case errors.Is(err, handlers.ErrApprovalNoStandaloneIdentity):
|
||||
return nil, fmt.Errorf("%w: %v", localapi.ErrApprovalUnavailable, err)
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -433,6 +469,10 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
}()
|
||||
}
|
||||
|
||||
if ctx.Cfg.IsStandalone() {
|
||||
return runStandaloneLoop(ctx, triggerScan)
|
||||
}
|
||||
|
||||
consecutiveFailures := 0
|
||||
lastSystemInfoUpdate := time.Time{}
|
||||
lastConfigRefresh := time.Time{} // zero → refresh on first successful check-in
|
||||
|
|
@ -892,7 +932,7 @@ func recordLocalAgentStatus(cfg *config.Config, status string, checkedIn bool) {
|
|||
log.Printf("[WARNING] [agent] [local_state] load_failed error=%v", err)
|
||||
localCache = &cache.LocalCache{}
|
||||
}
|
||||
if cfg != nil && cfg.IsRegistered() {
|
||||
if cfg != nil && cfg.AgentID != uuid.Nil {
|
||||
localCache.SetAgentInfo(cfg.AgentID, cfg.ServerURL)
|
||||
}
|
||||
localCache.SetAgentStatus(status)
|
||||
|
|
|
|||
|
|
@ -779,6 +779,31 @@ func (c *Config) IsRegistered() bool {
|
|||
return c.AgentID != uuid.Nil && c.Token != ""
|
||||
}
|
||||
|
||||
// IsStandalone reports whether this config has a stable local identity and no
|
||||
// fleet credential. A partial fleet enrollment is not standalone: refresh or
|
||||
// registration material must never silently become local mutation authority.
|
||||
func (c *Config) IsStandalone() bool {
|
||||
return c.AgentID != uuid.Nil && c.Token == "" && c.RefreshToken == "" && c.RegistrationToken == ""
|
||||
}
|
||||
|
||||
// InitializeStandalone gives a local-only Agent one durable UUID. It is
|
||||
// idempotent, but refuses any fleet credential so provisioning cannot convert a
|
||||
// fleet host into local authority by accident.
|
||||
func (c *Config) InitializeStandalone() error {
|
||||
if c.IsRegistered() || c.Token != "" || c.RefreshToken != "" || c.RegistrationToken != "" {
|
||||
return fmt.Errorf("standalone identity refused: fleet enrollment material is present")
|
||||
}
|
||||
if c.AgentID != uuid.Nil {
|
||||
return nil
|
||||
}
|
||||
id, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate standalone agent id: %w", err)
|
||||
}
|
||||
c.AgentID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
// OSType represents the operating system type
|
||||
type OSType string
|
||||
|
||||
|
|
|
|||
49
agent/internal/config/mode_test.go
Normal file
49
agent/internal/config/mode_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
func TestInitializeStandaloneIsStable(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := cfg.InitializeStandalone(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := cfg.AgentID
|
||||
if first == uuid.Nil || !cfg.IsStandalone() || cfg.IsRegistered() {
|
||||
t.Fatalf("standalone identity not established: id=%s", first)
|
||||
}
|
||||
if err := cfg.InitializeStandalone(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.AgentID != first {
|
||||
t.Fatalf("standalone identity changed: %s -> %s", first, cfg.AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeStandaloneRefusesFleetMaterial(t *testing.T) {
|
||||
for name, cfg := range map[string]*Config{
|
||||
"registration token": {RegistrationToken: "register"},
|
||||
"access token": {Token: "access"},
|
||||
"refresh token": {RefreshToken: "refresh"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := cfg.InitializeStandalone(); err == nil {
|
||||
t.Fatal("fleet material became standalone authority")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialFleetEnrollmentIsNotStandalone(t *testing.T) {
|
||||
id, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &Config{AgentID: id, RefreshToken: "refresh"}
|
||||
if cfg.IsStandalone() {
|
||||
t.Fatal("partial fleet enrollment reported as standalone")
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package handlers
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
|
|
@ -11,6 +12,7 @@ import (
|
|||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/installer"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/supplychain"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
// Standalone local approval (FEAT-003). The full gate pipeline runs locally:
|
||||
|
|
@ -25,6 +27,8 @@ var (
|
|||
ErrApprovalFleetMode = errors.New("local approval refused: fleet mode — approve via the server")
|
||||
// ErrApprovalBlocked: a gate refused and no override reason was supplied.
|
||||
ErrApprovalBlocked = errors.New("approval blocked by supply-chain gate")
|
||||
// ErrApprovalNoStandaloneIdentity: local authority was not provisioned.
|
||||
ErrApprovalNoStandaloneIdentity = errors.New("local approval refused: standalone identity is not provisioned")
|
||||
)
|
||||
|
||||
// LocalApproveRequest is RedFlag Desktop's approval submission.
|
||||
|
|
@ -39,18 +43,120 @@ type LocalApproveRequest struct {
|
|||
// LocalApproveResult carries the gate verdicts plus the execution outcome so
|
||||
// Desktop can render exactly what was checked and what happened.
|
||||
type LocalApproveResult struct {
|
||||
RequestID string `json:"request_id"`
|
||||
OSVStatus string `json:"osv_status"`
|
||||
OSVVulnCount int `json:"osv_vuln_count"`
|
||||
ClosureSize int `json:"closure_size"`
|
||||
Policy *supplychain.PolicyResult `json:"policy"`
|
||||
RequestID string `json:"request_id"`
|
||||
OSVStatus string `json:"osv_status"`
|
||||
OSVVulnCount int `json:"osv_vuln_count"`
|
||||
ClosureSize int `json:"closure_size"`
|
||||
Policy *supplychain.PolicyResult `json:"policy,omitempty"`
|
||||
Receipt *capability.MutationReceipt `json:"receipt,omitempty"`
|
||||
}
|
||||
|
||||
// gatedLocalApproval limits local approval to the ecosystems whose closures
|
||||
// the agent can resolve and pin from signed repo metadata. Mirrors the
|
||||
// capability-gate set, not the full installer set.
|
||||
func gatedLocalApproval(packageType string) bool {
|
||||
return packageType == "dnf" || packageType == "apt"
|
||||
return packageType == "dnf" || packageType == "apt" || packageType == "pacman"
|
||||
}
|
||||
|
||||
type pacmanExecutionLocation struct {
|
||||
Kind string `json:"kind"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type pacmanActionPayload struct {
|
||||
ArtifactSHA256 string `json:"artifact_sha256"`
|
||||
ExecutionLocation pacmanExecutionLocation `json:"execution_location"`
|
||||
Repository string `json:"repository"`
|
||||
Requested bool `json:"requested"`
|
||||
SignatureSHA256 string `json:"signature_sha256"`
|
||||
SignatureLocation pacmanExecutionLocation `json:"signature_location"`
|
||||
}
|
||||
|
||||
func handleLocalPacmanApproval(
|
||||
ctx context.Context,
|
||||
cfg *config.Config,
|
||||
req LocalApproveRequest,
|
||||
) (*LocalApproveResult, error) {
|
||||
// OSV has no Arch ecosystem mapping in RedFlag. Preserve that as an
|
||||
// unsupported gate, which requires the same recorded operator override as
|
||||
// an outage. Refuse before network and artifact work when intent is absent.
|
||||
osvStatus := supplychain.OSVStatusUnsupported
|
||||
if req.OverrideReason == "" {
|
||||
return nil, fmt.Errorf("%w: osv_status=%s — override requires an explicit reason",
|
||||
ErrApprovalBlocked, osvStatus)
|
||||
}
|
||||
resolution, err := installer.ResolvePacmanClosure(req.PackageName, req.AvailableVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve pacman closure: %w", err)
|
||||
}
|
||||
defer resolution.Cleanup()
|
||||
resolvedAt := time.Now().UTC()
|
||||
|
||||
actions := make([]capability.ResolvedAction, 0, len(resolution.Artifacts))
|
||||
evidence := make([]capability.Evidence, 0, len(resolution.Artifacts)*2)
|
||||
for _, artifact := range resolution.Artifacts {
|
||||
payload, err := json.Marshal(pacmanActionPayload{
|
||||
ArtifactSHA256: artifact.ArchiveSHA256,
|
||||
ExecutionLocation: pacmanExecutionLocation{Kind: "cache", Value: artifact.ArchivePath},
|
||||
Repository: artifact.Repository,
|
||||
Requested: artifact.Name == req.PackageName && (req.AvailableVersion == "" || artifact.Version == req.AvailableVersion),
|
||||
SignatureSHA256: artifact.SignatureSHA256,
|
||||
SignatureLocation: pacmanExecutionLocation{Kind: "cache", Value: artifact.SignaturePath},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode pacman action %s: %w", artifact.Name, err)
|
||||
}
|
||||
actions = append(actions, capability.ResolvedAction{
|
||||
Kind: "package", Identity: artifact.Name + "@" + artifact.Version, Payload: string(payload),
|
||||
})
|
||||
evidence = append(evidence,
|
||||
capability.Evidence{Kind: "pacman-package-archive", Digest: artifact.ArchiveSHA256},
|
||||
capability.Evidence{Kind: "pacman-package-signature", Digest: artifact.SignatureSHA256},
|
||||
)
|
||||
}
|
||||
|
||||
operationID, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate pacman operation id: %w", err)
|
||||
}
|
||||
manifest := capability.MutationManifest{
|
||||
ProtocolVersion: capability.MutationProtocolVersion,
|
||||
OperationID: operationID.String(),
|
||||
TargetID: cfg.AgentID.String(),
|
||||
Backend: "pacman",
|
||||
Operation: "upgrade",
|
||||
ResolvedActions: actions,
|
||||
Evidence: evidence,
|
||||
}
|
||||
gateEvidence := supplychain.GateEvidence{
|
||||
ResolvedAt: resolvedAt.Unix(),
|
||||
OSVCheckedAt: 0,
|
||||
OSVStatus: osvStatus,
|
||||
OSVVulnCount: 0,
|
||||
AgeGate: "not_applicable",
|
||||
SoakGate: "not_applicable",
|
||||
Operator: req.Operator,
|
||||
OverrideReason: req.OverrideReason,
|
||||
}
|
||||
executor := supplychain.NewExecutor("")
|
||||
envelope, requestID, err := executor.MintEnvelope(ctx, manifest, gateEvidence)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receipt, err := executor.ExecuteEnvelope(ctx, envelope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute pacman envelope authorization_id=%s: %w",
|
||||
envelope.Authorization.AuthorizationID, err)
|
||||
}
|
||||
log.Printf("[INFO] [agent] [localapprove] approval_completed pkg=%s decision=%s exit=%d authorization_id=%s",
|
||||
req.PackageName, receipt.Decision, receipt.ExitCode, receipt.AuthorizationID)
|
||||
return &LocalApproveResult{
|
||||
RequestID: requestID,
|
||||
OSVStatus: osvStatus,
|
||||
OSVVulnCount: 0,
|
||||
ClosureSize: len(resolution.Artifacts),
|
||||
Receipt: receipt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HandleLocalApprove runs the standalone approval flow end to end. Synchronous:
|
||||
|
|
@ -60,11 +166,14 @@ func HandleLocalApprove(ctx context.Context, cfg *config.Config, req LocalApprov
|
|||
if cfg.IsRegistered() {
|
||||
return nil, ErrApprovalFleetMode
|
||||
}
|
||||
if !cfg.IsStandalone() {
|
||||
return nil, ErrApprovalNoStandaloneIdentity
|
||||
}
|
||||
if req.PackageType == "" || req.PackageName == "" {
|
||||
return nil, fmt.Errorf("package_type and package_name are required")
|
||||
}
|
||||
if !gatedLocalApproval(req.PackageType) {
|
||||
return nil, fmt.Errorf("local approval not supported for package_type=%s (dnf|apt only)", req.PackageType)
|
||||
return nil, fmt.Errorf("local approval not supported for package_type=%s (dnf|apt|pacman only)", req.PackageType)
|
||||
}
|
||||
if req.Operator == "" {
|
||||
return nil, fmt.Errorf("operator is required")
|
||||
|
|
@ -72,6 +181,9 @@ func HandleLocalApprove(ctx context.Context, cfg *config.Config, req LocalApprov
|
|||
|
||||
log.Printf("[INFO] [agent] [localapprove] approval_started pkg=%s type=%s version=%s operator=%s",
|
||||
req.PackageName, req.PackageType, req.AvailableVersion, req.Operator)
|
||||
if req.PackageType == "pacman" {
|
||||
return handleLocalPacmanApproval(ctx, cfg, req)
|
||||
}
|
||||
|
||||
// Resolve the closure exactly as the fleet dry-run path does.
|
||||
inst, err := installer.InstallerFactory(req.PackageType, cfg.ServerURL)
|
||||
|
|
|
|||
49
agent/internal/handlers/local_approve_test.go
Normal file
49
agent/internal/handlers/local_approve_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
func TestGatedLocalApprovalEcosystems(t *testing.T) {
|
||||
for _, packageType := range []string{"apt", "dnf", "pacman"} {
|
||||
if !gatedLocalApproval(packageType) {
|
||||
t.Errorf("%s is not routed through local authority", packageType)
|
||||
}
|
||||
}
|
||||
for _, packageType := range []string{"docker", "winget", "windows_update", "cargo"} {
|
||||
if gatedLocalApproval(packageType) {
|
||||
t.Errorf("%s entered local authority without a migrated backend", packageType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalApprovalRefusesFleetModeBeforeResolution(t *testing.T) {
|
||||
id, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id, Token: "fleet"}, LocalApproveRequest{
|
||||
PackageType: "pacman", PackageName: "linux", Operator: "operator", OverrideReason: "accepted",
|
||||
})
|
||||
if !errors.Is(err, ErrApprovalFleetMode) {
|
||||
t.Fatalf("fleet approval error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacmanUnsupportedOSVRequiresReasonBeforeResolution(t *testing.T) {
|
||||
id, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id}, LocalApproveRequest{
|
||||
PackageType: "pacman", PackageName: "not-a-real-package", Operator: "operator",
|
||||
})
|
||||
if !errors.Is(err, ErrApprovalBlocked) {
|
||||
t.Fatalf("pacman approval error = %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,7 @@ func runStandaloneUpdateScan(cfg *config.Config, orch *orchestrator.Orchestrator
|
|||
candidates = []updateScanner{
|
||||
{"apt", scanner.NewAPTScanner().IsAvailable},
|
||||
{"dnf", scanner.NewDNFScanner().IsAvailable},
|
||||
{"pacman", scanner.NewPacmanScanner().IsAvailable},
|
||||
}
|
||||
case "windows":
|
||||
candidates = []updateScanner{
|
||||
|
|
|
|||
21
agent/internal/installer/pacman_resolver.go
Normal file
21
agent/internal/installer/pacman_resolver.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package installer
|
||||
|
||||
// PacmanResolvedArtifact is one exact archive in the transaction pacman
|
||||
// resolved for a requested package. Paths remain valid until Cleanup is called.
|
||||
type PacmanResolvedArtifact struct {
|
||||
Name string
|
||||
Version string
|
||||
Repository string
|
||||
ArchivePath string
|
||||
ArchiveSHA256 string
|
||||
SignaturePath string
|
||||
SignatureSHA256 string
|
||||
}
|
||||
|
||||
// PacmanResolution owns the private sync database and cache backing a resolved
|
||||
// transaction. The caller keeps it alive through helper execution, then calls
|
||||
// Cleanup even when mint or execution refuses.
|
||||
type PacmanResolution struct {
|
||||
Artifacts []PacmanResolvedArtifact
|
||||
Cleanup func()
|
||||
}
|
||||
216
agent/internal/installer/pacman_resolver_linux.go
Normal file
216
agent/internal/installer/pacman_resolver_linux.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
//go:build linux
|
||||
|
||||
package installer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
)
|
||||
|
||||
const pacmanCommandPath = "/usr/bin/pacman"
|
||||
|
||||
var pacmanResolverEnv = []string{
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"LC_ALL=C",
|
||||
"LANG=C",
|
||||
}
|
||||
|
||||
func pacmanResolverCommand(name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Env = pacmanResolverEnv
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
message := strings.TrimSpace(string(output))
|
||||
if len(message) > 4096 {
|
||||
message = message[:4096]
|
||||
}
|
||||
return nil, fmt.Errorf("%s failed: %w: %s", filepath.Base(name), err, message)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func parsePacmanPrint(output string) (map[string]string, error) {
|
||||
repositories := make(map[string]string)
|
||||
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(line, "|")
|
||||
if len(fields) != 3 || fields[0] == "" || fields[1] == "" || fields[2] == "" {
|
||||
return nil, fmt.Errorf("pacman printed malformed closure identity %q", line)
|
||||
}
|
||||
repositories[fields[0]+"@"+fields[1]] = fields[2]
|
||||
}
|
||||
if len(repositories) == 0 {
|
||||
return nil, fmt.Errorf("pacman resolved an empty transaction")
|
||||
}
|
||||
return repositories, nil
|
||||
}
|
||||
|
||||
func inspectPacmanArchive(path string) (string, string, error) {
|
||||
output, err := pacmanResolverCommand(pacmanCommandPath, "-Qp", "--", path)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
fields := strings.Fields(string(output))
|
||||
if len(fields) != 2 || fields[0] == "" || fields[1] == "" {
|
||||
return "", "", fmt.Errorf("pacman printed malformed archive identity for %s", filepath.Base(path))
|
||||
}
|
||||
return fields[0], fields[1], nil
|
||||
}
|
||||
|
||||
func regularPacmanArtifact(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("not a regular file")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolvePacmanClosure refreshes a private sync database, resolves the exact
|
||||
// transaction, and downloads its archives plus detached signatures into an
|
||||
// Agent-owned cache. It never mutates the host pacman database or package set.
|
||||
func ResolvePacmanClosure(packageName, version string) (*PacmanResolution, error) {
|
||||
if err := validatePackageName(packageName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if version != "" {
|
||||
if err := validatePackageName(version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(pacmanCommandPath); err != nil {
|
||||
return nil, fmt.Errorf("pacman is unavailable: %w", err)
|
||||
}
|
||||
if _, err := exec.LookPath("fakeroot"); err != nil {
|
||||
return nil, fmt.Errorf("fakeroot is required for private pacman resolution: %w", err)
|
||||
}
|
||||
|
||||
agentDir := filepath.Join(constants.GetBaseDir(), constants.AgentDir)
|
||||
resolutionRoot := filepath.Join(agentDir, "pacman-resolve")
|
||||
if err := os.MkdirAll(resolutionRoot, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create pacman resolution root: %w", err)
|
||||
}
|
||||
root, err := os.MkdirTemp(resolutionRoot, "operation-")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pacman resolution: %w", err)
|
||||
}
|
||||
cleanup := func() { _ = os.RemoveAll(root) }
|
||||
fail := func(err error) (*PacmanResolution, error) {
|
||||
cleanup()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
database := filepath.Join(root, "db")
|
||||
cache := filepath.Join(root, "cache")
|
||||
if err := os.Mkdir(database, 0o700); err != nil {
|
||||
return fail(fmt.Errorf("create pacman database: %w", err))
|
||||
}
|
||||
if err := os.Mkdir(cache, 0o700); err != nil {
|
||||
return fail(fmt.Errorf("create pacman cache: %w", err))
|
||||
}
|
||||
if err := os.Symlink("/var/lib/pacman/local", filepath.Join(database, "local")); err != nil {
|
||||
return fail(fmt.Errorf("bind installed pacman state: %w", err))
|
||||
}
|
||||
|
||||
fakeroot, err := exec.LookPath("fakeroot")
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if _, err := pacmanResolverCommand(
|
||||
fakeroot, "--", pacmanCommandPath, "-Sy", "--noconfirm",
|
||||
"--disable-sandbox-filesystem", "--dbpath", database, "--logfile", "/dev/null",
|
||||
); err != nil {
|
||||
return fail(fmt.Errorf("refresh private pacman metadata: %w", err))
|
||||
}
|
||||
|
||||
target := packageName
|
||||
if version != "" {
|
||||
target += "=" + version
|
||||
}
|
||||
printOutput, err := pacmanResolverCommand(
|
||||
pacmanCommandPath, "-Sp", "--print-format", "%n|%v|%r",
|
||||
"--dbpath", database, "--", target,
|
||||
)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("resolve pacman transaction: %w", err))
|
||||
}
|
||||
repositories, err := parsePacmanPrint(string(printOutput))
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if _, err := pacmanResolverCommand(
|
||||
fakeroot, "--", pacmanCommandPath, "-Sw", "--noconfirm", "--needed",
|
||||
"--disable-sandbox-filesystem", "--dbpath", database, "--cachedir", cache,
|
||||
"--logfile", "/dev/null", "--", target,
|
||||
); err != nil {
|
||||
return fail(fmt.Errorf("download pacman transaction: %w", err))
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(cache)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("read pacman cache: %w", err))
|
||||
}
|
||||
artifacts := make([]PacmanResolvedArtifact, 0, len(entries)/2)
|
||||
rootFound := false
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !strings.Contains(name, ".pkg.tar.") || strings.HasSuffix(name, ".sig") || strings.HasSuffix(name, ".part") {
|
||||
continue
|
||||
}
|
||||
archivePath := filepath.Join(cache, name)
|
||||
if err := regularPacmanArtifact(archivePath); err != nil {
|
||||
return fail(fmt.Errorf("unsafe pacman archive %s: %w", name, err))
|
||||
}
|
||||
signaturePath := archivePath + ".sig"
|
||||
if err := regularPacmanArtifact(signaturePath); err != nil {
|
||||
return fail(fmt.Errorf("pacman archive %s has no regular detached signature: %w", name, err))
|
||||
}
|
||||
resolvedName, resolvedVersion, err := inspectPacmanArchive(archivePath)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
repository, ok := repositories[resolvedName+"@"+resolvedVersion]
|
||||
if !ok {
|
||||
return fail(fmt.Errorf("pacman repository missing for %s@%s", resolvedName, resolvedVersion))
|
||||
}
|
||||
archiveHash, err := fileSHA256(archivePath)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("hash pacman archive %s: %w", name, err))
|
||||
}
|
||||
signatureHash, err := fileSHA256(signaturePath)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("hash pacman signature %s: %w", filepath.Base(signaturePath), err))
|
||||
}
|
||||
artifacts = append(artifacts, PacmanResolvedArtifact{
|
||||
Name: resolvedName, Version: resolvedVersion, Repository: repository,
|
||||
ArchivePath: archivePath, ArchiveSHA256: archiveHash,
|
||||
SignaturePath: signaturePath, SignatureSHA256: signatureHash,
|
||||
})
|
||||
if resolvedName == packageName && (version == "" || resolvedVersion == version) {
|
||||
rootFound = true
|
||||
}
|
||||
}
|
||||
if len(artifacts) == 0 {
|
||||
return fail(fmt.Errorf("pacman downloaded no package archives"))
|
||||
}
|
||||
if !rootFound {
|
||||
return fail(fmt.Errorf("pacman transaction did not contain requested root %s@%s", packageName, version))
|
||||
}
|
||||
sort.Slice(artifacts, func(i, j int) bool {
|
||||
if artifacts[i].Name == artifacts[j].Name {
|
||||
return artifacts[i].Version < artifacts[j].Version
|
||||
}
|
||||
return artifacts[i].Name < artifacts[j].Name
|
||||
})
|
||||
return &PacmanResolution{Artifacts: artifacts, Cleanup: cleanup}, nil
|
||||
}
|
||||
26
agent/internal/installer/pacman_resolver_linux_test.go
Normal file
26
agent/internal/installer/pacman_resolver_linux_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
//go:build linux
|
||||
|
||||
package installer
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParsePacmanPrint(t *testing.T) {
|
||||
repositories, err := parsePacmanPrint("linux|1:6.19.14-1|core\nmkinitcpio|39.2-3|core\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := repositories["linux@1:6.19.14-1"]; got != "core" {
|
||||
t.Fatalf("epoch-bearing identity repository = %q, want core", got)
|
||||
}
|
||||
if got := repositories["mkinitcpio@39.2-3"]; got != "core" {
|
||||
t.Fatalf("dependency repository = %q, want core", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePacmanPrintRefusesMalformedIdentity(t *testing.T) {
|
||||
for _, output := range []string{"", "linux 6.19 core", "linux|6.19|"} {
|
||||
if _, err := parsePacmanPrint(output); err == nil {
|
||||
t.Fatalf("parsePacmanPrint(%q) succeeded", output)
|
||||
}
|
||||
}
|
||||
}
|
||||
9
agent/internal/installer/pacman_resolver_other.go
Normal file
9
agent/internal/installer/pacman_resolver_other.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//go:build !linux
|
||||
|
||||
package installer
|
||||
|
||||
import "fmt"
|
||||
|
||||
func ResolvePacmanClosure(packageName, version string) (*PacmanResolution, error) {
|
||||
return nil, fmt.Errorf("pacman resolution is only available on Linux")
|
||||
}
|
||||
|
|
@ -164,6 +164,9 @@ func (e *Executor) Execute(ctx context.Context, token *capability.Token, extraAr
|
|||
}
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -100,8 +100,11 @@ func (e *Executor) Mint(ctx context.Context, req *MintRequest) (*capability.Toke
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqPath := filepath.Join(reqDir, fname+".json")
|
||||
tokPath := filepath.Join(tokDir, fname+".minted")
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
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
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ const (
|
|||
OSVStatusClear = "clear"
|
||||
OSVStatusVulnerable = "vulnerable"
|
||||
OSVStatusUnreachable = "unreachable"
|
||||
OSVStatusUnsupported = "unsupported"
|
||||
)
|
||||
|
||||
// osvBatchLimit mirrors the server's batch sizing for /v1/querybatch.
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ ApplicationWindow {
|
|||
// actually returned rather than by a hardcoded chip.
|
||||
function stageState(index) {
|
||||
const a = approval;
|
||||
const policy = a.policy || {
|
||||
const result = a.receipt || a.policy || {
|
||||
};
|
||||
if (index === 0)
|
||||
return "done";
|
||||
|
|
@ -152,10 +152,10 @@ ApplicationWindow {
|
|||
return "done";
|
||||
|
||||
if (index === 2)
|
||||
return policy.token_id ? "done" : "failed";
|
||||
return (result.authorization_id || result.token_id) ? "done" : "failed";
|
||||
|
||||
if (index === 3)
|
||||
return policy.executed ? "done" : "failed";
|
||||
return result.executed ? "done" : "failed";
|
||||
|
||||
return "idle";
|
||||
}
|
||||
|
|
@ -174,7 +174,7 @@ ApplicationWindow {
|
|||
}
|
||||
|
||||
function locallyApprovable(u) {
|
||||
return !!u && (u.package_type === "dnf" || u.package_type === "apt");
|
||||
return !!u && (u.package_type === "dnf" || u.package_type === "apt" || u.package_type === "pacman");
|
||||
}
|
||||
|
||||
function approvalBlockReason(u) {
|
||||
|
|
@ -185,11 +185,35 @@ ApplicationWindow {
|
|||
return "This machine is fleet enrolled — authority belongs to the RedFlag Server, and the Agent refuses a local mint.";
|
||||
|
||||
if (!locallyApprovable(u))
|
||||
return "Local approval covers DNF and APT, the ecosystems whose closure the Agent can resolve and pin from signed repository metadata. " + String(u.package_type || "this ecosystem").toUpperCase() + " still executes through the fleet signed-command path.";
|
||||
return "Local approval covers DNF, APT, and pacman. " + String(u.package_type || "this ecosystem").toUpperCase() + " still executes through the fleet signed-command path.";
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function approvalReasonRequired(u) {
|
||||
return !!u && u.package_type === "pacman";
|
||||
}
|
||||
|
||||
function approvalCanOverride() {
|
||||
return String(approval.error || "").indexOf("osv_status=") >= 0;
|
||||
}
|
||||
|
||||
function approvalResult() {
|
||||
return approval.receipt || approval.policy || {
|
||||
};
|
||||
}
|
||||
|
||||
function verifiedActionCount() {
|
||||
const result = approvalResult();
|
||||
if (result.verified_actions !== undefined)
|
||||
return result.verified_actions;
|
||||
|
||||
if (result.verified_artifacts !== undefined)
|
||||
return result.verified_artifacts;
|
||||
|
||||
return "—";
|
||||
}
|
||||
|
||||
function processCountLabel(n) {
|
||||
return n ? n + (n === 1 ? " process" : " processes") : "";
|
||||
}
|
||||
|
|
@ -668,6 +692,25 @@ ApplicationWindow {
|
|||
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: machine.connected && machine.agentGap.length > 0
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: visible ? gapText.implicitHeight + 20 : 0
|
||||
color: Theme.tint(Theme.warn, 0.12)
|
||||
|
||||
Text {
|
||||
id: gapText
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
text: machine.agentGap
|
||||
color: Theme.warn
|
||||
font.pixelSize: 11
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: !machine.connected && machine.connectionError.length > 0
|
||||
Layout.fillWidth: true
|
||||
|
|
@ -3458,10 +3501,10 @@ ApplicationWindow {
|
|||
"detail": app.selectedUpdate.repository_source || "reported by the scanner"
|
||||
}, {
|
||||
"label": "Checked",
|
||||
"detail": "closure resolved · hashes pinned · OSV"
|
||||
"detail": app.selectedUpdate.package_type === "pacman" ? "closure · hashes · package signatures" : "closure resolved · hashes pinned · OSV"
|
||||
}, {
|
||||
"label": "Authorized",
|
||||
"detail": "capability minted for this host"
|
||||
"detail": app.selectedUpdate.package_type === "pacman" ? "mutation envelope signed for this host" : "capability minted for this host"
|
||||
}, {
|
||||
"label": "Installed",
|
||||
"detail": "helper verified and executed"
|
||||
|
|
@ -3592,22 +3635,16 @@ ApplicationWindow {
|
|||
"v": String(app.approval.osv_vuln_count !== undefined ? app.approval.osv_vuln_count : "—")
|
||||
}, {
|
||||
"l": "Verified artifacts",
|
||||
"v": String((app.approval.policy || {
|
||||
}).verified_artifacts !== undefined ? (app.approval.policy || {
|
||||
}).verified_artifacts : "—")
|
||||
"v": String(app.verifiedActionCount())
|
||||
}, {
|
||||
"l": "Decision",
|
||||
"v": (app.approval.policy || {
|
||||
}).decision || "—"
|
||||
"v": app.approvalResult().decision || "—"
|
||||
}, {
|
||||
"l": "Exit code",
|
||||
"v": String((app.approval.policy || {
|
||||
}).exit_code !== undefined ? (app.approval.policy || {
|
||||
}).exit_code : "—")
|
||||
"v": String(app.approvalResult().exit_code !== undefined ? app.approvalResult().exit_code : "—")
|
||||
}, {
|
||||
"l": "Executed",
|
||||
"v": (app.approval.policy || {
|
||||
}).executed ? "yes" : "no"
|
||||
"v": app.approvalResult().executed ? "yes" : "no"
|
||||
}, {
|
||||
"l": "Request",
|
||||
"v": String(app.approval.request_id || "—").substring(0, 12)
|
||||
|
|
@ -3650,8 +3687,7 @@ ApplicationWindow {
|
|||
Text {
|
||||
visible: text.length > 0
|
||||
Layout.fillWidth: true
|
||||
text: app.approval.error || ((app.approval.policy || {
|
||||
}).reason || "")
|
||||
text: app.approval.error || app.approvalResult().reason || ""
|
||||
color: app.approval.error ? Theme.bad : Theme.dim
|
||||
font.pixelSize: 11
|
||||
font.family: Theme.mono
|
||||
|
|
@ -3678,13 +3714,33 @@ ApplicationWindow {
|
|||
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: app.approvalReasonRequired(app.selectedUpdate)
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: pacmanEvidenceText.implicitHeight + 22
|
||||
color: Theme.raised
|
||||
radius: Theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: Theme.warn
|
||||
|
||||
Text {
|
||||
id: pacmanEvidenceText
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: 11
|
||||
text: "OSV has no Arch Linux package mapping in this path. RedFlag can resolve and cryptographically verify the exact pacman transaction, but it cannot call the dependency closure advisory-clear. State why you accept that uncertainty; the root helper records the reason beside the request and authorization in its journal."
|
||||
color: Theme.warn
|
||||
font.pixelSize: 11
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
CheckBox {
|
||||
id: overrideToggle
|
||||
|
||||
visible: app.approvalBlockReason(app.selectedUpdate).length === 0 && !(app.approval.policy || {
|
||||
}).executed
|
||||
text: "Override policy refusal with recorded operator intent"
|
||||
checked: String(app.approval.error || "").indexOf("osv_status") >= 0
|
||||
visible: !app.approvalReasonRequired(app.selectedUpdate) && app.approvalCanOverride() && app.approvalBlockReason(app.selectedUpdate).length === 0 && !app.approvalResult().executed
|
||||
text: "Override this advisory refusal with recorded operator intent"
|
||||
onToggled: {
|
||||
if (!checked) {
|
||||
overrideField.text = "";
|
||||
|
|
@ -3703,10 +3759,10 @@ ApplicationWindow {
|
|||
TextArea {
|
||||
id: overrideField
|
||||
|
||||
visible: overrideToggle.checked
|
||||
visible: app.approvalReasonRequired(app.selectedUpdate) || overrideToggle.checked
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 76
|
||||
placeholderText: "Why the operator is breaking glass. This reason is bound into the same authorization and durable history."
|
||||
placeholderText: app.approvalReasonRequired(app.selectedUpdate) ? "Why this exact signed pacman transaction should proceed without OSV advisory coverage." : "Why the operator is breaking glass. The root helper records this reason beside the authorization in durable history."
|
||||
wrapMode: TextEdit.Wrap
|
||||
color: Theme.text
|
||||
placeholderTextColor: Theme.faint
|
||||
|
|
@ -3723,7 +3779,7 @@ ApplicationWindow {
|
|||
Text {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 4
|
||||
text: "Approval resolves the dependency closure, pins every artifact hash it can, checks the set against OSV, mints an Ed25519 capability bound to this host, and hands it to the privileged helper. The helper verifies independently. This window never touches a package manager."
|
||||
text: app.selectedUpdate.package_type === "pacman" ? "The Agent resolves a private pacman transaction and downloads every exact archive and detached signature. The root helper stages and verifies identity, hashes, Arch signatures, and forward-only versions before signing the mutation envelope, then repeats those checks before pacman runs. OSV has no Arch mapping here, so acceptance stays explicit. This window never touches pacman." : "Approval resolves the dependency closure, pins every artifact hash it can, checks the set against OSV, mints an Ed25519 capability bound to this host, and hands it to the privileged helper. The helper verifies independently. This window never touches a package manager."
|
||||
color: Theme.faint
|
||||
font.pixelSize: 10
|
||||
wrapMode: Text.Wrap
|
||||
|
|
@ -3735,12 +3791,10 @@ ApplicationWindow {
|
|||
|
||||
footer: DialogButtonBox {
|
||||
Button {
|
||||
text: machine.approvalRunning ? "Authorizing…" : ((app.approval.policy || {
|
||||
}).executed ? "Installed" : overrideToggle.checked ? "Override and install" : "Approve and install")
|
||||
enabled: !machine.approvalRunning && app.approvalBlockReason(app.selectedUpdate).length === 0 && !(app.approval.policy || {
|
||||
}).executed && (!overrideToggle.checked || overrideField.text.trim().length > 0)
|
||||
text: machine.approvalRunning ? "Authorizing…" : (app.approvalResult().executed ? "Installed" : app.approvalReasonRequired(app.selectedUpdate) ? "Approve with reason and install" : overrideToggle.checked ? "Override and install" : "Approve and install")
|
||||
enabled: !machine.approvalRunning && app.approvalBlockReason(app.selectedUpdate).length === 0 && !app.approvalResult().executed && (!(app.approvalReasonRequired(app.selectedUpdate) || overrideToggle.checked) || overrideField.text.trim().length > 0)
|
||||
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
|
||||
onClicked: machine.approveUpdate(app.selectedUpdate.package_type || "", app.selectedUpdate.package_name || "", app.selectedUpdate.available_version || "", overrideToggle.checked ? overrideField.text.trim() : "")
|
||||
onClicked: machine.approveUpdate(app.selectedUpdate.package_type || "", app.selectedUpdate.package_name || "", app.selectedUpdate.available_version || "", app.approvalReasonRequired(app.selectedUpdate) || overrideToggle.checked ? overrideField.text.trim() : "")
|
||||
|
||||
background: Rectangle {
|
||||
color: !parent.enabled ? Theme.raised : parent.pressed ? Qt.darker(Theme.red, 1.15) : Theme.red
|
||||
|
|
|
|||
2059
helper/src/main.rs
2059
helper/src/main.rs
File diff suppressed because it is too large
Load diff
|
|
@ -635,7 +635,12 @@ mod tests {
|
|||
#[test]
|
||||
fn evidence_carries_digests_not_prose() {
|
||||
let fixture = fixture();
|
||||
for bad in ["", "operator accepted the CVE risk", &"a".repeat(63), &"z".repeat(64)] {
|
||||
for bad in [
|
||||
"",
|
||||
"operator accepted the CVE risk",
|
||||
&"a".repeat(63),
|
||||
&"z".repeat(64),
|
||||
] {
|
||||
let mut changed = fixture.manifest.clone();
|
||||
changed.evidence[0].digest = bad.into();
|
||||
assert!(changed.validate().is_err(), "validated digest {bad:?}");
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
# Run as root, after the base agent install (agent user, redflag-local group,
|
||||
# helper binary, helper sudoers). The future native installers (.rpm/.deb/AUR)
|
||||
# call this for standalone installs; fleet installs must NOT run it — fleet
|
||||
# hosts have no local authority. Fleet join later runs:
|
||||
# redflag-helper mint --retire-key
|
||||
# hosts have no local authority. The helper has a key-retirement primitive, but
|
||||
# the full standalone-to-fleet transition is not implemented; the Agent refuses
|
||||
# registration while local standalone identity is active.
|
||||
#
|
||||
# Idempotent: safe to re-run. The key init refuses to overwrite an existing
|
||||
# authority by design (retire first).
|
||||
|
|
@ -16,20 +17,41 @@ set -euo pipefail
|
|||
|
||||
AGENT_USER="redflag-agent"
|
||||
LOCAL_GROUP="redflag-local"
|
||||
AGENT_BIN="/usr/local/bin/redflag-agent"
|
||||
HELPER_BIN="/usr/local/bin/redflag-helper"
|
||||
AGENT_CONFIG="/etc/redflag/agent/config.json"
|
||||
AGENT_ID_FILE="/etc/redflag/agent_id"
|
||||
JOURNAL_DIR="/var/lib/redflag/journal"
|
||||
MINT_REQUEST_DIR="/var/lib/redflag/agent/mint"
|
||||
TOKENS_DIR="/var/lib/redflag/agent/tokens"
|
||||
MUTATION_REQUEST_DIR="/var/lib/redflag/agent/mutation-requests"
|
||||
MUTATION_ENVELOPE_DIR="/var/lib/redflag/agent/mutation-envelopes"
|
||||
MUTATION_RECEIPT_DIR="/var/lib/redflag/agent/mutation-receipts"
|
||||
SUDOERS_FILE="/etc/sudoers.d/redflag-agent-mint"
|
||||
|
||||
log() { echo "[INFO] [provision] [standalone-authority] $*"; }
|
||||
fail() { echo "[ERROR] [provision] [standalone-authority] $*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || fail "must run as root"
|
||||
[ -x "$AGENT_BIN" ] || fail "agent binary missing at $AGENT_BIN — run the base install first"
|
||||
[ -x "$HELPER_BIN" ] || fail "helper binary missing at $HELPER_BIN — run the base install first"
|
||||
[ -f "$AGENT_CONFIG" ] || fail "agent config missing at $AGENT_CONFIG — run the base install first"
|
||||
id "$AGENT_USER" &>/dev/null || fail "agent user $AGENT_USER missing — run the base install first"
|
||||
getent group "$LOCAL_GROUP" &>/dev/null || fail "group $LOCAL_GROUP missing — run the base install first"
|
||||
|
||||
# A standalone host still needs one stable identity for signed target binding.
|
||||
# The Agent creates it once in config; this root provisioning step copies the
|
||||
# same UUID into the helper's independent bind file.
|
||||
STANDALONE_ID="$(runuser -u "$AGENT_USER" -- "$AGENT_BIN" --config "$AGENT_CONFIG" --init-standalone)" \
|
||||
|| fail "standalone Agent identity initialization failed"
|
||||
if [[ ! "$STANDALONE_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ ]]; then
|
||||
fail "standalone Agent returned an invalid identity"
|
||||
fi
|
||||
printf '%s' "$STANDALONE_ID" > "$AGENT_ID_FILE"
|
||||
chown root:root "$AGENT_ID_FILE"
|
||||
chmod 0644 "$AGENT_ID_FILE"
|
||||
log "standalone identity bound: $STANDALONE_ID"
|
||||
|
||||
# Journal dir: root-owned, setgid redflag-local, group-readable. Files the
|
||||
# helper writes 0640 inherit the group via setgid, so the unprivileged agent
|
||||
# (a group member) can serve journal entries over the local API while the dir
|
||||
|
|
@ -41,6 +63,10 @@ log "journal dir ready: $JOURNAL_DIR (root:$LOCAL_GROUP 2750)"
|
|||
install -d -m 0750 -o "$AGENT_USER" -g "$LOCAL_GROUP" "$MINT_REQUEST_DIR"
|
||||
log "mint request dir ready: $MINT_REQUEST_DIR"
|
||||
|
||||
install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" \
|
||||
"$MUTATION_REQUEST_DIR" "$MUTATION_ENVELOPE_DIR" "$MUTATION_RECEIPT_DIR"
|
||||
log "mutation exchange dirs ready"
|
||||
|
||||
# Tokens dir should already exist from the base install; ensure it does.
|
||||
[ -d "$TOKENS_DIR" ] || install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" "$TOKENS_DIR"
|
||||
|
||||
|
|
@ -58,12 +84,14 @@ fi
|
|||
# Sudoers: the agent user may invoke exactly the mint command shape, mirroring
|
||||
# the execute-path grant. Request and token paths are pinned to their dirs.
|
||||
cat > "$SUDOERS_FILE" <<EOF
|
||||
# RedFlag standalone authority — mint invocation (FEAT-003).
|
||||
# The agent user may request a mint; the gates + root-owned key decide.
|
||||
# RedFlag standalone authority — fixed helper protocols (FEAT-003 / ARCH-002).
|
||||
# The agent may request authority; the gates, signatures, and root-owned key decide.
|
||||
$AGENT_USER ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- $HELPER_BIN mint --request-file $MINT_REQUEST_DIR/* --token-out $TOKENS_DIR/*
|
||||
$AGENT_USER ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- $HELPER_BIN mint-envelope --request-file $MUTATION_REQUEST_DIR/* --envelope-out $MUTATION_ENVELOPE_DIR/*
|
||||
$AGENT_USER ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- $HELPER_BIN execute-envelope --envelope-file $MUTATION_ENVELOPE_DIR/* --receipt-file $MUTATION_RECEIPT_DIR/*
|
||||
EOF
|
||||
chmod 0440 "$SUDOERS_FILE"
|
||||
visudo -c -f "$SUDOERS_FILE" >/dev/null || fail "sudoers validation failed for $SUDOERS_FILE"
|
||||
log "sudoers installed: $SUDOERS_FILE"
|
||||
|
||||
log "standalone authority provisioned — fleet join later must run: $HELPER_BIN mint --retire-key"
|
||||
log "standalone authority provisioned — fleet join is not implemented; do not add fleet credentials beside this key"
|
||||
|
|
|
|||
|
|
@ -307,8 +307,6 @@ case "$PM" in
|
|||
# Disable lecture (first-time prompt) — agent runs as a service with no TTY
|
||||
Defaults:{{.AgentUser}} !lecture
|
||||
|
||||
# Mutation — ONLY through the capability-token executor
|
||||
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- /usr/local/bin/redflag-helper --token-file /var/lib/redflag/agent/tokens/* --result-file /var/lib/redflag/agent/results/*
|
||||
EOF
|
||||
;;
|
||||
dnf|yum)
|
||||
|
|
@ -320,21 +318,17 @@ EOF
|
|||
# Disable lecture (first-time prompt) — agent runs as a service with no TTY
|
||||
Defaults:{{.AgentUser}} !lecture
|
||||
|
||||
# Mutation — ONLY through the capability-token executor
|
||||
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- /usr/local/bin/redflag-helper --token-file /var/lib/redflag/agent/tokens/* --result-file /var/lib/redflag/agent/results/*
|
||||
EOF
|
||||
;;
|
||||
pacman)
|
||||
cat <<'EOF' | sudo tee "$SUDOERS_FILE" > /dev/null
|
||||
# RedFlag Agent minimal sudo permissions - Pacman
|
||||
# Discovery — non-mutating (read-only)
|
||||
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/pacman -Sy
|
||||
# Discovery and closure resolution use an unprivileged private pacman database.
|
||||
# The host sync database is never refreshed through sudo.
|
||||
|
||||
# Disable lecture (first-time prompt) — agent runs as a service with no TTY
|
||||
Defaults:{{.AgentUser}} !lecture
|
||||
|
||||
# Mutation — ONLY through the capability-token executor
|
||||
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- /usr/local/bin/redflag-helper --token-file /var/lib/redflag/agent/tokens/* --result-file /var/lib/redflag/agent/results/*
|
||||
EOF
|
||||
;;
|
||||
*)
|
||||
|
|
@ -346,17 +340,23 @@ EOF
|
|||
# Disable lecture (first-time prompt) — agent runs as a service with no TTY
|
||||
Defaults:{{.AgentUser}} !lecture
|
||||
|
||||
# Mutation — ONLY through the capability-token executor
|
||||
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- /usr/local/bin/redflag-helper --token-file /var/lib/redflag/agent/tokens/* --result-file /var/lib/redflag/agent/results/*
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
|
||||
# Fleet hosts receive capability-token execution only. Local mint and envelope
|
||||
# authority are provisioned by
|
||||
# scripts/provision-standalone-authority.sh and must never exist beside a fleet
|
||||
# credential. Package-manager commands never appear in sudoers.
|
||||
cat <<'EOF' | sudo tee -a "$SUDOERS_FILE" > /dev/null
|
||||
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- /usr/local/bin/redflag-helper --token-file /var/lib/redflag/agent/tokens/* --result-file /var/lib/redflag/agent/results/*
|
||||
EOF
|
||||
|
||||
# No Docker grants: the agent reaches the docker socket via the docker group
|
||||
# (added above), so docker discovery/pull need no sudo.
|
||||
# No self-update grants: the binary swap (cp/.bak/chmod/systemctl restart) is
|
||||
# performed by the privileged helper under an agent-self capability token, not by
|
||||
# the agent. The single systemd-run helper line above is the agent's only sudo.
|
||||
# performed by the privileged helper under a signed capability, not by the
|
||||
# agent. The fixed helper file protocol above is the agent's only sudo.
|
||||
|
||||
sudo chmod 440 "$SUDOERS_FILE"
|
||||
if visudo -c -f "$SUDOERS_FILE" &>/dev/null; then
|
||||
|
|
@ -366,12 +366,12 @@ else
|
|||
fi
|
||||
|
||||
# Step 4b: Install polkit rule for transient unit management
|
||||
# The agent's only sudo is `systemd-run --pipe ... redflag-helper`. systemd-run
|
||||
# The agent's only sudo is a fixed `systemd-run ... redflag-helper` protocol. systemd-run
|
||||
# spawns a transient unit over the system D-Bus, which polkit gates behind
|
||||
# org.freedesktop.systemd1.manage-units (auth_admin). polkit evaluates the
|
||||
# original caller ({{.AgentUser}}), so without this rule the helper invocation —
|
||||
# and therefore every gated install and the agent self-update — is denied on a
|
||||
# TTY-less service. The sudoers line above already pins the exact command; this
|
||||
# TTY-less service. The sudoers line above pins the exact command shape; this
|
||||
# grants the matching D-Bus permission to the same user, nothing wider.
|
||||
POLKIT_RULES_DIR="/etc/polkit-1/rules.d"
|
||||
POLKIT_RULE_FILE="${POLKIT_RULES_DIR}/50-redflag-agent.rules"
|
||||
|
|
|
|||
Loading…
Reference in a new issue