forked from Gitlink/gitlink-cli
230 lines
5.6 KiB
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)
|
|
}
|
|
}
|