package output import ( "fmt" "io" "path/filepath" "strings" "github.com/google/osv-scalibr/inventory/osvecosystem" depgroups "github.com/google/osv-scanner/v2/internal/utility/depgroup" "github.com/google/osv-scanner/v2/internal/utility/results" "github.com/google/osv-scanner/v2/internal/utility/severity" "github.com/google/osv-scanner/v2/pkg/models" "github.com/ossf/osv-schema/bindings/go/osvschema" "github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/text" ) // OSVBaseVulnerabilityURL is the base URL for detailed vulnerability views. // Copied in from osv package to avoid referencing the osv package unnecessarily const OSVBaseVulnerabilityURL = "https://osv.dev/" // PrintTableResults prints the osv scan results into a human friendly table. func PrintTableResults(vulnResult *models.VulnerabilityResults, outputWriter io.Writer, terminalWidth int, showAllVulns bool) { if terminalWidth <= 0 { text.DisableColors() } outputResult := BuildResults(vulnResult) // Add a newline to separate results from logs. fmt.Fprintln(outputWriter) // Render the vulnerabilities. if containsOSResult(outputResult) { printSummaryResult(outputResult, outputWriter, terminalWidth, showAllVulns) } else { // Print summary at the top printSummary(outputResult, outputWriter) outputTable := newTable(outputWriter, terminalWidth) outputTable = tableBuilder(outputTable, outputResult, showAllVulns) if outputTable.Length() != 0 { outputTable.Render() } // Render the licenses if any. licenseConfig := vulnResult.ExperimentalAnalysisConfig.Licenses if licenseConfig.Summary { buildLicenseSummaryTable(outputWriter, terminalWidth, vulnResult) } if len(licenseConfig.Allowlist) > 0 { buildLicenseViolationsTable(outputWriter, terminalWidth, vulnResult) } // Render deprecated packages if any. if outputResult.PkgDeprecatedCount > 0 { printPkgDeprecatedSummary(outputResult, outputWriter) buildDeprecatedPackagesTable(outputWriter, terminalWidth, vulnResult) } } } func newTable(outputWriter io.Writer, terminalWidth int) table.Writer { outputTable := table.NewWriter() outputTable.SetOutputMirror(outputWriter) // use fancy characters if we're outputting to a terminal if terminalWidth > 0 { outputTable.SetStyle(table.StyleRounded) outputTable.SetAllowedRowLength(terminalWidth) } outputTable.Style().Options.DoNotColorBordersAndSeparators = true outputTable.Style().Color.Row = text.Colors{text.Reset, text.BgHiBlack} outputTable.Style().Color.RowAlternate = text.Colors{text.Reset, text.BgBlack} return outputTable } func tableBuilder(outputTable table.Writer, result Result, showAllVulns bool) table.Writer { outputTable.AppendHeader(table.Row{"OSV URL", "CVSS", "Ecosystem", "Package", "Version", "Fixed Version", "Source"}) rows := tableBuilderInner(result, VulnTypeRegular) for _, elem := range rows { outputTable.AppendRow(elem.row, table.RowConfig{AutoMerge: elem.shouldMerge}) } uncalledRows := tableBuilderInner(result, VulnTypeUncalled) if showAllVulns && len(uncalledRows) != 0 { outputTable.AppendSeparator() outputTable.AppendRow(table.Row{"Uncalled vulnerabilities"}) outputTable.AppendSeparator() for _, elem := range uncalledRows { outputTable.AppendRow(elem.row, table.RowConfig{AutoMerge: elem.shouldMerge}) } } unimportantRows := tableBuilderInner(result, VulnTypeUnimportant) if showAllVulns && len(unimportantRows) != 0 { outputTable.AppendSeparator() outputTable.AppendRow(table.Row{"Unimportant vulnerabilities"}) outputTable.AppendSeparator() for _, elem := range unimportantRows { outputTable.AppendRow(elem.row, table.RowConfig{AutoMerge: elem.shouldMerge}) } } return outputTable } func printSummaryResult(result Result, outputWriter io.Writer, terminalWidth int, showAllVulns bool) { // Add a newline to separate results from logs. fmt.Fprintln(outputWriter) if result.IsContainerScanning { fmt.Fprintf(outputWriter, "%s:\n", GetContainerScanningHeader(result)) } else { fmt.Fprint(outputWriter, "Scanning Result (package view):\n") } printSummary(result, outputWriter) if result.PkgDeprecatedCount > 0 { printPkgDeprecatedSummary(result, outputWriter) } // Add a newline fmt.Fprintln(outputWriter) if result.LicenseSummary.Summary { printLicenseSummary(result.LicenseSummary, outputWriter, terminalWidth) } for _, eco := range result.Ecosystems { if ecosystemHasRegVuln(eco) { fmt.Fprintln(outputWriter, eco.Name) } for _, source := range eco.Sources { if source.PackageTypeCount.Regular == 0 && source.PkgDeprecatedCount == 0 { continue } outputTable := newTable(outputWriter, terminalWidth) // source.Name is user-controlled; sanitize \r/\n before printing to // prevent GitHub Actions workflow command injection. outputTable.SetTitle("Source:" + SanitizeForWorkflowCommand(source.Name)) sourcePackageHeader := "Package" if isOSResult(source.Type) { sourcePackageHeader = "Source Package" } tableHeader := table.Row{sourcePackageHeader, "Installed Version", "Fix Available", "Vuln Count"} if isOSResult(source.Type) { tableHeader = append(tableHeader, "Binary Packages (Count)") } if result.IsContainerScanning { tableHeader = append(tableHeader, "Introduced Layer", "In Base Image") } if result.LicenseSummary.ShowViolations { tableHeader = append(tableHeader, "License Violations") } if source.PkgDeprecatedCount > 0 { tableHeader = append(tableHeader, "Deprecated") } outputTable.AppendHeader(tableHeader) for _, pkg := range source.Packages { if pkg.VulnCount.AnalysisCount.Regular == 0 && len(pkg.LicenseViolations) == 0 && !pkg.Deprecated { continue } outputRow := table.Row{} totalCount := pkg.VulnCount.AnalysisCount.Regular var fixAvailable string if pkg.FixedVersion == UnfixedDescription { fixAvailable = UnfixedDescription } else { if pkg.VulnCount.FixableCount.UnFixed > 0 { fixAvailable = "Partial fixes Available" } else { fixAvailable = "Fix Available" } } outputRow = append(outputRow, pkg.Name, getInstalledVersionOrCommit(pkg), fixAvailable, totalCount) if isOSResult(source.Type) { outputRow = append(outputRow, formatBinaryPackages(pkg.OSPackageNames)) } if result.IsContainerScanning { layer := fmt.Sprintf("# %d Layer", pkg.LayerDetail.LayerIndex) inBaseImage := "--" if pkg.LayerDetail.BaseImageInfo.Index != 0 { inBaseImage = getBaseImageName(pkg.LayerDetail.BaseImageInfo) } outputRow = append(outputRow, layer, inBaseImage) } if result.LicenseSummary.ShowViolations { if len(pkg.LicenseViolations) == 0 { outputRow = append(outputRow, "--") } else { outputRow = append(outputRow, pkg.LicenseViolations) } } if source.PkgDeprecatedCount > 0 { if pkg.Deprecated { outputRow = append(outputRow, "True") } else { outputRow = append(outputRow, "--") } } outputTable.AppendRow(outputRow) } outputTable.Render() } } if showAllVulns && result.VulnTypeSummary.Hidden != 0 { // Add a newline fmt.Fprintln(outputWriter) fmt.Fprintln(outputWriter, "Filtered Vulnerabilities:") outputTable := newTable(outputWriter, terminalWidth) outputTable.AppendHeader(table.Row{"Package", "Ecosystem", "Installed Version", "Filtered Vuln Count", "Filter Reasons"}) for _, eco := range result.Ecosystems { for _, source := range eco.Sources { for _, pkg := range source.Packages { if pkg.VulnCount.AnalysisCount.Hidden == 0 { continue } outputRow := make(table.Row, 0, 5) totalCount := pkg.VulnCount.AnalysisCount.Hidden filteredReasons := getFilteredVulnReasons(pkg.HiddenVulns) outputRow = append(outputRow, pkg.Name, eco.Name, getInstalledVersionOrCommit(pkg), totalCount, filteredReasons) outputTable.AppendRow(outputRow) } } } outputTable.Render() } // Add a newline fmt.Fprintln(outputWriter) if !showAllVulns && result.VulnCount.AnalysisCount.Hidden != 0 { promptUnimportant := formatHiddenVulnsPrompt(result.VulnCount.AnalysisCount.Hidden) fmt.Fprintln(outputWriter, promptUnimportant) } const promptMessage = "For the most comprehensive scan results, we recommend using the HTML output: " + "`osv-scanner scan image --serve `.\n" + "You can also view the full vulnerability list in your terminal with: " + "`osv-scanner scan image --format vertical `." fmt.Fprintln(outputWriter, promptMessage) } func printLicenseSummary(licenseSummary LicenseSummary, outputWriter io.Writer, terminalWidth int) { outputTable := newTable(outputWriter, terminalWidth) outputTable.AppendHeader(table.Row{"License", "No. of package versions"}) for _, license := range licenseSummary.LicenseCount { outputTable.AppendRow(table.Row{license.Name, license.Count}) } outputTable.Render() fmt.Fprintln(outputWriter) } type tbInnerResponse struct { row table.Row shouldMerge bool } func tableBuilderInner(result Result, vulnAnalysisType VulnAnalysisType) []tbInnerResponse { allOutputRows := []tbInnerResponse{} workingDir := mustGetWorkingDirectory() for _, eco := range result.Ecosystems { for _, source := range eco.Sources { for _, pkg := range source.Packages { everything := make([]VulnResult, 0, len(pkg.RegularVulns)+len(pkg.HiddenVulns)) everything = append(everything, pkg.RegularVulns...) everything = append(everything, pkg.HiddenVulns...) for _, vuln := range everything { outputRow := make(table.Row, 0, 5) shouldMerge := false var links []string if vuln.VulnAnalysisType != vulnAnalysisType { continue } for _, id := range vuln.GroupIDs { links = append(links, OSVBaseVulnerabilityURL+text.Bold.Sprintf("%s", id)) // For container scanning results, if there is a DSA, then skip printing its sub-CVEs. if strings.Split(id, "-")[0] == "DSA" { break } } outputRow = append(outputRow, strings.Join(links, "\n")) // todo: this is just to make the snapshots pass without change if vuln.SeverityScore == "N/A" { outputRow = append(outputRow, "") } else { outputRow = append(outputRow, vuln.SeverityScore) } if eco.Name == "" && pkg.Commit != "" { pkgCommitStr := results.PkgToString(models.PackageInfo{ Name: pkg.Name, Commit: pkg.Commit, Version: pkg.InstalledVersion, }) outputRow = append(outputRow, "GIT", pkgCommitStr, pkgCommitStr) shouldMerge = true } else { outputRow = append(outputRow, eco.Name) name := pkg.Name // TODO(#1646): Migrate this earlier to the result struct directly if depgroups.IsDevGroup(osvecosystem.MustParse(eco.Name).Ecosystem, pkg.DepGroups) { name += " (dev)" } outputRow = append(outputRow, name) outputRow = append(outputRow, pkg.InstalledVersion) } if vuln.IsFixable { outputRow = append(outputRow, vuln.FixedVersion) } else { outputRow = append(outputRow, "--") } // todo: see if we want to start including any of this information p := strings.TrimPrefix(source.Name, ":") p = strings.TrimPrefix(p, string(source.Type)) p = strings.TrimPrefix(p, ":") p = strings.TrimPrefix(p, filepath.ToSlash(workingDir)) p = strings.TrimPrefix(p, "/") // p is user-controlled; sanitize \r/\n before printing to prevent // GitHub Actions workflow command injection. outputRow = append(outputRow, SanitizeForWorkflowCommand(p)) allOutputRows = append(allOutputRows, tbInnerResponse{ row: outputRow, shouldMerge: shouldMerge, }) } } } } return allOutputRows } func MaxSeverity(group models.GroupInfo, pkg models.PackageVulns) string { var maxSeverity float64 = -1 for _, vulnID := range group.IDs { var severities []*osvschema.Severity for _, vuln := range pkg.Vulnerabilities { if vuln.GetId() == vulnID { severities = vuln.GetSeverity() } } score, _, _ := severity.CalculateOverallScore(severities) maxSeverity = max(maxSeverity, score) } if maxSeverity < 0 { return "" } return fmt.Sprintf("%.1f", maxSeverity) } func buildLicenseSummaryTable(outputWriter io.Writer, terminalWidth int, vulnResult *models.VulnerabilityResults) { outputTable := newTable(outputWriter, terminalWidth) licenseSummaryTableBuilder(outputTable, vulnResult) if outputTable.Length() == 0 { return } outputTable.Render() } func licenseSummaryTableBuilder(outputTable table.Writer, vulnResult *models.VulnerabilityResults) table.Writer { outputTable.AppendHeader(table.Row{"License", "No. of package versions"}) for _, license := range vulnResult.LicenseSummary { outputTable.AppendRow(table.Row{license.Name, license.Count}) } return outputTable } func buildLicenseViolationsTable(outputWriter io.Writer, terminalWidth int, vulnResult *models.VulnerabilityResults) { outputTable := newTable(outputWriter, terminalWidth) outputTable = licenseViolationsTableBuilder(outputTable, vulnResult) if outputTable.Length() == 0 { return } outputTable.Render() } func licenseViolationsTableBuilder(outputTable table.Writer, vulnResult *models.VulnerabilityResults) table.Writer { outputTable.AppendHeader(table.Row{"License Violation", "Ecosystem", "Package", "Version", "Source"}) workingDir := mustGetWorkingDirectory() for _, pkgSource := range vulnResult.Results { for _, pkg := range pkgSource.Packages { if len(pkg.LicenseViolations) == 0 { continue } violations := make([]string, len(pkg.LicenseViolations)) for i, l := range pkg.LicenseViolations { violations[i] = string(l) } path := pkgSource.Source.Path if simplifiedPath, err := filepath.Rel(workingDir, pkgSource.Source.Path); err == nil { path = simplifiedPath } outputTable.AppendRow(table.Row{ strings.Join(violations, ", "), pkg.Package.Ecosystem, pkg.Package.Name, pkg.Package.Version, // path is user-controlled; sanitize \r/\n before printing to // prevent GitHub Actions workflow command injection. SanitizeForWorkflowCommand(path), }) } } return outputTable } func buildDeprecatedPackagesTable(outputWriter io.Writer, terminalWidth int, vulnResult *models.VulnerabilityResults) { outputTable := newTable(outputWriter, terminalWidth) outputTable = deprecatedPackagesTableBuilder(outputTable, vulnResult) if outputTable.Length() == 0 { return } outputTable.Render() } func deprecatedPackagesTableBuilder(outputTable table.Writer, vulnResult *models.VulnerabilityResults) table.Writer { outputTable.SetTitle("Deprecated packages") outputTable.AppendHeader(table.Row{"Ecosystem", "Package", "Version", "Source"}) workingDir := mustGetWorkingDirectory() for _, pkgSource := range vulnResult.Results { for _, pkg := range pkgSource.Packages { if !pkg.Package.Deprecated { continue } path := pkgSource.Source.Path if simplifiedPath, err := filepath.Rel(workingDir, pkgSource.Source.Path); err == nil { path = simplifiedPath } outputTable.AppendRow(table.Row{ pkg.Package.Ecosystem, pkg.Package.Name, pkg.Package.Version, // path is user-controlled; sanitize \r/\n before printing to // prevent GitHub Actions workflow command injection. SanitizeForWorkflowCommand(path), }) } } return outputTable } func formatBinaryPackages(slice []string) string { maxChars := 20 result := strings.Join(slice, ", ") if len(result) <= maxChars { return result } truncatedResult := result[:maxChars] // Find the last comma before truncation to avoid cutting words lastComma := strings.LastIndex(truncatedResult, ",") if lastComma != -1 { truncatedResult = truncatedResult[:lastComma] } return fmt.Sprintf("%s... (%d)", truncatedResult, len(slice)) }