baseline: P1 契约、agent 核心与中继投递(既有工作首次入库)
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestVendoredCopyMatchesContracts fails loudly when the embedded policy and contracts/ drift apart.
|
||||
// Without this the shipped binary can enforce something other than what the tests in test/ checked,
|
||||
// which is exactly the silent-divergence case embedding was supposed to avoid.
|
||||
func TestVendoredCopyMatchesContracts(t *testing.T) {
|
||||
upstream, err := os.ReadFile(filepath.Join("..", "..", "..", "contracts", "agent-policy.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read contracts/agent-policy.json: %v", err)
|
||||
}
|
||||
if !bytes.Equal(upstream, Raw()) {
|
||||
t.Fatal("internal/policy/agent-policy.json differs from contracts/agent-policy.json; run make vendor")
|
||||
}
|
||||
}
|
||||
|
||||
func loaded(t *testing.T) *Policy {
|
||||
t.Helper()
|
||||
p, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestDecisionTable(t *testing.T) {
|
||||
p := loaded(t)
|
||||
cases := []struct {
|
||||
method, path string
|
||||
allowed bool
|
||||
dangerous bool
|
||||
}{
|
||||
{"GET", "/api/health", true, false},
|
||||
{"GET", "/api/sms/list", true, false},
|
||||
{"GET", "/api/health?fresh=1", true, false},
|
||||
{"POST", "/api/sms/send", true, true},
|
||||
{"POST", "/api/sms/batch-delete", true, true},
|
||||
// Reads must not be writable even though the path is known.
|
||||
{"POST", "/api/health", false, false},
|
||||
{"DELETE", "/api/sms/list", false, false},
|
||||
// Console-only surface that the app never constructs.
|
||||
{"GET", "/api/esim/profiles", false, false},
|
||||
{"POST", "/api/device-network/ddns/config", false, false},
|
||||
{"DELETE", "/api/sim/1", false, false},
|
||||
// Auth is the agent's own business.
|
||||
{"POST", "/api/auth/login", false, false},
|
||||
{"GET", "/api/auth/logout", false, false},
|
||||
{"POST", "/api/login", false, false},
|
||||
// Traversal and off-api targets.
|
||||
{"GET", "/etc/passwd", false, false},
|
||||
{"GET", "/api/../etc/passwd", false, false},
|
||||
{"GET", "/api/health/../../etc/passwd", false, false},
|
||||
{"GET", "", false, false},
|
||||
{"", "/api/health", false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
allowed, dangerous, reason := p.Decision(c.method, c.path)
|
||||
if allowed != c.allowed {
|
||||
t.Errorf("%s %s: allowed=%v want %v (reason %q)", c.method, c.path, allowed, c.allowed, reason)
|
||||
}
|
||||
if allowed && dangerous != c.dangerous {
|
||||
t.Errorf("%s %s: dangerous=%v want %v", c.method, c.path, dangerous, c.dangerous)
|
||||
}
|
||||
if !allowed && reason == "" {
|
||||
t.Errorf("%s %s: denied without a reason", c.method, c.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailClosedOnTampering walks the mutations an operator or a bad merge could plausibly produce
|
||||
// and asserts Parse rejects each one rather than degrading to something looser.
|
||||
func TestFailClosedOnTampering(t *testing.T) {
|
||||
base := Raw()
|
||||
mutate := func(t *testing.T, set func(m map[string]any)) []byte {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(base, &m); err != nil {
|
||||
t.Fatalf("unmarshal base: %v", err)
|
||||
}
|
||||
set(m)
|
||||
out, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
t.Run("non loopback egress host", func(t *testing.T) {
|
||||
raw := mutate(t, func(m map[string]any) {
|
||||
m["egress"].(map[string]any)["dialedHosts"] = []any{"10.0.0.9"}
|
||||
})
|
||||
if _, err := Parse(raw); err == nil {
|
||||
t.Fatal("accepted a routable egress host")
|
||||
}
|
||||
})
|
||||
t.Run("hostname egress", func(t *testing.T) {
|
||||
raw := mutate(t, func(m map[string]any) {
|
||||
m["egress"].(map[string]any)["dialedHosts"] = []any{"attacker.example"}
|
||||
})
|
||||
if _, err := Parse(raw); err == nil {
|
||||
t.Fatal("accepted a hostname; that reopens DNS rebinding and /etc/hosts pivots")
|
||||
}
|
||||
})
|
||||
t.Run("mode changed", func(t *testing.T) {
|
||||
raw := mutate(t, func(m map[string]any) {
|
||||
m["egress"].(map[string]any)["mode"] = "any"
|
||||
})
|
||||
if _, err := Parse(raw); err == nil {
|
||||
t.Fatal("accepted egress.mode=any")
|
||||
}
|
||||
})
|
||||
t.Run("auth proxied", func(t *testing.T) {
|
||||
raw := mutate(t, func(m map[string]any) {
|
||||
m["authPaths"].(map[string]any)["proxied"] = true
|
||||
})
|
||||
if _, err := Parse(raw); err == nil {
|
||||
t.Fatal("accepted proxied auth paths")
|
||||
}
|
||||
})
|
||||
t.Run("read path is an auth path", func(t *testing.T) {
|
||||
raw := mutate(t, func(m map[string]any) {
|
||||
m["read"] = append(m["read"].([]any), "/api/auth/session")
|
||||
})
|
||||
if _, err := Parse(raw); err == nil {
|
||||
t.Fatal("accepted an auth path on the read allowlist")
|
||||
}
|
||||
})
|
||||
t.Run("write without danger", func(t *testing.T) {
|
||||
raw := mutate(t, func(m map[string]any) {
|
||||
m["write"].(map[string]any)["/api/sim/disable"] = []any{"POST"}
|
||||
})
|
||||
if _, err := Parse(raw); err == nil {
|
||||
t.Fatal("accepted a write that is not declared dangerous")
|
||||
}
|
||||
})
|
||||
t.Run("write allows GET", func(t *testing.T) {
|
||||
raw := mutate(t, func(m map[string]any) {
|
||||
m["write"].(map[string]any)["/api/sms/send"] = []any{"GET"}
|
||||
})
|
||||
if _, err := Parse(raw); err == nil {
|
||||
t.Fatal("accepted GET as a write method")
|
||||
}
|
||||
})
|
||||
t.Run("dangerous path not writable", func(t *testing.T) {
|
||||
raw := mutate(t, func(m map[string]any) {
|
||||
m["dangerous"] = append(m["dangerous"].([]any), "/api/stats")
|
||||
})
|
||||
if _, err := Parse(raw); err == nil {
|
||||
t.Fatal("accepted a dangerous path with no write entry")
|
||||
}
|
||||
})
|
||||
t.Run("ttl beyond skew", func(t *testing.T) {
|
||||
raw := mutate(t, func(m map[string]any) {
|
||||
m["limits"].(map[string]any)["requestTtlSeconds"] = 600
|
||||
})
|
||||
if _, err := Parse(raw); err == nil {
|
||||
t.Fatal("accepted a TTL the replay window cannot cover")
|
||||
}
|
||||
})
|
||||
t.Run("replay cache below mailbox", func(t *testing.T) {
|
||||
raw := mutate(t, func(m map[string]any) {
|
||||
m["limits"].(map[string]any)["replayCacheSize"] = 2
|
||||
})
|
||||
if _, err := Parse(raw); err == nil {
|
||||
t.Fatal("accepted a replay cache that evicts live requests")
|
||||
}
|
||||
})
|
||||
t.Run("schema bump", func(t *testing.T) {
|
||||
raw := mutate(t, func(m map[string]any) {
|
||||
m["schemaVersion"] = 2
|
||||
})
|
||||
if _, err := Parse(raw); err == nil {
|
||||
t.Fatal("accepted an unknown schemaVersion")
|
||||
}
|
||||
})
|
||||
t.Run("traversal path", func(t *testing.T) {
|
||||
raw := mutate(t, func(m map[string]any) {
|
||||
m["read"] = append(m["read"].([]any), "/api/../etc/shadow")
|
||||
})
|
||||
if _, err := Parse(raw); err == nil {
|
||||
t.Fatal("accepted a traversal path")
|
||||
}
|
||||
})
|
||||
t.Run("malformed json", func(t *testing.T) {
|
||||
if _, err := Parse([]byte("{")); err == nil {
|
||||
t.Fatal("accepted malformed json")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDigestIsStable(t *testing.T) {
|
||||
p := loaded(t)
|
||||
if len(p.SHA256()) != 64 {
|
||||
t.Fatalf("digest looks wrong: %q", p.SHA256())
|
||||
}
|
||||
again, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load twice: %v", err)
|
||||
}
|
||||
if again.SHA256() != p.SHA256() {
|
||||
t.Fatal("digest changed between loads")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user