From 09c87a42a6906d3a74c283a93b1cf20df59273e1 Mon Sep 17 00:00:00 2001 From: Vivek Kumar Sahu Date: Wed, 17 Sep 2025 19:05:26 +0530 Subject: [PATCH 1/3] few updates on policy structure, wrap long col values --- pkg/policy/engine.go | 11 +-- pkg/policy/evaluator.go | 59 ++++++++-------- pkg/policy/reporter.go | 152 ++++++++++++++++++++++++++-------------- pkg/policy/result.go | 54 +++++++------- 4 files changed, 161 insertions(+), 115 deletions(-) diff --git a/pkg/policy/engine.go b/pkg/policy/engine.go index 6e457a8..63d2c59 100644 --- a/pkg/policy/engine.go +++ b/pkg/policy/engine.go @@ -48,7 +48,7 @@ func Engine(ctx context.Context, policyConfig *Params, policies []Policy) error log.Debugf("field mapping done via extractor") - var results []Result + var policyResults []PolicyResult log.Debugf("Evaluation of policy against SBOM begins...") @@ -56,25 +56,26 @@ func Engine(ctx context.Context, policyConfig *Params, policies []Policy) error for _, policy := range policies { log.Debugf("Evaluating policy: ", policy.Name) + // evaluate each policy one by one against SBOM result, err := EvaluatePolicyAgainstSBOMs(ctx, policy, doc, fieldExtractor) if err != nil { return fmt.Errorf("policy %s evaluation failed: %w", policy.Name, err) } - results = append(results, result) + policyResults = append(policyResults, result) } // Reporting switch strings.ToLower(policyConfig.OutputFmt) { case "json": - if err := ReportJSON(ctx, results); err != nil { + if err := ReportJSON(ctx, policyResults); err != nil { return fmt.Errorf("failed to write json output: %w", err) } case "table": - if err := ReportTable(ctx, results); err != nil { + if err := ReportTable(ctx, policyResults); err != nil { return fmt.Errorf("failed to write yaml output: %w", err) } default: - if err := ReportBasic(ctx, results); err != nil { + if err := ReportBasic(ctx, policyResults); err != nil { return fmt.Errorf("failed to write table output: %w", err) } } diff --git a/pkg/policy/evaluator.go b/pkg/policy/evaluator.go index 594fc27..1abbc25 100644 --- a/pkg/policy/evaluator.go +++ b/pkg/policy/evaluator.go @@ -17,30 +17,29 @@ package policy import ( "context" "regexp" - "time" "github.com/interlynk-io/sbomqs/pkg/logger" "github.com/interlynk-io/sbomqs/pkg/sbom" ) // EvaluatePolicyAgainstSBOMs evaluates a single policy against a SBOMs. -func EvaluatePolicyAgainstSBOMs(ctx context.Context, p Policy, doc sbom.Document, fieldExtractor *Extractor) (Result, error) { +func EvaluatePolicyAgainstSBOMs(ctx context.Context, policy Policy, doc sbom.Document, fieldExtractor *Extractor) (PolicyResult, error) { log := logger.FromContext(ctx) - log.Debugf("processing policy evaluation: %s", p.Name, p.Type) + log.Debugf("processing policy evaluation: %s", policy.Name, policy.Type) - result := NewResult(p) - result.GeneratedAt = time.Now().UTC() + policyResult := NewPolicyResult(policy) + totalChecks := 0 components := doc.Components() - result.TotalChecked = len(components) + policyResult.TotalComponents = len(components) // compile regex present in pattern rules - compiledRules, err := compilePatternRules(p) + compiledRules, err := compilePatternRules(policy) if err != nil { - return Result{}, err + return PolicyResult{}, err } - policyResults := make([]PolicyResult, 0, len(components)*len(compiledRules)) + policyResults := make([]RuleResult, 0, len(components)*len(compiledRules)) // evaluate components against list of all rules in a single policy for _, comp := range components { @@ -54,6 +53,7 @@ func EvaluatePolicyAgainstSBOMs(ctx context.Context, p Policy, doc sbom.Document // evaluate each component against list of all rules for _, compileRule := range compiledRules { + totalChecks++ // evaluate rule declaredRule := compileRule.Rule @@ -66,14 +66,14 @@ func EvaluatePolicyAgainstSBOMs(ctx context.Context, p Policy, doc sbom.Document actualValues := fieldExtractor.RetrieveValues(comp, declaredField) // default outcome/pass reason - outcome := "pass" + result := "pass" reason := "present" // required rule: presence check - if RULE_TYPE(p.Type) == REQUIRED { + if RULE_TYPE(policy.Type) == REQUIRED { ok := fieldExtractor.HasField(comp, declaredField) if !ok { - outcome = "fail" + result = "fail" reason = "missing field" } @@ -81,15 +81,15 @@ func EvaluatePolicyAgainstSBOMs(ctx context.Context, p Policy, doc sbom.Document // for whitelist/blacklist do matching matched := anyMatch(actualValues, declaredValues, patterns) - switch RULE_TYPE(p.Type) { + switch RULE_TYPE(policy.Type) { case WHITELIST: if !matched { - outcome = "fail" + result = "fail" reason = "value not in whitelist" } case BLACKLIST: if matched { - outcome = "fail" + result = "fail" reason = "value in blacklist" } default: @@ -97,12 +97,12 @@ func EvaluatePolicyAgainstSBOMs(ctx context.Context, p Policy, doc sbom.Document } } - pr := PolicyResult{ + pr := RuleResult{ ComponentID: compID, ComponentName: compName, - Field: declaredField, - Actual: actualValues, - Outcome: outcome, + DeclaredField: declaredField, + ActualValues: actualValues, + Result: result, Reason: reason, } @@ -112,32 +112,33 @@ func EvaluatePolicyAgainstSBOMs(ctx context.Context, p Policy, doc sbom.Document } // assign results - result.PolicyResults = policyResults + policyResult.RuleResults = policyResults + policyResult.TotalChecks = totalChecks // compute ViolationCnt (failed outcomes) violationCount := 0 for _, pr := range policyResults { - if pr.Outcome == "fail" { + if pr.Result == "fail" { violationCount++ } } - result.ViolationCnt = violationCount + policyResult.ViolationCnt = violationCount // Decide outcome - if result.ViolationCnt == 0 { - result.Result = "pass" + if policyResult.ViolationCnt == 0 { + policyResult.OverallResult = "pass" } else { - switch p.Action { + switch policy.Action { case "warn": - result.Result = "warn" + policyResult.OverallResult = "warn" case "pass": - result.Result = "pass" + policyResult.OverallResult = "pass" default: - result.Result = "fail" + policyResult.OverallResult = "fail" } } - return *result, nil + return *policyResult, nil } // anyMatch returns true if at least one of the actual values diff --git a/pkg/policy/reporter.go b/pkg/policy/reporter.go index 55bef6d..d78c5aa 100644 --- a/pkg/policy/reporter.go +++ b/pkg/policy/reporter.go @@ -21,20 +21,20 @@ import ( "os" "sort" "strings" - "time" + "unicode/utf8" "github.com/interlynk-io/sbomqs/pkg/logger" "github.com/olekukonko/tablewriter" ) // ReportJSON writes results as pretty-printed JSON to stdout. -func ReportJSON(ctx context.Context, results []Result) error { +func ReportJSON(ctx context.Context, results []PolicyResult) error { log := logger.FromContext(ctx) log.Debugf("JSON Report...") - sorted := make([]Result, len(results)) + sorted := make([]PolicyResult, len(results)) copy(sorted, results) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].PolicyName < sorted[j].PolicyName }) enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") @@ -45,29 +45,28 @@ func ReportJSON(ctx context.Context, results []Result) error { } // ReportBasic writes results in a human-friendly basic format. -func ReportBasic(ctx context.Context, results []Result) error { +func ReportBasic(ctx context.Context, results []PolicyResult) error { log := logger.FromContext(ctx) log.Debugf("Basic Report....") // Sort results by policy name for deterministic output - sorted := make([]Result, len(results)) + sorted := make([]PolicyResult, len(results)) copy(sorted, results) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].PolicyName < sorted[j].PolicyName }) - fmt.Fprintf(os.Stdout, "\n\033[36m \t\t\t\t BASIC POLICY REPORT\033[36m\n") + fmt.Fprintf(os.Stdout, "\n\033[36m \t\t\t BASIC POLICY REPORT\033[36m\n") // === Summary Table === summary := tablewriter.NewWriter(os.Stdout) - summary.SetHeader([]string{"POLICY", "TYPE", "ACTION", "RESULT", "CHECKED", "VIOLATIONS", "GENERATED_AT"}) + summary.SetHeader([]string{"POLICY", "TYPE", "ACTION", "RESULT", "COMPONENTS", "VIOLATIONS"}) for _, r := range sorted { summary.Append([]string{ - r.Name, - r.Type, - r.Action, - r.Result, - fmt.Sprintf("%d", r.TotalChecked), + r.PolicyName, + r.PolicyType, + r.PolicyAction, + r.OverallResult, + fmt.Sprintf("%d", r.TotalComponents), fmt.Sprintf("%d", r.ViolationCnt), - r.GeneratedAt.Format(time.RFC3339), }) } summary.Render() // prints the table @@ -76,26 +75,29 @@ func ReportBasic(ctx context.Context, results []Result) error { } // ReportTable writes results in a per-policy, per-violation detail table format. -func ReportTable(ctx context.Context, results []Result) error { +func ReportTable(ctx context.Context, results []PolicyResult) error { log := logger.FromContext(ctx) log.Debugf("Table Report...") // Defensive copy + deterministic ordering by policy name - sorted := make([]Result, len(results)) + sorted := make([]PolicyResult, len(results)) copy(sorted, results) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].PolicyName < sorted[j].PolicyName }) - fmt.Fprintf(os.Stdout, "\n\033[1m \t\t--- TABLE DETAILED POLICY REPORT ---\033[0m\n") + fmt.Fprintf(os.Stdout, "\n\033[1m \t\t=== DETAILED POLICY REPORT ===\033[0m\n") // Per-policy tables for _, res := range sorted { // Policy header - fmt.Fprintf(os.Stdout, "\n\033[1mPolicy: %s (action=%s, result=%s, checked=%d, violations=%d)\033[0m\n", - res.Name, res.Action, res.Result, res.TotalChecked, res.ViolationCnt) + fmt.Fprintf(os.Stdout, "\n\033[1mPolicy: %s (result=%s, violations=%d, total_checks=%d, components=%d, total_rules_applied=%d)\033[0m\n", + res.PolicyName, res.OverallResult, res.ViolationCnt, res.TotalChecks, res.TotalComponents, res.TotalRules) // Prepare table writer per policy + maxColWidth := 36 tw := tablewriter.NewWriter(os.Stdout) - tw.SetAutoWrapText(false) + tw.SetAutoWrapText(true) + tw.SetReflowDuringAutoWrap(true) + tw.SetColWidth(maxColWidth) tw.SetBorder(true) tw.SetRowLine(false) @@ -106,27 +108,25 @@ func ReportTable(ctx context.Context, results []Result) error { component string field string actual string - outcome string + result string reason string } rows := []row{} // Prefer modern `PolicyResults` if present - if len(res.PolicyResults) > 0 { - for _, pr := range res.PolicyResults { - // show only failures by default - if pr.Outcome == "pass" { - continue - } + if len(res.RuleResults) > 0 { + for _, pr := range res.RuleResults { actual := "" - if len(pr.Actual) > 0 { - actual = strings.Join(pr.Actual, ", ") + if len(pr.ActualValues) > 0 { + actual = strings.Join(pr.ActualValues, ", ") + } else { + actual = "-" } rows = append(rows, row{ component: pr.ComponentName, - field: pr.Field, + field: pr.DeclaredField, actual: actual, - outcome: pr.Outcome, + result: pr.Result, reason: pr.Reason, }) } @@ -136,28 +136,23 @@ func ReportTable(ctx context.Context, results []Result) error { // - But we have PolicyResults (means all checks passed) -> print pass rows // - Else -> print "No violations" if len(rows) == 0 { - if len(res.PolicyResults) > 0 { + if len(res.RuleResults) > 0 { // populate rows with passing checks so we show non-violations - for _, pr := range res.PolicyResults { + for _, pr := range res.RuleResults { // include passes; if some fails existed they'd already be in rows above actual := "" - if len(pr.Actual) > 0 { - actual = strings.Join(pr.Actual, ", ") + if len(pr.ActualValues) > 0 { + actual = strings.Join(pr.ActualValues, ", ") } // For clarity put reason empty for passes rows = append(rows, row{ component: pr.ComponentName, - field: pr.Field, + field: pr.DeclaredField, actual: actual, - outcome: pr.Outcome, + result: pr.Reason, reason: pr.Reason, }) } - } else { - // truly no records at all (no PolicyResults and no Violations) - tw.Append([]string{"", "", "", "No violations"}) - tw.Render() - continue } } @@ -171,9 +166,9 @@ func ReportTable(ctx context.Context, results []Result) error { // Append to table writer for _, rr := range rows { - // We only render COMPONENT, FIELD, ACTUAL, REASON columns (no outcome column here). - // If needed in the future, we can switch to a verbose mode that shows outcome too. - tw.Append([]string{rr.component, rr.field, rr.actual, rr.reason}) + // warp long column values into multiple lines + componentWrapped, actualWrapped := wrapLongCells(rr.component, rr.actual, maxColWidth) + tw.Append([]string{componentWrapped, rr.field, actualWrapped, rr.reason}) } tw.Render() } @@ -181,12 +176,12 @@ func ReportTable(ctx context.Context, results []Result) error { fmt.Fprintf(os.Stdout, "\n\033[1m\033[32m \t\t--- SUMMARY TABLE ---\033[1m\n") sum := tablewriter.NewWriter(os.Stdout) - sum.SetHeader([]string{"POLICY", "RESULT", "CHECKED", "VIOLATIONS"}) + sum.SetHeader([]string{"POLICY", "RESULT", "COMPONENTS", "VIOLATIONS"}) for _, r := range sorted { sum.Append([]string{ - r.Name, - r.Result, - fmt.Sprintf("%d", r.TotalChecked), + r.PolicyName, + r.OverallResult, + fmt.Sprintf("%d", r.TotalComponents), fmt.Sprintf("%d", r.ViolationCnt), }) } @@ -194,3 +189,58 @@ func ReportTable(ctx context.Context, results []Result) error { return nil } + +// wrapLongCells mutates component and actual strings so long single-token values +// are broken into pieces. Choose width according to your column width. +func wrapLongCells(componentNameValue, actualValue string, width int) (string, string) { + cmp := componentNameValue + if utf8.RuneCountInString(cmp) > width { + cmp = splitEveryN(cmp, width) + } + + act := actualValue + if act != "" && utf8.RuneCountInString(act) > width { + // split on commas/spaces to preserve readability + sep := ", " + parts := strings.Split(act, sep) + for i, part := range parts { + if utf8.RuneCountInString(part) > width { + parts[i] = splitEveryN(part, width) + } + } + act = strings.Join(parts, sep) + // fallback: if still too long, hard-split + if utf8.RuneCountInString(act) > width { + act = splitEveryN(act, width) + } + } + return cmp, act +} + +// splitEveryN inserts '\n' every n runes into s. +// It preserves existing newlines and whitespace. +func splitEveryN(s string, n int) string { + if n <= 0 { + return s + } + if s == "" { + return s + } + + // Fast path: if already contains whitespace and is shorter than n, keep it + if utf8.RuneCountInString(s) <= n { + return s + } + + var b strings.Builder + count := 0 + for _, r := range s { + b.WriteRune(r) + count++ + if count >= n { + b.WriteRune('\n') + count = 0 + } + } + return b.String() +} diff --git a/pkg/policy/result.go b/pkg/policy/result.go index d40497a..acdfcce 100644 --- a/pkg/policy/result.go +++ b/pkg/policy/result.go @@ -14,40 +14,34 @@ package policy -import "time" - // Result represent the evaluation result of policay against SBOM -type Result struct { - Name string `json:"name,omitempty"` - Type string `json:"type,omitempty"` - Action string `json:"action,omitempty"` - Result string `json:"result"` // overall: pass|warn|fail - PolicyResults []PolicyResult `json:"policy_results,omitempty"` // both passes & fails - TotalChecked int `json:"total_checked,omitempty"` // number of components scanned - ViolationCnt int `json:"violation_count,omitempty"` // number of failed policy_results - GeneratedAt time.Time `json:"generated_at,omitempty"` +type PolicyResult struct { + PolicyName string `json:"name,omitempty"` + PolicyType string `json:"type,omitempty"` + PolicyAction string `json:"action,omitempty"` + OverallResult string `json:"overall_result"` // overall: pass|warn|fail + RuleResults []RuleResult `json:"policy_results,omitempty"` // both passes & fails + TotalChecks int `json:"total_checks,omitempty"` // number of total check + TotalRules int `json:"total_rules,omitempty"` + TotalComponents int `json:"total_components,omitempty"` // number of components scanned + ViolationCnt int `json:"violation_count,omitempty"` // number of failed policy_results } -// type Violation struct { -// ComponentName string `json:"component_name"` -// Field string `json:"field"` -// Actual []string `json:"actual,omitempty"` -// Reason string `json:"reason"` -// } - -type PolicyResult struct { - ComponentID string `json:"component_id,omitempty"` // component unique id (or "") - ComponentName string `json:"component_name,omitempty"` // friendly name - Field string `json:"field"` // the field evaluated (e.g., license) - Actual []string `json:"actual,omitempty"` // actual values seen on SBOM - Outcome string `json:"outcome"` // "pass" | "fail" - Reason string `json:"reason,omitempty"` // human-friendly reason for failure +type RuleResult struct { + ComponentID string `json:"component_id,omitempty"` // component unique id (or "") + ComponentName string `json:"component_name,omitempty"` // friendly name + DeclaredField string `json:"declared_field"` // the field evaluated (e.g., license) + DeclaredValues string `json:"declared_values"` // the decalred values + ActualValues []string `json:"actual_values,omitempty"` // actual values seen on SBOM + Result string `json:"result"` // "pass" | "fail" + Reason string `json:"reason,omitempty"` // human-friendly reason for failure } -func NewResult(p Policy) *Result { - return &Result{ - Name: p.Name, - Type: p.Type, - Action: p.Action, +func NewPolicyResult(p Policy) *PolicyResult { + return &PolicyResult{ + PolicyName: p.Name, + PolicyType: p.Type, + PolicyAction: p.Action, + TotalRules: len(p.Rules), } } From 29c2a6c799d29900a62ccd43bf8ed479a39f2ab9 Mon Sep 17 00:00:00 2001 From: Vivek Kumar Sahu Date: Wed, 17 Sep 2025 19:11:41 +0530 Subject: [PATCH 2/3] new column for total rules applied --- pkg/policy/reporter.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/policy/reporter.go b/pkg/policy/reporter.go index d78c5aa..241a0b2 100644 --- a/pkg/policy/reporter.go +++ b/pkg/policy/reporter.go @@ -57,7 +57,7 @@ func ReportBasic(ctx context.Context, results []PolicyResult) error { fmt.Fprintf(os.Stdout, "\n\033[36m \t\t\t BASIC POLICY REPORT\033[36m\n") // === Summary Table === summary := tablewriter.NewWriter(os.Stdout) - summary.SetHeader([]string{"POLICY", "TYPE", "ACTION", "RESULT", "COMPONENTS", "VIOLATIONS"}) + summary.SetHeader([]string{"POLICY", "TYPE", "ACTION", "RESULT", "COMPONENTS", "VIOLATIONS", "RULES APPLIED"}) for _, r := range sorted { summary.Append([]string{ @@ -67,6 +67,7 @@ func ReportBasic(ctx context.Context, results []PolicyResult) error { r.OverallResult, fmt.Sprintf("%d", r.TotalComponents), fmt.Sprintf("%d", r.ViolationCnt), + fmt.Sprintf("%d", r.TotalRules), }) } summary.Render() // prints the table @@ -84,7 +85,7 @@ func ReportTable(ctx context.Context, results []PolicyResult) error { copy(sorted, results) sort.Slice(sorted, func(i, j int) bool { return sorted[i].PolicyName < sorted[j].PolicyName }) - fmt.Fprintf(os.Stdout, "\n\033[1m \t\t=== DETAILED POLICY REPORT ===\033[0m\n") + fmt.Fprintf(os.Stdout, "\n\033[1m DETAILED POLICY REPORT\033[0m\n") // Per-policy tables for _, res := range sorted { @@ -176,13 +177,14 @@ func ReportTable(ctx context.Context, results []PolicyResult) error { fmt.Fprintf(os.Stdout, "\n\033[1m\033[32m \t\t--- SUMMARY TABLE ---\033[1m\n") sum := tablewriter.NewWriter(os.Stdout) - sum.SetHeader([]string{"POLICY", "RESULT", "COMPONENTS", "VIOLATIONS"}) + sum.SetHeader([]string{"POLICY", "RESULT", "COMPONENTS", "VIOLATIONS", "RULES APPLIED"}) for _, r := range sorted { sum.Append([]string{ r.PolicyName, r.OverallResult, fmt.Sprintf("%d", r.TotalComponents), fmt.Sprintf("%d", r.ViolationCnt), + fmt.Sprintf("%d", r.TotalRules), }) } sum.Render() From d7c35a06a85d53f98df552a70ae74bbc33dc3728 Mon Sep 17 00:00:00 2001 From: Vivek Kumar Sahu Date: Wed, 17 Sep 2025 19:30:58 +0530 Subject: [PATCH 3/3] fix linting issue, remove unused var --- pkg/policy/policy.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go index 36ba9a2..8700e30 100644 --- a/pkg/policy/policy.go +++ b/pkg/policy/policy.go @@ -34,9 +34,6 @@ type Params struct { // Output OutputFmt string - - // Debug - debug bool } // PolicyFile represents the top-level YAML structure