Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/cloud/platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ const (
PlatformKillSwitchDisabled PlatformFindingType = "KILL_SWITCH_DISABLED"
PlatformComplianceWeaker PlatformFindingType = "COMPLIANCE_WEAKER_THAN_TENANT"
PlatformTenantMissing PlatformFindingType = "TENANT_MISSING"

// A hipaa Platform whose model routes fall back to the cluster baseline
// guardrail instead of naming one of their own.
PlatformHipaaGuardrailInherited PlatformFindingType = "HIPAA_GUARDRAIL_INHERITED"
)

// AllPlatformFindingTypes is every type the platform auditor can emit. SARIF
Expand Down Expand Up @@ -74,6 +78,7 @@ var AllPlatformFindingTypes = []PlatformFindingType{
PlatformKillSwitchDisabled,
PlatformComplianceWeaker,
PlatformTenantMissing,
PlatformHipaaGuardrailInherited,
}

// IAMRoleInfo is the read-only view of an IAM role the platform auditor needs to
Expand Down
1 change: 1 addition & 0 deletions internal/output/sarif.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ func buildPlatformRules() []sarifRule {
{cloud.PlatformKillSwitchDisabled, "KillSwitchDisabled", "error"},
{cloud.PlatformComplianceWeaker, "ComplianceWeakerThanTenant", "error"},
{cloud.PlatformTenantMissing, "TenantMissing", "note"},
{cloud.PlatformHipaaGuardrailInherited, "HipaaGuardrailInherited", "error"},
}
rules := make([]sarifRule, 0, len(types))
for _, t := range types {
Expand Down
58 changes: 58 additions & 0 deletions internal/platform/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var (
platformGVR = schema.GroupVersionResource{Group: "platform.nanohype.dev", Version: "v1alpha1", Resource: "platforms"}
tenantGVR = schema.GroupVersionResource{Group: "platform.nanohype.dev", Version: "v1alpha1", Resource: "tenants"}
budgetGVR = schema.GroupVersionResource{Group: "governance.nanohype.dev", Version: "v1alpha1", Resource: "budgetpolicies"}
gatewayGVR = schema.GroupVersionResource{Group: "agents.nanohype.dev", Version: "v1alpha1", Resource: "modelgateways"}
)

const (
Expand Down Expand Up @@ -537,5 +538,62 @@ func auditBudgetCompliance(ctx context.Context, dyn dynamic.Interface, p *unstru
}
}
}

if hipaa {
out = append(out, auditHipaaGuardrails(ctx, dyn, p, f)...)
}
return out
}

// auditHipaaGuardrails requires a hipaa Platform's model routes to name a
// guardrail rather than fall back to the cluster baseline.
//
// Every route resolves to a guardrail: the route's own guardrailRef, else the
// gateway's defaultGuardrailRef, else the baseline the operator reads from SSM.
// That fallback is the point of the baseline and the right default for a general
// workload — but it is a general-purpose guardrail, and a route reaching it does
// so by omission rather than by anyone choosing it.
//
// The check is that a decision was made, not what the decision was. Whether a
// named guardrail carries the right PII entities and blocks rather than
// anonymizes is a question about Bedrock state, which this audit does not read;
// declaring HIPAA and silently inheriting a default is answerable from the CRs
// alone.
func auditHipaaGuardrails(ctx context.Context, dyn dynamic.Interface, p *unstructured.Unstructured, f findingFunc) []cloud.PlatformFinding {
ns := p.GetNamespace()
gws, err := dyn.Resource(gatewayGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if err != nil {
// A gateway the audit cannot read is not a gateway the audit can clear.
// Reporting nothing here would read as "no finding" on a Platform whose
// routes were never examined.
return []cloud.PlatformFinding{f(cloud.SeverityLow, cloud.PlatformHipaaGuardrailInherited, "",
"hipaa platform's ModelGateways could not be listed, so their guardrails were not checked",
"Re-run with permission to list modelgateways in this namespace.")}
}

var out []cloud.PlatformFinding
for _, gw := range gws.Items {
if owner, _, _ := unstructured.NestedString(gw.Object, "spec", "platformRef", "name"); owner != p.GetName() {
continue
}
gwDefault, _, _ := unstructured.NestedString(gw.Object, "spec", "defaultGuardrailRef", "name")
if gwDefault != "" {
continue // covers every route on this gateway
}
routes, _, _ := unstructured.NestedSlice(gw.Object, "spec", "routes")
for _, r := range routes {
route, ok := r.(map[string]any)
if !ok {
continue
}
if ref, _, _ := unstructured.NestedString(route, "guardrailRef", "name"); ref != "" {
continue
}
name, _, _ := unstructured.NestedString(route, "name")
out = append(out, f(cloud.SeverityHigh, cloud.PlatformHipaaGuardrailInherited, ns+"/"+gw.GetName()+"/"+name,
"hipaa platform's route "+name+" names no guardrail, so it falls back to the cluster baseline",
"Set spec.routes[].guardrailRef, or the gateway's spec.defaultGuardrailRef, to a guardrail chosen for this workload."))
}
}
return out
}
141 changes: 136 additions & 5 deletions internal/platform/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package platform
import (
"context"
"errors"
"sort"
"strings"
"testing"

Expand All @@ -19,11 +20,11 @@ import (
)

const (
tName = "app1"
tNS = "tenants-app1"
tMgmtNS = "eks-agent-platform"
tTen = "acme"
tPers = "eng"
tName = "app1"
tNS = "tenants-app1"
tMgmtNS = "eks-agent-platform"
tTen = "acme"
tPers = "eng"
tBudget = "tenant-budget"
tRole = "arn:aws:iam::123456789012:role/dev-app1-tenant"
tCluster = "development-cluster"
Expand Down Expand Up @@ -94,13 +95,70 @@ func tenantCR(soc2, hipaa bool) *unstructured.Unstructured {
}}
}

// gatewayCR is a ModelGateway owned by ownerPlatform. defaultRef is the
// gateway-wide guardrail ("" for none); routes maps a route name to its own
// guardrailRef ("" for none, i.e. the route falls back).
func gatewayCR(name, ownerPlatform, defaultRef string, routes map[string]string) *unstructured.Unstructured {
rs := make([]interface{}, 0, len(routes))
for _, rn := range sortedKeys(routes) {
route := map[string]interface{}{"name": rn}
if routes[rn] != "" {
route["guardrailRef"] = map[string]interface{}{"name": routes[rn]}
}
rs = append(rs, route)
}
spec := map[string]interface{}{
"platformRef": map[string]interface{}{"name": ownerPlatform},
"routes": rs,
}
if defaultRef != "" {
spec["defaultGuardrailRef"] = map[string]interface{}{"name": defaultRef}
}
return &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "agents.nanohype.dev/v1alpha1",
"kind": "ModelGateway",
"metadata": map[string]interface{}{"name": name, "namespace": tMgmtNS},
"spec": spec,
}}
}

// sortedKeys keeps the rendered route order deterministic so a finding's
// resource string is stable across runs.
func sortedKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}

// addGateways seeds ModelGateways under the resource the audit actually queries.
//
// They cannot be passed to dynClient as constructor objects: the fake files an
// object under meta.UnsafeGuessKindToResource(kind), which lowercases the kind
// and turns a trailing "y" into "ies" — so "ModelGateway" lands under
// "modelgatewaies" while the CRD's real plural, and gatewayGVR, is
// "modelgateways". Nothing errors; List simply returns an empty set, and a test
// asserting a finding fails while a test asserting its absence passes for the
// wrong reason. Tracker().Create takes the GVR explicitly and sidesteps the guess.
func addGateways(t *testing.T, dyn *dynamicfake.FakeDynamicClient, gws ...*unstructured.Unstructured) {
t.Helper()
for _, gw := range gws {
if err := dyn.Tracker().Create(gatewayGVR, gw, gw.GetNamespace()); err != nil {
t.Fatalf("seed ModelGateway %s: %v", gw.GetName(), err)
}
}
}

func dynClient(objs ...runtime.Object) *dynamicfake.FakeDynamicClient {
return dynamicfake.NewSimpleDynamicClientWithCustomListKinds(
runtime.NewScheme(),
map[schema.GroupVersionResource]string{
platformGVR: "PlatformList",
budgetGVR: "BudgetPolicyList",
tenantGVR: "TenantList",
gatewayGVR: "ModelGatewayList",
},
objs...,
)
Expand Down Expand Up @@ -481,6 +539,79 @@ func TestAudit_KillSwitchDisabled(t *testing.T) {
}
}

// Every route resolves to a guardrail — its own, the gateway default, or the
// cluster baseline the operator reads from SSM. The baseline is the right
// default for a general workload, but a route reaches it by omission. These
// cases pin that a hipaa Platform has to have chosen.
func TestAudit_HipaaRouteInheritingBaselineIsAFinding(t *testing.T) {
typed := kubefake.NewSimpleClientset(conformantObjects()...)
dyn := dynClient(platformCRCompliance(true, true), budgetCR(true), tenantCR(true, true))
addGateways(t, dyn, gatewayCR("gw", tName, "", map[string]string{"review": ""}))
findings, err := Audit(context.Background(), typed, dyn, nil)
if err != nil {
t.Fatal(err)
}
if !types(findings)[cloud.PlatformHipaaGuardrailInherited] {
t.Fatalf("expected HIPAA_GUARDRAIL_INHERITED, got %+v", findings)
}
}

func TestAudit_HipaaGuardrailSatisfiedByEitherRef(t *testing.T) {
for _, tc := range []struct {
name string
defaultRef string
routes map[string]string
}{
{"gateway default covers every route", "phi-guardrail", map[string]string{"review": "", "summarize": ""}},
{"each route names its own", "", map[string]string{"review": "phi-guardrail", "summarize": "phi-guardrail"}},
} {
t.Run(tc.name, func(t *testing.T) {
typed := kubefake.NewSimpleClientset(conformantObjects()...)
dyn := dynClient(platformCRCompliance(true, true), budgetCR(true), tenantCR(true, true))
addGateways(t, dyn, gatewayCR("gw", tName, tc.defaultRef, tc.routes))
findings, err := Audit(context.Background(), typed, dyn, nil)
if err != nil {
t.Fatal(err)
}
if types(findings)[cloud.PlatformHipaaGuardrailInherited] {
t.Fatalf("a named guardrail must satisfy the check, got %+v", findings)
}
})
}
}

// The rule is scoped to hipaa. Inheriting the baseline is the intended default
// everywhere else, so flagging it generally would make the finding noise.
func TestAudit_NonHipaaRouteMayInheritBaseline(t *testing.T) {
typed := kubefake.NewSimpleClientset(conformantObjects()...)
dyn := dynClient(platformCRCompliance(true, false), budgetCR(true), tenantCR(true, false))
addGateways(t, dyn, gatewayCR("gw", tName, "", map[string]string{"review": ""}))
findings, err := Audit(context.Background(), typed, dyn, nil)
if err != nil {
t.Fatal(err)
}
if types(findings)[cloud.PlatformHipaaGuardrailInherited] {
t.Fatalf("a non-hipaa platform may inherit the baseline, got %+v", findings)
}
}

// A gateway in the same namespace owned by a different Platform. Without the
// platformRef filter this would report one Platform's route against another.
func TestAudit_HipaaIgnoresAnotherPlatformsGateway(t *testing.T) {
typed := kubefake.NewSimpleClientset(conformantObjects()...)
dyn := dynClient(platformCRCompliance(true, true), budgetCR(true), tenantCR(true, true))
addGateways(t, dyn,
gatewayCR("gw-self", tName, "phi-guardrail", map[string]string{"review": ""}),
gatewayCR("gw-other", "some-other-platform", "", map[string]string{"unguarded": ""}))
findings, err := Audit(context.Background(), typed, dyn, nil)
if err != nil {
t.Fatal(err)
}
if types(findings)[cloud.PlatformHipaaGuardrailInherited] {
t.Fatalf("another Platform's gateway must not be attributed here, got %+v", findings)
}
}

func TestAudit_ComplianceWeakerThanTenant(t *testing.T) {
typed := kubefake.NewSimpleClientset(conformantObjects()...)
// Tenant requires SOC2; Platform does not set it.
Expand Down
Loading