diff --git a/internal/tencent/security_commands.go b/internal/tencent/security_commands.go new file mode 100644 index 0000000..3603b8a --- /dev/null +++ b/internal/tencent/security_commands.go @@ -0,0 +1,242 @@ +package tencent + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// securityScan describes one of the ten Tencent security scans the CLI +// exposes. Each scan returns raw JSON shaped for both the dashboard +// surface and `jq` piping. The HTTP API under `clanker server` already +// surfaces the same set at /api/v1/tencent/scan/* — this command suite +// is the shell-out path used by clanker-cloud's TencentSecurityPanel. +type securityScan struct { + name string // sub-command verb, e.g. "public-exposure" + short string // one-line description for `--help` + needsRG bool // true when the underlying call accepts a region + run func(ctx context.Context, c *Client, region string, days int) (string, error) +} + +// securityScans is the load-bearing registry. Adding a new scan here is +// the only place that needs to change to surface it both as a sub-command +// and inside `clanker tencent security all`. +var securityScans = []securityScan{ + { + name: "public-exposure", + short: "CVMs reachable from the public internet (CVM × SG × public IP)", + needsRG: true, + run: func(ctx context.Context, c *Client, region string, _ int) (string, error) { + return c.PublicExposureScanJSON(ctx, region) + }, + }, + { + name: "clb-exposure", + short: "Public-facing CLB listeners with risky protocol/port combos", + needsRG: true, + run: func(ctx context.Context, c *Client, region string, _ int) (string, error) { + return c.CLBExposureScanJSON(ctx, region) + }, + }, + { + name: "db-exposure", + short: "MySQL/Postgres/Redis/MongoDB instances exposed beyond the VPC", + needsRG: true, + run: func(ctx context.Context, c *Client, region string, _ int) (string, error) { + return c.DBExposureScanJSON(ctx, region) + }, + }, + { + name: "idle-eips", + short: "Unassociated Elastic IPs still billed at the hourly idle rate", + needsRG: true, + run: func(ctx context.Context, c *Client, region string, _ int) (string, error) { + return c.IdleEIPScanJSON(ctx, region) + }, + }, + { + name: "unencrypted-cbs", + short: "CBS volumes that are not server-side encrypted", + needsRG: true, + run: func(ctx context.Context, c *Client, region string, _ int) (string, error) { + return c.UnencryptedCBSScanJSON(ctx, region) + }, + }, + { + name: "cert-expiry", + short: "SSL certificates expiring within --days (default 30)", + run: func(ctx context.Context, c *Client, _ string, days int) (string, error) { + return c.CertExpiryScanJSON(ctx, days) + }, + }, + { + name: "cam-hygiene", + short: "CAM sub-accounts missing MFA, with old access keys, or no login restriction", + run: func(ctx context.Context, c *Client, _ string, _ int) (string, error) { + return c.CAMHygieneScanJSON(ctx) + }, + }, + { + name: "waf-coverage", + short: "EdgeOne / CLB / public CVM hosts that don't have WAF in front", + run: func(ctx context.Context, c *Client, _ string, _ int) (string, error) { + return c.WAFCoverageScanJSON(ctx) + }, + }, + { + name: "antiddos-coverage", + short: "Public Elastic IPs not protected by Anti-DDoS Advanced", + needsRG: true, + run: func(ctx context.Context, c *Client, region string, _ int) (string, error) { + return c.AntiDDoSCoverageScanJSON(ctx, region) + }, + }, + { + name: "audit-coverage", + short: "Whether Cloud Audit is enabled and writing to durable storage", + run: func(ctx context.Context, c *Client, _ string, _ int) (string, error) { + return c.AuditLogCoverageScanJSON(ctx) + }, + }, +} + +// buildSecurityCmd builds the `clanker tencent security` subtree. region +// is shared with the parent command's persistent --region flag so users +// can write `clanker tencent --region ap-jakarta security clb-exposure`. +func buildSecurityCmd(region *string) *cobra.Command { + securityCmd := &cobra.Command{ + Use: "security", + Short: "Run Tencent Cloud security scans", + Long: `Run one (or all) of the ten Tencent Cloud security scans the +clanker-cloud dashboard surfaces. + +Each scan returns raw JSON on stdout so it's safe to pipe into jq, an +incident ticket, or the clanker-cloud HTTP API. The scan envelopes are +shaped for both human reading and machine parsing — see the dashboard's +Security tab for the canonical UI.`, + } + + // Per-scan sub-commands. Bind the loop variable so each closure + // captures its own scan definition rather than the last iteration. + for _, scan := range securityScans { + scan := scan + sub := &cobra.Command{ + Use: scan.name, + Short: scan.short, + RunE: func(cmd *cobra.Command, args []string) error { + return runSecurityScan(cmd.Context(), scan, region, cmd) + }, + } + if scan.name == "cert-expiry" { + sub.Flags().Int("days", 30, "Flag certificates expiring within this many days") + } + securityCmd.AddCommand(sub) + } + + // `all` fan-out — runs the 10 scans in parallel and emits a wrapped + // envelope so callers can consume the whole set in one round-trip. + allCmd := &cobra.Command{ + Use: "all", + Short: "Run every security scan and emit a wrapped JSON envelope", + RunE: func(cmd *cobra.Command, args []string) error { + creds := ResolveCredentials() + if region != nil && *region != "" { + creds.Region = *region + } + client, err := NewClient(creds, viper.GetBool("debug")) + if err != nil { + return err + } + days, _ := cmd.Flags().GetInt("days") + return runAllSecurityScans(cmd.Context(), client, creds.Region, days, securityScans, cmd) + }, + } + allCmd.Flags().Int("days", 30, "Days threshold for cert-expiry within the bundle") + securityCmd.AddCommand(allCmd) + + return securityCmd +} + +// runSecurityScan executes a single scan and emits its raw JSON on stdout. +// Region defaults flow through ResolveCredentials so the same precedence +// (--region > env > config > ap-singapore) applies as the other tencent +// commands. +func runSecurityScan(ctx context.Context, scan securityScan, regionFlag *string, cmd *cobra.Command) error { + creds := ResolveCredentials() + if regionFlag != nil && strings.TrimSpace(*regionFlag) != "" { + creds.Region = *regionFlag + } + client, err := NewClient(creds, viper.GetBool("debug")) + if err != nil { + return err + } + + days := 30 + if scan.name == "cert-expiry" { + if v, err := cmd.Flags().GetInt("days"); err == nil && v > 0 { + days = v + } + } + + body, err := scan.run(ctx, client, creds.Region, days) + if err != nil { + return fmt.Errorf("%s scan: %w", scan.name, err) + } + fmt.Fprintln(cmd.OutOrStdout(), body) + return nil +} + +// allScanResult is the per-scan record inside the `security all` envelope. +// `data` is the raw JSON the individual scan produced (re-encoded so the +// outer wrapper stays valid JSON); `error` is set when that scan failed +// without aborting the rest of the bundle. +type allScanResult struct { + Name string `json:"name"` + Data json.RawMessage `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// runAllSecurityScans fans out across every registered scan in parallel. +// Each scan's failure is captured in its envelope rather than aborting +// the whole call — operators want to see the 9 scans that succeeded +// even if one IAM-permission gap broke the 10th. `scans` is passed in +// rather than read from the package global so tests can supply a fake +// registry without mutating shared state under -race. +func runAllSecurityScans(ctx context.Context, client *Client, region string, days int, scans []securityScan, cmd *cobra.Command) error { + results := make([]allScanResult, len(scans)) + var wg sync.WaitGroup + for i, scan := range scans { + i, scan := i, scan + wg.Add(1) + go func() { + defer wg.Done() + body, err := scan.run(ctx, client, region, days) + res := allScanResult{Name: scan.name} + if err != nil { + res.Error = err.Error() + } else if json.Valid([]byte(body)) { + res.Data = json.RawMessage(body) + } else { + res.Error = "scan returned non-JSON output" + } + results[i] = res + }() + } + wg.Wait() + + envelope := struct { + Region string `json:"region"` + Scans []allScanResult `json:"scans"` + }{Region: region, Scans: results} + out, err := json.Marshal(envelope) + if err != nil { + return fmt.Errorf("encode security-all envelope: %w", err) + } + fmt.Fprintln(cmd.OutOrStdout(), string(out)) + return nil +} diff --git a/internal/tencent/security_commands_test.go b/internal/tencent/security_commands_test.go new file mode 100644 index 0000000..b5536e6 --- /dev/null +++ b/internal/tencent/security_commands_test.go @@ -0,0 +1,152 @@ +package tencent + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// TestSecurityScansRegistry guards two invariants the rest of the +// command surface relies on: every registered scan has a name + run +// function, and the dashboard's documented set of 10 scans is present. +// If a future PR drops or renames a scan, this fails fast so the +// downstream clanker-cloud panel doesn't ship with a dead button. +func TestSecurityScansRegistry(t *testing.T) { + t.Parallel() + + wantNames := map[string]bool{ + "public-exposure": true, + "clb-exposure": true, + "db-exposure": true, + "idle-eips": true, + "unencrypted-cbs": true, + "cert-expiry": true, + "cam-hygiene": true, + "waf-coverage": true, + "antiddos-coverage": true, + "audit-coverage": true, + } + + seen := map[string]bool{} + for _, s := range securityScans { + if s.name == "" { + t.Error("scan with empty name in registry") + } + if s.run == nil { + t.Errorf("scan %q has nil run func", s.name) + } + if seen[s.name] { + t.Errorf("scan %q registered twice", s.name) + } + seen[s.name] = true + if !wantNames[s.name] { + t.Errorf("scan %q not in expected dashboard set", s.name) + } + } + for name := range wantNames { + if !seen[name] { + t.Errorf("expected scan %q not registered", name) + } + } +} + +// TestBuildSecurityCmd verifies the cobra subtree carries one child per +// scan plus the `all` fan-out command, and that cert-expiry advertises +// the --days flag. +func TestBuildSecurityCmd(t *testing.T) { + t.Parallel() + region := "" + cmd := buildSecurityCmd(®ion) + if cmd.Use != "security" { + t.Errorf("Use = %q, want %q", cmd.Use, "security") + } + + children := map[string]*cobra.Command{} + for _, sub := range cmd.Commands() { + children[sub.Use] = sub + } + + for _, scan := range securityScans { + if _, ok := children[scan.name]; !ok { + t.Errorf("subcommand %q missing", scan.name) + } + } + if _, ok := children["all"]; !ok { + t.Fatal(`subcommand "all" missing`) + } + if children["cert-expiry"].Flag("days") == nil { + t.Error(`cert-expiry should advertise --days flag`) + } +} + +// TestRunAllSecurityScans_CapturesPerScanErrors confirms the fan-out +// path returns a wrapped envelope where individual failures are +// surfaced in the per-scan `error` field rather than aborting the +// bundle. Passes a fake registry through the parameter so the test +// is race-clean alongside the other parallel tests. +func TestRunAllSecurityScans_CapturesPerScanErrors(t *testing.T) { + t.Parallel() + + fake := []securityScan{ + { + name: "ok-scan", + run: func(_ context.Context, _ *Client, _ string, _ int) (string, error) { + return `{"items":[1,2,3]}`, nil + }, + }, + { + name: "failing-scan", + run: func(_ context.Context, _ *Client, _ string, _ int) (string, error) { + return "", errors.New("permission denied") + }, + }, + { + name: "bad-json-scan", + run: func(_ context.Context, _ *Client, _ string, _ int) (string, error) { + return "not json", nil + }, + }, + } + + var buf strings.Builder + cmd := &cobra.Command{} + cmd.SetOut(&buf) + if err := runAllSecurityScans(context.Background(), nil, "ap-singapore", 30, fake, cmd); err != nil { + t.Fatal(err) + } + + var got struct { + Region string `json:"region"` + Scans []allScanResult `json:"scans"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &got); err != nil { + t.Fatalf("envelope is not valid JSON: %v", err) + } + if got.Region != "ap-singapore" { + t.Errorf("Region = %q, want ap-singapore", got.Region) + } + if len(got.Scans) != 3 { + t.Fatalf("len(Scans) = %d, want 3", len(got.Scans)) + } + + byName := map[string]allScanResult{} + for _, s := range got.Scans { + byName[s.Name] = s + } + if byName["ok-scan"].Error != "" { + t.Errorf("ok-scan should not have an error; got %q", byName["ok-scan"].Error) + } + if len(byName["ok-scan"].Data) == 0 { + t.Error("ok-scan should carry a Data payload") + } + if byName["failing-scan"].Error == "" { + t.Error("failing-scan should surface its error") + } + if byName["bad-json-scan"].Error == "" { + t.Error("bad-json-scan should be flagged as non-JSON") + } +} diff --git a/internal/tencent/static_commands.go b/internal/tencent/static_commands.go index ddc58fe..9934532 100644 --- a/internal/tencent/static_commands.go +++ b/internal/tencent/static_commands.go @@ -349,5 +349,6 @@ voucher-status enum: tencentCmd.AddCommand(kubeconfigCmd) tencentCmd.AddCommand(costCmd) tencentCmd.AddCommand(buildExpiryCmd(®ion)) + tencentCmd.AddCommand(buildSecurityCmd(®ion)) return tencentCmd }