cli/pkg/monitor/cmd_monitor.go
Snider a4971fe0df 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 05:44:46 +00:00

589 lines
16 KiB
Go

// 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)
}