diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..81ed6786 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "fantomas": { + "version": "6.2.3", + "commands": [ + "fantomas" + ] + } + } +} \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..27963c85 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence +* @tonycknight diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..c237b81f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,142 @@ +name: Build & Release + + +on: + push: + pull_request: + branches: [ main ] + workflow_dispatch: + +env: + build-version-number: 0.1.${{ github.run_number }} + dotnet_version: 7.x + +jobs: + sca: + name: Check SCA + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v3 + with: + dotnet-version: "${{ env.dotnet_version }}" + + - name: dotnet SCA + run: | + dotnet tool restore + dotnet restore + dotnet list package --vulnerable --include-transitive | tee results.log + + FOUND_VULN=`grep -c 'has the following vulnerable packages' results.log` || true + FOUND_CRIT=`grep -c 'Critical' results.log` || true + FOUND_HIGH=`grep -c 'High' results.log` || true + + if [[ "$FOUND_VULN" != "0" ]] + then + if [ "$FOUND_CRIT" == "0" -a "$FOUND_HIGH" == "0"] + then + echo "### Vulnerable packages found ###" + exit 0 + fi + echo "### Critical/High vulnerable packages found ###" + exit 1 + fi + echo "## No problems found ##" + exit 0 + + style-rules: + name: Check style rules + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup NET SDK + uses: actions/setup-dotnet@v3 + with: + dotnet-version: "${{ env.dotnet_version }}" + + - name: Tool restore + run: dotnet tool restore + + - name: App restore + run: dotnet restore + + - name: Check style + run: dotnet fantomas ./ --check + + build: + name: Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v3 + with: + dotnet-version: "${{ env.dotnet_version }}" + + - name: Tool restore + run: dotnet tool restore + + - name: App restore + run: dotnet restore + + - name: Build + run: dotnet build -c Release + + + + + + nuget-release: + name: Nuget package & release + runs-on: ubuntu-latest + needs: [ sca, build, style-rules ] + + steps: + - uses: actions/checkout@v3 + + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v3 + with: + dotnet-version: "${{ env.dotnet_version }}" + + - name: Tool restore + run: dotnet tool restore + + - name: App restore + run: dotnet restore + + - name: Build package for Preview + if: ${{ github.ref != 'refs/heads/main'}} + run: dotnet pack -c Release -o ./package/ -p:PackageVersion=${{ env.build-version-number }}-preview -p:Version=${{ env.build-version-number }}-preview + + - name: Build package for Release + if: ${{ github.ref == 'refs/heads/main'}} + run: dotnet pack -c Release -o ./package/ -p:PackageVersion=${{ env.build-version-number }} -p:Version=${{ env.build-version-number }} + + - name: Push nuget package + if: github.event_name == 'push' + run: dotnet nuget push "package/*.nupkg" --api-key ${{ secrets.NUGET_PAT }} --source "nuget.org" + + gh-release: + name: gh release + runs-on: ubuntu-latest + needs: [ nuget-release ] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v3 + + - name: Create Release + uses: ncipollo/release-action@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + tag: v${{ env.build-version-number }} + prerelease: true + generateReleaseNotes: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8a30d258..d6f74b40 100644 --- a/.gitignore +++ b/.gitignore @@ -396,3 +396,6 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml + +launchSettings.json +package/* \ No newline at end of file diff --git a/README.md b/README.md index d089fda1..2f977f14 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,70 @@ # pkgchk-cli -A dotnet tool for package dependency checks + +A dotnet tool for package dependency checks. + +`dotnet list package` is a wonderful tool, and with its `--vulnerable` switch it is essential for code provenance. If you're not famlilar with it or why it's recommended, [see this blog post](https://devblogs.microsoft.com/nuget/how-to-scan-nuget-packages-for-security-vulnerabilities/). + +Unfortunately, simple integration into CI pipelines isn't feasible: the tool does not return a non-zero return code when vulnerabilities are found; and CI pipelines rely on return codes. Users are left to parse the tool's console output, and so must maintain different scripts for different environments. + +There are long-lived issues on the Dotnet & Nuget boards, which seem to be stuck: +- [Dotnet issue 16852](https://github.com/dotnet/sdk/issues/16852) +- [Dotnet issue 25091](https://github.com/dotnet/sdk/issues/25091) +- [Nuget issue 11781](https://github.com/NuGet/Home/issues/11781) + +So until those issues are resolved, `dotnet list package` needs some workarounds in CI pipelines. + +This tool wraps `dotnet list package` and interprets the output for vulnerabilities. Anything found will return in a non-zero return code. CI integration is as easy as local use. + +## Installation into your repository + +Create a tool manifest for your reepository: + +```dotnet new tool-manifest``` + +Add the tool to your repository's toolset: + +```dotnet tool install pkgchk-cli``` + +## Use + +To check for top-level dependency vulnerabilities: + +```pkgchk ``` + +To check for top-level and transitive dependency vulnerabilities: + +```pkgchk --transitive``` + +If there's only one project or solution file in your directory, omit the `` argument. + + +## Integration within Github actions + +Simply: + +``` +name: run SCA +runs: | + dotnet tool restore + pkgchk --transitive +``` + +## Integration within other CI platforms + +Most CI platforms fail on non-zero return codes from steps. + +Simply ensure your repository has `pkgchk-cli` in its tools manifest, your CI includes `nuget.org` as a package source and run: + +``` +dotnet tool restore +pkgchk --transitive +``` + + +## Licence + +`pkgchk-cli` is licenced under MIT. + +`pkgchk-cli` uses [Spectre.Console](https://spectreconsole.net/) - please check their licence. + +`pkgchk-cli` uses [`dotnet list package`](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-list-package) published by Microsoft. \ No newline at end of file diff --git a/pkgchk-cli.sln b/pkgchk-cli.sln new file mode 100644 index 00000000..916db00f --- /dev/null +++ b/pkgchk-cli.sln @@ -0,0 +1,40 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.8.34330.188 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "pkgchk-cli", "src\pkgchk-cli\pkgchk-cli.fsproj", "{8FF6F838-0619-400B-B5EA-79597E00E8B7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9030FD14-8EA0-4FD1-A8FA-2639C11CB540}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{CC70A61A-416F-48AD-9BCA-902150B48636}" + ProjectSection(SolutionItems) = preProject + .github\workflows\build.yml = .github\workflows\build.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{F2E3E9A8-73C7-4E10-A391-5ACAA235174C}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8FF6F838-0619-400B-B5EA-79597E00E8B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8FF6F838-0619-400B-B5EA-79597E00E8B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8FF6F838-0619-400B-B5EA-79597E00E8B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8FF6F838-0619-400B-B5EA-79597E00E8B7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {8FF6F838-0619-400B-B5EA-79597E00E8B7} = {9030FD14-8EA0-4FD1-A8FA-2639C11CB540} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {59CE1C89-868D-4DAB-A9EC-BE3E1A1207EF} + EndGlobalSection +EndGlobal diff --git a/src/pkgchk-cli/Commands.fs b/src/pkgchk-cli/Commands.fs new file mode 100644 index 00000000..3ce1c3e3 --- /dev/null +++ b/src/pkgchk-cli/Commands.fs @@ -0,0 +1,33 @@ +namespace pkgchk + +open System.ComponentModel +open Spectre.Console.Cli + +type PackageCheckCommandSettings() = + inherit CommandSettings() + + [] + [] + member val ProjectPath = "" with get, set + + [] + [] + member val IncludeTransitives = false with get, set + +type PackageCheckCommand() = + inherit Command() + + override _.Execute(context, settings) = + let r = + settings.ProjectPath + |> Io.toFullPath + |> Sca.createProcess settings.IncludeTransitives + |> Sca.get + + match r with + | Choice1Of2 json -> + match Sca.parse json with + | Choice1Of2 [] -> Console.returnNoVulnerabilities () + | Choice1Of2 hits -> Console.returnVulnerabilities hits + | Choice2Of2 error -> Console.returnError error + | Choice2Of2 error -> Console.returnError error diff --git a/src/pkgchk-cli/Console.fs b/src/pkgchk-cli/Console.fs new file mode 100644 index 00000000..7b5f2ff3 --- /dev/null +++ b/src/pkgchk-cli/Console.fs @@ -0,0 +1,24 @@ +namespace pkgchk + +open System +open Spectre.Console + +module Console = + let returnNoVulnerabilities () = + "[bold green]No vulnerabilities found![/]" + |> AnsiConsole.Markup + |> Console.Out.WriteLine + + 0 + + let returnVulnerabilities hits = + "[bold red]Vulnerabilities found![/]" + |> AnsiConsole.Markup + |> Console.Out.WriteLine + + hits |> Sca.formatHits |> Console.Out.WriteLine + 1 + + let returnError (error: string) = + Console.Error.WriteLine error + 2 diff --git a/src/pkgchk-cli/Io.fs b/src/pkgchk-cli/Io.fs new file mode 100644 index 00000000..88842bf7 --- /dev/null +++ b/src/pkgchk-cli/Io.fs @@ -0,0 +1,14 @@ +namespace pkgchk + +open System +open System.IO + +module Io = + + let toFullPath (path: string) = + if not <| Path.IsPathRooted(path) then + let wd = Environment.CurrentDirectory + + Path.Combine(wd, path) + else + path diff --git a/src/pkgchk-cli/Program.fs b/src/pkgchk-cli/Program.fs new file mode 100644 index 00000000..698c0125 --- /dev/null +++ b/src/pkgchk-cli/Program.fs @@ -0,0 +1,15 @@ +namespace pkgchk + +open Spectre.Console.Cli + +module Program = + [] + let main argv = + let app = CommandApp() + + app.Configure(fun c -> c.PropagateExceptions().ValidateExamples() |> ignore) + + try + app.Run(argv) + with ex -> + Console.returnError ex.Message diff --git a/src/pkgchk-cli/README.md b/src/pkgchk-cli/README.md new file mode 100644 index 00000000..d089fda1 --- /dev/null +++ b/src/pkgchk-cli/README.md @@ -0,0 +1,2 @@ +# pkgchk-cli +A dotnet tool for package dependency checks diff --git a/src/pkgchk-cli/Sca.fs b/src/pkgchk-cli/Sca.fs new file mode 100644 index 00000000..38240c01 --- /dev/null +++ b/src/pkgchk-cli/Sca.fs @@ -0,0 +1,129 @@ +namespace pkgchk + +open System +open System.Diagnostics +open FSharp.Data +open Spectre.Console + +type ScaData = JsonProvider<"ScaSample.json"> + +type ScaHit = + { framework: string + projectPath: string + packageId: string + resolvedVersion: string + severity: string + advisoryUri: string } + +module Sca = + + let createProcess includeTransitive path = + let p = new Process() + + p.StartInfo.UseShellExecute <- false + p.StartInfo.RedirectStandardOutput <- true + p.StartInfo.FileName <- "dotnet" + p.StartInfo.CreateNoWindow <- true + p.StartInfo.WindowStyle <- ProcessWindowStyle.Hidden + p.StartInfo.RedirectStandardError <- true + p.StartInfo.RedirectStandardOutput <- true + p.StartInfo.WorkingDirectory <- Environment.CurrentDirectory + + let transitives = + match includeTransitive with + | true -> "--include-transitive" + | _ -> "" + + let args = + 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 + + p.StartInfo.Arguments <- args + + p + + + let get (proc: Process) = + try + if proc.Start() then + let out = proc.StandardOutput.ReadToEnd() + let err = proc.StandardError.ReadToEnd() + proc.WaitForExit() + + if (String.IsNullOrWhiteSpace(err)) then + Choice1Of2(out) + else + Choice2Of2(err) + else + Choice2Of2("Cannot start process") + with ex -> + Choice2Of2(ex.Message) + + let parse json = + + try + let r = ScaData.Parse(json) + + let topLevelVuls = + r.Projects + |> Seq.collect (fun p -> + p.Frameworks + |> Seq.collect (fun f -> + f.TopLevelPackages + |> Seq.collect (fun tp -> + tp.Vulnerabilities + |> Seq.map (fun v -> + { ScaHit.projectPath = p.Path + framework = f.Framework + packageId = tp.Id + resolvedVersion = tp.ResolvedVersion + severity = v.Severity + advisoryUri = v.Advisoryurl })))) + + let transitiveVuls = + r.Projects + |> Seq.collect (fun p -> + p.Frameworks + |> Seq.collect (fun f -> + f.TransitivePackages + |> Seq.collect (fun tp -> + tp.Vulnerabilities + |> Seq.map (fun v -> + { ScaHit.projectPath = p.Path + framework = f.Framework + packageId = tp.Id + resolvedVersion = tp.ResolvedVersion + severity = v.Severity + advisoryUri = v.Advisoryurl })))) + + let hits = topLevelVuls |> Seq.append transitiveVuls |> List.ofSeq + Choice1Of2 hits + with ex -> + Choice2Of2("An error occurred parsing results" + Environment.NewLine + ex.Message) + + let formatHits (hits: seq) = + let formatSeverity value = + let code = + match value with + | "High" -> "red" + | "Critical" -> "italic red" + | "Moderate" -> "#d75f00" + | _ -> "yellow" + + sprintf "[%s]%s[/]" code value + + let formatProject value = sprintf "[bold yellow]%s[/]" value + + let fmt (hit: ScaHit) = + seq { + "" + sprintf "Project: %s" hit.projectPath |> formatProject + sprintf "Severity: %s" (formatSeverity hit.severity) + sprintf "Package: [cyan]%s[/] version [cyan]%s[/]" hit.packageId hit.resolvedVersion + sprintf "Advisory URL: %s" hit.advisoryUri + } + + let lines = hits |> Seq.collect fmt + String.Join(Environment.NewLine, lines) |> AnsiConsole.MarkupLine diff --git a/src/pkgchk-cli/ScaSample.json b/src/pkgchk-cli/ScaSample.json new file mode 100644 index 00000000..439e8598 --- /dev/null +++ b/src/pkgchk-cli/ScaSample.json @@ -0,0 +1,104 @@ +{ + "version": 1, + "parameters": "--vulnerable --include-transitive", + "sources": [ + "https://api.nuget.org/v3/index.json", + "C:/Program Files (x86)/Microsoft SDKs/NuGetPackages/" + ], + "projects": [ + { + "path": "/testapp/src/testapp/testapp.fsproj", + "frameworks": [ + { + "framework": "net7.0", + "topLevelPackages": [ + { + "id": "System.Net.Http", + "requestedVersion": "vsn 4.3.0", + "resolvedVersion": "vsn 4.3.0", + "vulnerabilities": [ + { + "severity": "High", + "advisoryurl": "https://github.com/advisories/GHSA-7jgj-8wvc-jh57" + } + ] + } + ] + } + ] + }, + { + "path": "/testapp/src/testapp.common/testapp.common.fsproj", + "frameworks": [ + { + "framework": "net7.0", + "transitivePackages": [ + { + "id": "System.Net.Http", + "resolvedVersion": "vsn 4.3.0", + "vulnerabilities": [ + { + "severity": "High", + "advisoryurl": "https://github.com/advisories/GHSA-7jgj-8wvc-jh57" + } + ] + }, + { + "id": "System.Text.RegularExpressions", + "resolvedVersion": "aaa 4.3.0", + "vulnerabilities": [ + { + "severity": "High", + "advisoryurl": "https://github.com/advisories/GHSA-cmhx-cq75-c4mj" + } + ] + } + ] + } + ] + }, + { + "path": "/testapp/tests/testapp.tests.unit/testapp.tests.unit.fsproj", + "frameworks": [ + { + "framework": "net7.0", + "topLevelPackages": [ + { + "id": "System.Net.Http", + "requestedVersion": "vsn 4.3.0", + "resolvedVersion": "vsn 4.3.0", + "vulnerabilities": [ + { + "severity": "High", + "advisoryurl": "https://github.com/advisories/GHSA-7jgj-8wvc-jh57" + } + ] + } + ], + "transitivePackages": [ + { + "id": "System.Net.Http", + "resolvedVersion": "vsn 4.3.0", + "vulnerabilities": [ + { + "severity": "High", + "advisoryurl": "https://github.com/advisories/GHSA-7jgj-8wvc-jh57" + } + ] + }, + { + "id": "System.Text.RegularExpressions", + "resolvedVersion": "aaa 4.3.0", + "vulnerabilities": [ + { + "severity": "High", + "advisoryurl": "https://github.com/advisories/GHSA-cmhx-cq75-c4mj" + } + ] + } + ] + } + ] + } + ] +} diff --git a/src/pkgchk-cli/pkgchk-cli.fsproj b/src/pkgchk-cli/pkgchk-cli.fsproj new file mode 100644 index 00000000..b87fbf4c --- /dev/null +++ b/src/pkgchk-cli/pkgchk-cli.fsproj @@ -0,0 +1,33 @@ + + + + Exe + net7.0 + Copyright 2023 Tony Knight + Tony Knight + pkgchk + Tooling for .net package checks + true + pkgchk + pkgchk-cli + https://github.com/tonycknight/pkgchk-cli + https://github.com/tonycknight/pkgchk-cli + README.md + + + + + + + + + + + + + + + + + + diff --git a/src/pkgchk-cli/pkgchk-cli.sln b/src/pkgchk-cli/pkgchk-cli.sln new file mode 100644 index 00000000..46333c45 --- /dev/null +++ b/src/pkgchk-cli/pkgchk-cli.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.8.34330.188 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "pkgchk-cli", "pkgchk-cli.fsproj", "{8FF6F838-0619-400B-B5EA-79597E00E8B7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9030FD14-8EA0-4FD1-A8FA-2639C11CB540}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{CC70A61A-416F-48AD-9BCA-902150B48636}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{F2E3E9A8-73C7-4E10-A391-5ACAA235174C}" + ProjectSection(SolutionItems) = preProject + ..\..\README.md = ..\..\README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8FF6F838-0619-400B-B5EA-79597E00E8B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8FF6F838-0619-400B-B5EA-79597E00E8B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8FF6F838-0619-400B-B5EA-79597E00E8B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8FF6F838-0619-400B-B5EA-79597E00E8B7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {8FF6F838-0619-400B-B5EA-79597E00E8B7} = {9030FD14-8EA0-4FD1-A8FA-2639C11CB540} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {59CE1C89-868D-4DAB-A9EC-BE3E1A1207EF} + EndGlobalSection +EndGlobal