From 8e169bb1c0b77ed65b6989f9bd7c1f9622649ae3 Mon Sep 17 00:00:00 2001 From: stxkxs <139715017+stxkxs@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:15:04 -0700 Subject: [PATCH] feat(platform): a hipaa platform must choose its guardrail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compliance.hipaa` declared a posture and checked nothing particular to it. Its only invariant was the generic one it shares with `soc2` — a Platform must declare at least what its Tenant declares — so the flag was strictly weaker than its sibling, which additionally requires the budget kill-switch. Every model route resolves to a guardrail: the route's own guardrailRef, else the gateway's defaultGuardrailRef, else the cluster baseline the operator reads from SSM. That fallback is deliberate and is the right default for a general workload. It is also a general-purpose guardrail — it anonymizes email, phone and card numbers, blocks SSN, and covers no other entity — and a route reaches it by omission rather than by anyone choosing it. So a hipaa Platform whose routes name no guardrail now reports one finding per unguarded route. The check is that a decision was made, not what the decision was: whether a named guardrail carries the right entities and blocks rather than anonymizes is a question about Bedrock state this audit does not read, while declaring HIPAA and silently inheriting a default is answerable from the CRs alone. Gateways are matched to their Platform by spec.platformRef, so a peer tenant's gateway in the same namespace is never attributed here. A list that fails reports rather than returning clean — an unread gateway is not a cleared one. Adds the SARIF rule alongside the finding type, which the rule-coverage test requires. Mutation-tested: dropping the hipaa gate, firing for every platform, removing the platformRef filter, and ignoring either guardrail ref are all caught. --- internal/cloud/platform.go | 5 ++ internal/output/sarif.go | 1 + internal/platform/audit.go | 58 +++++++++++++ internal/platform/audit_test.go | 141 ++++++++++++++++++++++++++++++-- 4 files changed, 200 insertions(+), 5 deletions(-) diff --git a/internal/cloud/platform.go b/internal/cloud/platform.go index dacb0ac..6b294f9 100644 --- a/internal/cloud/platform.go +++ b/internal/cloud/platform.go @@ -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 @@ -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 diff --git a/internal/output/sarif.go b/internal/output/sarif.go index ca22d41..61e1934 100644 --- a/internal/output/sarif.go +++ b/internal/output/sarif.go @@ -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 { diff --git a/internal/platform/audit.go b/internal/platform/audit.go index dcb7870..e76e17e 100644 --- a/internal/platform/audit.go +++ b/internal/platform/audit.go @@ -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 ( @@ -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 } diff --git a/internal/platform/audit_test.go b/internal/platform/audit_test.go index 5b3f534..48647e7 100644 --- a/internal/platform/audit_test.go +++ b/internal/platform/audit_test.go @@ -3,6 +3,7 @@ package platform import ( "context" "errors" + "sort" "strings" "testing" @@ -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" @@ -94,6 +95,62 @@ 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(), @@ -101,6 +158,7 @@ func dynClient(objs ...runtime.Object) *dynamicfake.FakeDynamicClient { platformGVR: "PlatformList", budgetGVR: "BudgetPolicyList", tenantGVR: "TenantList", + gatewayGVR: "ModelGatewayList", }, objs..., ) @@ -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.