From a4971fe0df087f109e0d0ed64270c368340bbfec Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 1 Feb 2026 05:44:46 +0000 Subject: [PATCH] feat(monitor): add security findings aggregation command (#68) * feat(monitor): add security findings aggregation command Implements `core monitor` to aggregate security findings from GitHub: - Code scanning alerts (Semgrep, Trivy, Gitleaks, CodeQL, etc.) - Dependabot vulnerability alerts - Secret scanning alerts Features: - Scan current repo, specific repo, or all repos via registry - Filter by severity (--severity critical,high) - JSON output for piping to other tools (--json) - Grouped output by repo with severity highlighting Closes #49 Co-Authored-By: Claude Opus 4.5 * fix(monitor): address CodeRabbit review feedback - Fix DependabotAlert JSON parsing with proper nested struct for dependency.manifest_path field - Remove unnecessary --jq flag from code scanning API call - Fix truncate() to use runes for proper UTF-8 handling - Sort repo names for deterministic output ordering - Document hardcoded org fallback behavior Co-Authored-By: Claude Opus 4.5 * fix(monitor): improve error handling per CodeRabbit review - Use errors.E() consistently instead of errors.Wrap() - Pass underlying errors to errors.E() for better context - Return errors from fetch functions instead of swallowing - Distinguish expected conditions (feature not enabled) from real errors - Display fetch warnings in non-JSON mode - Continue scanning other repos even if one fails Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- pkg/i18n/locales/en_GB.json | 13 + pkg/monitor/cmd_commands.go | 47 +++ pkg/monitor/cmd_monitor.go | 589 ++++++++++++++++++++++++++++++++++++ 3 files changed, 649 insertions(+) create mode 100644 pkg/monitor/cmd_commands.go create mode 100644 pkg/monitor/cmd_monitor.go diff --git a/pkg/i18n/locales/en_GB.json b/pkg/i18n/locales/en_GB.json index d58c444c..d00018d5 100644 --- a/pkg/i18n/locales/en_GB.json +++ b/pkg/i18n/locales/en_GB.json @@ -346,6 +346,19 @@ "logs.short": "View VM logs", "exec.short": "Execute a command in a VM", "templates.short": "Manage LinuxKit templates" + }, + "monitor": { + "short": "Aggregate security findings from GitHub", + "long": "Monitor GitHub Security Tab, Dependabot, and secret scanning for actionable findings. Aggregates results from free tier scanners (Semgrep, Trivy, Gitleaks, OSV-Scanner, Checkov, CodeQL).", + "flag.repo": "Specific repository to scan", + "flag.severity": "Filter by severity (critical, high, medium, low)", + "flag.json": "Output as JSON for piping to other tools", + "flag.all": "Scan all repos in registry", + "scanning": "Scanning", + "found": "Found", + "no_findings": "No security findings", + "error.no_repos": "No repositories to scan. Use --repo, --all, or run from a git repo", + "error.not_git_repo": "Not in a git repository. Use --repo to specify one" } }, "common": { diff --git a/pkg/monitor/cmd_commands.go b/pkg/monitor/cmd_commands.go new file mode 100644 index 00000000..e7602261 --- /dev/null +++ b/pkg/monitor/cmd_commands.go @@ -0,0 +1,47 @@ +// Package monitor provides security monitoring commands. +// +// Commands: +// - monitor: Aggregate security findings from GitHub Security Tab, workflow artifacts, and PR comments +// +// Data sources (all free tier): +// - Code scanning: Semgrep, Trivy, Gitleaks, OSV-Scanner, Checkov, CodeQL +// - Dependabot: Dependency vulnerability alerts +// - Secret scanning: Exposed secrets/credentials +package monitor + +import ( + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/i18n" +) + +func init() { + cli.RegisterCommands(AddMonitorCommands) +} + +// Style aliases from shared package +var ( + successStyle = cli.SuccessStyle + errorStyle = cli.ErrorStyle + warningStyle = cli.WarningStyle + dimStyle = cli.DimStyle +) + +// AddMonitorCommands registers the 'monitor' command. +func AddMonitorCommands(root *cli.Command) { + monitorCmd := &cli.Command{ + Use: "monitor", + Short: i18n.T("cmd.monitor.short"), + Long: i18n.T("cmd.monitor.long"), + RunE: func(cmd *cli.Command, args []string) error { + return runMonitor() + }, + } + + // Flags + monitorCmd.Flags().StringVarP(&monitorRepo, "repo", "r", "", i18n.T("cmd.monitor.flag.repo")) + monitorCmd.Flags().StringSliceVarP(&monitorSeverity, "severity", "s", []string{}, i18n.T("cmd.monitor.flag.severity")) + monitorCmd.Flags().BoolVar(&monitorJSON, "json", false, i18n.T("cmd.monitor.flag.json")) + monitorCmd.Flags().BoolVar(&monitorAll, "all", false, i18n.T("cmd.monitor.flag.all")) + + root.AddCommand(monitorCmd) +} diff --git a/pkg/monitor/cmd_monitor.go b/pkg/monitor/cmd_monitor.go new file mode 100644 index 00000000..d4821e27 --- /dev/null +++ b/pkg/monitor/cmd_monitor.go @@ -0,0 +1,589 @@ +// cmd_monitor.go implements the 'monitor' command for aggregating security findings. +// +// Usage: +// core monitor # Monitor current repo +// core monitor --repo X # Monitor specific repo +// core monitor --all # Monitor all repos in registry +// core monitor --severity high # Filter by severity +// core monitor --json # Output as JSON + +package monitor + +import ( + "encoding/json" + "fmt" + "os/exec" + "sort" + "strings" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/errors" + "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/repos" +) + +// Command flags +var ( + monitorRepo string + monitorSeverity []string + monitorJSON bool + monitorAll bool +) + +// Finding represents a security finding from any source +type Finding struct { + Source string `json:"source"` // semgrep, trivy, dependabot, secret-scanning, etc. + Severity string `json:"severity"` // critical, high, medium, low + Rule string `json:"rule"` // Rule ID or CVE + File string `json:"file"` // Affected file path + Line int `json:"line"` // Line number (0 if N/A) + Message string `json:"message"` // Description + URL string `json:"url"` // Link to finding + State string `json:"state"` // open, dismissed, fixed + RepoName string `json:"repo"` // Repository name + CreatedAt string `json:"created_at"` // When found + Labels []string `json:"suggested_labels,omitempty"` +} + +// CodeScanningAlert represents a GitHub code scanning alert +type CodeScanningAlert struct { + Number int `json:"number"` + State string `json:"state"` // open, dismissed, fixed + Rule struct { + ID string `json:"id"` + Severity string `json:"severity"` + Description string `json:"description"` + } `json:"rule"` + Tool struct { + Name string `json:"name"` + } `json:"tool"` + MostRecentInstance struct { + Location struct { + Path string `json:"path"` + StartLine int `json:"start_line"` + } `json:"location"` + Message struct { + Text string `json:"text"` + } `json:"message"` + } `json:"most_recent_instance"` + HTMLURL string `json:"html_url"` + CreatedAt string `json:"created_at"` +} + +// DependabotAlert represents a GitHub Dependabot alert +type DependabotAlert struct { + Number int `json:"number"` + State string `json:"state"` // open, dismissed, fixed + SecurityVulnerability struct { + Severity string `json:"severity"` + Package struct { + Name string `json:"name"` + Ecosystem string `json:"ecosystem"` + } `json:"package"` + } `json:"security_vulnerability"` + SecurityAdvisory struct { + CVEID string `json:"cve_id"` + Summary string `json:"summary"` + Description string `json:"description"` + } `json:"security_advisory"` + Dependency struct { + ManifestPath string `json:"manifest_path"` + } `json:"dependency"` + HTMLURL string `json:"html_url"` + CreatedAt string `json:"created_at"` +} + +// SecretScanningAlert represents a GitHub secret scanning alert +type SecretScanningAlert struct { + Number int `json:"number"` + State string `json:"state"` // open, resolved + SecretType string `json:"secret_type"` + Secret string `json:"secret"` // Partial, redacted + HTMLURL string `json:"html_url"` + LocationType string `json:"location_type"` + CreatedAt string `json:"created_at"` +} + +func runMonitor() error { + // Check gh is available + if _, err := exec.LookPath("gh"); err != nil { + return errors.E("monitor", i18n.T("error.gh_not_found"), err) + } + + // Determine repos to scan + repoList, err := resolveRepos() + if err != nil { + return err + } + + if len(repoList) == 0 { + return errors.E("monitor", i18n.T("cmd.monitor.error.no_repos"), nil) + } + + // Collect all findings and errors + var allFindings []Finding + var fetchErrors []string + for _, repo := range repoList { + if !monitorJSON { + cli.Print("\033[2K\r%s %s...", dimStyle.Render(i18n.T("cmd.monitor.scanning")), repo) + } + + findings, errs := fetchRepoFindings(repo) + allFindings = append(allFindings, findings...) + fetchErrors = append(fetchErrors, errs...) + } + + // Filter by severity if specified + if len(monitorSeverity) > 0 { + allFindings = filterBySeverity(allFindings, monitorSeverity) + } + + // Sort by severity (critical first) + sortBySeverity(allFindings) + + // Output + if monitorJSON { + return outputJSON(allFindings) + } + + cli.Print("\033[2K\r") // Clear scanning line + + // Show any fetch errors as warnings + if len(fetchErrors) > 0 { + for _, e := range fetchErrors { + cli.Print("%s %s\n", warningStyle.Render("!"), e) + } + cli.Blank() + } + + return outputTable(allFindings) +} + +// resolveRepos determines which repos to scan +func resolveRepos() ([]string, error) { + if monitorRepo != "" { + // Specific repo - if fully qualified (org/repo), use as-is + if strings.Contains(monitorRepo, "/") { + return []string{monitorRepo}, nil + } + // Otherwise, try to detect org from git remote, fallback to host-uk + // Note: Users outside host-uk org should use fully qualified names + org := detectOrgFromGit() + if org == "" { + org = "host-uk" + } + return []string{org + "/" + monitorRepo}, nil + } + + if monitorAll { + // All repos from registry + registry, err := repos.FindRegistry() + if err != nil { + return nil, errors.E("monitor", "failed to find registry", err) + } + + loaded, err := repos.LoadRegistry(registry) + if err != nil { + return nil, errors.E("monitor", "failed to load registry", err) + } + + var repoList []string + for _, r := range loaded.Repos { + repoList = append(repoList, loaded.Org+"/"+r.Name) + } + return repoList, nil + } + + // Default to current repo + repo, err := detectRepoFromGit() + if err != nil { + return nil, err + } + return []string{repo}, nil +} + +// fetchRepoFindings fetches all security findings for a repo +// Returns findings and any errors encountered (errors don't stop other fetches) +func fetchRepoFindings(repoFullName string) ([]Finding, []string) { + var findings []Finding + var errs []string + repoName := strings.Split(repoFullName, "/")[1] + + // Fetch code scanning alerts + codeFindings, err := fetchCodeScanningAlerts(repoFullName) + if err != nil { + errs = append(errs, fmt.Sprintf("%s: code-scanning: %s", repoName, err)) + } + findings = append(findings, codeFindings...) + + // Fetch Dependabot alerts + depFindings, err := fetchDependabotAlerts(repoFullName) + if err != nil { + errs = append(errs, fmt.Sprintf("%s: dependabot: %s", repoName, err)) + } + findings = append(findings, depFindings...) + + // Fetch secret scanning alerts + secretFindings, err := fetchSecretScanningAlerts(repoFullName) + if err != nil { + errs = append(errs, fmt.Sprintf("%s: secret-scanning: %s", repoName, err)) + } + findings = append(findings, secretFindings...) + + return findings, errs +} + +// fetchCodeScanningAlerts fetches code scanning alerts +func fetchCodeScanningAlerts(repoFullName string) ([]Finding, error) { + args := []string{ + "api", + fmt.Sprintf("repos/%s/code-scanning/alerts", repoFullName), + } + + cmd := exec.Command("gh", args...) + output, err := cmd.Output() + if err != nil { + // Check for expected "not enabled" responses vs actual errors + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + // These are expected conditions, not errors + if strings.Contains(stderr, "Advanced Security must be enabled") || + strings.Contains(stderr, "no analysis found") || + strings.Contains(stderr, "Not Found") { + return nil, nil + } + } + return nil, errors.E("monitor.fetchCodeScanning", "API request failed", err) + } + + var alerts []CodeScanningAlert + if err := json.Unmarshal(output, &alerts); err != nil { + return nil, errors.E("monitor.fetchCodeScanning", "failed to parse response", err) + } + + repoName := strings.Split(repoFullName, "/")[1] + var findings []Finding + for _, alert := range alerts { + if alert.State != "open" { + continue + } + f := Finding{ + Source: alert.Tool.Name, + Severity: normalizeSeverity(alert.Rule.Severity), + Rule: alert.Rule.ID, + File: alert.MostRecentInstance.Location.Path, + Line: alert.MostRecentInstance.Location.StartLine, + Message: alert.MostRecentInstance.Message.Text, + URL: alert.HTMLURL, + State: alert.State, + RepoName: repoName, + CreatedAt: alert.CreatedAt, + Labels: []string{"type:security"}, + } + if f.Message == "" { + f.Message = alert.Rule.Description + } + findings = append(findings, f) + } + + return findings, nil +} + +// fetchDependabotAlerts fetches Dependabot alerts +func fetchDependabotAlerts(repoFullName string) ([]Finding, error) { + args := []string{ + "api", + fmt.Sprintf("repos/%s/dependabot/alerts", repoFullName), + } + + cmd := exec.Command("gh", args...) + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + // Dependabot not enabled is expected + if strings.Contains(stderr, "Dependabot alerts are not enabled") || + strings.Contains(stderr, "Not Found") { + return nil, nil + } + } + return nil, errors.E("monitor.fetchDependabot", "API request failed", err) + } + + var alerts []DependabotAlert + if err := json.Unmarshal(output, &alerts); err != nil { + return nil, errors.E("monitor.fetchDependabot", "failed to parse response", err) + } + + repoName := strings.Split(repoFullName, "/")[1] + var findings []Finding + for _, alert := range alerts { + if alert.State != "open" { + continue + } + f := Finding{ + Source: "dependabot", + Severity: normalizeSeverity(alert.SecurityVulnerability.Severity), + Rule: alert.SecurityAdvisory.CVEID, + File: alert.Dependency.ManifestPath, + Line: 0, + Message: fmt.Sprintf("%s: %s", alert.SecurityVulnerability.Package.Name, alert.SecurityAdvisory.Summary), + URL: alert.HTMLURL, + State: alert.State, + RepoName: repoName, + CreatedAt: alert.CreatedAt, + Labels: []string{"type:security", "dependencies"}, + } + findings = append(findings, f) + } + + return findings, nil +} + +// fetchSecretScanningAlerts fetches secret scanning alerts +func fetchSecretScanningAlerts(repoFullName string) ([]Finding, error) { + args := []string{ + "api", + fmt.Sprintf("repos/%s/secret-scanning/alerts", repoFullName), + } + + cmd := exec.Command("gh", args...) + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + // Secret scanning not enabled is expected + if strings.Contains(stderr, "Secret scanning is disabled") || + strings.Contains(stderr, "Not Found") { + return nil, nil + } + } + return nil, errors.E("monitor.fetchSecretScanning", "API request failed", err) + } + + var alerts []SecretScanningAlert + if err := json.Unmarshal(output, &alerts); err != nil { + return nil, errors.E("monitor.fetchSecretScanning", "failed to parse response", err) + } + + repoName := strings.Split(repoFullName, "/")[1] + var findings []Finding + for _, alert := range alerts { + if alert.State != "open" { + continue + } + f := Finding{ + Source: "secret-scanning", + Severity: "critical", // Secrets are always critical + Rule: alert.SecretType, + File: alert.LocationType, + Line: 0, + Message: fmt.Sprintf("Exposed %s detected", alert.SecretType), + URL: alert.HTMLURL, + State: alert.State, + RepoName: repoName, + CreatedAt: alert.CreatedAt, + Labels: []string{"type:security", "secrets"}, + } + findings = append(findings, f) + } + + return findings, nil +} + +// normalizeSeverity normalizes severity strings to standard values +func normalizeSeverity(s string) string { + s = strings.ToLower(s) + switch s { + case "critical", "crit": + return "critical" + case "high", "error": + return "high" + case "medium", "moderate", "warning": + return "medium" + case "low", "info", "note": + return "low" + default: + return "medium" + } +} + +// filterBySeverity filters findings by severity +func filterBySeverity(findings []Finding, severities []string) []Finding { + sevSet := make(map[string]bool) + for _, s := range severities { + sevSet[strings.ToLower(s)] = true + } + + var filtered []Finding + for _, f := range findings { + if sevSet[f.Severity] { + filtered = append(filtered, f) + } + } + return filtered +} + +// sortBySeverity sorts findings by severity (critical first) +func sortBySeverity(findings []Finding) { + severityOrder := map[string]int{ + "critical": 0, + "high": 1, + "medium": 2, + "low": 3, + } + + sort.Slice(findings, func(i, j int) bool { + oi := severityOrder[findings[i].Severity] + oj := severityOrder[findings[j].Severity] + if oi != oj { + return oi < oj + } + return findings[i].RepoName < findings[j].RepoName + }) +} + +// outputJSON outputs findings as JSON +func outputJSON(findings []Finding) error { + data, err := json.MarshalIndent(findings, "", " ") + if err != nil { + return errors.E("monitor", "failed to marshal findings", err) + } + cli.Print("%s\n", string(data)) + return nil +} + +// outputTable outputs findings as a formatted table +func outputTable(findings []Finding) error { + if len(findings) == 0 { + cli.Print("%s\n", successStyle.Render(i18n.T("cmd.monitor.no_findings"))) + return nil + } + + // Count by severity + counts := make(map[string]int) + for _, f := range findings { + counts[f.Severity]++ + } + + // Header summary + var parts []string + if counts["critical"] > 0 { + parts = append(parts, errorStyle.Render(fmt.Sprintf("%d critical", counts["critical"]))) + } + if counts["high"] > 0 { + parts = append(parts, errorStyle.Render(fmt.Sprintf("%d high", counts["high"]))) + } + if counts["medium"] > 0 { + parts = append(parts, warningStyle.Render(fmt.Sprintf("%d medium", counts["medium"]))) + } + if counts["low"] > 0 { + parts = append(parts, dimStyle.Render(fmt.Sprintf("%d low", counts["low"]))) + } + cli.Print("%s: %s\n", i18n.T("cmd.monitor.found"), strings.Join(parts, ", ")) + cli.Blank() + + // Group by repo + byRepo := make(map[string][]Finding) + for _, f := range findings { + byRepo[f.RepoName] = append(byRepo[f.RepoName], f) + } + + // Sort repos for consistent output + repoNames := make([]string, 0, len(byRepo)) + for repo := range byRepo { + repoNames = append(repoNames, repo) + } + sort.Strings(repoNames) + + // Print by repo + for _, repo := range repoNames { + repoFindings := byRepo[repo] + cli.Print("%s\n", cli.BoldStyle.Render(repo)) + for _, f := range repoFindings { + sevStyle := dimStyle + switch f.Severity { + case "critical", "high": + sevStyle = errorStyle + case "medium": + sevStyle = warningStyle + } + + // Format: [severity] source: message (file:line) + location := "" + if f.File != "" { + location = f.File + if f.Line > 0 { + location = fmt.Sprintf("%s:%d", f.File, f.Line) + } + } + + cli.Print(" %s %s: %s", + sevStyle.Render(fmt.Sprintf("[%s]", f.Severity)), + dimStyle.Render(f.Source), + truncate(f.Message, 60)) + if location != "" { + cli.Print(" %s", dimStyle.Render("("+location+")")) + } + cli.Blank() + } + cli.Blank() + } + + return nil +} + +// truncate truncates a string to max runes (Unicode-safe) +func truncate(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max-3]) + "..." +} + +// detectRepoFromGit detects the repo from git remote +func detectRepoFromGit() (string, error) { + cmd := exec.Command("git", "remote", "get-url", "origin") + output, err := cmd.Output() + if err != nil { + return "", errors.E("monitor", i18n.T("cmd.monitor.error.not_git_repo"), err) + } + + url := strings.TrimSpace(string(output)) + return parseGitHubRepo(url) +} + +// detectOrgFromGit tries to detect the org from git remote +func detectOrgFromGit() string { + repo, err := detectRepoFromGit() + if err != nil { + return "" + } + parts := strings.Split(repo, "/") + if len(parts) >= 1 { + return parts[0] + } + return "" +} + +// parseGitHubRepo extracts org/repo from a git URL +func parseGitHubRepo(url string) (string, error) { + // Handle SSH URLs: git@github.com:org/repo.git + if strings.HasPrefix(url, "git@github.com:") { + path := strings.TrimPrefix(url, "git@github.com:") + path = strings.TrimSuffix(path, ".git") + return path, nil + } + + // Handle HTTPS URLs: https://github.com/org/repo.git + if strings.Contains(url, "github.com/") { + parts := strings.Split(url, "github.com/") + if len(parts) >= 2 { + path := strings.TrimSuffix(parts[1], ".git") + return path, nil + } + } + + return "", fmt.Errorf("could not parse GitHub repo from URL: %s", url) +}