gitlink-cli/internal/compliance/cmd.go

230 lines
5.6 KiB
Go

package compliance
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
// NewCommand returns the top-level compliance command.
func NewCommand() *cobra.Command {
var format string
cmd := &cobra.Command{
Use: "compliance",
Short: "Compliance and security scan operations",
Long: "Run license compliance checks, dependency scanning, secret detection, and exposure analysis on local repositories.",
}
cmd.PersistentFlags().StringVar(&format, "format", "table", "Output format: json, table")
cmd.AddCommand(newScanCmd(&format))
cmd.AddCommand(newLicenseCmd(&format))
cmd.AddCommand(newDepsCmd(&format))
cmd.AddCommand(newSecretsCmd(&format))
cmd.AddCommand(newExposureCmd(&format))
cmd.AddCommand(newVocabCmd(&format))
return cmd
}
func newScanCmd(format *string) *cobra.Command {
var module string
cmd := &cobra.Command{
Use: "+scan",
Short: "Full compliance scan (all five modules)",
RunE: func(cmd *cobra.Command, args []string) error {
root, err := repoRoot()
if err != nil {
return err
}
modules := []string{"secrets", "exposure", "vocab"}
if module != "" {
modules = parseModules(module)
}
var allFindings []Finding
for _, m := range modules {
switch m {
case "license":
allFindings = append(allFindings, checkLicense(root)...)
break
case "deps":
allFindings = append(allFindings, checkDeps(root)...)
break
case "secrets", "exposure", "vocab":
rules := allRules()[m]
allFindings = append(allFindings, scanFiles(root, rules)...)
break
}
}
return outputReport(allFindings, modules, *format)
},
}
cmd.Flags().StringVarP(&module, "module", "m", "", "Comma-separated modules: license,deps,secrets,exposure,vocab")
return cmd
}
func newLicenseCmd(format *string) *cobra.Command {
return &cobra.Command{
Use: "+license",
Short: "License compliance check",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := repoRoot()
findings := checkLicense(root)
return outputReport(findings, []string{"license"}, *format)
},
}
}
func newDepsCmd(format *string) *cobra.Command {
return &cobra.Command{
Use: "+deps",
Short: "Dependency license check",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := repoRoot()
findings := checkDeps(root)
return outputReport(findings, []string{"deps"}, *format)
},
}
}
func newSecretsCmd(format *string) *cobra.Command {
return &cobra.Command{
Use: "+secrets",
Short: "Hardcoded secrets scan",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := repoRoot()
findings := scanFiles(root, allRules()["secrets"])
return outputReport(findings, []string{"secrets"}, *format)
},
}
}
func newExposureCmd(format *string) *cobra.Command {
return &cobra.Command{
Use: "+exposure",
Short: "PII and network exposure scan",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := repoRoot()
findings := scanFiles(root, allRules()["exposure"])
return outputReport(findings, []string{"exposure"}, *format)
},
}
}
func newVocabCmd(format *string) *cobra.Command {
return &cobra.Command{
Use: "+vocab",
Short: "Sensitive vocabulary scan",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := repoRoot()
findings := scanFiles(root, allRules()["vocab"])
return outputReport(findings, []string{"vocab"}, *format)
},
}
}
func repoRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return dir, nil
}
dir = parent
}
}
func parseModules(s string) []string {
var result []string
seen := map[string]bool{}
for _, m := range strings.Split(s, ",") {
m = strings.TrimSpace(m)
valid := map[string]bool{"license": true, "deps": true, "secrets": true, "exposure": true, "vocab": true}
if valid[m] && !seen[m] {
result = append(result, m)
seen[m] = true
}
}
return result
}
// summary holds aggregated stats.
type summary struct {
Total int `json:"total"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
}
type reportData struct {
Modules []string `json:"modules"`
Findings []Finding `json:"findings"`
Summary summary `json:"summary"`
}
func outputReport(findings []Finding, modules []string, format string) error {
s := summary{}
for _, f := range findings {
s.Total++
switch f.Severity {
case "critical":
s.Critical++
case "high":
s.High++
case "medium":
s.Medium++
case "low":
s.Low++
}
}
if format == "json" {
return output.Print(output.SuccessEnvelope(reportData{Modules: modules, Findings: findings, Summary: s}, nil), format)
}
fmt.Println()
printHR()
fmt.Printf(" Compliance Scan Report\n")
fmt.Printf(" Modules: %s | Findings: %d (critical:%d high:%d medium:%d low:%d)\n",
strings.Join(modules, ", "), s.Total, s.Critical, s.High, s.Medium, s.Low)
printHR()
if len(findings) == 0 {
fmt.Println(" All clear — no issues found.")
} else {
printFindings(findings)
}
printHR()
return nil
}
func printHR() {
fmt.Println(strings.Repeat("─", 60))
}
func printFindings(findings []Finding) {
labels := map[string]string{
"critical": "CRIT", "high": "HIGH", "medium": "MED", "low": "LOW",
}
for _, f := range findings {
label := labels[f.Severity]
if label == "" {
label = f.Severity
}
fmt.Printf(" [%s] %s %s:%d %s\n", label, f.ID, f.File, f.Line, f.Summary)
}
}