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>
This commit is contained in:
parent
33c0eeb6da
commit
4670dbd501
3 changed files with 649 additions and 0 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
47
pkg/monitor/cmd_commands.go
Normal file
47
pkg/monitor/cmd_commands.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
589
pkg/monitor/cmd_monitor.go
Normal file
589
pkg/monitor/cmd_monitor.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue