FEAT-003 core (design: RAF/security/06-standalone-authority.md, approved 2026-06-10). On a host with no fleet server, the trust boundary preserved is root-vs-unprivileged: a root-owned 0600 Ed25519 key signs capability tokens via a new privileged helper invocation; redflag-local membership lets you request a mint, never perform one. Helper gains the mint subcommand: validates forward-only ops, mintable-type allowlist (no agent-self), host agent-id bind, closure shape (64-hex sha256 fail-closed), hard-coded 15-minute gate-evidence freshness with future-dating rejection, override-reason requirement for vulnerable/unreachable/overridden verdicts, duplicate request_id dedupe, journal-before-emission. --init-key / --retire-key manage the authority lifecycle (retire = the fleet-join swap). Deny taxonomy 22-25. Round-trip test proves a minted token passes the execute path's own verification and parses as the wire CapabilityToken. Agent gains POST /v1/actions/approve-update (single-flight, 409/503 mapping): fleet-mode refusal, dnf/apt dry-run closure resolve + hash pin (no pin, no mint), best-effort OSV.dev closure check with honest verdicts (unreachable is never silent-clear), mint via sudo systemd-run mirroring the execute grant, then the unchanged verify+execute path. Provisioning script sets up the journal dir (root:redflag-local 2750 setgid), mint request dir, key init, and the pinned mint sudoers line.
88 lines
2.5 KiB
Go
88 lines
2.5 KiB
Go
package localapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/Fimeg/RedFlag/agent/internal/cache"
|
|
)
|
|
|
|
func triggerHandler(t *testing.T, trigger func(source string) error) http.Handler {
|
|
t.Helper()
|
|
return newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
|
|
return &cache.LocalCache{}, nil
|
|
}, TriggerScan: trigger})
|
|
}
|
|
|
|
func postTrigger(t *testing.T, handler http.Handler) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/actions/trigger-scan", nil))
|
|
return rec
|
|
}
|
|
|
|
func TestTriggerScanAccepted(t *testing.T) {
|
|
var gotSource string
|
|
handler := triggerHandler(t, func(source string) error {
|
|
gotSource = source
|
|
return nil
|
|
})
|
|
|
|
rec := postTrigger(t, handler)
|
|
if rec.Code != http.StatusAccepted {
|
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
|
|
}
|
|
if gotSource != "localapi" {
|
|
t.Fatalf("source = %q, want localapi", gotSource)
|
|
}
|
|
var resp map[string]interface{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp["accepted"] != true {
|
|
t.Fatalf("accepted = %v, want true", resp["accepted"])
|
|
}
|
|
}
|
|
|
|
func TestTriggerScanInFlightIsConflict(t *testing.T) {
|
|
handler := triggerHandler(t, func(string) error { return ErrScanInFlight })
|
|
|
|
rec := postTrigger(t, handler)
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusConflict)
|
|
}
|
|
}
|
|
|
|
func TestTriggerScanUnavailableWithoutCallback(t *testing.T) {
|
|
handler := triggerHandler(t, nil)
|
|
|
|
rec := postTrigger(t, handler)
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
|
|
}
|
|
}
|
|
|
|
func TestTriggerScanFailureIs500(t *testing.T) {
|
|
handler := triggerHandler(t, func(string) error { return errors.New("boom") })
|
|
|
|
rec := postTrigger(t, handler)
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func TestTriggerScanRejectsGet(t *testing.T) {
|
|
handler := triggerHandler(t, func(string) error { return nil })
|
|
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/actions/trigger-scan", nil))
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
|
|
}
|
|
if rec.Header().Get("Allow") != http.MethodPost {
|
|
t.Fatalf("Allow = %q, want POST", rec.Header().Get("Allow"))
|
|
}
|
|
}
|