diff --git a/pkg/compliance/bsi.go b/pkg/compliance/bsi.go index a2a3718..71a9dcc 100644 --- a/pkg/compliance/bsi.go +++ b/pkg/compliance/bsi.go @@ -83,7 +83,7 @@ const ( SBOM_VULNERABILITES ) -func bsiResult(ctx context.Context, doc sbom.Document, fileName string, outFormat string) { +func bsiResult(ctx context.Context, doc sbom.Document, fileName string, outFormat string, colorOutput bool) { log := logger.FromContext(ctx) log.Debug("compliance.bsiResult()") @@ -107,7 +107,7 @@ func bsiResult(ctx context.Context, doc sbom.Document, fileName string, outForma } if outFormat == "detailed" { - bsiDetailedReport(dtb, fileName) + bsiDetailedReport(dtb, fileName, colorOutput) } } diff --git a/pkg/compliance/bsi_report.go b/pkg/compliance/bsi_report.go index a154b1f..d01d1f9 100644 --- a/pkg/compliance/bsi_report.go +++ b/pkg/compliance/bsi_report.go @@ -18,9 +18,11 @@ import ( "encoding/json" "fmt" "os" + "strings" "time" "github.com/google/uuid" + "github.com/interlynk-io/sbomqs/pkg/compliance/common" db "github.com/interlynk-io/sbomqs/pkg/compliance/db" "github.com/olekukonko/tablewriter" "sigs.k8s.io/release-utils/version" @@ -173,7 +175,7 @@ func constructSections(dtb *db.DB) []bsiSection { return sortedSections } -func bsiDetailedReport(dtb *db.DB, fileName string) { +func bsiDetailedReport(dtb *db.DB, fileName string, colorOutput bool) { table := tablewriter.NewWriter(os.Stdout) score := bsiAggregateScore(dtb) @@ -189,15 +191,66 @@ func bsiDetailedReport(dtb *db.DB, fileName string) { for _, section := range sections { sectionID := section.ID if !section.Required { - sectionID = sectionID + "*" + sectionID += "*" + } + + if colorOutput { + // disable tablewriter's auto-wrapping + table.SetAutoWrapText(false) + columnWidth := 30 + common.SetHeaderColor(table, 5) + + table = common.ColorTable(table, + section.ElementID, + section.ID, + section.ElementResult, + section.DataField, + section.Score, + columnWidth) + } else { + table.Append([]string{section.ElementID, sectionID, section.DataField, section.ElementResult, fmt.Sprintf("%0.1f", section.Score)}) } - table.Append([]string{section.ElementID, sectionID, section.DataField, section.ElementResult, fmt.Sprintf("%0.1f", section.Score)}) } table.Render() } +// Custom wrapping function to ensure consistent coloring. +func wrapAndColoredContent(content string, width int, color int) string { + words := strings.Fields(content) // Split into words for wrapping + var wrappedContent []string + var currentLine string + + for _, word := range words { + if len(currentLine)+len(word)+1 > width { + // Wrap the current line and color it + wrappedContent = append(wrappedContent, fmt.Sprintf("\033[%d;%dm%s\033[0m", 1, color, currentLine)) + currentLine = word // Start a new line + } else { + if currentLine != "" { + currentLine += " " + } + currentLine += word + } + } + // Add the last line + if currentLine != "" { + wrappedContent = append(wrappedContent, fmt.Sprintf("\033[%d;%dm%s\033[0m", 1, color, currentLine)) + } + + return strings.Join(wrappedContent, "\n") +} + func bsiBasicReport(dtb *db.DB, fileName string) { score := bsiAggregateScore(dtb) fmt.Printf("BSI TR-03183-2 v1.1 Compliance Report\n") fmt.Printf("Score:%0.1f RequiredScore:%0.1f OptionalScore:%0.1f for %s\n", score.totalScore(), score.totalRequiredScore(), score.totalOptionalScore(), fileName) } + +func getScoreColor(score float64) tablewriter.Colors { + if score == 0.0 { + return tablewriter.Colors{tablewriter.FgRedColor, tablewriter.Bold} + } else if score < 5.0 { + return tablewriter.Colors{tablewriter.FgHiYellowColor, tablewriter.Bold} + } + return tablewriter.Colors{tablewriter.FgGreenColor, tablewriter.Bold} +} diff --git a/pkg/compliance/common/common.go b/pkg/compliance/common/common.go index 21c97cf..e27f205 100644 --- a/pkg/compliance/common/common.go +++ b/pkg/compliance/common/common.go @@ -15,6 +15,7 @@ package common import ( + "fmt" "path" "strings" "time" @@ -25,6 +26,7 @@ import ( "github.com/interlynk-io/sbomqs/pkg/sbom" "github.com/interlynk-io/sbomqs/pkg/swhid" "github.com/interlynk-io/sbomqs/pkg/swid" + "github.com/olekukonko/tablewriter" "github.com/samber/lo" ) @@ -375,3 +377,74 @@ func IsComponentPartOfPrimaryDependency(primaryCompDeps []string, comp string) b } return false } + +func SetHeaderColor(table *tablewriter.Table, header int) { + colors := make([]tablewriter.Colors, header) + + // each column with same color and style + for i := 0; i < header; i++ { + colors[i] = tablewriter.Colors{tablewriter.FgHiWhiteColor, tablewriter.Bold} + } + + table.SetHeaderColor(colors...) +} + +func ColorTable(table *tablewriter.Table, elementID, id string, elementResult string, dataFields string, score float64, columnWidth int) *tablewriter.Table { + elementRe := wrapAndColoredContent(elementResult, columnWidth, tablewriter.FgHiCyanColor) + dataField := wrapAndColoredContent(dataFields, columnWidth, tablewriter.FgHiBlueColor) + + scoreColor := GetScoreColor(score) + + table.Rich([]string{ + elementID, + id, + dataField, + elementRe, + fmt.Sprintf("%0.1f", score), + }, []tablewriter.Colors{ + {tablewriter.FgHiMagentaColor, tablewriter.Bold}, + {tablewriter.FgHiCyanColor}, + {}, + {}, + scoreColor, + }) + return table +} + +// custom wrapping function to ensure consistent coloring instead of tablewritter's in-built wrapping +// 1. split content into multiple lines, each fitting within the specified width +// 2. each line of the content is formatted with color and bold styling using ANSI escape codes +// 3. wrapped lines are joined together with newline characters (\n) to maintain proper multi-line formatting. +func wrapAndColoredContent(content string, width int, color int) string { + words := strings.Fields(content) + var wrappedContent []string + var currentLine string + + for _, word := range words { + if len(currentLine)+len(word)+1 > width { + + // wrap the current line and color it + wrappedContent = append(wrappedContent, fmt.Sprintf("\033[%d;%dm%s\033[0m", 1, color, currentLine)) + currentLine = word + } else { + if currentLine != "" { + currentLine += " " + } + currentLine += word + } + } + if currentLine != "" { + wrappedContent = append(wrappedContent, fmt.Sprintf("\033[%d;%dm%s\033[0m", 1, color, currentLine)) + } + + return strings.Join(wrappedContent, "\n") +} + +func GetScoreColor(score float64) tablewriter.Colors { + if score == 0.0 { + return tablewriter.Colors{tablewriter.FgRedColor, tablewriter.Bold} + } else if score < 5.0 { + return tablewriter.Colors{tablewriter.FgHiYellowColor, tablewriter.Bold} + } + return tablewriter.Colors{tablewriter.FgGreenColor, tablewriter.Bold} +} diff --git a/pkg/compliance/compliance.go b/pkg/compliance/compliance.go index 21ba1e5..307ed80 100644 --- a/pkg/compliance/compliance.go +++ b/pkg/compliance/compliance.go @@ -70,20 +70,20 @@ func ComplianceResult(ctx context.Context, doc sbom.Document, reportType, fileNa switch { case reportType == BSI_REPORT: - bsiResult(ctx, doc, fileName, outFormat) + bsiResult(ctx, doc, fileName, outFormat, coloredOutput) case reportType == BSI_V2_REPORT: bsiV2Result(ctx, doc, fileName, outFormat) case reportType == NTIA_REPORT: - ntiaResult(ctx, doc, fileName, outFormat) + ntiaResult(ctx, doc, fileName, outFormat, coloredOutput) case reportType == OCT_TELCO: if doc.Spec().GetSpecType() != "spdx" { fmt.Println("The Provided SBOM spec is other than SPDX. Open Chain Telco only support SPDX specs SBOMs.") return nil } - octResult(ctx, doc, fileName, outFormat) + octResult(ctx, doc, fileName, outFormat, coloredOutput) case reportType == FSCT_V3: fsct.Result(ctx, doc, fileName, outFormat, coloredOutput) diff --git a/pkg/compliance/fsct/fsct_report.go b/pkg/compliance/fsct/fsct_report.go index 6cc185e..39bce5c 100644 --- a/pkg/compliance/fsct/fsct_report.go +++ b/pkg/compliance/fsct/fsct_report.go @@ -22,6 +22,7 @@ import ( "time" "github.com/google/uuid" + "github.com/interlynk-io/sbomqs/pkg/compliance/common" "github.com/interlynk-io/sbomqs/pkg/compliance/db" "github.com/olekukonko/tablewriter" "sigs.k8s.io/release-utils/version" @@ -199,15 +200,7 @@ func fsctDetailedReport(db *db.DB, fileName string, coloredOutput bool) { table.SetAutoMergeCellsByColumnIndex([]int{0}) if coloredOutput { - // Set header colors if the colors flag is true - table.SetHeaderColor( - tablewriter.Colors{tablewriter.FgHiWhiteColor, tablewriter.Bold}, - tablewriter.Colors{tablewriter.FgHiWhiteColor, tablewriter.Bold}, - tablewriter.Colors{tablewriter.FgHiWhiteColor, tablewriter.Bold}, - tablewriter.Colors{tablewriter.FgHiWhiteColor, tablewriter.Bold}, - tablewriter.Colors{tablewriter.FgHiWhiteColor, tablewriter.Bold}, - tablewriter.Colors{tablewriter.FgHiWhiteColor, tablewriter.Bold}, - ) + common.SetHeaderColor(table, 6) } sections := fsctConstructSections(db) @@ -218,23 +211,11 @@ func fsctDetailedReport(db *db.DB, fileName string, coloredOutput bool) { sectionID += "*" } - // Define maturity color based on the flag - var maturityColor tablewriter.Colors if coloredOutput { - switch section.Maturity { - case "None": - maturityColor = tablewriter.Colors{tablewriter.FgRedColor, tablewriter.Bold} - case "Minimum": - maturityColor = tablewriter.Colors{tablewriter.FgGreenColor, tablewriter.Bold} - case "Recommended": - maturityColor = tablewriter.Colors{tablewriter.FgCyanColor, tablewriter.Bold} - case "Aspirational": - maturityColor = tablewriter.Colors{tablewriter.FgHiYellowColor, tablewriter.Bold} - } - } - // Use Rich() with color settings only if colors is true - if coloredOutput { + var maturityColor tablewriter.Colors + maturityColor = getMaturityColor(section.Maturity) + table.Rich([]string{ section.ElementID, sectionID, @@ -244,9 +225,9 @@ func fsctDetailedReport(db *db.DB, fileName string, coloredOutput bool) { section.Maturity, }, []tablewriter.Colors{ {tablewriter.FgHiMagentaColor, tablewriter.Bold}, - {}, + {tablewriter.FgHiCyanColor}, {tablewriter.FgHiBlueColor, tablewriter.Bold}, - {tablewriter.FgHiWhiteColor, tablewriter.Bold}, + {tablewriter.FgHiCyanColor, tablewriter.Bold}, maturityColor, maturityColor, }) @@ -269,3 +250,18 @@ func fsctBasicReport(db *db.DB, fileName string) { fmt.Printf("Framing Software Component Transparency (v3)\n") fmt.Printf("Score:%0.1f for %s\n", score.totalScore(), fileName) } + +func getMaturityColor(maturity string) tablewriter.Colors { + switch maturity { + case "None": + return tablewriter.Colors{tablewriter.FgRedColor, tablewriter.Bold} + case "Minimum": + return tablewriter.Colors{tablewriter.FgGreenColor, tablewriter.Bold} + case "Recommended": + return tablewriter.Colors{tablewriter.FgCyanColor, tablewriter.Bold} + case "Aspirational": + return tablewriter.Colors{tablewriter.FgHiYellowColor, tablewriter.Bold} + default: + return tablewriter.Colors{} + } +} diff --git a/pkg/compliance/ntia.go b/pkg/compliance/ntia.go index f78ee7d..065361d 100644 --- a/pkg/compliance/ntia.go +++ b/pkg/compliance/ntia.go @@ -38,7 +38,7 @@ const ( SCORE_ZERO = 0.0 ) -func ntiaResult(ctx context.Context, doc sbom.Document, fileName string, outFormat string) { +func ntiaResult(ctx context.Context, doc sbom.Document, fileName string, outFormat string, colorOutput bool) { log := logger.FromContext(ctx) log.Debug("compliance.ntiaResult()") @@ -59,7 +59,7 @@ func ntiaResult(ctx context.Context, doc sbom.Document, fileName string, outForm } if outFormat == "detailed" { - ntiaDetailedReport(db, fileName) + ntiaDetailedReport(db, fileName, colorOutput) } } diff --git a/pkg/compliance/ntia_report.go b/pkg/compliance/ntia_report.go index 0dc6554..736719e 100644 --- a/pkg/compliance/ntia_report.go +++ b/pkg/compliance/ntia_report.go @@ -8,6 +8,7 @@ import ( "time" "github.com/google/uuid" + "github.com/interlynk-io/sbomqs/pkg/compliance/common" "github.com/interlynk-io/sbomqs/pkg/compliance/db" "github.com/olekukonko/tablewriter" "sigs.k8s.io/release-utils/version" @@ -136,7 +137,7 @@ func ntiaConstructSections(db *db.DB) []ntiaSection { return sortedSections } -func ntiaDetailedReport(db *db.DB, fileName string) { +func ntiaDetailedReport(db *db.DB, fileName string, colorOutput bool) { table := tablewriter.NewWriter(os.Stdout) score := ntiaAggregateScore(db) @@ -162,7 +163,23 @@ func ntiaDetailedReport(db *db.DB, fileName string) { if !section.Required { sectionID = sectionID + "*" } - table.Append([]string{section.ElementID, sectionID, section.DataField, section.ElementResult, fmt.Sprintf("%0.1f", section.Score)}) + + if colorOutput { + // disable tablewriter's auto-wrapping + table.SetAutoWrapText(false) + columnWidth := 30 + common.SetHeaderColor(table, 5) + + table = common.ColorTable(table, + section.ElementID, + section.ID, + section.ElementResult, + section.DataField, + section.Score, + columnWidth) + } else { + table.Append([]string{section.ElementID, sectionID, section.DataField, section.ElementResult, fmt.Sprintf("%0.1f", section.Score)}) + } } table.Render() } diff --git a/pkg/compliance/oct.go b/pkg/compliance/oct.go index 3e3b95b..d6d5ce7 100644 --- a/pkg/compliance/oct.go +++ b/pkg/compliance/oct.go @@ -27,7 +27,7 @@ import ( "github.com/samber/lo" ) -func octResult(ctx context.Context, doc sbom.Document, fileName string, outFormat string) { +func octResult(ctx context.Context, doc sbom.Document, fileName string, outFormat string, colorOutput bool) { log := logger.FromContext(ctx) log.Debug("compliance.octResult()") dtb := db.NewDB() @@ -58,7 +58,7 @@ func octResult(ctx context.Context, doc sbom.Document, fileName string, outForma } if outFormat == "detailed" { - octDetailedReport(dtb, fileName) + octDetailedReport(dtb, fileName, colorOutput) } } diff --git a/pkg/compliance/oct_report.go b/pkg/compliance/oct_report.go index e64c8a6..d131292 100644 --- a/pkg/compliance/oct_report.go +++ b/pkg/compliance/oct_report.go @@ -8,6 +8,7 @@ import ( "time" "github.com/google/uuid" + "github.com/interlynk-io/sbomqs/pkg/compliance/common" "github.com/interlynk-io/sbomqs/pkg/compliance/db" "github.com/olekukonko/tablewriter" "sigs.k8s.io/release-utils/version" @@ -155,7 +156,7 @@ func octConstructSections(dtb *db.DB) []octSection { return sortedSections } -func octDetailedReport(dtb *db.DB, fileName string) { +func octDetailedReport(dtb *db.DB, fileName string, colorOutput bool) { table := tablewriter.NewWriter(os.Stdout) score := octAggregateScore(dtb) @@ -172,7 +173,23 @@ func octDetailedReport(dtb *db.DB, fileName string) { if !section.Required { sectionID = sectionID + "*" } - table.Append([]string{section.ElementID, sectionID, section.DataField, section.ElementResult, fmt.Sprintf("%0.1f", section.Score)}) + + if colorOutput { + // disable tablewriter's auto-wrapping + table.SetAutoWrapText(false) + columnWidth := 30 + common.SetHeaderColor(table, 5) + + table = common.ColorTable(table, + section.ElementID, + section.ID, + section.ElementResult, + section.DataField, + section.Score, + columnWidth) + } else { + table.Append([]string{section.ElementID, sectionID, section.DataField, section.ElementResult, fmt.Sprintf("%0.1f", section.Score)}) + } } table.Render() }