From 08ba73d303228eb4d92a6a5f75350d78230bca30 Mon Sep 17 00:00:00 2001 From: Gustavo Castellanos Alfonzo Date: Mon, 9 Sep 2024 11:13:30 -0700 Subject: [PATCH] Add a SBOM Generation Task (#674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Experimenting with dotnet * Add comments * Make the Sbom.Targets project to build. * Add all arguments to GenerateSBOMTask (#1) Co-authored-by: vpatakottu * Add call to the SBOM API from GenerateSbomTask#Execute (#2) * Start implementing Execute * Make SBOM Targets test target .NET 8 only. * Make the Component Path not required. * Build the current project in test, and check that the manifest was generated. * Ad a few comments for TODOs. * Validate and Sanitize Arguments (#3) * Validate arguments * ignore case for enum conversion * create method for manifestinfo --------- Co-authored-by: vpatakottu * Add tests for Generate SBOM Task (#4) * Add more tests for GenerateSbomTask.Execute logic * Add SBOM Validation to the Generate SBOM Task tests. * Add a utility method that validates the SBOM being generated during tests * Make more tests use the new utility validator method * Pass sbom specification during tests * Refactor GenerateSbomTask tests to be parametrized through the SBOM Specification * Fix typo * Add an abstract method for the Sbom Specification of the AbstractGenerateSbomTaskTests * Address PR suggestions * Made fields internal instead of private in AbstractGenerateSbomTaskTests * Add unit tests for GenerateSbomTask inputs (#6) * add unit tests for GenerateSbomTask inputs * remove console print * Addressing feedback * addressing feedback and adding more tests' --------- Co-authored-by: vpatakottu * Add additional unit tests for valid cases (#7) * add additional unit tests for valid cases * address feedback and add few more cases --------- Co-authored-by: vpatakottu * Merging Varshita's branch into our feature branch (#12) * setting up imports * Add reference to local Nuget package, for testing purposes * rename targets and props, export them to the build folder * Fix test project * Fix Targets file to include props * Manually adding the Sources Providers that support ProviderType.Packages * Manually add the missing classes for SBOM generation * Add MSBuild properties to our Props file (#8) * Use MSBuild/.NET props for default values of the Generate SBOM task. * Remove hardcoded path from the Targets * Add default value to props file for SbomGenerationManifestDirPath * Add final ManifestDirPath to SbomGenerationResult. * Fix typo * Change Summary comment for ManifestDirPath * include sbom files in user's nuget packages (#11) Co-authored-by: vpatakottu * Make the task target .net 8 and .net 6 (#13) * Remove unrooted checks (#14) * Downgrade Microsoft.Extensions.Hosting back to 7.0.1 * Remove LocalNuget configuration * Stop tracking nuspec file * Remove unnecessary comments. * Remove reference to Microsoft.Sbom.Targets Nuget * Apply suggestions from the linter and PR comments. --------- Co-authored-by: vpatakottu Co-authored-by: vpatakottu <47004464+vpatakottu@users.noreply.github.com> * Fix ubuntu tests (#16) * add users/gustavoca/net-sdk-sbom-tool branch to PR pipelines * Fix Ubuntu tests for Targets project * Update feature branch (#17) * build(deps): bump actions/checkout from 4.1.1 to 4.1.6 (#574) Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.1 to 4.1.6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/b4ffde65f46336ab88eb53be808477a3936bae11...a5ac7e51b41094c92402da3b24376905380afc29) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump github/codeql-action from 3.24.3 to 3.25.8 (#591) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.24.3 to 3.25.8. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/379614612a29c9e28f31f39a59013eb8012a51f0...2e230e8fe0ad3a14a340ad0815ddb96d599d2aff) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Add missing header to AbstractGenerateSbomTaskTests (#598) * Create template tool task (#600) * create template tool task * Make the tests successfully run * Include the SBOM CLI Tool to the .NET Framework package folder. * refactor code a little and address feedback @microsoft-github-policy-service agree company="Microsoft" --------- Co-authored-by: vpatakottu Co-authored-by: gustavoaca1997 * Run the Github build also for feature branches. * Implement SBOM CLI ToolTask (#607) * implement ToolTask * addressing feedback * addressing feedback pt. 2 --------- Co-authored-by: vpatakottu * Update System.Text.Json * Stop importing the props twice when referencing the Nuget package (#612) * Add tests for the MSBuild Full version of the Generate SBOM task (#613) * Bring changes from feautre branch * Make the Targets.Tests project also target .NET Framework * Remove props file * Implement tests for MSBuild Full version of the task * Simplify how the CLI tool is called from the tests. * Add test for file being in use. * Test the output of the ToolTask. * Change the name of AbstractGenerateSBomTaskInputTests to AbstractGenerateSbomTaskInputTests * Include .NET Framework output in Sbom_Generation_Succeeds_For_Null_Verbosity * Update src/Microsoft.Sbom.Targets/SbomCLIToolTask.cs Co-authored-by: Dave Tryon <45672944+DaveTryon@users.noreply.github.com> * Skip tests that are failing due to known issues * Add debug messages for the test pipeline * Fix .net core tests * Target .NET Framework only on Windows * Remove unnecessary comment. * Update default Verbosity. * Address comments. * Change name of AbstractGenerateSbomTaskInputTests * Address PR Comments --------- Co-authored-by: Dave Tryon <45672944+DaveTryon@users.noreply.github.com> * Update NuGet Package Format and Surface Errors (#619) * update nuget package format and surface errors * simplify sbom output * update targets to use SbomPath output var for ToolTask * fix bad merge * append manifest folder name for manifestdirpath * add path.combine and property checks * append platform version * create ManifestDirPath if needed * temporarily comment out * remove manifestdirpath logic for now * use path.combine and full path --------- Co-authored-by: vpatakottu * Add README for Microsoft.Sbom.Targets project (#651) * Adding readme * add code quotes --------- Co-authored-by: vpatakottu * Workaround for generating a SBOM manifest at the root level of the Nuget Package (#656) * Add buildMultiTargeting folder to the Nuget package * Unzip and Zip again for including the SBOM into the Nuget package. * Append GUID to the temporary unzipped folder. * Use Path.Combine for Unzip and Nupkg paths (#663) * Add E2E tests for Microsoft.Sbom.Targets project (#658) * add base setup for tests * updates to test * cleanup * Add more tests * update package version * cleanup * mini fix for copying sample project * add unloading step * create separate project for E2E tests * cleanup * rearrange method * cleanup * check for platform * try with locator * disable analyzers for sample project --------- Co-authored-by: vpatakottu * Remove GenerateSBOMTest project (#673) * Remove GenerateSBOMTest project * Remove N/A comment * Add ContinueOnError=ErrorAndContinue to the ZipDirectory, GenerateSBOM and Unzip (#672) * User/gustavoca/update with main (#675) * Bump Component Detection version (#624) * Bump Component Detection version * Bump NuGet Config and Framework versions * Raise dependabot PR limit (#629) * build(deps): bump stefanzweifel/git-auto-commit-action (#552) Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 5.0.0 to 5.0.1. - [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases) - [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/8756aa072ef5b4a080af5dc8fef36c5d586e521d...8621497c8c39c72f3e2a999a26b4ca1b5058a842) --- updated-dependencies: - dependency-name: stefanzweifel/git-auto-commit-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Dave Tryon <45672944+DaveTryon@users.noreply.github.com> * build(deps): bump Microsoft.NET.Test.Sdk from 17.7.2 to 17.10.0 (#630) Bumps [Microsoft.NET.Test.Sdk](https://github.com/microsoft/vstest) from 17.7.2 to 17.10.0. - [Release notes](https://github.com/microsoft/vstest/releases) - [Changelog](https://github.com/microsoft/vstest/blob/main/docs/releases.md) - [Commits](https://github.com/microsoft/vstest/compare/v17.7.2...v17.10.0) --- updated-dependencies: - dependency-name: Microsoft.NET.Test.Sdk dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump MSTest.TestAdapter from 3.1.1 to 3.5.0 (#644) Bumps [MSTest.TestAdapter](https://github.com/microsoft/testfx) from 3.1.1 to 3.5.0. - [Release notes](https://github.com/microsoft/testfx/releases) - [Changelog](https://github.com/microsoft/testfx/blob/main/docs/Changelog.md) - [Commits](https://github.com/microsoft/testfx/compare/v3.1.1...v3.5.0) --- updated-dependencies: - dependency-name: MSTest.TestAdapter dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump Spectre.Console.Cli from 0.48.0 to 0.49.1 (#637) Bumps [Spectre.Console.Cli](https://github.com/spectreconsole/spectre.console) from 0.48.0 to 0.49.1. - [Release notes](https://github.com/spectreconsole/spectre.console/releases) - [Commits](https://github.com/spectreconsole/spectre.console/compare/0.48.0...0.49.1) --- updated-dependencies: - dependency-name: Spectre.Console.Cli dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump github/codeql-action from 3.25.12 to 3.25.15 (#625) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.25.12 to 3.25.15. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/4fa2a7953630fd2f3fb380f21be14ede0169dd4f...afb54ba388a7dca6ecae48f608c4ff05ff4cc77a) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump MSTest.TestFramework from 3.1.1 to 3.5.0 (#642) Bumps [MSTest.TestFramework](https://github.com/microsoft/testfx) from 3.1.1 to 3.5.0. - [Release notes](https://github.com/microsoft/testfx/releases) - [Changelog](https://github.com/microsoft/testfx/blob/main/docs/Changelog.md) - [Commits](https://github.com/microsoft/testfx/compare/v3.1.1...v3.5.0) --- updated-dependencies: - dependency-name: MSTest.TestFramework dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump Microsoft.VisualStudio.Threading.Analyzers (#638) Bumps [Microsoft.VisualStudio.Threading.Analyzers](https://github.com/microsoft/vs-threading) from 17.7.30 to 17.10.48. - [Release notes](https://github.com/microsoft/vs-threading/releases) - [Commits](https://github.com/microsoft/vs-threading/compare/v17.7.30...v17.10.48) --- updated-dependencies: - dependency-name: Microsoft.VisualStudio.Threading.Analyzers dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump github/codeql-action from 3.25.15 to 3.26.0 (#654) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.25.15 to 3.26.0. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/afb54ba388a7dca6ecae48f608c4ff05ff4cc77a...eb055d739abdc2e8de2e5f4ba1a8b246daa779aa) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump MSTest.TestAdapter from 3.5.0 to 3.5.1 (#653) Bumps [MSTest.TestAdapter](https://github.com/microsoft/testfx) from 3.5.0 to 3.5.1. - [Release notes](https://github.com/microsoft/testfx/releases) - [Changelog](https://github.com/microsoft/testfx/blob/main/docs/Changelog.md) - [Commits](https://github.com/microsoft/testfx/compare/v3.5.0...v3.5.1) --- updated-dependencies: - dependency-name: MSTest.TestAdapter dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Sarah Oslund * build(deps): bump MSTest.TestFramework from 3.5.0 to 3.5.1 (#652) Bumps [MSTest.TestFramework](https://github.com/microsoft/testfx) from 3.5.0 to 3.5.1. - [Release notes](https://github.com/microsoft/testfx/releases) - [Changelog](https://github.com/microsoft/testfx/blob/main/docs/Changelog.md) - [Commits](https://github.com/microsoft/testfx/compare/v3.5.0...v3.5.1) --- updated-dependencies: - dependency-name: MSTest.TestFramework dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Sarah Oslund * build(deps): bump Moq from 4.17.2 to 4.20.70 (#640) Bumps [Moq](https://github.com/moq/moq) from 4.17.2 to 4.20.70. - [Release notes](https://github.com/moq/moq/releases) - [Changelog](https://github.com/devlooped/moq/blob/main/CHANGELOG.md) - [Commits](https://github.com/moq/moq/compare/v4.17.2...v4.20.70) --- updated-dependencies: - dependency-name: Moq dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump coverlet.collector from 6.0.0 to 6.0.2 (#641) Bumps [coverlet.collector](https://github.com/coverlet-coverage/coverlet) from 6.0.0 to 6.0.2. - [Release notes](https://github.com/coverlet-coverage/coverlet/releases) - [Commits](https://github.com/coverlet-coverage/coverlet/compare/v6.0.0...v6.0.2) --- updated-dependencies: - dependency-name: coverlet.collector dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump StyleCop.Analyzers (#636) Bumps [StyleCop.Analyzers](https://github.com/DotNetAnalyzers/StyleCopAnalyzers) from 1.2.0-beta.507 to 1.2.0-beta.556. - [Release notes](https://github.com/DotNetAnalyzers/StyleCopAnalyzers/releases) - [Changelog](https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/KnownChanges.md) - [Commits](https://github.com/DotNetAnalyzers/StyleCopAnalyzers/compare/1.2.0-beta.507...1.2.0-beta.556) --- updated-dependencies: - dependency-name: StyleCop.Analyzers dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump Microsoft.SourceLink.GitHub from 1.1.1 to 8.0.0 (#645) Bumps [Microsoft.SourceLink.GitHub](https://github.com/dotnet/sourcelink) from 1.1.1 to 8.0.0. - [Release notes](https://github.com/dotnet/sourcelink/releases) - [Commits](https://github.com/dotnet/sourcelink/compare/1.1.1...8.0.0) --- updated-dependencies: - dependency-name: Microsoft.SourceLink.GitHub dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump MinVer from 4.3.0 to 5.0.0 (#634) Bumps [MinVer](https://github.com/adamralph/minver) from 4.3.0 to 5.0.0. - [Changelog](https://github.com/adamralph/minver/blob/main/CHANGELOG.md) - [Commits](https://github.com/adamralph/minver/compare/4.3.0...5.0.0) --- updated-dependencies: - dependency-name: MinVer dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump Microsoft.Extensions.Http, Microsoft.Extensions.Logging.Abstractions and Microsoft.Extensions.DependencyInjection (#649) Bumps [Microsoft.Extensions.Http](https://github.com/dotnet/runtime), [Microsoft.Extensions.Logging.Abstractions](https://github.com/dotnet/runtime) and [Microsoft.Extensions.DependencyInjection](https://github.com/dotnet/runtime). These dependencies needed to be updated together. Updates `Microsoft.Extensions.Http` from 7.0.0 to 8.0.0 - [Release notes](https://github.com/dotnet/runtime/releases) - [Commits](https://github.com/dotnet/runtime/compare/v7.0.0...v8.0.0) Updates `Microsoft.Extensions.Logging.Abstractions` from 7.0.1 to 8.0.0 - [Release notes](https://github.com/dotnet/runtime/releases) - [Commits](https://github.com/dotnet/runtime/compare/v7.0.1...v8.0.0) Updates `Microsoft.Extensions.DependencyInjection` from 7.0.0 to 8.0.0 - [Release notes](https://github.com/dotnet/runtime/releases) - [Commits](https://github.com/dotnet/runtime/compare/v7.0.0...v8.0.0) --- updated-dependencies: - dependency-name: Microsoft.Extensions.Http dependency-type: direct:production update-type: version-update:semver-major - dependency-name: Microsoft.Extensions.Logging.Abstractions dependency-type: direct:production update-type: version-update:semver-major - dependency-name: Microsoft.Extensions.DependencyInjection dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump Microsoft.Extensions.Logging.Abstractions and Microsoft.Extensions.DependencyInjection.Abstractions (#650) Bumps [Microsoft.Extensions.Logging.Abstractions](https://github.com/dotnet/runtime) and [Microsoft.Extensions.DependencyInjection.Abstractions](https://github.com/dotnet/runtime). These dependencies needed to be updated together. Updates `Microsoft.Extensions.Logging.Abstractions` from 7.0.1 to 8.0.1 - [Release notes](https://github.com/dotnet/runtime/releases) - [Commits](https://github.com/dotnet/runtime/compare/v7.0.1...v8.0.1) Updates `Microsoft.Extensions.DependencyInjection.Abstractions` from 8.0.0 to 8.0.1 - [Release notes](https://github.com/dotnet/runtime/releases) - [Commits](https://github.com/dotnet/runtime/compare/v8.0.0...v8.0.1) --- updated-dependencies: - dependency-name: Microsoft.Extensions.Logging.Abstractions dependency-type: direct:production update-type: version-update:semver-major - dependency-name: Microsoft.Extensions.DependencyInjection.Abstractions dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump Scrutor from 4.2.0 to 4.2.2 (#646) Bumps [Scrutor](https://github.com/khellang/Scrutor) from 4.2.0 to 4.2.2. - [Release notes](https://github.com/khellang/Scrutor/releases) - [Commits](https://github.com/khellang/Scrutor/compare/v4.2.0...v4.2.2) --- updated-dependencies: - dependency-name: Scrutor dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Fix tests * Bump Microsoft.Extensions.Hosting --------- Signed-off-by: dependabot[bot] Co-authored-by: José Renan Co-authored-by: Dave Tryon <45672944+DaveTryon@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Sarah Oslund * Update README * Address Feedback (#679) * Address feedback and remove SbomPath * remove whitespace * remove comment --------- Co-authored-by: vpatakottu * address more feedback (#682) Co-authored-by: vpatakottu * Pack each project separately (#681) * Pack each project separately * Remove extra dotnet apck * Inspect the content of the Nuget package instead of extracting to disk during e2e tests. * User/gustavoca/dont extract e2e tests (#684) * Inspect the content of the Nuget package instead of extracting to disk during e2e tests. * Remove extra changes in Directory.Packages.Props * Remove instance of Newtonsoft.Json * Remove not needed Message * Revert "Remove instance of Newtonsoft.Json" This reverts commit 52329d44aa1b42029aedfeb8541acabb51c7c389. --------- Signed-off-by: dependabot[bot] Co-authored-by: Sarah Oslund Co-authored-by: vpatakottu <47004464+vpatakottu@users.noreply.github.com> Co-authored-by: vpatakottu Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Dave Tryon <45672944+DaveTryon@users.noreply.github.com> Co-authored-by: José Renan --- .gitignore | 1 + Directory.Packages.props | 8 +- Microsoft.Sbom.sln | 18 + nuget.config | 4 +- pipelines/sbom-tool-main-build.yaml | 2 +- src/Microsoft.Sbom.Targets/GenerateSbom.cs | 102 ++++ .../GenerateSbomTask.cs | 125 +++++ .../Microsoft.Sbom.Targets.csproj | 84 ++++ .../Microsoft.Sbom.Targets.targets | 79 +++ src/Microsoft.Sbom.Targets/README.md | 53 ++ src/Microsoft.Sbom.Targets/SbomCLIToolTask.cs | 103 ++++ .../SbomInputValidator.cs | 127 +++++ .../GenerateSbomE2ETests.cs | 334 +++++++++++++ .../Microsoft.Sbom.Targets.E2E.Tests.csproj | 62 +++ .../ProjectSample1/ProjectSample1.csproj | 21 + .../ProjectSample1/SampleLibrary.cs | 7 + .../AbstractGenerateSbomTaskInputTests.cs | 375 +++++++++++++++ .../AbstractGenerateSbomTaskTests.cs | 454 ++++++++++++++++++ .../GenerateSbomTaskSPDX_2_2InputTests.cs | 15 + .../GenerateSbomTaskSPDX_2_2Tests.cs | 22 + .../Microsoft.Sbom.Targets.Tests.csproj | 41 ++ .../Utility/GeneratedSbomValidator.cs | 138 ++++++ 22 files changed, 2171 insertions(+), 4 deletions(-) create mode 100644 src/Microsoft.Sbom.Targets/GenerateSbom.cs create mode 100644 src/Microsoft.Sbom.Targets/GenerateSbomTask.cs create mode 100644 src/Microsoft.Sbom.Targets/Microsoft.Sbom.Targets.csproj create mode 100644 src/Microsoft.Sbom.Targets/Microsoft.Sbom.Targets.targets create mode 100644 src/Microsoft.Sbom.Targets/README.md create mode 100644 src/Microsoft.Sbom.Targets/SbomCLIToolTask.cs create mode 100644 src/Microsoft.Sbom.Targets/SbomInputValidator.cs create mode 100644 test/Microsoft.Sbom.Targets.E2E.Tests/GenerateSbomE2ETests.cs create mode 100644 test/Microsoft.Sbom.Targets.E2E.Tests/Microsoft.Sbom.Targets.E2E.Tests.csproj create mode 100644 test/Microsoft.Sbom.Targets.E2E.Tests/ProjectSamples/ProjectSample1/ProjectSample1.csproj create mode 100644 test/Microsoft.Sbom.Targets.E2E.Tests/ProjectSamples/ProjectSample1/SampleLibrary.cs create mode 100644 test/Microsoft.Sbom.Targets.Tests/AbstractGenerateSbomTaskInputTests.cs create mode 100644 test/Microsoft.Sbom.Targets.Tests/AbstractGenerateSbomTaskTests.cs create mode 100644 test/Microsoft.Sbom.Targets.Tests/GenerateSbomTaskSPDX_2_2InputTests.cs create mode 100644 test/Microsoft.Sbom.Targets.Tests/GenerateSbomTaskSPDX_2_2Tests.cs create mode 100644 test/Microsoft.Sbom.Targets.Tests/Microsoft.Sbom.Targets.Tests.csproj create mode 100644 test/Microsoft.Sbom.Targets.Tests/Utility/GeneratedSbomValidator.cs diff --git a/.gitignore b/.gitignore index 94f3f90dc..7f5dec04f 100644 --- a/.gitignore +++ b/.gitignore @@ -189,6 +189,7 @@ PublishScripts/ # NuGet Packages *.nupkg +*.nuspec # NuGet Symbol Packages *.snupkg # The packages folder can be ignored because of Package Restore diff --git a/Directory.Packages.props b/Directory.Packages.props index 1eea610a5..8c75642e4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,11 @@ + + + + + @@ -22,7 +27,7 @@ - + @@ -44,6 +49,7 @@ + diff --git a/Microsoft.Sbom.sln b/Microsoft.Sbom.sln index 199c973ba..643283e93 100644 --- a/Microsoft.Sbom.sln +++ b/Microsoft.Sbom.sln @@ -49,8 +49,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Sbom.Extensions.D EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Sbom.Extensions.DependencyInjection.Tests", "test\Microsoft.Sbom.Extensions.DependencyInjection.Tests\Microsoft.Sbom.Extensions.DependencyInjection.Tests.csproj", "{EE4E2E03-7B4C-46E5-B9D2-89E84A18D787}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Sbom.Targets", "src\Microsoft.Sbom.Targets\Microsoft.Sbom.Targets.csproj", "{E6C3C851-EEA0-466E-BA36-73ED85F13EEA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Sbom.Targets.Tests", "test\Microsoft.Sbom.Targets.Tests\Microsoft.Sbom.Targets.Tests.csproj", "{E31B914C-F24B-4DC8-ACC7-CAEA952563B8}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Sbom.Tool.Tests", "test\Microsoft.Sbom.Tool.Tests\Microsoft.Sbom.Tool.Tests.csproj", "{FC5A9799-7C44-4BFA-BA22-55DCAF1A1B9F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Sbom.Targets.E2E.Tests", "test\Microsoft.Sbom.Targets.E2E.Tests\Microsoft.Sbom.Targets.E2E.Tests.csproj", "{3FDE7800-F61F-4C45-93AB-648A4C7979C7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -109,10 +115,22 @@ Global {EE4E2E03-7B4C-46E5-B9D2-89E84A18D787}.Debug|Any CPU.Build.0 = Debug|Any CPU {EE4E2E03-7B4C-46E5-B9D2-89E84A18D787}.Release|Any CPU.ActiveCfg = Release|Any CPU {EE4E2E03-7B4C-46E5-B9D2-89E84A18D787}.Release|Any CPU.Build.0 = Release|Any CPU + {E6C3C851-EEA0-466E-BA36-73ED85F13EEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6C3C851-EEA0-466E-BA36-73ED85F13EEA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6C3C851-EEA0-466E-BA36-73ED85F13EEA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6C3C851-EEA0-466E-BA36-73ED85F13EEA}.Release|Any CPU.Build.0 = Release|Any CPU + {E31B914C-F24B-4DC8-ACC7-CAEA952563B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E31B914C-F24B-4DC8-ACC7-CAEA952563B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E31B914C-F24B-4DC8-ACC7-CAEA952563B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E31B914C-F24B-4DC8-ACC7-CAEA952563B8}.Release|Any CPU.Build.0 = Release|Any CPU {FC5A9799-7C44-4BFA-BA22-55DCAF1A1B9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FC5A9799-7C44-4BFA-BA22-55DCAF1A1B9F}.Debug|Any CPU.Build.0 = Debug|Any CPU {FC5A9799-7C44-4BFA-BA22-55DCAF1A1B9F}.Release|Any CPU.ActiveCfg = Release|Any CPU {FC5A9799-7C44-4BFA-BA22-55DCAF1A1B9F}.Release|Any CPU.Build.0 = Release|Any CPU + {3FDE7800-F61F-4C45-93AB-648A4C7979C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3FDE7800-F61F-4C45-93AB-648A4C7979C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FDE7800-F61F-4C45-93AB-648A4C7979C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3FDE7800-F61F-4C45-93AB-648A4C7979C7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/nuget.config b/nuget.config index 248a5bb51..765346e53 100644 --- a/nuget.config +++ b/nuget.config @@ -1,7 +1,7 @@ - + - \ No newline at end of file + diff --git a/pipelines/sbom-tool-main-build.yaml b/pipelines/sbom-tool-main-build.yaml index 22593f0ed..ed5733ba0 100644 --- a/pipelines/sbom-tool-main-build.yaml +++ b/pipelines/sbom-tool-main-build.yaml @@ -105,7 +105,7 @@ extends: ] condition: and(succeeded(), startswith(variables['Build.SourceBranch'], 'refs/tags/')) - - powershell: 'dotnet pack Microsoft.Sbom.sln -c $(BuildConfiguration) --no-restore --no-build -o $(Build.ArtifactStagingDirectory)/nuget --include-symbols -p:SymbolPackageFormat=snupkg' + - powershell: 'Get-ChildItem -Recurse -Filter *.csproj -Path src | ForEach-Object { dotnet pack $_.FullName -c $(BuildConfiguration) --no-restore --no-build -o $(Build.ArtifactStagingDirectory)/nuget --include-symbols -p:SymbolPackageFormat=snupkg }' displayName: 'Pack NuGet package' - task: SFP.build-tasks.custom-build-task-1.EsrpCodeSigning@3 diff --git a/src/Microsoft.Sbom.Targets/GenerateSbom.cs b/src/Microsoft.Sbom.Targets/GenerateSbom.cs new file mode 100644 index 000000000..0bdd6ff5f --- /dev/null +++ b/src/Microsoft.Sbom.Targets/GenerateSbom.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Sbom.Targets; + +using System; +using System.Collections.Generic; +using System.Diagnostics.Tracing; +using Microsoft.Build.Framework; + +/// +/// This partial class defines and sanitizes the arguments that will be passed +/// into the SBOM API and CLI tool for generation. +/// +public partial class GenerateSbom +{ + /// + /// Gets or sets the path to the drop directory for which the SBOM will be generated. + /// + [Required] + public string BuildDropPath { get; set; } + + /// + /// Gets or sets the supplier of the package the SBOM represents. + /// + [Required] + public string PackageSupplier { get; set; } + + /// + /// Gets or sets the name of the package the SBOM represents. + /// + [Required] + public string PackageName { get; set; } + + /// + /// Gets or sets the version of the package the SBOM represents. + /// + [Required] + public string PackageVersion { get; set; } + + /// + /// Gets or sets the base path of the SBOM namespace uri. + /// + [Required] + public string NamespaceBaseUri { get; set; } + + /// + /// Gets or sets the path to the directory containing build components and package information. + /// For example, path to a .csproj or packages.config file. + /// + public string BuildComponentPath { get; set; } + + /// + /// Gets or sets a unique URI part that will be appended to NamespaceBaseUri. + /// + public string NamespaceUriUniquePart { get; set; } + + /// + /// Gets or sets the path to a file containing a list of external SBOMs that will be appended to the + /// SBOM that is being generated. + /// + public string ExternalDocumentListFile { get; set; } + + /// + /// Indicates whether licensing information will be fetched for detected packages. + /// + public bool FetchLicenseInformation { get; set; } + + /// + /// Indicates whether to parse licensing and supplier information from a packages metadata file. + /// + public bool EnablePackageMetadataParsing { get; set; } + + /// + /// Gets or sets the verbosity level for logging output. + /// + public string Verbosity { get; set; } + + /// + /// Gets or sets a list of names and versions of the manifest format being used. + /// + public string ManifestInfo { get; set; } + + /// + /// Indicates whether the previously generated SBOM manifest directory should be deleted + /// before generating a new SBOM in the directory specified by ManifestDirPath. + /// Defaults to true. + /// + public bool DeleteManifestDirIfPresent { get; set; } = true; + + /// + /// Gets or sets the path where the SBOM will be generated. For now, this property + /// will be unset as the _manifest directory is intended to be at the root of a NuGet package + /// specified by BuildDropPath. + /// + public string ManifestDirPath { get; set; } + + /// + /// Gets or sets the path to the SBOM CLI tool + /// + public string SbomToolPath { get; set; } +} diff --git a/src/Microsoft.Sbom.Targets/GenerateSbomTask.cs b/src/Microsoft.Sbom.Targets/GenerateSbomTask.cs new file mode 100644 index 000000000..b817ea3bd --- /dev/null +++ b/src/Microsoft.Sbom.Targets/GenerateSbomTask.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Sbom.Targets; + +using System; +using System.Collections.Generic; +using System.Diagnostics.Tracing; +using System.IO; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Sbom.Api.Manifest.ManifestConfigHandlers; +using Microsoft.Sbom.Api.Metadata; +using Microsoft.Sbom.Api.Providers; +using Microsoft.Sbom.Api.Providers.ExternalDocumentReferenceProviders; +using Microsoft.Sbom.Api.Providers.FilesProviders; +using Microsoft.Sbom.Api.Providers.PackagesProviders; +using Microsoft.Sbom.Contracts; +using Microsoft.Sbom.Contracts.Entities; +using Microsoft.Sbom.Contracts.Interfaces; +using Microsoft.Sbom.Extensions; +using Microsoft.Sbom.Extensions.DependencyInjection; +using Microsoft.Sbom.Parsers.Spdx22SbomParser; + +/// +/// MSBuild task for generating SBOMs from build output. +/// +public partial class GenerateSbom : Task +{ + private ISBOMGenerator Generator { get; set; } + + /// + /// Constructor for the GenerateSbomTask. + /// + public GenerateSbom() + { + var host = Host.CreateDefaultBuilder() + .ConfigureServices((host, services) => + services + .AddSbomTool() + /* Manually adding some dependencies since `AddSbomTool()` does not add them when + * running the MSBuild Task from another project. + */ + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton()) + .Build(); + this.Generator = host.Services.GetRequiredService(); + } + + /// + public override bool Execute() + { + try + { + // Validate required args and args that take paths as input. + if (!ValidateAndSanitizeRequiredParams() || !ValidateAndSanitizeNamespaceUriUniquePart()) + { + return false; + } + + // Set other configurations. The GenerateSBOMAsync() already sanitizes and checks for + // a valid namespace URI and generates a random guid for NamespaceUriUniquePart if + // one is not provided. + var sbomMetadata = new SBOMMetadata + { + PackageSupplier = this.PackageSupplier, + PackageName = this.PackageName, + PackageVersion = this.PackageVersion, + }; + var runtimeConfiguration = new RuntimeConfiguration + { + NamespaceUriBase = this.NamespaceBaseUri, + NamespaceUriUniquePart = this.NamespaceUriUniquePart, + DeleteManifestDirectoryIfPresent = this.DeleteManifestDirIfPresent, + Verbosity = ValidateAndAssignVerbosity(), + }; +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + var result = System.Threading.Tasks.Task.Run(() => this.Generator.GenerateSbomAsync( + rootPath: this.BuildDropPath, + manifestDirPath: this.ManifestDirPath, + metadata: sbomMetadata, + componentPath: this.BuildComponentPath, + runtimeConfiguration: runtimeConfiguration, + specifications: ValidateAndAssignSpecifications(), + externalDocumentReferenceListFile: this.ExternalDocumentListFile)).GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + + return result.IsSuccessful; + } + catch (Exception e) + { + Log.LogError($"SBOM generation failed: {e.Message}"); + return false; + } + } + + /// + /// Check for ManifestInfo and create an SbomSpecification accordingly. + /// + /// A list of the parsed manifest info. Null if the manifest info is null or empty. + private IList ValidateAndAssignSpecifications() + { + if (!string.IsNullOrWhiteSpace(this.ManifestInfo)) + { + return [SbomSpecification.Parse(this.ManifestInfo)]; + } + + return null; + } +} diff --git a/src/Microsoft.Sbom.Targets/Microsoft.Sbom.Targets.csproj b/src/Microsoft.Sbom.Targets/Microsoft.Sbom.Targets.csproj new file mode 100644 index 000000000..bcdcdf15c --- /dev/null +++ b/src/Microsoft.Sbom.Targets/Microsoft.Sbom.Targets.csproj @@ -0,0 +1,84 @@ + + + + Microsoft.Sbom.Targets + net6.0;net8.0;net472 + win-x64;osx-x64;linux-x64 + true + true + true + 1.0.0 + GenerateSbomTask + Tasks and targets for running the SBOM tool. + true + net8.0 + + + + $(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage + + + tasks + true + + NU5100 + + true + true + + + + + + + + + + + + + + + + + + + + + + + Always + true + \tasks\net472\sbom-tool\ + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Microsoft.Sbom.Targets/Microsoft.Sbom.Targets.targets b/src/Microsoft.Sbom.Targets/Microsoft.Sbom.Targets.targets new file mode 100644 index 000000000..12e9bd691 --- /dev/null +++ b/src/Microsoft.Sbom.Targets/Microsoft.Sbom.Targets.targets @@ -0,0 +1,79 @@ + + + net472 + net8.0 + + $([System.IO.Path]::Combine($(MSBuildThisFileDirectory),..,tasks,$(GenerateSbom_TFM),sbom-tool)) + $([System.IO.Path]::Combine($(MSBuildThisFileDirectory),..,tasks,$(GenerateSbom_TFM),Microsoft.Sbom.Targets.dll)) + + + $(SbomToolBinaryOutputPath) + _manifest + spdx_2.2 + + + + + + + false + $(MSBuildProjectDirectory) + $(Authors) + $(AssemblyName) + $(PackageId) + $(AssemblyName) + $(Version) + 1.0.0 + http://spdx.org/spdxdocs/$(SbomGenerationPackageName) + false + false + information + SPDX:2.2 + true + $([System.Guid]::NewGuid()) + $([System.String]::Copy('$(UnzipGuid)').Substring(0, 8)) + + + + + + + $([System.IO.Path]::GetFullPath('$(PackageOutputPath)')) + + + $([System.IO.Path]::Combine($(PackageOutputFullPath), $(PackageId).$(PackageVersion).nupkg)) + + + $([System.IO.Path]::Combine($(PackageOutputFullPath), $(PackageId).$(PackageVersion).$(ShortUnzipGuidFolder).temp)) + + + + + + + + + + + + + diff --git a/src/Microsoft.Sbom.Targets/README.md b/src/Microsoft.Sbom.Targets/README.md new file mode 100644 index 000000000..df74d50b9 --- /dev/null +++ b/src/Microsoft.Sbom.Targets/README.md @@ -0,0 +1,53 @@ +# SBOM Generation for .NET Projects +## Microsoft.Sbom.Targets +This project implements a custom MSBuild task that generates an SBOM using the SBOM API and CLI tool. The MSBuild task binaries along with the associated targets are packaged as a NuGet package and can be consumed within a .NET project. Once installed, an SBOM will automatically be generated upon packing the .NET project. + +## MSBuild Task Implementation +The custom MSBuild task is implemented across the following partial classes: +- `GenerateSbom.cs` +- `GenerateSbomTask.cs` +- `SbomCLIToolTask.cs` +- `SbomInputValidator.cs` + +Due to differences in [MSBuild versions](https://learn.microsoft.com/en-us/visualstudio/msbuild/tutorial-custom-task-code-generation?view=vs-2022#create-the-appsettingstronglytyped-project) between Visual Studio and the .Net Core CLI tool, the SBOM generation logic needed to be split into two parts: + +1) `GenerateSbomTask.cs` is invoked if the MSBuild version targets the "Core" (.NET Core) runtime bundled with the .NET Core CLI tool. This class utilizes the SBOM API to generate an SBOM. + +2) `SbomCLIToolTask.cs` is invoked if the MSBuild version targets the "Full" (.NET Framework) runtime bundled with Visual Studio. Because the SBOM API does not support .NET Framework, this class utilizes the SBOM CLI Tool to generate an SBOM. + +Finally, the `Microsoft.Sbom.Targets.targets` file creates a target that will execute the custom MSBuild task. This file will be automatically imported when consuming the NuGet package. + +## SBOM Generation Properties +The custom MSBuild task accepts most of the arguments available for the [SBOM CLI Tool](../../docs/sbom-tool-arguments.md). After the .targets file is imported into a .NET project, the following properties can be set: + +| Property | Default Value | Required | +|-----------------------------------------------------|-------------|---------| +| `` | `false` | No. To enable SBOM generation, set this to true. | +| `` | `$(MSBuildProjectDirectory)` | No | +| `` | `$(Authors)`. If `$(Authors)` is null, it will set `$(AssemblyName)` | Yes | +| `` | `$(PackageId)`. If `$(PackageId)` is null, it will set `$(AssemblyName)` | Yes | +| `` | `$(Version)`. If `$(Version)` is null, it will set "1.0.0" | Yes | +| `` | `http://spdx.org/spdxdocs/$(SbomGenerationPackageName)` | Yes | +| `` | N/A | No | +| `` | N/A | No | +| `` | `false` | No | +| `` | `false` | No | +| `` | `Information` | No | +| `` | `SPDX:2.2` | No | +| `` | `true` | No | + +## Local SBOM Generation Workflow +After building the Microsoft.Sbom.Targets project, it will generate a NuGet package containing the MSBuild task's binaries and associated .targets file in the `bin\$(Configuration)` folder. The following steps describe how to consume this NuGet package and generate an SBOM: + +1) Create a sample .NET project. +2) Open the project's NuGet package manager. +3) Add the path to the Microsoft.Sbom.Targets NuGet package as a package source. You can name it "Local". +4) Look for the Microsoft.Sbom.Targets package within the package manager and install it. +5) Add the following to your sample project's .csproj file: +``` + + true + +``` +6) Build the sample project. +7) Pack the sample project. The SBOM will be generated under the `_manifest` folder at the root of the NuGet package. diff --git a/src/Microsoft.Sbom.Targets/SbomCLIToolTask.cs b/src/Microsoft.Sbom.Targets/SbomCLIToolTask.cs new file mode 100644 index 000000000..396563dd1 --- /dev/null +++ b/src/Microsoft.Sbom.Targets/SbomCLIToolTask.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Sbom.Targets; + +using System; +using System.IO; +using Microsoft.Build.Utilities; + +/// +/// MSBuild ToolTask for generating an SBOM using the SBOM CLI tool +/// +public partial class GenerateSbom : ToolTask +{ + protected override string ToolName => "Microsoft.Sbom.Tool"; + + /// + /// Get full path to SBOM CLI tool. + /// + /// + protected override string GenerateFullPathToTool() + { + return Path.Combine(this.SbomToolPath, $"{this.ToolName}.exe"); + } + + /// + /// Return a formatted list of arguments for the SBOM CLI tool. + /// + /// string list of args + protected override string GenerateCommandLineCommands() + { + var builder = new CommandLineBuilder(); + + builder.AppendSwitch("generate"); + builder.AppendSwitchIfNotNull("-BuildDropPath ", this.BuildDropPath); + builder.AppendSwitchIfNotNull("-BuildComponentPath ", this.BuildComponentPath); + builder.AppendSwitchIfNotNull("-PackageName ", this.PackageName); + builder.AppendSwitchIfNotNull("-PackageVersion ", this.PackageVersion); + builder.AppendSwitchIfNotNull("-PackageSupplier ", this.PackageSupplier); + builder.AppendSwitchIfNotNull("-NamespaceUriBase ", this.NamespaceBaseUri); + builder.AppendSwitchIfNotNull("-DeleteManifestDirIfPresent ", $"{this.DeleteManifestDirIfPresent}"); + builder.AppendSwitchIfNotNull("-FetchLicenseInformation ", $"{this.FetchLicenseInformation}"); + builder.AppendSwitchIfNotNull("-EnablePackageMetadataParsing ", $"{this.EnablePackageMetadataParsing}"); + builder.AppendSwitchIfNotNull("-Verbosity ", this.Verbosity); + + // For optional arguments, append them only if they are specified by the user + if (!string.IsNullOrWhiteSpace(this.ManifestDirPath)) + { + builder.AppendSwitchIfNotNull("-ManifestDirPath ", this.ManifestDirPath); + } + + if (!string.IsNullOrWhiteSpace(this.ExternalDocumentListFile)) + { + builder.AppendSwitchIfNotNull("-ExternalDocumentListFile ", this.ExternalDocumentListFile); + } + + if (!string.IsNullOrWhiteSpace(this.NamespaceUriUniquePart)) + { + builder.AppendSwitchIfNotNull("-NamespaceUriUniquePart ", this.NamespaceUriUniquePart); + } + + if (!string.IsNullOrWhiteSpace(this.ManifestInfo)) + { + builder.AppendSwitchIfNotNull("-ManifestInfo ", this.ManifestInfo); + } + + return builder.ToString(); + } + + /// + /// Validates the SBOM CLI tool parameters + /// + /// + protected override bool ValidateParameters() + { + // Validate required args and args that take paths as input. + if (!ValidateAndSanitizeRequiredParams() || !ValidateAndSanitizeNamespaceUriUniquePart()) + { + return false; + } + + ValidateAndAssignVerbosity(); + SetOutputImportance(); + return true; + } + + /// + /// This method sets the standard output importance. Setting + /// it to "High" ensures all output from the SBOM CLI is printed to + /// Visual Studio's output console; otherwise, it is hidden. + /// + private void SetOutputImportance() + { + this.StandardOutputImportance = "High"; + + if (this.Verbosity.ToLower().Equals("fatal")) + { + this.StandardOutputImportance = "Low"; + } + + this.LogStandardErrorAsError = true; + } +} diff --git a/src/Microsoft.Sbom.Targets/SbomInputValidator.cs b/src/Microsoft.Sbom.Targets/SbomInputValidator.cs new file mode 100644 index 000000000..747595eed --- /dev/null +++ b/src/Microsoft.Sbom.Targets/SbomInputValidator.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Sbom.Targets; + +using System; +using System.Collections.Generic; +using System.Diagnostics.Tracing; + +/// +/// Validation class used to sanitize and validate arguments passed into +/// the GenerateSbomTask and SbomCLIToolTask +/// +public partial class GenerateSbom +{ + private const string DefaultVerbosity = "Information"; + private const EventLevel DefaultEventLevel = EventLevel.Informational; + + /// + /// Ensure all required arguments are non-null/empty, + /// and do not contain whitespaces, tabs, or newline characters. + /// + /// True if the required parameters are valid. False otherwise. + public bool ValidateAndSanitizeRequiredParams() + { + var requiredProperties = new Dictionary + { + { nameof(this.BuildDropPath), this.BuildDropPath }, + { nameof(this.PackageSupplier), this.PackageSupplier }, + { nameof(this.PackageName), this.PackageName }, + { nameof(this.PackageVersion), this.PackageVersion }, + { nameof(this.NamespaceBaseUri), this.NamespaceBaseUri } + }; + + foreach (var property in requiredProperties) + { + if (string.IsNullOrWhiteSpace(property.Value)) + { + Log.LogError($"SBOM generation failed: Empty argument detected for {property.Key}. Please provide a valid value."); + return false; + } + } + + this.PackageSupplier = Remove_Spaces_Tabs_Newlines(this.PackageSupplier); + this.PackageName = Remove_Spaces_Tabs_Newlines(this.PackageName); + this.PackageVersion = Remove_Spaces_Tabs_Newlines(this.PackageVersion); + this.NamespaceBaseUri = this.NamespaceBaseUri.Trim(); + this.BuildDropPath = this.BuildDropPath.Trim(); + + return true; + } + + public string Remove_Spaces_Tabs_Newlines(string value) + { + return value.Replace("\n", string.Empty).Replace("\t", string.Empty).Replace(" ", string.Empty); + } + + /// + /// Checks the user's input for Verbosity and assigns the + /// associated EventLevel value for logging. The SBOM API accepts + /// an EventLevel for verbosity while the CLI accepts LogEventLevel. + /// + public EventLevel ValidateAndAssignVerbosity() + { + // The following shows the accepted verbosity inputs for the SBOM CLI and API respectively + // ********************************* + // The SBOM CLI | The SBOM API | + // ********************************* + // Verbose | EventLevel.Verbose + // Debug | EventLevel.LogAlways + // Information | EventLevel.Informational + // Warning | EventLevel.Warning + // Error | EventLevel.Error + // Fatal | EventLevel.Critical + + // We should standardize on the SBOM CLI verbosity inputs and convert them to the associated + // EventLevel value for the API. + if (string.IsNullOrWhiteSpace(this.Verbosity)) + { + Log.LogWarning($"No verbosity level specified. Setting verbosity level at {DefaultVerbosity}."); + this.Verbosity = DefaultVerbosity; + return DefaultEventLevel; + } + + switch (this.Verbosity.ToLower().Trim()) + { + case "verbose": + return EventLevel.Verbose; + case "debug": + return EventLevel.Verbose; + case "information": + return EventLevel.Informational; + case "warning": + return EventLevel.Warning; + case "error": + return EventLevel.Error; + case "fatal": + return EventLevel.Critical; + default: + Log.LogWarning($"Unrecognized verbosity level specified. Setting verbosity level at {DefaultVerbosity}."); + this.Verbosity = DefaultVerbosity; + return DefaultEventLevel; + } + } + + /// + /// Ensure a valid NamespaceUriUniquePart is provided. + /// + /// True if the Namespace URI unique part is valid. False otherwise. + public bool ValidateAndSanitizeNamespaceUriUniquePart() + { + // Ensure the NamespaceUriUniquePart is valid if provided. + if (!string.IsNullOrWhiteSpace(this.NamespaceUriUniquePart) + && (!Guid.TryParse(this.NamespaceUriUniquePart, out _) + || this.NamespaceUriUniquePart.Equals(Guid.Empty.ToString()))) + { + Log.LogError($"SBOM generation failed: NamespaceUriUniquePart '{this.NamespaceUriUniquePart}' must be a valid unique GUID."); + return false; + } + else if (!string.IsNullOrWhiteSpace(this.NamespaceUriUniquePart)) + { + this.NamespaceUriUniquePart = this.NamespaceUriUniquePart.Trim(); + } + + return true; + } +} diff --git a/test/Microsoft.Sbom.Targets.E2E.Tests/GenerateSbomE2ETests.cs b/test/Microsoft.Sbom.Targets.E2E.Tests/GenerateSbomE2ETests.cs new file mode 100644 index 000000000..297577bda --- /dev/null +++ b/test/Microsoft.Sbom.Targets.E2E.Tests/GenerateSbomE2ETests.cs @@ -0,0 +1,334 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Sbom.Targets.E2E.Tests; + +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Runtime.InteropServices; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Locator; +using Microsoft.Build.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public class GenerateSbomE2ETests +{ + /* + * The following tests validate the end-to-end workflow for importing the Microsoft.Sbom.Targets.targets + * into a .NET project, building it, packing it, and validating the generated SBOM contents. + * + * NOTE: These tests are only compatible with net6.0 and net472, as there are issues when resolving NuGet assemblies when + * targeting net8.0. + */ + private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + private static string projectDirectory = Path.Combine(Directory.GetCurrentDirectory(), "ProjectSamples", "ProjectSample1"); + private static string sbomToolPath = Path.Combine(Directory.GetCurrentDirectory(), "sbom-tool"); + private static string generateSbomTaskPath = Path.Combine(Directory.GetCurrentDirectory(), "Microsoft.Sbom.Targets.dll"); + + private static string sbomSpecificationName = "SPDX"; + private static string sbomSpecificationVersion = "2.2"; + private static string sbomSpecificationDirectoryName = $"{sbomSpecificationName}_{sbomSpecificationVersion}".ToLowerInvariant(); + private string expectedPackageName; + private string expectedVersion; + private string expectedSupplier; + private string assemblyName; + private string expectedNamespace; + private string configuration; + + [TestInitialize] + public void SetupLocator() + { + if (MSBuildLocator.CanRegister) + { + MSBuildLocator.RegisterDefaults(); + } + } + + [TestCleanup] + public void CleanOutputFolders() + { + var binDir = Path.Combine(projectDirectory, "bin"); + var objDir = Path.Combine(projectDirectory, "obj"); + + try + { + if (Directory.Exists(binDir)) + { + Directory.Delete(binDir, true); + } + + if (Directory.Exists(objDir)) + { + Directory.Delete(objDir, true); + } + + ProjectCollection.GlobalProjectCollection.UnloadAllProjects(); + } + catch (Exception ex) + { + Assert.Fail($"Failed to cleanup output directories. {ex}"); + } + } + + private Project SetupSampleProject() + { + // Create a Project object for ProjectSample1 + var projectFile = Path.Combine(projectDirectory, "ProjectSample1.csproj"); + var sampleProject = new Project(projectFile); + + // Get all the expected default properties + SetDefaultProperties(sampleProject); + + // Set the TargetFrameworks property to empty. By default, it sets this property to net6.0 and net8.0, which fails for net8.0 builds. + sampleProject.SetProperty("TargetFrameworks", string.Empty); + + // Set the paths to the sbom-tool CLI tool and Microsoft.Sbom.Targets.dll + sampleProject.SetProperty("SbomToolBinaryOutputPath", sbomToolPath); + sampleProject.SetProperty("GenerateSbomTaskAssemblyFilePath", generateSbomTaskPath); + + return sampleProject; + } + + private void SetDefaultProperties(Project sampleProject) + { + expectedPackageName = sampleProject.GetPropertyValue("PackageId"); + expectedVersion = sampleProject.GetPropertyValue("Version"); + assemblyName = sampleProject.GetPropertyValue("AssemblyName"); + configuration = sampleProject.GetPropertyValue("Configuration"); + + if (string.IsNullOrEmpty(expectedPackageName)) + { + expectedPackageName = assemblyName; + } + + if (string.IsNullOrEmpty(expectedPackageName)) + { + expectedVersion = "1.0.0"; + } + } + + private void RestoreBuildPack(Project sampleProject) + { + var logger = new ConsoleLogger(); + + // Restore the project to create project.assets.json file + var restore = sampleProject.Build("Restore", new[] { logger }); + Assert.IsTrue(restore, "Failed to restore the project"); + + // Next, build the project + var build = sampleProject.Build(logger); + Assert.IsTrue(build, "Failed to build the project"); + + // Finally, pack the project + var pack = sampleProject.Build("Pack", new[] { logger }); + Assert.IsTrue(pack, "Failed to pack the project"); + } + + private void InspectPackageIsWellFormed(bool isManifestPathGenerated = true) + { + const string backSlash = "\\"; + const string forwardSlash = "/"; + // Unzip the contents of the NuGet package + var nupkgPath = Path.Combine(projectDirectory, "bin", configuration); + var nupkgFile = Path.Combine(nupkgPath, $"{expectedPackageName}.{expectedVersion}.nupkg"); + var manifestRelativePath = Path.Combine("_manifest", sbomSpecificationDirectoryName, "manifest.spdx.json") + .Replace(backSlash, forwardSlash); + + // Check the content of the NuGet package + using (var archive = ZipFile.Open(nupkgFile, ZipArchiveMode.Read)) + { + Assert.IsTrue(archive.Entries.Count() > 0); + // Nuget's zip code expects forward slashes as path separators. + Assert.IsTrue(archive.Entries.All(entry => !entry.FullName.Contains(backSlash))); + Assert.AreEqual(isManifestPathGenerated, archive.Entries.Any(entry => entry.FullName.Equals(manifestRelativePath))); + } + } + + [TestMethod] + public void SbomGenerationSucceedsForDefaultProperties() + { + if (!IsWindows) + { + Assert.Inconclusive("This test is not (yet) supported on non-Windows platforms."); + return; + } + + // Create and setup a Project object for ProjectSample1 + var sampleProject = SetupSampleProject(); + + // Restore, build, and pack the project + RestoreBuildPack(sampleProject); + + // Extract the NuGet package + InspectPackageIsWellFormed(); + } + + [TestMethod] + public void SbomGenerationSucceedsForValidNamespaceBaseUriUniquePart() + { + if (!IsWindows) + { + Assert.Inconclusive("This test is not (yet) supported on non-Windows platforms."); + return; + } + + // Create and setup a Project object for ProjectSample1 + var sampleProject = SetupSampleProject(); + + // Manually set the NamespaceUriUniquePart + var namespaceUriUniquePart = Guid.NewGuid().ToString(); + sampleProject.SetProperty("SbomGenerationNamespaceUriUniquePart", namespaceUriUniquePart); + + // Restore, build, and pack the project + RestoreBuildPack(sampleProject); + + // Extract the NuGet package + InspectPackageIsWellFormed(); + } + + [TestMethod] + public void SbomGenerationSucceedsForValidRequiredParams() + { + if (!IsWindows) + { + Assert.Inconclusive("This test is not (yet) supported on non-Windows platforms."); + return; + } + + // Create and setup a Project object for ProjectSample1 + var sampleProject = SetupSampleProject(); + + // Set require params + expectedPackageName = "SampleName"; + expectedVersion = "3.2.5"; + expectedSupplier = "SampleSupplier"; + expectedNamespace = "https://example.com"; + + sampleProject.SetProperty("PackageId", expectedPackageName); + sampleProject.SetProperty("Version", expectedVersion); + sampleProject.SetProperty("SbomGenerationPackageName", expectedPackageName); + sampleProject.SetProperty("SbomGenerationPackageVersion", expectedVersion); + sampleProject.SetProperty("SbomGenerationPackageSupplier", expectedSupplier); + sampleProject.SetProperty("SbomGenerationNamespaceBaseUri", expectedNamespace); + + // Restore, build, and pack the project + RestoreBuildPack(sampleProject); + + // Extract the NuGet package + InspectPackageIsWellFormed(); + } + + [TestMethod] + public void SbomGenerationFailsForInvalidNamespaceUri() + { + if (!IsWindows) + { + Assert.Inconclusive("This test is not (yet) supported on non-Windows platforms."); + return; + } + + // Create and setup a Project object for ProjectSample1 + var sampleProject = SetupSampleProject(); + + // Set invalid namespace + expectedNamespace = "incorrect_uri"; + sampleProject.SetProperty("SbomGenerationNamespaceBaseUri", expectedNamespace); + + // Restore, build, and pack the project + var logger = new ConsoleLogger(); + + // Restore the project to create project.assets.json file + var restore = sampleProject.Build("Restore", new[] { logger }); + Assert.IsTrue(restore, "Failed to restore the project"); + + // Next, build the project + var build = sampleProject.Build(logger); + Assert.IsTrue(build, "Failed to build the project"); + + // Ensure the packing step fails + var pack = sampleProject.Build("Pack", new[] { logger }); + Assert.IsFalse(pack, "Packing succeeded when it should have failed"); + } + + [TestMethod] + public void SbomGenerationFailsForInvalidSupplierName() + { + if (!IsWindows) + { + Assert.Inconclusive("This test is not (yet) supported on non-Windows platforms."); + return; + } + + // Create and setup a Project object for ProjectSample1 + var sampleProject = SetupSampleProject(); + + // Set invalid supplier name + sampleProject.SetProperty("Authors", string.Empty); + sampleProject.SetProperty("AssemblyName", string.Empty); + sampleProject.SetProperty("SbomGenerationPackageSupplier", string.Empty); + + // Restore, build, and pack the project + var logger = new ConsoleLogger(); + + // Restore the project to create project.assets.json file + var restore = sampleProject.Build("Restore", new[] { logger }); + Assert.IsTrue(restore, "Failed to restore the project"); + + // Next, build the project + var build = sampleProject.Build(logger); + Assert.IsTrue(build, "Failed to build the project"); + + // Ensure the packing step fails + var pack = sampleProject.Build("Pack", new[] { logger }); + Assert.IsFalse(pack, "Packing succeeded when it should have failed"); + } + + [TestMethod] + public void SbomGenerationSkipsForUnsetGenerateSBOMFlag() + { + if (!IsWindows) + { + Assert.Inconclusive("This test is not (yet) supported on non-Windows platforms."); + return; + } + + // Create and setup a Project object for ProjectSample1 + var sampleProject = SetupSampleProject(); + + // Set the GenerateSBOM property to empty. + sampleProject.SetProperty("GenerateSBOM", "false"); + + // Restore, build, and pack the project + RestoreBuildPack(sampleProject); + + // Extract the NuGet package + InspectPackageIsWellFormed(isManifestPathGenerated: false); + } + + [TestMethod] + public void SbomGenerationSucceedsForMultiTargetedProject() + { + if (!IsWindows) + { + Assert.Inconclusive("This test is not (yet) supported on non-Windows platforms."); + return; + } + + // Create and setup a Project object for ProjectSample1 + var sampleProject = SetupSampleProject(); + + // Set multi-target frameworks + sampleProject.SetProperty("TargetFramework", string.Empty); + sampleProject.SetProperty("TargetFrameworks", "net472;net6.0"); + + // Restore, build, and pack the project + RestoreBuildPack(sampleProject); + + // Extract the NuGet package + InspectPackageIsWellFormed(); + } +} diff --git a/test/Microsoft.Sbom.Targets.E2E.Tests/Microsoft.Sbom.Targets.E2E.Tests.csproj b/test/Microsoft.Sbom.Targets.E2E.Tests/Microsoft.Sbom.Targets.E2E.Tests.csproj new file mode 100644 index 000000000..09b56f108 --- /dev/null +++ b/test/Microsoft.Sbom.Targets.E2E.Tests/Microsoft.Sbom.Targets.E2E.Tests.csproj @@ -0,0 +1,62 @@ + + + + net6.0;net472 + true + false + True + Microsoft.Sbom.Targets.E2E.Tests + net6.0 + $(MSBuildThisFileDirectory)..\..\src\Microsoft.Sbom.Tool\ + $(MSBuildThisFileDirectory)..\..\src\Microsoft.Sbom.Targets\Microsoft.Sbom.Targets.targets + + + + TRACE + + + + + + + + + + + + + + + + + + + + + + + + + + <_SbomToolFiles Include="$(SBOMCLIToolProjectDir)bin\$(Configuration)\$(SbomCLIToolTargetFramework)\publish\**\*.*"> + false + + + + + + + + + + false + + + + + + + + + + diff --git a/test/Microsoft.Sbom.Targets.E2E.Tests/ProjectSamples/ProjectSample1/ProjectSample1.csproj b/test/Microsoft.Sbom.Targets.E2E.Tests/ProjectSamples/ProjectSample1/ProjectSample1.csproj new file mode 100644 index 000000000..ab3494088 --- /dev/null +++ b/test/Microsoft.Sbom.Targets.E2E.Tests/ProjectSamples/ProjectSample1/ProjectSample1.csproj @@ -0,0 +1,21 @@ + + + Library + true + net6.0 + ProjectSample + 1.2.4 + false + true + true + false + + + + + $(NoWarn);NU1507;NU5128 + + + + + diff --git a/test/Microsoft.Sbom.Targets.E2E.Tests/ProjectSamples/ProjectSample1/SampleLibrary.cs b/test/Microsoft.Sbom.Targets.E2E.Tests/ProjectSamples/ProjectSample1/SampleLibrary.cs new file mode 100644 index 000000000..053891714 --- /dev/null +++ b/test/Microsoft.Sbom.Targets.E2E.Tests/ProjectSamples/ProjectSample1/SampleLibrary.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System; + +public class SampleLibrary +{ +} diff --git a/test/Microsoft.Sbom.Targets.Tests/AbstractGenerateSbomTaskInputTests.cs b/test/Microsoft.Sbom.Targets.Tests/AbstractGenerateSbomTaskInputTests.cs new file mode 100644 index 000000000..5274dbf5d --- /dev/null +++ b/test/Microsoft.Sbom.Targets.Tests/AbstractGenerateSbomTaskInputTests.cs @@ -0,0 +1,375 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using Microsoft.Build.Framework; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Microsoft.Sbom.Targets.Tests; + +[TestClass] +public abstract class AbstractGenerateSbomTaskInputTests +{ + internal abstract string SbomSpecification { get; } + + internal static readonly string CurrentDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + internal static readonly string DefaultManifestDirectory = Path.Combine(CurrentDirectory, "_manifest"); + internal static readonly string TemporaryDirectory = Path.Combine(CurrentDirectory, "_temporary"); + internal static readonly string BuildComponentPath = Path.Combine(CurrentDirectory, "..", "..", ".."); + internal static readonly string ExternalDocumentListFile = Path.GetRandomFileName(); + internal static string SbomToolPath = Path.Combine(Directory.GetCurrentDirectory(), "sbom-tool"); + internal const string PackageSupplier = "Test-Microsoft"; + internal const string PackageName = "CoseSignTool"; + internal const string PackageVersion = "0.0.1"; + internal const string NamespaceBaseUri = "https://base0.uri"; + private Mock buildEngine; + private List errors; + private List messages; + + [TestInitialize] + public void Startup() + { + // Setup the build engine + this.buildEngine = new Mock(); + this.errors = new List(); + this.messages = new List(); + this.buildEngine.Setup(x => x.LogErrorEvent(It.IsAny())).Callback(e => errors.Add(e)); + this.buildEngine.Setup(x => x.LogMessageEvent(It.IsAny())).Callback(msg => messages.Add(msg)); + } + + [TestCleanup] + public void Cleanup() { + // Clean up the manifest directory + if (Directory.Exists(DefaultManifestDirectory)) + { + Directory.Delete(DefaultManifestDirectory, true); + } + + // Clean up the manifest directory + if (Directory.Exists(TemporaryDirectory)) + { + Directory.Delete(TemporaryDirectory, true); + } + } + + /// + /// Test for ensuring the GenerateSbom fails for null or empty inputs for + /// required params, which includes BuildDropPath, PackageSupplier, PackageName, + /// PackageVersion, and NamespaceBaseUri. + /// + [TestMethod] + [DynamicData(nameof(GetNullRequiredParamsData), DynamicDataSourceType.Method)] + [DynamicData(nameof(GetEmptyRequiredParamsData), DynamicDataSourceType.Method)] + [DynamicData(nameof(GetWhiteSpace_Tabs_NewLineParamsData), DynamicDataSourceType.Method)] + public void Sbom_Fails_With_Null_Empty_And_WhiteSpace_Required_Params( + string buildDropPath, + string packageSupplier, + string packageName, + string packageVersion, + string namespaceBaseUri, + string sbomToolPath) + { + // Arrange. + var task = new GenerateSbom + { + BuildDropPath = buildDropPath, + PackageSupplier = packageSupplier, + PackageName = packageName, + PackageVersion = packageVersion, + NamespaceBaseUri = namespaceBaseUri, + ManifestInfo = this.SbomSpecification, + BuildEngine = this.buildEngine.Object, +#if NET472 + SbomToolPath = sbomToolPath, +#endif + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.IsFalse(result); + } + + private static IEnumerable GetNullRequiredParamsData() + { + yield return new object[] { null, PackageSupplier, PackageName, PackageVersion, NamespaceBaseUri, SbomToolPath }; + yield return new object[] { CurrentDirectory, null, PackageName, PackageVersion, NamespaceBaseUri, SbomToolPath }; + yield return new object[] { CurrentDirectory, PackageSupplier, null, PackageVersion, NamespaceBaseUri, SbomToolPath }; + yield return new object[] { CurrentDirectory, PackageSupplier, PackageName, null, NamespaceBaseUri, SbomToolPath }; + yield return new object[] { CurrentDirectory, PackageSupplier, PackageName, PackageVersion, null, SbomToolPath }; +#if NET472 + yield return new object[] { CurrentDirectory, PackageSupplier, PackageName, PackageVersion, NamespaceBaseUri, null }; +#endif + } + + private static IEnumerable GetEmptyRequiredParamsData() + { + yield return new object[] { string.Empty, PackageSupplier, PackageName, PackageVersion, NamespaceBaseUri, SbomToolPath }; + yield return new object[] { CurrentDirectory, string.Empty, PackageName, PackageVersion, NamespaceBaseUri, SbomToolPath }; + yield return new object[] { CurrentDirectory, PackageSupplier, string.Empty, PackageVersion, NamespaceBaseUri, SbomToolPath }; + yield return new object[] { CurrentDirectory, PackageSupplier, PackageName, string.Empty, NamespaceBaseUri, SbomToolPath }; + yield return new object[] { CurrentDirectory, PackageSupplier, PackageName, PackageVersion, string.Empty, SbomToolPath }; +#if NET472 + yield return new object[] { CurrentDirectory, PackageSupplier, PackageName, PackageVersion, NamespaceBaseUri, string.Empty }; +#endif + } + + private static IEnumerable GetWhiteSpace_Tabs_NewLineParamsData() + { + yield return new object[] { " ", PackageSupplier, PackageName, PackageVersion, NamespaceBaseUri, SbomToolPath }; + yield return new object[] { CurrentDirectory, "\n", PackageName, PackageVersion, NamespaceBaseUri, SbomToolPath }; + yield return new object[] { CurrentDirectory, PackageSupplier, "\t", PackageVersion, NamespaceBaseUri, SbomToolPath }; + yield return new object[] { CurrentDirectory, PackageSupplier, PackageName, " \n \t \n \t \n ", NamespaceBaseUri, SbomToolPath }; + yield return new object[] { CurrentDirectory, PackageSupplier, PackageName, PackageVersion, "\t \t \t ", SbomToolPath }; +#if NET472 + yield return new object[] { CurrentDirectory, PackageSupplier, PackageName, PackageVersion, NamespaceBaseUri, "\t \t \t " }; +#endif + } + + /// + /// Test for ensuring the GenerateSbom fails when user provides an + /// invalid URI format. + /// + [TestMethod] + [DataRow("incorrectly_formatted_uri.com")] // Missing protocol + [DataRow("http://invalid.com:70000")] // Invalid port + [DataRow("http://inv\nalid.com")] // Contains new line character + [DataRow("http://invalid.com/path with spaces")] // Contains spaces + [DataRow("http:invalid.com")] // Missing // after protocol + [DataRow("http://")] // Missing domain + public void Sbom_Fails_With_Invalid_NamespaceBaseUri(string namespaceBaseUri) + { + // Arrange + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = namespaceBaseUri, + ManifestInfo = this.SbomSpecification, + BuildEngine = this.buildEngine.Object, +#if NET472 + SbomToolPath = SbomToolPath, +#endif + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.IsFalse(result); + } + + /// + /// Test for ensuring the GenerateSbom fails when user provides + /// an invalid GUID for NamespaceUriUniquePart. + /// + [TestMethod] + [DataRow("-1")] // starts with hyphen + [DataRow("1234567890")] // Too less digits + [DataRow("12345678-1234-1234-1234-123456789abcd")] // Too many digits + [DataRow("12345678-1234-1234-1234-123456789abg")] // invalid character g + [DataRow("12345678-1234-1234-1234-123456789ab!")] // invalid character ! + [DataRow("12345678-1234-1234-1234-123456789ab")] // Too less digits + [DataRow("12345678-1234-1234-1234-123456789ac-")] // Ends with a hyphen + [DataRow("12345678-1234-1234-1234-12345\n6789ac")] // Contains newline + [DataRow("00000000-0000-0000-0000-000000000000")] // Empty guid + public void Sbom_Generation_Fails_For_Invalid_NamespaceUriUniquePart(string namespaceUriUniquePart) + { + // Arrange + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = NamespaceBaseUri, + NamespaceUriUniquePart = namespaceUriUniquePart, + ManifestInfo = this.SbomSpecification, + BuildEngine = this.buildEngine.Object, +#if NET472 + SbomToolPath = SbomToolPath, +#endif + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.IsFalse(result); + } + + /// + /// Test for ensuring GenerateSbom assigns a defualt Verbosity + /// level when null input is provided. + /// + [TestMethod] + public void Sbom_Generation_Succeeds_For_Null_Verbosity() + { + // Arrange + // If Verbosity is null, the default value should be Information and is printed in the + // tool's standard output. + var pattern = new Regex("Verbosity=.*Value=Information"); + var stringWriter = new StringWriter(); + Console.SetOut(stringWriter); + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = NamespaceBaseUri, + ManifestInfo = this.SbomSpecification, + Verbosity = null, + BuildEngine = this.buildEngine.Object, +#if NET472 + SbomToolPath = SbomToolPath, +#endif + }; + + // Act + var result = task.Execute(); + var output = stringWriter.ToString(); + + // Assert + Assert.IsTrue(result); +#if NET472 + Assert.IsTrue(this.messages.Any(msg => pattern.IsMatch(msg.Message))); +#else + Assert.IsTrue(pattern.IsMatch(output)); +#endif + } + + /// + /// Test for ensuring GenerateSbom assigns a default Verbosity for + /// unrecognized input. + /// + [TestMethod] + public void Sbom_Generation_Succeeds_For_Invalid_Verbosity() + { + // Arrange + // If an invalid Verbosity is specified, the default value should be Information. It is also printed in the + // tool's standard output for the MSBuild Core task. + var pattern = new Regex("Verbosity=.*Value=Information"); + var stringWriter = new StringWriter(); + Console.SetOut(stringWriter); + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = NamespaceBaseUri, + Verbosity = "Invalid Verbosity", + ManifestInfo = this.SbomSpecification, + BuildEngine = this.buildEngine.Object, +#if NET472 + SbomToolPath = SbomToolPath, +#endif + }; + + // Act + var result = task.Execute(); + var output = stringWriter.ToString(); + + // Assert + Assert.IsTrue(result); +#if NET472 + Assert.IsTrue(this.messages.Any(msg => pattern.IsMatch(msg.Message))); +#else + Assert.IsTrue(pattern.IsMatch(output)); +#endif + } + +#if !NET472 + /// + /// Test to ensure GenerateSbom correctly parses and provides each EventLevel verbosity + /// values to the SBOM API. + /// + [TestMethod] + [DataRow("FATAL", "Fatal", false)] + [DataRow("information", "Information", true)] + [DataRow("vErBose", "Verbose", true)] + [DataRow("Warning", "Warning", false)] + [DataRow("eRRor", "Error", false)] + [DataRow("DeBug", "Verbose", true)] + public void Sbom_Generation_Assigns_Correct_Verbosity_IgnoreCase(string inputVerbosity, string mappedVerbosity, bool messageShouldBeLogged) + { + if (!messageShouldBeLogged) + { + Assert.Inconclusive("Cases where the input Verbosity is more restrictive than `Information` are failing due to this issue: https://github.com/microsoft/sbom-tool/issues/616"); + } + + // Arrange + var pattern = new Regex($"Verbosity=.*Value={mappedVerbosity}"); + var stringWriter = new StringWriter(); + Console.SetOut(stringWriter); + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = NamespaceBaseUri, + Verbosity = inputVerbosity, + ManifestInfo = this.SbomSpecification, + BuildEngine = this.buildEngine.Object, + }; + + // Act + var result = task.Execute(); + var output = stringWriter.ToString(); + + // Assert + Assert.IsTrue(result, $"result: {result} is not set to true"); + Assert.AreEqual(messageShouldBeLogged, pattern.IsMatch(output)); + } +#else + /// + /// Test to ensure GenerateSbom correctly parses and provides each verbosity option + /// to the SBOM CLI. + /// + [TestMethod] + [DataRow("FATAL", "Fatal", false)] + [DataRow("information", "Information", true)] + [DataRow("vErBose", "Verbose", true)] + [DataRow("Warning", "Warning", false)] + [DataRow("eRRor", "Error", false)] + [DataRow("DeBug", "Debug", true)] + public void Sbom_Generation_Assigns_Correct_Verbosity_IgnoreCase(string inputVerbosity, string mappedVerbosity, bool messageShouldBeLogged) + { + // Arrange + var pattern = new Regex($"Verbosity=.*Value={mappedVerbosity}"); + var stringWriter = new StringWriter(); + Console.SetOut(stringWriter); + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = NamespaceBaseUri, + Verbosity = inputVerbosity, + ManifestInfo = this.SbomSpecification, + BuildEngine = this.buildEngine.Object, + SbomToolPath = SbomToolPath, + }; + + // Act + var result = task.Execute(); + var output = stringWriter.ToString(); + + // Assert + Assert.IsTrue(result, $"result: {result} is not set to true"); + Assert.AreEqual(messageShouldBeLogged, this.messages.Any(msg => pattern.IsMatch(msg.Message))); + } +#endif +} diff --git a/test/Microsoft.Sbom.Targets.Tests/AbstractGenerateSbomTaskTests.cs b/test/Microsoft.Sbom.Targets.Tests/AbstractGenerateSbomTaskTests.cs new file mode 100644 index 000000000..97e57b40a --- /dev/null +++ b/test/Microsoft.Sbom.Targets.Tests/AbstractGenerateSbomTaskTests.cs @@ -0,0 +1,454 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Microsoft.Build.Framework; +using Microsoft.Sbom.Targets.Tests.Utility; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Microsoft.Sbom.Targets.Tests; + +/// +/// Base class for testing SBOM generation through the GenerateSbom. +/// +[TestClass] +public abstract class AbstractGenerateSbomTaskTests +{ + internal abstract string SbomSpecificationName { get; } + + internal abstract string SbomSpecificationVersion { get; } + + internal static readonly string CurrentDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + internal static readonly string DefaultManifestDirectory = Path.Combine(CurrentDirectory, "_manifest"); + internal static readonly string TemporaryDirectory = Path.Combine(CurrentDirectory, "_temp"); + internal static readonly string ExternalDocumentListFile = Path.GetRandomFileName(); + internal static string SbomToolPath = Path.Combine(Directory.GetCurrentDirectory(), "sbom-tool"); + + internal const string PackageSupplier = "Test-Microsoft"; + internal const string PackageName = "CoseSignTool"; + internal const string PackageVersion = "0.0.1"; + internal const string NamespaceBaseUri = "https://base0.uri"; + + internal Mock BuildEngine; + internal List Errors; + internal string ManifestPath; + internal GeneratedSbomValidator GeneratedSbomValidator; + + internal string SbomSpecification => $"{this.SbomSpecificationName}:{this.SbomSpecificationVersion}"; + + internal string SbomSpecificationDirectoryName => $"{this.SbomSpecificationName}_{this.SbomSpecificationVersion}".ToLowerInvariant(); + + private void CleanupManifestDirectory() + { + // Clean up the manifest directory + if (Directory.Exists(DefaultManifestDirectory)) + { + Directory.Delete(DefaultManifestDirectory, true); + } + + // Clean up the manifest directory + if (Directory.Exists(TemporaryDirectory)) + { + Directory.Delete(TemporaryDirectory, true); + } + } + + [TestInitialize] + public void Startup() + { + // Setup the build engine + this.BuildEngine = new Mock(); + this.Errors = new List(); + this.BuildEngine.Setup(x => x.LogErrorEvent(It.IsAny())).Callback(e => Errors.Add(e)); + + this.CleanupManifestDirectory(); + + this.ManifestPath = Path.Combine(DefaultManifestDirectory, this.SbomSpecificationDirectoryName, "manifest.spdx.json"); + this.GeneratedSbomValidator = new(this.SbomSpecification); +#if NET472 + Assert.IsTrue(Directory.Exists(SbomToolPath)); +#endif + } + + [TestCleanup] + public void Cleanup() + { + this.CleanupManifestDirectory(); + } + + [TestMethod] + public void Sbom_Is_Successfully_Generated() + { + // Arrange + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = NamespaceBaseUri, + BuildEngine = this.BuildEngine.Object, + ManifestInfo = this.SbomSpecification, +#if NET472 + SbomToolPath = SbomToolPath, +#endif + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.IsTrue(result); + this.GeneratedSbomValidator.AssertSbomIsValid(this.ManifestPath, CurrentDirectory, PackageName, PackageVersion, PackageSupplier, NamespaceBaseUri); + } + + [TestMethod] + [DataRow("http://example.com/hello/world")] // Regular valid URI + [DataRow("http://example.com/hello%20world")] // Valid URI with space encoded + [DataRow("http://ExAmplE.com")] // Mix of cases + [DataRow(" http://example.com ")] // Trailing spaces + [DataRow("http://www.example.com/path/to/resource?param1=value1¶m2=value2¶m3=value3¶m4=value4¶m5=" + + "value5¶m6=value6¶m7=value7¶m8=value8¶m9=value9¶m10=value10¶m11=value11¶m12=value12" + + "¶m13=value13¶m14=value14¶m15=value15¶m16=value16¶m17=value17¶m18=value18¶m19=value19¶m20=value20#section1")] // Super long URI + public void Sbom_Is_Successfully_Generated_Valid_URI(string namespaceBaseUri) + { + // Arrange + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = namespaceBaseUri, + BuildEngine = this.BuildEngine.Object, + ManifestInfo = this.SbomSpecification, +#if NET472 + SbomToolPath = SbomToolPath, +#endif + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.IsTrue(result); + this.GeneratedSbomValidator.AssertSbomIsValid(this.ManifestPath, CurrentDirectory, PackageName, PackageVersion, PackageSupplier, namespaceBaseUri); + } + + [TestMethod] + [DynamicData(nameof(GetPackageSupplierCases), DynamicDataSourceType.Method)] + [DynamicData(nameof(GetPackageNameCases), DynamicDataSourceType.Method)] + [DynamicData(nameof(GetPackageVersionCases), DynamicDataSourceType.Method)] + public void Sbom_Is_Successfully_Generated_Valid_RequiredParams(string packageSupplier, string packageName, string packageVersion) + { + // Arrange + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + PackageSupplier = packageSupplier, + PackageName = packageName, + PackageVersion = packageVersion, + NamespaceBaseUri = NamespaceBaseUri, + BuildEngine = this.BuildEngine.Object, + ManifestInfo = this.SbomSpecification, +#if NET472 + SbomToolPath = SbomToolPath, +#endif + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.IsTrue(result); + this.GeneratedSbomValidator.AssertSbomIsValid(this.ManifestPath, CurrentDirectory, PackageName, PackageVersion, PackageSupplier, NamespaceBaseUri); + } + + private static IEnumerable GetPackageSupplierCases() + { + yield return new object[] { "Test-\nMicrosoft", PackageName, PackageVersion }; + yield return new object[] { "Test\t-Microsoft", PackageName, PackageVersion }; + yield return new object[] { "Test - Microsoft ", PackageName, PackageVersion }; + yield return new object[] { "Test - Mic\tro\nsoft", PackageName, PackageVersion }; + } + + private static IEnumerable GetPackageNameCases() + { + yield return new object[] { PackageSupplier, "CoseSign\nTool", PackageVersion }; + yield return new object[] { PackageSupplier, "Cose\tSign\tTool", PackageVersion }; + yield return new object[] { PackageSupplier, "Cose Sign Tool ", PackageVersion }; + yield return new object[] { PackageSupplier, "Cose S\ti\ngn \n Too\tl", PackageVersion }; + } + + private static IEnumerable GetPackageVersionCases() + { + yield return new object[] { PackageSupplier, PackageName, "0.0\n.1" }; + yield return new object[] { PackageSupplier, PackageName, "0.0\t.1" }; + yield return new object[] { PackageSupplier, PackageName, "0. 0. 1" }; + yield return new object[] { PackageSupplier, PackageName, "0 . \t 0 \n .1" }; + } + + [TestMethod] + public void Sbom_Is_Successfully_Generated_In_Specified_Location() + { + var manifestDirPath = Path.Combine(TemporaryDirectory, "sub-directory"); + Directory.CreateDirectory(manifestDirPath); + // Arrange + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + ManifestDirPath = manifestDirPath, + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = NamespaceBaseUri, + BuildEngine = this.BuildEngine.Object, + ManifestInfo = this.SbomSpecification, +#if NET472 + SbomToolPath = SbomToolPath, +#endif + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.IsTrue(result); + + this.ManifestPath = Path.Combine(manifestDirPath, "_manifest", this.SbomSpecificationDirectoryName, "manifest.spdx.json"); + this.GeneratedSbomValidator.AssertSbomIsValid(this.ManifestPath, CurrentDirectory, PackageName, PackageVersion, PackageSupplier, NamespaceBaseUri); + } + + [TestMethod] + public void Sbom_Generation_Fails_With_NotFound_BuildDropPath() + { + // Arrange + var task = new GenerateSbom + { + BuildDropPath = ".\\non-existent\\path", + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = NamespaceBaseUri, + BuildEngine = this.BuildEngine.Object, + ManifestInfo = this.SbomSpecification, +#if NET472 + SbomToolPath = SbomToolPath, +#endif + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public void Sbom_Generation_Fails_With_NotFound_BuildComponentPath() + { + // Arrange + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + BuildComponentPath = ".\\non-existent\\path", + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = NamespaceBaseUri, + BuildEngine = this.BuildEngine.Object, + ManifestInfo = this.SbomSpecification, +#if NET472 + SbomToolPath = SbomToolPath, +#endif + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.IsFalse(result); + Assert.IsFalse(Directory.Exists(DefaultManifestDirectory)); + } + + [TestMethod] + public void Sbom_Generation_Fails_With_NotFound_ExternalDocumentListFile() + { + // Arrange + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + ExternalDocumentListFile = ".\\non-existent\\path", + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = NamespaceBaseUri, + BuildEngine = this.BuildEngine.Object, + ManifestInfo = this.SbomSpecification, +#if NET472 + SbomToolPath = SbomToolPath, +#endif + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.IsFalse(result); + Assert.IsFalse(Directory.Exists(DefaultManifestDirectory)); + } + + [TestMethod] + public void Sbom_Generation_Fails_With_NotFound_ManifestDirPath() + { + // Arrange + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + ManifestDirPath = ".\\non-existent\\path", + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = NamespaceBaseUri, + BuildEngine = this.BuildEngine.Object, + ManifestInfo = this.SbomSpecification, +#if NET472 + SbomToolPath = SbomToolPath, +#endif + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.IsFalse(result); + Assert.IsFalse(Directory.Exists(DefaultManifestDirectory)); + } + + [TestMethod] + public void Sbom_Is_Successfully_Generated_With_Component_Path() + { + // Let's generate a SBOM for the current assembly + var sourceDirectory = Path.Combine(CurrentDirectory, "..", "..", ".."); + + // Arrange + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + BuildComponentPath = sourceDirectory, + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = NamespaceBaseUri, + BuildEngine = this.BuildEngine.Object, + ManifestInfo = this.SbomSpecification, +#if NET472 + SbomToolPath = SbomToolPath, +#endif + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.IsTrue(result); + this.GeneratedSbomValidator.AssertSbomIsValid(this.ManifestPath, CurrentDirectory, PackageName, PackageVersion, PackageSupplier, NamespaceBaseUri, buildComponentPath: sourceDirectory); + } + + [TestMethod] + [DataRow("550e8400-e29b-41d4-a716-446655440000")] // Standard random GUID + [DataRow("3F2504E0-4f89-11D3-9A0C-0305E82c3301")] // Mixed cases + [DataRow("3F2504E04F8911D39A0C0305E82C3301")] // Guids without hyphens + [DataRow(" 3F2504E0-4F89-11D3-9A0C-0305E82C3301 ")] // Guids with trailing spaces + public void Sbom_Is_Successfully_Generated_With_Unique_Namespace_Part_Defined(string uniqueNamespacePart) + { + // Arrange + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = NamespaceBaseUri, + NamespaceUriUniquePart = uniqueNamespacePart, + BuildEngine = this.BuildEngine.Object, + ManifestInfo = this.SbomSpecification, +#if NET472 + SbomToolPath = SbomToolPath, +#endif + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.IsTrue(result, $"{result} is not set to true."); + this.GeneratedSbomValidator.AssertSbomIsValid(this.ManifestPath, CurrentDirectory, PackageName, PackageVersion, PackageSupplier, NamespaceBaseUri, expectedNamespaceUriUniquePart: uniqueNamespacePart); + } + +#if NET472 + [TestMethod] + public void Sbom_Generation_Fails_With_Tool_Path_Not_Found() + { + // Arrange + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = NamespaceBaseUri, + BuildEngine = this.BuildEngine.Object, + ManifestInfo = this.SbomSpecification, + SbomToolPath = "C:\\Not-Found\\Path\\", + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.IsFalse(result); + Assert.IsFalse(Directory.Exists(DefaultManifestDirectory)); + } +#endif + + [TestMethod] + public void Sbom_Fails_To_Generate_Due_To_File_In_Use() + { + var manifestDirPath = Path.Combine(TemporaryDirectory, "sub-directory"); + this.ManifestPath = Path.Combine(manifestDirPath, "_manifest", this.SbomSpecificationDirectoryName, "manifest.spdx.json"); + Directory.CreateDirectory(manifestDirPath); + // Arrange + var task = new GenerateSbom + { + BuildDropPath = CurrentDirectory, + ManifestDirPath = manifestDirPath, + PackageSupplier = PackageSupplier, + PackageName = PackageName, + PackageVersion = PackageVersion, + NamespaceBaseUri = NamespaceBaseUri, + BuildEngine = this.BuildEngine.Object, + ManifestInfo = this.SbomSpecification, +#if NET472 + SbomToolPath = SbomToolPath, +#endif + }; + + // Write JSON content to the manifest file, and create the directory if it doesn't exist + var jsonContent = "{}"; + Directory.CreateDirectory(Path.GetDirectoryName(ManifestPath)); + File.WriteAllText(ManifestPath, jsonContent); + // Open a handle to the manifest file to simulate it being in use + using (var fileStream = File.Open(this.ManifestPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None)) + { + // Act + var result = task.Execute(); + + // Assert + Assert.IsFalse(result); + } + } +} diff --git a/test/Microsoft.Sbom.Targets.Tests/GenerateSbomTaskSPDX_2_2InputTests.cs b/test/Microsoft.Sbom.Targets.Tests/GenerateSbomTaskSPDX_2_2InputTests.cs new file mode 100644 index 000000000..1efe7e8a3 --- /dev/null +++ b/test/Microsoft.Sbom.Targets.Tests/GenerateSbomTaskSPDX_2_2InputTests.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Sbom.Targets.Tests; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Class to test the generation of SBOM using SPDX 2.2 specification. +/// +[TestClass] +public class GenerateSbomTaskSPDX_2_2InputTests : AbstractGenerateSbomTaskInputTests +{ + internal override string SbomSpecification => "SPDX:2.2"; +} diff --git a/test/Microsoft.Sbom.Targets.Tests/GenerateSbomTaskSPDX_2_2Tests.cs b/test/Microsoft.Sbom.Targets.Tests/GenerateSbomTaskSPDX_2_2Tests.cs new file mode 100644 index 000000000..63e66701c --- /dev/null +++ b/test/Microsoft.Sbom.Targets.Tests/GenerateSbomTaskSPDX_2_2Tests.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Sbom.Targets.Tests; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Class to test the generation of SBOM using SPDX 2.2 specification. +/// +[TestClass] +public class GenerateSbomTaskSPDX_2_2Tests : AbstractGenerateSbomTaskTests +{ + internal override string SbomSpecificationName => "SPDX"; + + internal override string SbomSpecificationVersion => "2.2"; +} diff --git a/test/Microsoft.Sbom.Targets.Tests/Microsoft.Sbom.Targets.Tests.csproj b/test/Microsoft.Sbom.Targets.Tests/Microsoft.Sbom.Targets.Tests.csproj new file mode 100644 index 000000000..70c4c91c0 --- /dev/null +++ b/test/Microsoft.Sbom.Targets.Tests/Microsoft.Sbom.Targets.Tests.csproj @@ -0,0 +1,41 @@ + + + + net8.0;net472 + net8.0 + false + True + Microsoft.Sbom.Targets.Tests + net8.0 + $(MSBuildThisFileDirectory)..\..\src\Microsoft.Sbom.Tool\ + + + + TRACE + + + + + + + + + + + + + <_SbomToolFiles Include="$(SBOMCLIToolProjectDir)bin\$(Configuration)\$(SbomCLIToolTargetFramework)\publish\**\*.*"> + false + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/Microsoft.Sbom.Targets.Tests/Utility/GeneratedSbomValidator.cs b/test/Microsoft.Sbom.Targets.Tests/Utility/GeneratedSbomValidator.cs new file mode 100644 index 000000000..44cb95d1f --- /dev/null +++ b/test/Microsoft.Sbom.Targets.Tests/Utility/GeneratedSbomValidator.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Sbom.Targets.Tests.Utility; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; + +/// +/// This class is used to validate that the generated SBOM has valid fields and data. +/// +#pragma warning disable CA5350 // Suppress Do Not Use Weak Cryptographic Algorithms as we use SHA1 intentionally +internal class GeneratedSbomValidator +{ + private const string SPDX22Specification = "SPDX:2.2"; + private readonly string sbomSpecification; + + public GeneratedSbomValidator(string sbomSpecification) + { + this.sbomSpecification = sbomSpecification; + } + + internal void AssertSbomIsValid(string manifestPath, string buildDropPath, string expectedPackageName, string expectedPackageVersion, string expectedPackageSupplier, string expectedNamespaceUriBase, string expectedNamespaceUriUniquePart = null, string buildComponentPath = null) + { + Assert.IsTrue(File.Exists(manifestPath)); + + // Read and parse the manifest + var manifestContent = File.ReadAllText(manifestPath); + var manifest = JsonConvert.DeserializeObject(manifestContent); + + if (this.sbomSpecification.Equals(SPDX22Specification)) + { + // Check the manifest has expected file data + var filesValue = manifest["files"]; + Assert.IsNotNull(filesValue); + + var expectedFilesHashes = this.GetBuildDropFileHashes(buildDropPath); + Assert.AreEqual(expectedFilesHashes.Count, filesValue.Count, $"Manifest {manifestPath} has {filesValue.Count} files instead of {expectedFilesHashes.Count}"); + foreach (var file in filesValue) + { + var filePath = Path.GetFullPath(Path.Combine(buildDropPath, (string)file["fileName"])); + var fileChecksums = file["checksums"]; + Assert.IsNotNull(fileChecksums); + + foreach (var checksum in fileChecksums) + { + var algorithm = (string)checksum["algorithm"]; + var hash = (string)checksum["checksumValue"]; + Assert.IsNotNull(algorithm); + Assert.IsNotNull(hash); + + Assert.IsTrue(expectedFilesHashes.ContainsKey(filePath)); + Assert.IsTrue(expectedFilesHashes[filePath].ContainsKey(algorithm)); + Assert.IsTrue(expectedFilesHashes[filePath][algorithm].Equals(hash, StringComparison.InvariantCultureIgnoreCase)); + } + } + + var packagesValue = manifest["packages"]; + Assert.IsNotNull(packagesValue); + if (string.IsNullOrEmpty(buildComponentPath)) + { + Assert.IsTrue(packagesValue.Count == 1, $"Expected 1 package but actual value was {packagesValue.Count}"); + } + else + { + Assert.IsTrue(packagesValue.Count > 1); + } + + var nameValue = manifest["name"]; + Assert.IsNotNull(nameValue); + Assert.AreEqual($"{expectedPackageName} {expectedPackageVersion}", (string)nameValue); + + var creatorsValue = manifest["creationInfo"]["creators"]; + Assert.IsNotNull(creatorsValue); + Assert.IsTrue(creatorsValue.Count > 0); + Assert.IsTrue(((string)creatorsValue[0]).Contains(expectedPackageSupplier)); + + string namespaceValue = manifest["documentNamespace"]; + Assert.IsNotNull(namespaceValue); + + if (expectedNamespaceUriUniquePart != null) + { + Assert.IsTrue(namespaceValue.Equals($"{expectedNamespaceUriBase.Trim()}/{expectedPackageName}/{expectedPackageVersion}/{expectedNamespaceUriUniquePart.Trim()}", StringComparison.InvariantCultureIgnoreCase)); + } + else + { + Assert.IsTrue(namespaceValue.Contains($"{expectedNamespaceUriBase.Trim()}/{expectedPackageName}/{expectedPackageVersion}")); + } + } else + { + Assert.Fail("An unexpected SBOM specification was used. Please specify SPDX 2.2."); + } + } + + private IDictionary> GetBuildDropFileHashes(string buildDropPath) + { + var filesHashes = new Dictionary>(); + + // Get all files in the buildDropPath and its subfolders + var files = Directory.GetFiles(buildDropPath, "*", SearchOption.AllDirectories) + .Where(f => !f.Contains("manifest.spdx.json")) + .Select(Path.GetFullPath); + + // Compute hashes for each file. + foreach (var filePath in files) + { + var fileHashes = new Dictionary(); + // Compute hashes for the file. + foreach (var hashAlgorithmPair in this.GetListOfHashAlgorithmCreators()) + { + using var stream = File.OpenRead(filePath); + using var hashAlgorithmInstance = hashAlgorithmPair.Item2(); + var hash = hashAlgorithmInstance.ComputeHash(stream); + var hashString = BitConverter.ToString(hash).Replace("-", string.Empty); + fileHashes.Add(hashAlgorithmPair.Item1, hashString); + } + + filesHashes.Add(filePath, fileHashes); + } + + return filesHashes; + } + + private IList<(string, Func)> GetListOfHashAlgorithmCreators() + { + if (this.sbomSpecification.Equals(SPDX22Specification)) + { + return [("SHA1", SHA1.Create), ("SHA256", SHA256.Create)]; + } + + return []; + } +}