Skip to content
73 changes: 34 additions & 39 deletions src/pkgchk-cli/Commands.fs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type PackageCheckCommandSettings() =

[<CommandOption("-s|--severity")>]
[<Description("Severity levels to scan for. Matches will return non-zero exit codes. Multiple levels can be specified.")>]
[<DefaultValue([| "High"; "Critical"; "Critical Bugs"; "Legacy" |])>]
member val SeverityLevels: string array = [||] with get, set

[<CommandOption("--trace")>]
Expand All @@ -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"

Expand Down Expand Up @@ -87,7 +94,6 @@ type PackageCheckCommand() =
|> Io.createProcess
|> runRestoreProcParse (runProc logging)


let runScaProcParse run procs =
procs
|> Array.map run
Expand All @@ -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
70 changes: 47 additions & 23 deletions src/pkgchk-cli/Console.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScaHit>) =

Expand All @@ -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)
Expand All @@ -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))

""
}
Expand All @@ -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
71 changes: 61 additions & 10 deletions src/pkgchk-cli/Markdown.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

module Markdown =

let formatSeverityColour value =
$"<span style='color:{Rendering.severityColour value}'>{value}</span>"

let formatSeverity value =
$"{Rendering.severityEmote value} <span style='color:{Rendering.severityColour value}'>{value}</span>"
$"{Rendering.severityEmote value} {formatSeverityColour value}"

let nugetLinkPkgVsn package version =
$"[{package}]({Rendering.nugetLink (package, version)})"
Expand All @@ -12,14 +15,20 @@ module Markdown =
let url = Rendering.nugetLink (package, "")
$"[{suggestion}]({url})"

let formatReasons values =
let formatReason value =
$"<span style='color:{Rendering.reasonColour value}'>{value}</span>"

values |> Seq.map formatReason |> String.join ", "
let formatReason value =
$"<span style='color:{Rendering.reasonColour value}'>{value}</span>"

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 {
""
Expand All @@ -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<string>, counts: seq<ScaHitKind * string * int>) =
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<ScaHit>) =
let grps = hits |> Seq.groupBy (fun h -> h.projectPath) |> Seq.sortBy fst
let hdr = seq { "# :warning: Vulnerabilities found!" }

let grpHdr =
seq {
Expand Down Expand Up @@ -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
}
19 changes: 14 additions & 5 deletions src/pkgchk-cli/Sca.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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<ScaHit>) =
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)))
Loading