package policy import ( "encoding/json" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "gopkg.in/yaml.v3" ) // POLICY-ATTRIBUTE-UNDECLARED is reported when a package reads // input.{resource,subject}.attributes. that no sibling registry or // manifest supplies. The key then resolves only from caller input // (FLEX-WP-0025-T03 / FLEX-DEC-2026-012). const undeclaredAttributeCode = "POLICY-ATTRIBUTE-UNDECLARED" var ( attrDot = regexp.MustCompile(`input\.(resource|subject)\.attributes\.([A-Za-z_][A-Za-z0-9_]*)`) attrGet = regexp.MustCompile(`object\.get\(\s*input\.(resource|subject)\.attributes\s*,\s*"([A-Za-z_][A-Za-z0-9_]*)"`) attrNestedGet = regexp.MustCompile(`object\.get\(\s*object\.get\(\s*input\.(resource|subject)\s*,\s*"attributes"\s*,\s*\{\}\s*\)\s*,\s*"([A-Za-z_][A-Za-z0-9_]*)"`) ) type attributeRead struct { Kind string Key string } func (p *Package) undeclaredAttributeDiagnostics() []Diagnostic { reads := attributeReads(p.RegoModule) if len(reads) == 0 { return nil } dir := filepath.Dir(p.Source) if dir == "." || dir == "" || !filepath.IsAbs(p.Source) && !fileExists(p.Source) { // Inline documents have no sibling registry. Skip unless the source // path points at a real file (LoadAndValidateFile). if _, err := os.Stat(p.Source); err != nil { return nil } } supplied := siblingSuppliedAttributes(filepath.Dir(p.Source)) var diagnostics []Diagnostic seen := map[string]bool{} for _, read := range reads { id := read.Kind + "." + read.Key if seen[id] { continue } seen[id] = true if supplied[read.Kind][read.Key] { continue } diagnostics = append(diagnostics, Diagnostic{ Code: undeclaredAttributeCode, Severity: "error", Message: fmt.Sprintf("package reads input.%s.attributes.%s, which no sibling registry or manifest supplies; the value can only come from the caller", read.Kind, read.Key), Fields: []string{"input." + read.Kind + ".attributes." + read.Key}, Metadata: map[string]any{ "kind": read.Kind, "key": read.Key, }, }) } sort.Slice(diagnostics, func(i, j int) bool { return diagnostics[i].Message < diagnostics[j].Message }) return diagnostics } func attributeReads(regoModule string) []attributeRead { var reads []attributeRead add := func(kind, key string) { reads = append(reads, attributeRead{Kind: kind, Key: key}) } for _, re := range []*regexp.Regexp{attrDot, attrGet, attrNestedGet} { for _, match := range re.FindAllStringSubmatch(regoModule, -1) { add(match[1], match[2]) } } return reads } func siblingSuppliedAttributes(dir string) map[string]map[string]bool { out := map[string]map[string]bool{ "resource": {}, "subject": {}, } names := []string{ "registry_snapshot.json", "production_registry_snapshot.json", "registry.json", "resource_manifest.yaml", "subject_manifest.yaml", } for _, name := range names { path := filepath.Join(dir, name) data, err := os.ReadFile(path) if err != nil { continue } var doc any if strings.HasSuffix(name, ".json") { if err := json.Unmarshal(data, &doc); err != nil { continue } } else if err := yaml.Unmarshal(data, &doc); err != nil { continue } collectSuppliedAttributes(doc, out) } return out } func collectSuppliedAttributes(doc any, out map[string]map[string]bool) { obj, ok := asMap(doc) if !ok { return } if resources, ok := asList(obj["resources"]); ok { for _, item := range resources { collectResourceKeys(item, out["resource"]) } } if manifests, ok := asList(obj["resource_manifests"]); ok { for _, item := range manifests { collectSuppliedAttributes(item, out) } } if subjects, ok := asList(obj["subjects"]); ok { for _, item := range subjects { collectSubjectKeys(item, out["subject"]) } } } func asList(value any) ([]any, bool) { switch typed := value.(type) { case []any: return typed, true default: return nil, false } } func collectResourceKeys(item any, keys map[string]bool) { obj, ok := asMap(item) if !ok { return } // First-class fields enrichment copies into attributes. for _, key := range []string{"path", "parent", "labels", "trust_zone", "owner"} { keys[key] = true } addMapKeys(obj["attributes"], keys) } func collectSubjectKeys(item any, keys map[string]bool) { obj, ok := asMap(item) if !ok { return } for _, key := range []string{"display_name", "organization_relation", "roles", "groups"} { keys[key] = true } addMapKeys(obj["attributes"], keys) addMapKeys(obj["claims"], keys) addMapKeys(obj["metadata"], keys) } func addMapKeys(value any, keys map[string]bool) { obj, ok := asMap(value) if !ok { return } for key := range obj { keys[key] = true } } func asMap(value any) (map[string]any, bool) { switch typed := value.(type) { case map[string]any: return typed, true case map[any]any: return stringifyKeys(typed), true default: return nil, false } } func stringifyKeys(in map[any]any) map[string]any { out := make(map[string]any, len(in)) for key, value := range in { out[fmt.Sprint(key)] = value } return out } func fileExists(path string) bool { _, err := os.Stat(path) return err == nil }