diff --git a/src/pkgchk-cli/Commands.fs b/src/pkgchk-cli/Commands.fs index 0abb8b6e..f9b85594 100644 --- a/src/pkgchk-cli/Commands.fs +++ b/src/pkgchk-cli/Commands.fs @@ -1,5 +1,6 @@ namespace pkgchk +open System open System.ComponentModel open System.Diagnostics.CodeAnalysis open Spectre.Console.Cli @@ -14,10 +15,15 @@ type PackageCheckCommandSettings() = member val ProjectPath = "" with get, set [] - [] + [] [] member val IncludeTransitives = true with get, set + [] + [] + [] + member val IncludeDeprecations = true with get, set + [] [] [] @@ -29,10 +35,46 @@ type PackageCheckCommand() = let console = Spectre.Console.AnsiConsole.Console |> Console.send + let genArgs (settings: PackageCheckCommandSettings) = + let projPath = settings.ProjectPath |> Io.toFullPath + + [| yield projPath |> ScaArgs.scanVulnerabilities settings.IncludeTransitives + if settings.IncludeDeprecations then + yield projPath |> ScaArgs.scanDeprecations settings.IncludeTransitives |] + + + let runProc proc = + try + Io.run proc + finally + proc.Dispose() + + let runProcParse procs = + procs + |> Array.map runProc + |> Array.map (fun r -> + match r with + | Choice1Of2 json -> Sca.parse json + | Choice2Of2 x -> Choice2Of2 x) + let returnError error = error |> Console.error |> console Console.sysError + let getErrors procResults = + procResults + |> Seq.map (function + | Choice2Of2 x -> x + | _ -> "") + |> Seq.filter String.isNotEmpty + + let getHits procResults = + procResults + |> Seq.collect (function + | Choice1Of2 xs -> xs + | _ -> []) + |> List.ofSeq + let returnCode = function | [] -> Console.validationOk @@ -53,25 +95,20 @@ type PackageCheckCommand() = hits |> genMarkdown |> Io.writeFile reportFile reportFile - override _.Execute(context, settings) = - use proc = - settings.ProjectPath - |> Io.toFullPath - |> Sca.scanVulnerabilitiesArgs settings.IncludeTransitives - |> Io.createProcess + let results = settings |> genArgs |> Array.map Io.createProcess |> runProcParse + + let errors = getErrors results - match Io.run proc with - | Choice1Of2 json -> - match Sca.parse json with - | Choice1Of2 hits -> - genConsole hits + if Seq.isEmpty errors |> not then + errors |> String.joinLines |> returnError + else + let hits = getHits results - if settings.OutputDirectory <> "" then - hits |> genReport settings.OutputDirectory |> Console.reportFileBuilt |> console + hits |> genConsole - returnCode hits + if settings.OutputDirectory <> "" then + hits |> genReport settings.OutputDirectory |> Console.reportFileBuilt |> console - | Choice2Of2 error -> error |> returnError - | Choice2Of2 error -> error |> returnError + returnCode hits diff --git a/src/pkgchk-cli/Console.fs b/src/pkgchk-cli/Console.fs index 35d9baab..88ee6c17 100644 --- a/src/pkgchk-cli/Console.fs +++ b/src/pkgchk-cli/Console.fs @@ -14,7 +14,15 @@ module Console = [] let sysError = 2 - let joinLines (lines: seq) = String.Join(Environment.NewLine, lines) + let formatHitKind = + function + | ScaHitKind.Vulnerability -> "Vulnerable package" + | ScaHitKind.Deprecated -> "Deprecated package" + + let formatReason value = + match value with + | "Legacy" -> sprintf "[yellow]%s[/]" value + | _ -> sprintf "[red]%s[/]" value let formatSeverity value = let code = @@ -32,22 +40,49 @@ module Console = let fmt (hit: ScaHit) = seq { - sprintf - "Package: %s - [cyan]%s[/] version [cyan]%s[/]" - (formatSeverity hit.severity) - hit.packageId - hit.resolvedVersion + match hit.kind with + | ScaHitKind.Vulnerability -> + sprintf + "%s: %s - [cyan]%s[/] version [cyan]%s[/]" + (formatHitKind hit.kind) + (formatSeverity hit.severity) + hit.packageId + hit.resolvedVersion + | ScaHitKind.Deprecated -> + sprintf + "%s: [cyan]%s[/] version [cyan]%s[/]" + (formatHitKind hit.kind) + hit.packageId + hit.resolvedVersion + + if String.isNotEmpty hit.advisoryUri then + sprintf " [italic]%s[/]" hit.advisoryUri + + if String.isNotEmpty hit.reason && String.isNotEmpty hit.suggestedReplacement then + sprintf + " [italic]%s - use [cyan]%s[/][/]" + (formatReason hit.reason) + hit.suggestedReplacement + else if String.isNotEmpty hit.reason then + sprintf " [italic]%s " hit.reason - sprintf " [italic]%s[/]" hit.advisoryUri "" } let fmtGrp (hit: (string * seq)) = let projectPath, hits = hit + let hits = + hits + |> Seq.sortBy (fun h -> + (match h.kind with + | ScaHitKind.Vulnerability -> 0 + | _ -> 1), + h.packageId) + seq { sprintf "Project: %s" projectPath |> formatProject - yield! hits |> Seq.sortBy (fun h -> h.packageId) |> Seq.collect fmt + yield! hits |> Seq.collect fmt } let grps = hits |> Seq.groupBy (fun h -> h.projectPath) |> Seq.sortBy fst @@ -61,7 +96,7 @@ module Console = "[bold red]Vulnerabilities found![/]" yield! formatHits hits } - |> joinLines + |> String.joinLines let error (error: string) = sprintf "[red]%s[/]" error diff --git a/src/pkgchk-cli/Markdown.fs b/src/pkgchk-cli/Markdown.fs index 86f02807..53fc563f 100644 --- a/src/pkgchk-cli/Markdown.fs +++ b/src/pkgchk-cli/Markdown.fs @@ -7,6 +7,7 @@ module Markdown = let formatHitKind (value: ScaHitKind) = match value with | ScaHitKind.Vulnerability -> "Vulnerable package" + | ScaHitKind.Deprecated -> "Deprecated package" let formatSeverity value = let (emote, colour) = @@ -14,10 +15,20 @@ module Markdown = | "High" -> (":bangbang:", "red") | "Critical" -> (":heavy_exclamation_mark:", "red") | "Moderate" -> (":heavy_exclamation_mark:", "orange") + | "" -> ("", "") | _ -> (":heavy_exclamation_mark:", "yellow") sprintf "%s %s" emote colour value + let formatReason value = + let colour = + function + | "Legacy" -> "yellow" + | "Critical Bugs" -> "red" + | _ -> "" + + sprintf "%s" (colour value) value + let formatProject value = sprintf "## **%s**" value let footer = @@ -47,13 +58,25 @@ module Markdown = let fmt (hit: ScaHit) = seq { - sprintf - "| %s | %s | %s %s | [Advisory](%s) | " - (formatHitKind hit.kind) - (formatSeverity hit.severity) - hit.packageId - hit.resolvedVersion - hit.advisoryUri + match hit.kind with + | ScaHitKind.Vulnerability -> + sprintf + "| %s | %s | %s %s | [Advisory](%s) | " + (formatHitKind hit.kind) + (formatSeverity hit.severity) + hit.packageId + hit.resolvedVersion + hit.advisoryUri + | ScaHitKind.Deprecated -> + sprintf + "| %s | %s | %s %s | %s | " + (formatHitKind hit.kind) + (formatReason hit.reason) + hit.packageId + hit.resolvedVersion + (match hit.suggestedReplacement with + | "" -> "" + | x -> sprintf "Use %s" x) } let fmtGrp (hit: (string * seq)) = @@ -63,7 +86,15 @@ module Markdown = projectPath |> formatProject "" yield! grpHdr - yield! hits |> Seq.sortBy (fun h -> h.packageId) |> Seq.collect fmt + + yield! + hits + |> Seq.sortBy (fun h -> + ((match h.kind with + | ScaHitKind.Vulnerability -> 0 + | _ -> 1), + h.packageId)) + |> Seq.collect fmt } footer |> Seq.append (grps |> Seq.collect fmtGrp) |> Seq.append hdr diff --git a/src/pkgchk-cli/Program.fs b/src/pkgchk-cli/Program.fs index a0804edb..0bce5593 100644 --- a/src/pkgchk-cli/Program.fs +++ b/src/pkgchk-cli/Program.fs @@ -10,9 +10,11 @@ module Program = [] let main argv = - let app = CommandApp() + let app = + CommandApp() + .WithDescription("Check project dependency packages for vulnerabilities and deprecations.") - app.Configure(fun c -> c.PropagateExceptions().ValidateExamples() |> ignore) + app.Configure(fun c -> c.PropagateExceptions().ValidateExamples().TrimTrailingPeriods(false) |> ignore) try app.Run(argv) diff --git a/src/pkgchk-cli/Sca.fs b/src/pkgchk-cli/Sca.fs index dc8c99c9..1d23dc12 100644 --- a/src/pkgchk-cli/Sca.fs +++ b/src/pkgchk-cli/Sca.fs @@ -5,7 +5,9 @@ open FSharp.Data type ScaData = JsonProvider<"ScaSample.json"> -type ScaHitKind = | Vulnerability +type ScaHitKind = + | Vulnerability + | Deprecated type ScaHit = { kind: ScaHitKind @@ -14,21 +16,29 @@ type ScaHit = packageId: string resolvedVersion: string severity: string - advisoryUri: string } + advisoryUri: string + reason: string + suggestedReplacement: string } -module Sca = +module ScaArgs = + + let scanArgs vulnerable includeTransitive path = + sprintf + "%s %s %s %s" + (sprintf "list %s package" path) + (match vulnerable with + | true -> "--vulnerable" + | false -> "--deprecated") + (match includeTransitive with + | true -> "--include-transitive" + | _ -> "") + "--format json --output-version 1" - let scanVulnerabilitiesArgs includeTransitive path = - let transitives = - match includeTransitive with - | true -> "--include-transitive" - | _ -> "" + let scanVulnerabilities = scanArgs true - if String.IsNullOrWhiteSpace path then - sprintf " list package --vulnerable %s --format json --output-version 1 " transitives - else - sprintf " list %s package --vulnerable %s --format json --output-version 1 " path transitives + let scanDeprecations = scanArgs false +module Sca = let parse json = @@ -50,8 +60,34 @@ module Sca = packageId = tp.Id resolvedVersion = tp.ResolvedVersion severity = v.Severity + reason = "" + suggestedReplacement = "" advisoryUri = v.Advisoryurl })))) + let topLevelDeprecations = + r.Projects + |> Seq.collect (fun p -> + p.Frameworks + |> Seq.collect (fun f -> + f.TopLevelPackages + |> Seq.collect (fun tp -> + tp.DeprecationReasons + |> Seq.filter String.isNotEmpty + |> Seq.map (fun d -> + { ScaHit.projectPath = System.IO.Path.GetFullPath(p.Path) + kind = ScaHitKind.Deprecated + framework = f.Framework + packageId = tp.Id + resolvedVersion = tp.ResolvedVersion + severity = "" + suggestedReplacement = + match tp.AlternativePackage with + | Some ap -> sprintf "%s %s" ap.Id ap.VersionRange + | None -> "" + reason = d + + advisoryUri = "" })))) + let transitiveVuls = r.Projects |> Seq.collect (fun p -> @@ -67,9 +103,16 @@ module Sca = packageId = tp.Id resolvedVersion = tp.ResolvedVersion severity = v.Severity + reason = "" + suggestedReplacement = "" advisoryUri = v.Advisoryurl })))) - let hits = topLevelVuls |> Seq.append transitiveVuls |> List.ofSeq + let hits = + topLevelDeprecations + |> Seq.append transitiveVuls + |> Seq.append topLevelVuls + |> List.ofSeq + Choice1Of2 hits with ex -> - Choice2Of2("An error occurred parsing results" + Environment.NewLine + ex.Message) + Choice2Of2("An error occurred parsing results." + Environment.NewLine) diff --git a/src/pkgchk-cli/ScaSample.json b/src/pkgchk-cli/ScaSample.json index 439e8598..2efebbeb 100644 --- a/src/pkgchk-cli/ScaSample.json +++ b/src/pkgchk-cli/ScaSample.json @@ -21,7 +21,14 @@ "severity": "High", "advisoryurl": "https://github.com/advisories/GHSA-7jgj-8wvc-jh57" } - ] + ], + "deprecationReasons": [ + "Legacy" + ], + "alternativePackage": { + "id": "Microsoft.Identity.Client", + "versionRange": ">= 0.0.0" + } } ] } diff --git a/src/pkgchk-cli/Utils.fs b/src/pkgchk-cli/Utils.fs new file mode 100644 index 00000000..dc50c0a0 --- /dev/null +++ b/src/pkgchk-cli/Utils.fs @@ -0,0 +1,17 @@ +namespace pkgchk + +open System +open System.Diagnostics + +module String = + [] + let join (separator) (lines: seq) = String.Join(separator, lines) + + [] + let joinLines (lines: seq) = join Environment.NewLine lines + + [] + let isEmpty = String.IsNullOrWhiteSpace + + [] + let isNotEmpty = isEmpty >> not diff --git a/src/pkgchk-cli/pkgchk-cli.fsproj b/src/pkgchk-cli/pkgchk-cli.fsproj index 55aa6316..1e872a35 100644 --- a/src/pkgchk-cli/pkgchk-cli.fsproj +++ b/src/pkgchk-cli/pkgchk-cli.fsproj @@ -18,6 +18,7 @@ + diff --git a/tests/pkgchk-cli.tests/IntegrationTests.fs b/tests/pkgchk-cli.tests/IntegrationTests.fs index 7c5e9b86..9f1198ec 100644 --- a/tests/pkgchk-cli.tests/IntegrationTests.fs +++ b/tests/pkgchk-cli.tests/IntegrationTests.fs @@ -13,6 +13,9 @@ module IntegrationTests = [] let regexPackage = "System.Text.RegularExpressions" + [] + let aadPackage = "Microsoft.IdentityModel.Clients.ActiveDirectory" + let cmdArgs (cmd: string) = let x = cmd.IndexOf(' ') @@ -39,8 +42,11 @@ module IntegrationTests = let addGoodRegexPackageArgs outDir = sprintf "dotnet add ./%s/testproj.csproj package %s -v 4.3.1" outDir regexPackage + let addDeprecatedAadPackageArgs outDir = + sprintf "dotnet add ./%s/testproj.csproj package %s -v 5.3.0" outDir aadPackage + let runPkgChkArgs outDir = - sprintf "dotnet pkgchk-cli.dll ./%s/testproj.csproj -t true" outDir + sprintf "dotnet pkgchk-cli.dll ./%s/testproj.csproj -t true -d true" outDir let createProc cmd = let (exec, args) = cmdArgs cmd @@ -157,3 +163,32 @@ module IntegrationTests = |> execFailedPkgChk |> assertPackagesFound [ httpPackage ] |> assertPackagesNotFound [ regexPackage ] + + [] + let ``Project with multiple deprecated packages returns Error`` () = + + let outDir = getOutDir () + + createProjectArgs outDir |> execSuccess + + addDeprecatedAadPackageArgs outDir |> execSuccess + + runPkgChkArgs outDir |> execFailedPkgChk |> assertPackagesFound [ aadPackage ] + + [] + let ``Project with mixed vulnerable / good / deprecated packages returns Error`` () = + + let outDir = getOutDir () + + createProjectArgs outDir |> execSuccess + + addGoodRegexPackageArgs outDir |> execSuccess + + addBadHttpPackageArgs outDir |> execSuccess + + addDeprecatedAadPackageArgs outDir |> execSuccess + + runPkgChkArgs outDir + |> execFailedPkgChk + |> assertPackagesFound [ httpPackage; aadPackage ] + |> assertPackagesNotFound [ regexPackage ] diff --git a/tests/pkgchk-cli.tests/ScaArgsTests.fs b/tests/pkgchk-cli.tests/ScaArgsTests.fs new file mode 100644 index 00000000..c76ba997 --- /dev/null +++ b/tests/pkgchk-cli.tests/ScaArgsTests.fs @@ -0,0 +1,38 @@ +namespace pkgchk.tests + +open FsUnit.Xunit +open pkgchk.ScaArgs +open Xunit + +module ScaArgsTests = + + let includeTransitives = + function + | true -> "--include-transitive" + | _ -> "" + + [] + [] + [] + [] + [] + let ``Vulnerabilities with project`` (project, transitives) = + let r = scanVulnerabilities transitives project + + let expected = + $"list {project} package --vulnerable {includeTransitives transitives} --format json --output-version 1" + + r |> should equal expected + + [] + [] + [] + [] + [] + let ``Deprecations with project`` (project, transitives) = + let r = scanDeprecations transitives project + + let expected = + $"list {project} package --deprecated {includeTransitives transitives} --format json --output-version 1" + + r |> should equal expected diff --git a/tests/pkgchk-cli.tests/ScaTests.fs b/tests/pkgchk-cli.tests/ScaTests.fs index a7fd3e5a..4d615ff9 100644 --- a/tests/pkgchk-cli.tests/ScaTests.fs +++ b/tests/pkgchk-cli.tests/ScaTests.fs @@ -48,7 +48,7 @@ module ScaTests = | Choice1Of2 xs -> match xs with | [] -> failwith "Empty list returned" - | [ y; x ] -> + | [ x; y ] -> x.framework |> should equal "net7.0" x.packageId |> should equal "System.Net.Http" x.resolvedVersion |> should equal "4.3.0" diff --git a/tests/pkgchk-cli.tests/pkgchk-cli.tests.fsproj b/tests/pkgchk-cli.tests/pkgchk-cli.tests.fsproj index e12696ae..abd58fe5 100644 --- a/tests/pkgchk-cli.tests/pkgchk-cli.tests.fsproj +++ b/tests/pkgchk-cli.tests/pkgchk-cli.tests.fsproj @@ -12,14 +12,13 @@ + - - all