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
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 20 additions & 7 deletions cmd/tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
44 changes: 44 additions & 0 deletions internal/tags/standard.go
Original file line number Diff line number Diff line change
@@ -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
}
70 changes: 70 additions & 0 deletions internal/tags/standard_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
Loading