From 3c9bbb0b64081a90ea88201f115b77182d9fa7b3 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 1 Feb 2026 06:04:21 +0000 Subject: [PATCH] feat(security): add core security command for vulnerability alerts (#66) * feat(security): add core security command for vulnerability alerts Adds `core security` command area to expose GitHub security data: - `core security alerts` - aggregated view of all security alerts - `core security deps` - Dependabot vulnerability alerts with upgrade paths - `core security scan` - CodeQL and code scanning alerts - `core security secrets` - secret scanning alerts Features: - Filter by --repo, --severity (critical,high,medium,low) - JSON output with --json for AI agent consumption - Aggregated summary with severity breakdown - Shows patched versions for easy upgrades Closes #48 Co-Authored-By: Claude Opus 4.5 * fix(security): address CodeRabbit review feedback - Remove unused flattened fields from DependabotAlert struct - Add Unknown field to AlertSummary for unrecognized severities - Add doc comments for exported Add and String methods - Use cli.Wrap for contextual error wrapping - Fix secret scanning summary counting after filter - Remove unused --vulnerable flag from deps command - Fix JSON output to only include open alerts in secrets command Co-Authored-By: Claude Opus 4.5 * fix(security): handle json.MarshalIndent errors Address CodeRabbit review feedback by properly handling errors from json.MarshalIndent in all security subcommands instead of ignoring them. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- internal/variants/full.go | 1 + pkg/i18n/locales/en_GB.json | 16 +++ pkg/security/cmd.go | 7 + pkg/security/cmd_alerts.go | 216 +++++++++++++++++++++++++++++ pkg/security/cmd_deps.go | 134 ++++++++++++++++++ pkg/security/cmd_scan.go | 142 +++++++++++++++++++ pkg/security/cmd_secrets.go | 121 ++++++++++++++++ pkg/security/cmd_security.go | 259 +++++++++++++++++++++++++++++++++++ 8 files changed, 896 insertions(+) create mode 100644 pkg/security/cmd.go create mode 100644 pkg/security/cmd_alerts.go create mode 100644 pkg/security/cmd_deps.go create mode 100644 pkg/security/cmd_scan.go create mode 100644 pkg/security/cmd_secrets.go create mode 100644 pkg/security/cmd_security.go diff --git a/internal/variants/full.go b/internal/variants/full.go index e456fca9..30542eb6 100644 --- a/internal/variants/full.go +++ b/internal/variants/full.go @@ -33,6 +33,7 @@ import ( _ "github.com/host-uk/core/pkg/php" _ "github.com/host-uk/core/pkg/pkgcmd" _ "github.com/host-uk/core/pkg/sdk" + _ "github.com/host-uk/core/pkg/security" _ "github.com/host-uk/core/pkg/setup" _ "github.com/host-uk/core/pkg/test" _ "github.com/host-uk/core/pkg/vm" diff --git a/pkg/i18n/locales/en_GB.json b/pkg/i18n/locales/en_GB.json index d00018d5..7044b1c0 100644 --- a/pkg/i18n/locales/en_GB.json +++ b/pkg/i18n/locales/en_GB.json @@ -275,6 +275,22 @@ "github.error.config_not_found": "GitHub config file not found", "github.error.conflicting_flags": "Cannot use --repo and --all together" }, + "security": { + "short": "Security alerts and vulnerability scanning", + "long": "View security alerts from Dependabot, code scanning, and secret scanning across repositories.", + "alerts.short": "List all security alerts across repos", + "alerts.long": "List security alerts from Dependabot, code scanning, and secret scanning. Aggregates alerts across all repos in the registry.", + "deps.short": "List Dependabot vulnerability alerts", + "deps.long": "List vulnerable dependencies detected by Dependabot with upgrade recommendations.", + "deps.flag.vulnerable": "Show only vulnerable dependencies", + "scan.short": "List code scanning alerts", + "scan.long": "List code scanning alerts from tools like CodeQL, Semgrep, etc.", + "scan.flag.tool": "Filter by tool name (e.g., codeql, semgrep)", + "secrets.short": "List exposed secrets", + "secrets.long": "List secrets detected by GitHub secret scanning.", + "flag.repo": "Specific repo to check", + "flag.severity": "Filter by severity (critical,high,medium,low)" + }, "qa": { "short": "Quality assurance workflows", "long": "Quality assurance commands for verifying work - CI status, reviews, issues.", diff --git a/pkg/security/cmd.go b/pkg/security/cmd.go new file mode 100644 index 00000000..23b13f40 --- /dev/null +++ b/pkg/security/cmd.go @@ -0,0 +1,7 @@ +package security + +import "github.com/host-uk/core/pkg/cli" + +func init() { + cli.RegisterCommands(AddSecurityCommands) +} diff --git a/pkg/security/cmd_alerts.go b/pkg/security/cmd_alerts.go new file mode 100644 index 00000000..62b205d4 --- /dev/null +++ b/pkg/security/cmd_alerts.go @@ -0,0 +1,216 @@ +package security + +import ( + "encoding/json" + "fmt" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/i18n" +) + +func addAlertsCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "alerts", + Short: i18n.T("cmd.security.alerts.short"), + Long: i18n.T("cmd.security.alerts.long"), + RunE: func(c *cli.Command, args []string) error { + return runAlerts() + }, + } + + cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry")) + cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo")) + cmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity")) + cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json")) + + parent.AddCommand(cmd) +} + +// AlertOutput represents a unified alert for output. +type AlertOutput struct { + Repo string `json:"repo"` + Severity string `json:"severity"` + ID string `json:"id"` + Package string `json:"package,omitempty"` + Version string `json:"version,omitempty"` + Location string `json:"location,omitempty"` + Type string `json:"type"` + Message string `json:"message"` +} + +func runAlerts() error { + if err := checkGH(); err != nil { + return err + } + + reg, err := loadRegistry(securityRegistryPath) + if err != nil { + return err + } + + repoList := getReposToCheck(reg, securityRepo) + if len(repoList) == 0 { + return cli.Err("repo not found: %s", securityRepo) + } + + var allAlerts []AlertOutput + summary := &AlertSummary{} + + for _, repo := range repoList { + repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name) + + // Fetch Dependabot alerts + depAlerts, err := fetchDependabotAlerts(repoFullName) + if err == nil { + for _, alert := range depAlerts { + if alert.State != "open" { + continue + } + severity := alert.Advisory.Severity + if !filterBySeverity(severity, securitySeverity) { + continue + } + summary.Add(severity) + allAlerts = append(allAlerts, AlertOutput{ + Repo: repo.Name, + Severity: severity, + ID: alert.Advisory.CVEID, + Package: alert.Dependency.Package.Name, + Version: alert.SecurityVulnerability.VulnerableVersionRange, + Type: "dependabot", + Message: alert.Advisory.Summary, + }) + } + } + + // Fetch code scanning alerts + codeAlerts, err := fetchCodeScanningAlerts(repoFullName) + if err == nil { + for _, alert := range codeAlerts { + if alert.State != "open" { + continue + } + severity := alert.Rule.Severity + if !filterBySeverity(severity, securitySeverity) { + continue + } + summary.Add(severity) + location := fmt.Sprintf("%s:%d", alert.MostRecentInstance.Location.Path, alert.MostRecentInstance.Location.StartLine) + allAlerts = append(allAlerts, AlertOutput{ + Repo: repo.Name, + Severity: severity, + ID: alert.Rule.ID, + Location: location, + Type: alert.Tool.Name, + Message: alert.Rule.Description, + }) + } + } + + // Fetch secret scanning alerts + secretAlerts, err := fetchSecretScanningAlerts(repoFullName) + if err == nil { + for _, alert := range secretAlerts { + if alert.State != "open" { + continue + } + if !filterBySeverity("high", securitySeverity) { + continue + } + summary.Add("high") // Secrets are always high severity + allAlerts = append(allAlerts, AlertOutput{ + Repo: repo.Name, + Severity: "high", + ID: fmt.Sprintf("secret-%d", alert.Number), + Type: "secret-scanning", + Message: alert.SecretType, + }) + } + } + } + + if securityJSON { + output, err := json.MarshalIndent(allAlerts, "", " ") + if err != nil { + return cli.Wrap(err, "marshal JSON output") + } + cli.Text(string(output)) + return nil + } + + // Print summary + cli.Blank() + cli.Print("%s %s\n", cli.DimStyle.Render("Alerts:"), summary.String()) + cli.Blank() + + if len(allAlerts) == 0 { + return nil + } + + // Print table + for _, alert := range allAlerts { + sevStyle := severityStyle(alert.Severity) + + // Format: repo SEVERITY ID package/location type + location := alert.Package + if location == "" { + location = alert.Location + } + if alert.Version != "" { + location = fmt.Sprintf("%s %s", location, cli.DimStyle.Render(alert.Version)) + } + + cli.Print("%-20s %s %-16s %-40s %s\n", + cli.ValueStyle.Render(alert.Repo), + sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)), + alert.ID, + location, + cli.DimStyle.Render(alert.Type), + ) + } + cli.Blank() + + return nil +} + +func fetchDependabotAlerts(repoFullName string) ([]DependabotAlert, error) { + endpoint := fmt.Sprintf("repos/%s/dependabot/alerts?state=open", repoFullName) + output, err := runGHAPI(endpoint) + if err != nil { + return nil, cli.Wrap(err, fmt.Sprintf("fetch dependabot alerts for %s", repoFullName)) + } + + var alerts []DependabotAlert + if err := json.Unmarshal(output, &alerts); err != nil { + return nil, cli.Wrap(err, fmt.Sprintf("parse dependabot alerts for %s", repoFullName)) + } + return alerts, nil +} + +func fetchCodeScanningAlerts(repoFullName string) ([]CodeScanningAlert, error) { + endpoint := fmt.Sprintf("repos/%s/code-scanning/alerts?state=open", repoFullName) + output, err := runGHAPI(endpoint) + if err != nil { + return nil, cli.Wrap(err, fmt.Sprintf("fetch code-scanning alerts for %s", repoFullName)) + } + + var alerts []CodeScanningAlert + if err := json.Unmarshal(output, &alerts); err != nil { + return nil, cli.Wrap(err, fmt.Sprintf("parse code-scanning alerts for %s", repoFullName)) + } + return alerts, nil +} + +func fetchSecretScanningAlerts(repoFullName string) ([]SecretScanningAlert, error) { + endpoint := fmt.Sprintf("repos/%s/secret-scanning/alerts?state=open", repoFullName) + output, err := runGHAPI(endpoint) + if err != nil { + return nil, cli.Wrap(err, fmt.Sprintf("fetch secret-scanning alerts for %s", repoFullName)) + } + + var alerts []SecretScanningAlert + if err := json.Unmarshal(output, &alerts); err != nil { + return nil, cli.Wrap(err, fmt.Sprintf("parse secret-scanning alerts for %s", repoFullName)) + } + return alerts, nil +} diff --git a/pkg/security/cmd_deps.go b/pkg/security/cmd_deps.go new file mode 100644 index 00000000..0d13a932 --- /dev/null +++ b/pkg/security/cmd_deps.go @@ -0,0 +1,134 @@ +package security + +import ( + "encoding/json" + "fmt" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/i18n" +) + +func addDepsCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "deps", + Short: i18n.T("cmd.security.deps.short"), + Long: i18n.T("cmd.security.deps.long"), + RunE: func(c *cli.Command, args []string) error { + return runDeps() + }, + } + + cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry")) + cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo")) + cmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity")) + cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json")) + + parent.AddCommand(cmd) +} + +// DepAlert represents a dependency vulnerability for output. +type DepAlert struct { + Repo string `json:"repo"` + Severity string `json:"severity"` + CVE string `json:"cve"` + Package string `json:"package"` + Ecosystem string `json:"ecosystem"` + Vulnerable string `json:"vulnerable_range"` + PatchedVersion string `json:"patched_version,omitempty"` + Manifest string `json:"manifest"` + Summary string `json:"summary"` +} + +func runDeps() error { + if err := checkGH(); err != nil { + return err + } + + reg, err := loadRegistry(securityRegistryPath) + if err != nil { + return err + } + + repoList := getReposToCheck(reg, securityRepo) + if len(repoList) == 0 { + return cli.Err("repo not found: %s", securityRepo) + } + + var allAlerts []DepAlert + summary := &AlertSummary{} + + for _, repo := range repoList { + repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name) + + alerts, err := fetchDependabotAlerts(repoFullName) + if err != nil { + continue + } + + for _, alert := range alerts { + if alert.State != "open" { + continue + } + + severity := alert.Advisory.Severity + if !filterBySeverity(severity, securitySeverity) { + continue + } + + summary.Add(severity) + + depAlert := DepAlert{ + Repo: repo.Name, + Severity: severity, + CVE: alert.Advisory.CVEID, + Package: alert.Dependency.Package.Name, + Ecosystem: alert.Dependency.Package.Ecosystem, + Vulnerable: alert.SecurityVulnerability.VulnerableVersionRange, + PatchedVersion: alert.SecurityVulnerability.FirstPatchedVersion.Identifier, + Manifest: alert.Dependency.ManifestPath, + Summary: alert.Advisory.Summary, + } + allAlerts = append(allAlerts, depAlert) + } + } + + if securityJSON { + output, err := json.MarshalIndent(allAlerts, "", " ") + if err != nil { + return cli.Wrap(err, "marshal JSON output") + } + cli.Text(string(output)) + return nil + } + + // Print summary + cli.Blank() + cli.Print("%s %s\n", cli.DimStyle.Render("Dependabot:"), summary.String()) + cli.Blank() + + if len(allAlerts) == 0 { + return nil + } + + // Print table + for _, alert := range allAlerts { + sevStyle := severityStyle(alert.Severity) + + // Format upgrade suggestion + upgrade := alert.Vulnerable + if alert.PatchedVersion != "" { + upgrade = fmt.Sprintf("%s -> %s", alert.Vulnerable, cli.SuccessStyle.Render(alert.PatchedVersion)) + } + + cli.Print("%-16s %s %-16s %-30s %s\n", + cli.ValueStyle.Render(alert.Repo), + sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)), + alert.CVE, + alert.Package, + upgrade, + ) + } + cli.Blank() + + return nil +} diff --git a/pkg/security/cmd_scan.go b/pkg/security/cmd_scan.go new file mode 100644 index 00000000..a11e2adb --- /dev/null +++ b/pkg/security/cmd_scan.go @@ -0,0 +1,142 @@ +package security + +import ( + "encoding/json" + "fmt" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/i18n" +) + +var ( + scanTool string +) + +func addScanCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "scan", + Short: i18n.T("cmd.security.scan.short"), + Long: i18n.T("cmd.security.scan.long"), + RunE: func(c *cli.Command, args []string) error { + return runScan() + }, + } + + cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry")) + cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo")) + cmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity")) + cmd.Flags().StringVar(&scanTool, "tool", "", i18n.T("cmd.security.scan.flag.tool")) + cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json")) + + parent.AddCommand(cmd) +} + +// ScanAlert represents a code scanning alert for output. +type ScanAlert struct { + Repo string `json:"repo"` + Severity string `json:"severity"` + RuleID string `json:"rule_id"` + Tool string `json:"tool"` + Path string `json:"path"` + Line int `json:"line"` + Description string `json:"description"` + Message string `json:"message"` +} + +func runScan() error { + if err := checkGH(); err != nil { + return err + } + + reg, err := loadRegistry(securityRegistryPath) + if err != nil { + return err + } + + repoList := getReposToCheck(reg, securityRepo) + if len(repoList) == 0 { + return cli.Err("repo not found: %s", securityRepo) + } + + var allAlerts []ScanAlert + summary := &AlertSummary{} + + for _, repo := range repoList { + repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name) + + alerts, err := fetchCodeScanningAlerts(repoFullName) + if err != nil { + continue + } + + for _, alert := range alerts { + if alert.State != "open" { + continue + } + + // Filter by tool if specified + if scanTool != "" && alert.Tool.Name != scanTool { + continue + } + + severity := alert.Rule.Severity + if severity == "" { + severity = "medium" // Default if not specified + } + + if !filterBySeverity(severity, securitySeverity) { + continue + } + + summary.Add(severity) + + scanAlert := ScanAlert{ + Repo: repo.Name, + Severity: severity, + RuleID: alert.Rule.ID, + Tool: alert.Tool.Name, + Path: alert.MostRecentInstance.Location.Path, + Line: alert.MostRecentInstance.Location.StartLine, + Description: alert.Rule.Description, + Message: alert.MostRecentInstance.Message.Text, + } + allAlerts = append(allAlerts, scanAlert) + } + } + + if securityJSON { + output, err := json.MarshalIndent(allAlerts, "", " ") + if err != nil { + return cli.Wrap(err, "marshal JSON output") + } + cli.Text(string(output)) + return nil + } + + // Print summary + cli.Blank() + cli.Print("%s %s\n", cli.DimStyle.Render("Code Scanning:"), summary.String()) + cli.Blank() + + if len(allAlerts) == 0 { + return nil + } + + // Print table + for _, alert := range allAlerts { + sevStyle := severityStyle(alert.Severity) + + location := fmt.Sprintf("%s:%d", alert.Path, alert.Line) + + cli.Print("%-16s %s %-20s %-40s %s\n", + cli.ValueStyle.Render(alert.Repo), + sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)), + alert.RuleID, + location, + cli.DimStyle.Render(alert.Tool), + ) + } + cli.Blank() + + return nil +} diff --git a/pkg/security/cmd_secrets.go b/pkg/security/cmd_secrets.go new file mode 100644 index 00000000..87549db4 --- /dev/null +++ b/pkg/security/cmd_secrets.go @@ -0,0 +1,121 @@ +package security + +import ( + "encoding/json" + "fmt" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/i18n" +) + +func addSecretsCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "secrets", + Short: i18n.T("cmd.security.secrets.short"), + Long: i18n.T("cmd.security.secrets.long"), + RunE: func(c *cli.Command, args []string) error { + return runSecrets() + }, + } + + cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry")) + cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo")) + cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json")) + + parent.AddCommand(cmd) +} + +// SecretAlert represents a secret scanning alert for output. +type SecretAlert struct { + Repo string `json:"repo"` + Number int `json:"number"` + SecretType string `json:"secret_type"` + State string `json:"state"` + Resolution string `json:"resolution,omitempty"` + PushProtection bool `json:"push_protection_bypassed"` +} + +func runSecrets() error { + if err := checkGH(); err != nil { + return err + } + + reg, err := loadRegistry(securityRegistryPath) + if err != nil { + return err + } + + repoList := getReposToCheck(reg, securityRepo) + if len(repoList) == 0 { + return cli.Err("repo not found: %s", securityRepo) + } + + var allAlerts []SecretAlert + openCount := 0 + + for _, repo := range repoList { + repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name) + + alerts, err := fetchSecretScanningAlerts(repoFullName) + if err != nil { + continue + } + + for _, alert := range alerts { + if alert.State != "open" { + continue + } + openCount++ + + secretAlert := SecretAlert{ + Repo: repo.Name, + Number: alert.Number, + SecretType: alert.SecretType, + State: alert.State, + Resolution: alert.Resolution, + PushProtection: alert.PushProtection, + } + allAlerts = append(allAlerts, secretAlert) + } + } + + if securityJSON { + output, err := json.MarshalIndent(allAlerts, "", " ") + if err != nil { + return cli.Wrap(err, "marshal JSON output") + } + cli.Text(string(output)) + return nil + } + + // Print summary + cli.Blank() + if openCount > 0 { + cli.Print("%s %s\n", cli.DimStyle.Render("Secrets:"), cli.ErrorStyle.Render(fmt.Sprintf("%d open", openCount))) + } else { + cli.Print("%s %s\n", cli.DimStyle.Render("Secrets:"), cli.SuccessStyle.Render("No exposed secrets")) + } + cli.Blank() + + if len(allAlerts) == 0 { + return nil + } + + // Print table + for _, alert := range allAlerts { + bypassed := "" + if alert.PushProtection { + bypassed = cli.WarningStyle.Render(" (push protection bypassed)") + } + + cli.Print("%-16s %-6d %-30s%s\n", + cli.ValueStyle.Render(alert.Repo), + alert.Number, + cli.ErrorStyle.Render(alert.SecretType), + bypassed, + ) + } + cli.Blank() + + return nil +} diff --git a/pkg/security/cmd_security.go b/pkg/security/cmd_security.go new file mode 100644 index 00000000..63b3d1b2 --- /dev/null +++ b/pkg/security/cmd_security.go @@ -0,0 +1,259 @@ +package security + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/repos" +) + +var ( + // Command flags + securityRegistryPath string + securityRepo string + securitySeverity string + securityJSON bool +) + +// AddSecurityCommands adds the 'security' command to the root. +func AddSecurityCommands(root *cli.Command) { + secCmd := &cli.Command{ + Use: "security", + Short: i18n.T("cmd.security.short"), + Long: i18n.T("cmd.security.long"), + } + + addAlertsCommand(secCmd) + addDepsCommand(secCmd) + addScanCommand(secCmd) + addSecretsCommand(secCmd) + + root.AddCommand(secCmd) +} + +// DependabotAlert represents a Dependabot vulnerability alert. +type DependabotAlert struct { + Number int `json:"number"` + State string `json:"state"` + Advisory struct { + Severity string `json:"severity"` + CVEID string `json:"cve_id"` + Summary string `json:"summary"` + Description string `json:"description"` + } `json:"security_advisory"` + Dependency struct { + Package struct { + Name string `json:"name"` + Ecosystem string `json:"ecosystem"` + } `json:"package"` + ManifestPath string `json:"manifest_path"` + } `json:"dependency"` + SecurityVulnerability struct { + Package struct { + Name string `json:"name"` + Ecosystem string `json:"ecosystem"` + } `json:"package"` + FirstPatchedVersion struct { + Identifier string `json:"identifier"` + } `json:"first_patched_version"` + VulnerableVersionRange string `json:"vulnerable_version_range"` + } `json:"security_vulnerability"` +} + +// CodeScanningAlert represents a code scanning alert. +type CodeScanningAlert struct { + Number int `json:"number"` + State string `json:"state"` + DismissedReason string `json:"dismissed_reason"` + Rule struct { + ID string `json:"id"` + Severity string `json:"severity"` + Description string `json:"description"` + Tags []string `json:"tags"` + } `json:"rule"` + Tool struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"tool"` + MostRecentInstance struct { + Location struct { + Path string `json:"path"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + } `json:"location"` + Message struct { + Text string `json:"text"` + } `json:"message"` + } `json:"most_recent_instance"` +} + +// SecretScanningAlert represents a secret scanning alert. +type SecretScanningAlert struct { + Number int `json:"number"` + State string `json:"state"` + SecretType string `json:"secret_type"` + Secret string `json:"secret"` + PushProtection bool `json:"push_protection_bypassed"` + Resolution string `json:"resolution"` +} + +// loadRegistry loads the repository registry. +func loadRegistry(registryPath string) (*repos.Registry, error) { + if registryPath != "" { + reg, err := repos.LoadRegistry(registryPath) + if err != nil { + return nil, cli.Wrap(err, "load registry") + } + return reg, nil + } + + path, err := repos.FindRegistry() + if err != nil { + return nil, cli.Wrap(err, "find registry") + } + reg, err := repos.LoadRegistry(path) + if err != nil { + return nil, cli.Wrap(err, "load registry") + } + return reg, nil +} + +// checkGH verifies gh CLI is available. +func checkGH() error { + if _, err := exec.LookPath("gh"); err != nil { + return fmt.Errorf(i18n.T("error.gh_not_found")) + } + return nil +} + +// runGHAPI runs a gh api command and returns the output. +func runGHAPI(endpoint string) ([]byte, error) { + cmd := exec.Command("gh", "api", endpoint, "--paginate") + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + // Handle common errors gracefully + if strings.Contains(stderr, "404") || strings.Contains(stderr, "Not Found") { + return []byte("[]"), nil // Return empty array for not found + } + if strings.Contains(stderr, "403") { + return nil, fmt.Errorf("access denied (check token permissions)") + } + } + return nil, cli.Wrap(err, "run gh api") + } + return output, nil +} + +// severityRank returns a numeric rank for severity (higher = more severe). +func severityRank(severity string) int { + switch strings.ToLower(severity) { + case "critical": + return 4 + case "high": + return 3 + case "medium": + return 2 + case "low": + return 1 + default: + return 0 + } +} + +// severityStyle returns the appropriate style for a severity level. +func severityStyle(severity string) *cli.AnsiStyle { + switch strings.ToLower(severity) { + case "critical": + return cli.ErrorStyle + case "high": + return cli.WarningStyle + case "medium": + return cli.ValueStyle + default: + return cli.DimStyle + } +} + +// filterBySeverity checks if the severity matches the filter. +func filterBySeverity(severity, filter string) bool { + if filter == "" { + return true + } + + severities := strings.Split(strings.ToLower(filter), ",") + sev := strings.ToLower(severity) + + for _, s := range severities { + if strings.TrimSpace(s) == sev { + return true + } + } + return false +} + +// getReposToCheck returns the list of repos to check based on flags. +func getReposToCheck(reg *repos.Registry, repoFilter string) []*repos.Repo { + if repoFilter != "" { + if repo, ok := reg.Get(repoFilter); ok { + return []*repos.Repo{repo} + } + return nil + } + return reg.List() +} + +// AlertSummary holds aggregated alert counts. +type AlertSummary struct { + Critical int + High int + Medium int + Low int + Unknown int + Total int +} + +// Add increments summary counters for the provided severity. +func (s *AlertSummary) Add(severity string) { + s.Total++ + switch strings.ToLower(severity) { + case "critical": + s.Critical++ + case "high": + s.High++ + case "medium": + s.Medium++ + case "low": + s.Low++ + default: + s.Unknown++ + } +} + +// String renders a human-readable summary of alert counts. +func (s *AlertSummary) String() string { + parts := []string{} + if s.Critical > 0 { + parts = append(parts, cli.ErrorStyle.Render(fmt.Sprintf("%d critical", s.Critical))) + } + if s.High > 0 { + parts = append(parts, cli.WarningStyle.Render(fmt.Sprintf("%d high", s.High))) + } + if s.Medium > 0 { + parts = append(parts, cli.ValueStyle.Render(fmt.Sprintf("%d medium", s.Medium))) + } + if s.Low > 0 { + parts = append(parts, cli.DimStyle.Render(fmt.Sprintf("%d low", s.Low))) + } + if s.Unknown > 0 { + parts = append(parts, cli.DimStyle.Render(fmt.Sprintf("%d unknown", s.Unknown))) + } + if len(parts) == 0 { + return cli.SuccessStyle.Render("No alerts") + } + return strings.Join(parts, " | ") +}