diff --git a/src/pkgchk-cli/Commands.fs b/src/pkgchk-cli/Commands.fs index 335c7900..7dbee302 100644 --- a/src/pkgchk-cli/Commands.fs +++ b/src/pkgchk-cli/Commands.fs @@ -31,6 +31,7 @@ type PackageCheckCommandSettings() = [] [] + [] member val SeverityLevels: string array = [||] with get, set [] @@ -54,6 +55,12 @@ type PackageCheckCommand() = let console = Spectre.Console.AnsiConsole.Console |> Console.send + let trace traceLogging = + if traceLogging then + (fun value -> $"[grey]{value}[/]" |> console) + else + ignore + let genRestoreArgs (settings: PackageCheckCommandSettings) = settings.ProjectPath |> Io.toFullPath |> sprintf "restore %s" @@ -87,7 +94,6 @@ type PackageCheckCommand() = |> Io.createProcess |> runRestoreProcParse (runProc logging) - let runScaProcParse run procs = procs |> Array.map run @@ -113,73 +119,62 @@ type PackageCheckCommand() = | _ -> []) |> List.ofSeq - let getHitCounts (hits: ScaHit list) = - - hits - |> Seq.groupBy (fun h -> h.kind) - |> Seq.collect (fun (kind, hs) -> - hs - |> Seq.collect (fun h -> - seq { - h.severity - yield! h.reasons - } - |> Seq.filter String.isNotEmpty) - |> Seq.groupBy id - |> Seq.map (fun (s, xs) -> (kind, s, xs |> Seq.length))) - - let reportHitCounts console counts = - - let lines = - counts |> Seq.map (fun (k, s, c) -> $"{k} - {s}: {c} hits.") |> List.ofSeq - - if lines |> List.isEmpty |> not then - "Issues found:" |> console - lines |> String.joinLines |> console let returnCode (hits: ScaHit list) = match hits with | [] -> ReturnCodes.validationOk | _ -> ReturnCodes.validationFailed - let genConsole = Console.generate >> String.joinLines >> console - - let genReport outDir hits = - let reportFile = outDir |> Io.toFullPath |> Io.combine "pkgchk.md" |> Io.normalise - hits |> Markdown.generate |> Io.writeFile reportFile - reportFile - - let trace value = $"[grey]{value}[/]" |> console + let reportFile outDir = + outDir |> Io.toFullPath |> Io.combine "pkgchk.md" |> Io.normalise override _.Execute(context, settings) = - let logging = if settings.TraceLogging then trace else (fun x -> ignore x) + let trace = trace settings.TraceLogging if settings.NoBanner |> not then $"[cyan]Pkgchk-Cli[/] version [white]{App.version ()}[/]" |> console - match runRestore settings logging with + match runRestore settings trace with | Choice2Of2 error -> error |> returnError | _ -> let results = settings |> genScanArgs |> Array.map Io.createProcess - |> runScaProcParse (runProc logging) + |> runScaProcParse (runProc trace) let errors = getErrors results if Seq.isEmpty errors |> not then errors |> String.joinLines |> returnError else + trace "Analysing results..." let hits = getHits results - let errorHits = hits |> Sca.hitsByLevels settings.SeverityLevels + let hitCounts = errorHits |> Sca.hitCountSummary |> List.ofSeq + + trace "Building display..." - errorHits |> getHitCounts |> reportHitCounts logging + let lines = + seq { + yield! hits |> Console.formatHits + yield! errorHits |> Console.title - hits |> genConsole + if hitCounts |> List.isEmpty |> not then + yield! Console.formatSeverities settings.SeverityLevels + yield! Console.formatHitCounts hitCounts + } + + lines |> String.joinLines |> console if settings.OutputDirectory <> "" then - hits |> genReport settings.OutputDirectory |> Console.reportFileBuilt |> console + trace "Rendering reports..." + let reportFile = reportFile settings.OutputDirectory + + (hits, errorHits, hitCounts, settings.SeverityLevels) + |> Markdown.generate + |> Io.writeFile reportFile + + reportFile |> Console.reportFileBuilt |> console errorHits |> returnCode diff --git a/src/pkgchk-cli/Console.fs b/src/pkgchk-cli/Console.fs index baa6bb38..c82424d4 100644 --- a/src/pkgchk-cli/Console.fs +++ b/src/pkgchk-cli/Console.fs @@ -5,29 +5,30 @@ open Spectre.Console module Console = - let formatReasons values = - let formatReason value = - let colour = Rendering.reasonColour value - $"[{colour}]{value}[/]" + let italic value = $"[italic]{value}[/]" - values |> Seq.map formatReason |> String.join ", " + let formatReason value = + let colour = Rendering.reasonColour value + $"[{colour}]{value}[/]" + + let formatReasons = Seq.map formatReason >> String.join ", " let formatSeverity value = let code = $"{Rendering.severityStyle value} {Rendering.severityColour value}" |> String.trim - sprintf "[%s]%s[/]" code value + $"[{code}]{value}[/]" let nugetLinkPkgVsn package version = - let url = $"https://www.nuget.org/packages/{package}/{version}" + let url = $"{Rendering.nugetPrefix}/{package}/{version}" $"[link={url}]{package} {version}[/]" let nugetLinkPkgSuggestion package suggestion = - let url = $"https://www.nuget.org/packages/{package}" + let url = $"{Rendering.nugetPrefix}/{package}" $"[link={url}]{package} {suggestion}[/]" - let formatProject value = sprintf "[bold yellow]%s[/]" value + let formatProject value = $"[bold yellow]{value}[/]" let formatHits (hits: seq) = @@ -49,7 +50,7 @@ module Console = hit.resolvedVersion if String.isNotEmpty hit.advisoryUri then - sprintf " [italic]%s[/]" hit.advisoryUri + sprintf " %s" (italic hit.advisoryUri) if (hit.reasons |> Array.isEmpty |> not) @@ -63,7 +64,7 @@ module Console = | x, y when x <> "" && y <> "" -> nugetLinkPkgSuggestion y x |> sprintf "Use %s" | x, _ -> x |> sprintf "Use %s") else if (hit.reasons |> Array.isEmpty |> not) then - sprintf " [italic]%s[/]" (formatReasons hit.reasons) + sprintf " %s" (italic (formatReasons hit.reasons)) "" } @@ -80,25 +81,48 @@ module Console = h.packageId) seq { - sprintf "Project: %s" projectPath |> formatProject + $"Project: {projectPath}" |> formatProject yield! hits |> Seq.collect fmt } - let grps = hits |> Seq.groupBy (fun h -> h.projectPath) |> Seq.sortBy fst - (grps |> Seq.collect fmtGrp) + hits + |> Seq.groupBy (fun h -> h.projectPath) + |> Seq.sortBy fst + |> Seq.collect fmtGrp - let generate hits = + let title hits = match hits with - | [] -> seq { "[green]No vulnerabilities found.[/]" } - | hits -> - seq { - "[red]Vulnerabilities found![/]" - yield! formatHits hits - } + | [] -> seq { "[lime]No vulnerabilities found.[/]" } + | _ -> seq { "[red]Vulnerabilities found![/]" } + + let formatSeverities severities = + severities + |> Seq.map formatSeverity + |> String.join ", " + |> sprintf "Vulnerabilities found matching %s" + |> italic + |> Seq.singleton + + + let formatHitCounts counts = + counts + |> Seq.map (fun (k, s, c) -> + let fmtCount value = + match value with + | 1 -> $"{value} hit" + | _ -> $"{value} hits" + + let fmtSeverity = + function + | ScaHitKind.Vulnerability -> formatSeverity + | ScaHitKind.Deprecated -> formatReason + + $"{Rendering.formatHitKind k} - {fmtSeverity k s}: {fmtCount c}.") + |> List.ofSeq - let error (error: string) = sprintf "[red]%s[/]" error + let error value = $"[red]{value}[/]" let reportFileBuilt path = - sprintf "[italic]Report file [link=%s]%s[/] built.[/]" path path + $"Report file [link={path}]{path}[/] built." |> italic let send (console: IAnsiConsole) = console.MarkupLine diff --git a/src/pkgchk-cli/Markdown.fs b/src/pkgchk-cli/Markdown.fs index 0d91cf9d..68484ff7 100644 --- a/src/pkgchk-cli/Markdown.fs +++ b/src/pkgchk-cli/Markdown.fs @@ -2,8 +2,11 @@ module Markdown = + let formatSeverityColour value = + $"{value}" + let formatSeverity value = - $"{Rendering.severityEmote value} {value}" + $"{Rendering.severityEmote value} {formatSeverityColour value}" let nugetLinkPkgVsn package version = $"[{package}]({Rendering.nugetLink (package, version)})" @@ -12,14 +15,20 @@ module Markdown = let url = Rendering.nugetLink (package, "") $"[{suggestion}]({url})" - let formatReasons values = - let formatReason value = - $"{value}" - values |> Seq.map formatReason |> String.join ", " + let formatReason value = + $"{value}" + + let formatReasons = Seq.map formatReason >> String.join ", " let formatProject value = sprintf "## **%s**" value + let formatSeverities severities = + severities + |> Seq.map formatSeverityColour + |> String.join ", " + |> sprintf "__Vulnerabilities found matching %s__" + let footer = seq { "" @@ -30,14 +39,48 @@ module Markdown = "---" } + let title hits = + match hits with + | [] -> seq { "# :heavy_check_mark: No vulnerabilities found!" } + | _ -> seq { "# :warning: Vulnerabilities found!" } + let formatNoHits () = let content = seq { "# :heavy_check_mark: No vulnerabilities found!" } footer |> Seq.append content + let formatHitCounts (severities: seq, counts: seq) = + let tableHdr = + seq { + "| Kind | Severity | Count |" + "| - | - | - |" + } + + let lines = + counts + |> Seq.map (fun (k, s, c) -> + let fmt = + function + | ScaHitKind.Vulnerability -> formatSeverity + | ScaHitKind.Deprecated -> formatReason + + $"|{Rendering.formatHitKind k}|{fmt k s}|{c}|") + + if Seq.isEmpty counts then + Seq.empty + else + seq { + yield formatProject "Matching severities" + yield formatSeverities severities + yield! tableHdr + yield! lines + yield "---" + } + + + let formatHits (hits: seq) = let grps = hits |> Seq.groupBy (fun h -> h.projectPath) |> Seq.sortBy fst - let hdr = seq { "# :warning: Vulnerabilities found!" } let grpHdr = seq { @@ -87,9 +130,17 @@ module Markdown = |> Seq.collect fmt } - footer |> Seq.append (grps |> Seq.collect fmtGrp) |> Seq.append hdr + (grps |> Seq.collect fmtGrp) + + let generate (hits, errorHits, countSummary, severities) = + let title = title errorHits - let generate hits = match hits with - | [] -> formatNoHits () - | hits -> hits |> formatHits + | [] -> Seq.append title footer + | hits -> + seq { + yield! title + yield! formatHitCounts (severities, countSummary) + yield! formatHits hits + yield! footer + } diff --git a/src/pkgchk-cli/Sca.fs b/src/pkgchk-cli/Sca.fs index 6c466d10..93112991 100644 --- a/src/pkgchk-cli/Sca.fs +++ b/src/pkgchk-cli/Sca.fs @@ -123,11 +123,6 @@ module Sca = Choice2Of2("An error occurred parsing results." + Environment.NewLine) let hitsByLevels (levels: string[]) (hits: ScaHit list) = - let levels = - match levels with - | [||] -> [| "High"; "Critical"; "Critical Bugs"; "Legacy" |] - | ls -> ls - let set = levels |> HashSet.ofSeq StringComparer.InvariantCultureIgnoreCase let levels = @@ -137,3 +132,17 @@ module Sca = | ScaHitKind.Deprecated -> h.reasons |> Seq.exists (fun r -> r |> HashSet.contains set)) hits |> List.filter levels + + let hitCountSummary (hits: seq) = + hits + |> Seq.groupBy (fun h -> h.kind) + |> Seq.collect (fun (kind, hs) -> + hs + |> Seq.collect (fun h -> + seq { + h.severity + yield! h.reasons + } + |> Seq.filter String.isNotEmpty) + |> Seq.groupBy id + |> Seq.map (fun (s, xs) -> (kind, s, xs |> Seq.length))) diff --git a/tests/pkgchk-cli.tests/IntegrationTests.fs b/tests/pkgchk-cli.tests/IntegrationTests.fs index 9ee62209..89a8cb50 100644 --- a/tests/pkgchk-cli.tests/IntegrationTests.fs +++ b/tests/pkgchk-cli.tests/IntegrationTests.fs @@ -242,7 +242,9 @@ type IntegrationTests(output: ITestOutputHelper) = |> assertPackagesNotFound [ regexPackage ] [] - let ``Project with mixed vulnerable / good / deprecated packages with unknown severity returns Ok`` () = + let ``Project with mixed vulnerable / good / deprecated packages where not matching severity requirements returns Ok`` + () + = let outDir = getOutDir () @@ -257,13 +259,15 @@ type IntegrationTests(output: ITestOutputHelper) = [ "test" ] |> runPkgChkSeverityArgs outDir |> execSuccessPkgChk - |> assertTitleShowsVulnerabilities + |> assertTitleShowsNoVulnerabilities |> assertPackagesFound [ httpPackage; aadPackage ] |> assertPackagesNotFound [ regexPackage ] [] - let ``Project with mixed vulnerable / good / deprecated packages with known severity returns Error`` () = + let ``Project with mixed vulnerable / good / deprecated packages when matching severity requirements returns Error`` + () + = let outDir = getOutDir ()