package security import ( "errors" "fmt" "os/exec" "strings" "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/i18n" "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/repos" ) var ( // Command flags securityRegistryPath string securityRepo string securitySeverity string securityJSON bool securityTarget string // External repo target (e.g. "wailsapp/wails") ) // 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) addJobsCommand(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(io.Local, registryPath) if err != nil { return nil, cli.Wrap(err, "load registry") } return reg, nil } path, err := repos.FindRegistry(io.Local) if err != nil { return nil, cli.Wrap(err, "find registry") } reg, err := repos.LoadRegistry(io.Local, 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 errors.New(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 } // 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() } // buildTargetRepo creates a synthetic Repo entry for an external target (e.g. "wailsapp/wails"). func buildTargetRepo(target string) (*repos.Repo, string) { parts := strings.SplitN(target, "/", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return nil, "" } return &repos.Repo{Name: parts[1]}, target } // 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, " | ") }