diff --git a/README.md b/README.md index a74a7a8..aa75a06 100644 --- a/README.md +++ b/README.md @@ -449,22 +449,30 @@ All findings are **MEDIUM** severity. # Require owner, env, and cost-center tags cloudgov tags --require owner,env,cost-center -# Require a smaller tag set -cloudgov tags --require owner,env +# Gate on a published tagging standard's required AWS keys instead of typing them +cloudgov tags --standard-file resource-tagging.json + +# Fail CI if anything is missing a required tag +cloudgov tags --standard-file resource-tagging.json --fail-on medium # JSON output cloudgov tags --require owner,env --output json --output-file tags.json ``` +The required tag set comes from `--require` (ad-hoc) or `--standard-file` (the required AWS keys of a [nanohype resource-tagging standard](https://github.com/nanohype/nanohype/blob/main/standards/resource-tagging.json) JSON — `content.required_by_surface.aws`). `--require` wins when both are set. Pair with the global `--fail-on medium` to gate CI (all findings are MEDIUM, so `--fail-on medium` exits non-zero on any missing required tag). + **Flags** | Flag | Default | Description | |------|---------|-------------| -| `--require` | (required) | Comma-separated tag keys that must be present | +| `--require` | | Comma-separated tag keys that must be present | +| `--standard-file` | | Path to a resource-tagging standard JSON; gates on its required AWS keys | | `--severity` | `MEDIUM` | Minimum severity to report | | `--output` | `table` | Output format: `table`, `json` | | `--output-file` | | Write output to file instead of stdout | +One of `--require` or `--standard-file` is required. + --- ### `cloudgov lambda audit` — Lambda resource-policy exposure (AWS) diff --git a/cmd/tags.go b/cmd/tags.go index 4de8b15..e109675 100644 --- a/cmd/tags.go +++ b/cmd/tags.go @@ -20,22 +20,35 @@ var tagsCmd = &cobra.Command{ } var ( - tagsRequired []string - tagsSeverity string - tagsOutputFmt string - tagsOutputFile string + tagsRequired []string + tagsStandardFile string + tagsSeverity string + tagsOutputFmt string + tagsOutputFile string ) func init() { tagsCmd.Flags().StringSliceVar(&tagsRequired, "require", []string{}, "required tag/label keys (comma-separated, e.g. owner,env,cost-center)") + tagsCmd.Flags().StringVar(&tagsStandardFile, "standard-file", "", "path to a nanohype resource-tagging standard JSON; gates on its required AWS keys (content.required_by_surface.aws)") tagsCmd.Flags().StringVar(&tagsSeverity, "severity", "MEDIUM", "minimum severity to report") tagsCmd.Flags().StringVar(&tagsOutputFmt, "output", "table", "output format: table, json") tagsCmd.Flags().StringVar(&tagsOutputFile, "output-file", "", "write output to file") } func runTags(_ *cobra.Command, _ []string) error { - if len(tagsRequired) == 0 { - return fmt.Errorf("--require must specify at least one tag key") + // Precedence: explicit --require wins (ad-hoc override); else the required + // AWS keys from --standard-file; else error. Keeps --require working for + // one-off checks while --standard-file is the CI gate's source of truth. + required := tagsRequired + if len(required) == 0 && tagsStandardFile != "" { + loaded, err := tags.LoadRequired(tagsStandardFile) + if err != nil { + return err + } + required = loaded + } + if len(required) == 0 { + return fmt.Errorf("specify required tag keys via --require or --standard-file") } ctx := context.Background() @@ -46,7 +59,7 @@ func runTags(_ *cobra.Command, _ []string) error { findings, err := tags.Scan(ctx, providers, tags.ScanOptions{ MinSeverity: cloud.Severity(strings.ToUpper(tagsSeverity)), - Required: tagsRequired, + Required: required, }) if err != nil { return err diff --git a/internal/tags/standard.go b/internal/tags/standard.go new file mode 100644 index 0000000..c158aae --- /dev/null +++ b/internal/tags/standard.go @@ -0,0 +1,44 @@ +package tags + +import ( + "encoding/json" + "fmt" + "os" +) + +// standardKind is the kind discriminator a resource-tagging standard file must +// declare. Guards against pointing --standard-file at the wrong JSON. +const standardKind = "nanohype/standards/resource-tagging" + +// standardFile is the minimal shape cloudgov reads from a published nanohype +// resource-tagging standard — just enough to pull the required AWS tag keys. +// The standard pre-renders them (content.required_by_surface.aws holds the +// PascalCase keys), so there's no rendering logic here. +type standardFile struct { + Kind string `json:"kind"` + Content struct { + RequiredBySurface map[string][]string `json:"required_by_surface"` + } `json:"content"` +} + +// LoadRequired reads the required AWS tag keys from a nanohype resource-tagging +// standard JSON file (content.required_by_surface.aws) — the keys cloudgov then +// audits every AWS resource for. The same file the SDK/MCP serve and CI gates on. +func LoadRequired(path string) ([]string, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read standard file %s: %w", path, err) + } + var sf standardFile + if err := json.Unmarshal(b, &sf); err != nil { + return nil, fmt.Errorf("parse standard file %s: %w", path, err) + } + if sf.Kind != standardKind { + return nil, fmt.Errorf("standard file %s: unexpected kind %q (want %s)", path, sf.Kind, standardKind) + } + keys := sf.Content.RequiredBySurface["aws"] + if len(keys) == 0 { + return nil, fmt.Errorf("standard file %s: content.required_by_surface.aws is empty", path) + } + return keys, nil +} diff --git a/internal/tags/standard_test.go b/internal/tags/standard_test.go new file mode 100644 index 0000000..24f3ee4 --- /dev/null +++ b/internal/tags/standard_test.go @@ -0,0 +1,70 @@ +package tags + +import ( + "os" + "path/filepath" + "testing" +) + +func writeTemp(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "resource-tagging.json") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatalf("write temp: %v", err) + } + return p +} + +const validStandard = `{ + "kind": "nanohype/standards/resource-tagging", + "content": { + "required_by_surface": { + "aws": ["Environment", "ManagedBy", "CostCenter"], + "gcp": ["environment"] + } + } +}` + +func TestLoadRequired(t *testing.T) { + t.Run("valid returns aws keys", func(t *testing.T) { + got, err := LoadRequired(writeTemp(t, validStandard)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"Environment", "ManagedBy", "CostCenter"} + if len(got) != len(want) { + t.Fatalf("got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("key %d: got %q want %q", i, got[i], want[i]) + } + } + }) + + t.Run("missing file errors", func(t *testing.T) { + if _, err := LoadRequired(filepath.Join(t.TempDir(), "nope.json")); err == nil { + t.Error("expected error for missing file") + } + }) + + t.Run("bad json errors", func(t *testing.T) { + if _, err := LoadRequired(writeTemp(t, "{not json")); err == nil { + t.Error("expected error for bad json") + } + }) + + t.Run("wrong kind errors", func(t *testing.T) { + body := `{"kind":"nanohype/standards/llm-policy","content":{"required_by_surface":{"aws":["Environment"]}}}` + if _, err := LoadRequired(writeTemp(t, body)); err == nil { + t.Error("expected error for wrong kind") + } + }) + + t.Run("empty aws list errors", func(t *testing.T) { + body := `{"kind":"nanohype/standards/resource-tagging","content":{"required_by_surface":{"gcp":["environment"]}}}` + if _, err := LoadRequired(writeTemp(t, body)); err == nil { + t.Error("expected error for empty aws list") + } + }) +}