diff --git a/docs/detectors/spdx.md b/docs/detectors/spdx.md index 733e18281..f722215d2 100644 --- a/docs/detectors/spdx.md +++ b/docs/detectors/spdx.md @@ -19,6 +19,8 @@ The detector: - Document name - SPDX version - Root element ID from `documentDescribes` (defaults to `SPDXRef-Document` if not specified) + - Creator tool from `creationInfo.creators` (e.g., `Tool: microsoft/sbom-tool-2.2.0`) + - Creator organization from `creationInfo.creators` (e.g., `Organization: Microsoft`) - Creates an `SpdxComponent` to represent the SPDX document The detector does not parse or register individual packages listed within the SPDX document; it only registers the SPDX document itself as a component. diff --git a/src/Microsoft.ComponentDetection.Contracts/TypedComponent/SpdxComponent.cs b/src/Microsoft.ComponentDetection.Contracts/TypedComponent/SpdxComponent.cs index 8625c89a4..72ee33e73 100644 --- a/src/Microsoft.ComponentDetection.Contracts/TypedComponent/SpdxComponent.cs +++ b/src/Microsoft.ComponentDetection.Contracts/TypedComponent/SpdxComponent.cs @@ -42,5 +42,15 @@ public SpdxComponent(string spdxVersion, Uri documentNamespace, string name, str [JsonPropertyName("path")] public string Path { get; set; } +#nullable enable + [JsonPropertyName("creatorTool")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CreatorTool { get; set; } + + [JsonPropertyName("creatorOrganization")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CreatorOrganization { get; set; } +#nullable disable + protected override string ComputeBaseId() => $"{this.Name}-{this.SpdxVersion}-{this.Checksum}"; } diff --git a/src/Microsoft.ComponentDetection.Detectors/spdx/Spdx22ComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/spdx/Spdx22ComponentDetector.cs index 5a3b4bb1f..77de63730 100644 --- a/src/Microsoft.ComponentDetection.Detectors/spdx/Spdx22ComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/spdx/Spdx22ComponentDetector.cs @@ -121,6 +121,42 @@ private SpdxComponent ConvertJsonElementToSbomComponent(ProcessRequest processRe var path = processRequest.ComponentStream.Location; var component = new SpdxComponent(spdxVersion, new Uri(sbomNamespace), name, fileHash, rootElementId, path); + if (document.TryGetProperty("creationInfo", out var creationInfoElement) + && creationInfoElement.TryGetProperty("creators", out var creatorsElement) + && creatorsElement.ValueKind == JsonValueKind.Array) + { + foreach (var creator in creatorsElement.EnumerateArray()) + { + if (creator.ValueKind != JsonValueKind.String) + { + continue; + } + + var creatorString = creator.GetString(); + if (creatorString == null) + { + continue; + } + + if (component.CreatorTool == null && creatorString.StartsWith("Tool: ", StringComparison.Ordinal)) + { + var tool = creatorString["Tool: ".Length..].Trim(); + if (!string.IsNullOrWhiteSpace(tool)) + { + component.CreatorTool = tool; + } + } + else if (component.CreatorOrganization == null && creatorString.StartsWith("Organization: ", StringComparison.Ordinal)) + { + var org = creatorString["Organization: ".Length..].Trim(); + if (!string.IsNullOrWhiteSpace(org)) + { + component.CreatorOrganization = org; + } + } + } + } + return component; } diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/SPDX22ComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/SPDX22ComponentDetectorTests.cs index 6571f0b0c..c53bfedb2 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/SPDX22ComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/SPDX22ComponentDetectorTests.cs @@ -127,6 +127,9 @@ public async Task TestSbomDetector_SimpleSbomAsync() sbomComponent.SpdxVersion.Should().Be("SPDX-2.2"); sbomComponent.Checksum.Should().Be(checksum); sbomComponent.Path.Should().Be(Path.Combine(Path.GetTempPath(), spdxFileName)); + + sbomComponent.CreatorTool.Should().Be("Microsoft.SBOMTool-1.0.0"); + sbomComponent.CreatorOrganization.Should().Be("Microsoft"); } [TestMethod] @@ -160,4 +163,213 @@ public async Task TestSbomDetector_InvalidFileAsync() var components = detectedComponents.ToList(); components.Should().BeEmpty(); } + + [TestMethod] + public async Task TestSbomDetector_ExtractsCreatorToolAndOrganizationAsync() + { + var spdxFile = /*lang=json,strict*/ @"{ + ""spdxVersion"": ""SPDX-2.2"", + ""SPDXID"": ""SPDXRef-DOCUMENT"", + ""name"": ""TestDoc"", + ""documentNamespace"": ""https://sbom.microsoft/test/1.0.0/abc"", + ""creationInfo"": { + ""created"": ""2024-01-01T00:00:00Z"", + ""creators"": [ + ""Tool: microsoft/sbom-tool-2.2.0"", + ""Organization: Microsoft"" + ] + }, + ""documentDescribes"": [""SPDXRef-RootPackage""], + ""packages"": [], + ""relationships"": [] +}"; + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile("manifest.spdx.json", spdxFile) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var sbomComponent = (SpdxComponent)componentRecorder.GetDetectedComponents().Single().Component; + sbomComponent.CreatorTool.Should().Be("microsoft/sbom-tool-2.2.0"); + sbomComponent.CreatorOrganization.Should().Be("Microsoft"); + } + + [TestMethod] + public async Task TestSbomDetector_MissingCreationInfoReturnsNullAsync() + { + var spdxFile = /*lang=json,strict*/ @"{ + ""spdxVersion"": ""SPDX-2.2"", + ""SPDXID"": ""SPDXRef-DOCUMENT"", + ""name"": ""TestDoc"", + ""documentNamespace"": ""https://sbom.microsoft/test/1.0.0/abc"", + ""documentDescribes"": [""SPDXRef-RootPackage""], + ""packages"": [], + ""relationships"": [] +}"; + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile("manifest.spdx.json", spdxFile) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var sbomComponent = (SpdxComponent)componentRecorder.GetDetectedComponents().Single().Component; + sbomComponent.CreatorTool.Should().BeNull(); + sbomComponent.CreatorOrganization.Should().BeNull(); + } + + [TestMethod] + public async Task TestSbomDetector_WhitespaceOnlyCreatorsReturnNullAsync() + { + var spdxFile = /*lang=json,strict*/ @"{ + ""spdxVersion"": ""SPDX-2.2"", + ""SPDXID"": ""SPDXRef-DOCUMENT"", + ""name"": ""TestDoc"", + ""documentNamespace"": ""https://sbom.microsoft/test/1.0.0/abc"", + ""creationInfo"": { + ""created"": ""2024-01-01T00:00:00Z"", + ""creators"": [ + ""Tool: "", + ""Organization: "" + ] + }, + ""documentDescribes"": [""SPDXRef-RootPackage""], + ""packages"": [], + ""relationships"": [] +}"; + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile("manifest.spdx.json", spdxFile) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var sbomComponent = (SpdxComponent)componentRecorder.GetDetectedComponents().Single().Component; + sbomComponent.CreatorTool.Should().BeNull(); + sbomComponent.CreatorOrganization.Should().BeNull(); + } + + [TestMethod] + public async Task TestSbomDetector_MultipleCreatorsPicksFirstToolAndOrgAsync() + { + var spdxFile = /*lang=json,strict*/ @"{ + ""spdxVersion"": ""SPDX-2.2"", + ""SPDXID"": ""SPDXRef-DOCUMENT"", + ""name"": ""TestDoc"", + ""documentNamespace"": ""https://sbom.microsoft/test/1.0.0/abc"", + ""creationInfo"": { + ""created"": ""2024-01-01T00:00:00Z"", + ""creators"": [ + ""Tool: first-tool-1.0"", + ""Tool: second-tool-2.0"", + ""Organization: FirstOrg"", + ""Organization: SecondOrg"" + ] + }, + ""documentDescribes"": [""SPDXRef-RootPackage""], + ""packages"": [], + ""relationships"": [] +}"; + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile("manifest.spdx.json", spdxFile) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var sbomComponent = (SpdxComponent)componentRecorder.GetDetectedComponents().Single().Component; + sbomComponent.CreatorTool.Should().Be("first-tool-1.0"); + sbomComponent.CreatorOrganization.Should().Be("FirstOrg"); + } + + [TestMethod] + public async Task TestSbomDetector_OnlyToolNoOrganizationAsync() + { + var spdxFile = /*lang=json,strict*/ @"{ + ""spdxVersion"": ""SPDX-2.2"", + ""SPDXID"": ""SPDXRef-DOCUMENT"", + ""name"": ""TestDoc"", + ""documentNamespace"": ""https://sbom.microsoft/test/1.0.0/abc"", + ""creationInfo"": { + ""created"": ""2024-01-01T00:00:00Z"", + ""creators"": [ + ""Tool: my-tool-3.0"" + ] + }, + ""documentDescribes"": [""SPDXRef-RootPackage""], + ""packages"": [], + ""relationships"": [] +}"; + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile("manifest.spdx.json", spdxFile) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var sbomComponent = (SpdxComponent)componentRecorder.GetDetectedComponents().Single().Component; + sbomComponent.CreatorTool.Should().Be("my-tool-3.0"); + sbomComponent.CreatorOrganization.Should().BeNull(); + } + + [TestMethod] + public async Task TestSbomDetector_OnlyOrganizationNoToolAsync() + { + var spdxFile = /*lang=json,strict*/ @"{ + ""spdxVersion"": ""SPDX-2.2"", + ""SPDXID"": ""SPDXRef-DOCUMENT"", + ""name"": ""TestDoc"", + ""documentNamespace"": ""https://sbom.microsoft/test/1.0.0/abc"", + ""creationInfo"": { + ""created"": ""2024-01-01T00:00:00Z"", + ""creators"": [ + ""Organization: Contoso"" + ] + }, + ""documentDescribes"": [""SPDXRef-RootPackage""], + ""packages"": [], + ""relationships"": [] +}"; + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile("manifest.spdx.json", spdxFile) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var sbomComponent = (SpdxComponent)componentRecorder.GetDetectedComponents().Single().Component; + sbomComponent.CreatorTool.Should().BeNull(); + sbomComponent.CreatorOrganization.Should().Be("Contoso"); + } + + [TestMethod] + public async Task TestSbomDetector_CreatorsWithNoToolOrOrgPrefixAsync() + { + var spdxFile = /*lang=json,strict*/ @"{ + ""spdxVersion"": ""SPDX-2.2"", + ""SPDXID"": ""SPDXRef-DOCUMENT"", + ""name"": ""TestDoc"", + ""documentNamespace"": ""https://sbom.microsoft/test/1.0.0/abc"", + ""creationInfo"": { + ""created"": ""2024-01-01T00:00:00Z"", + ""creators"": [ + ""Person: John Doe (john@example.com)"" + ] + }, + ""documentDescribes"": [""SPDXRef-RootPackage""], + ""packages"": [], + ""relationships"": [] +}"; + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile("manifest.spdx.json", spdxFile) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var sbomComponent = (SpdxComponent)componentRecorder.GetDetectedComponents().Single().Component; + sbomComponent.CreatorTool.Should().BeNull(); + sbomComponent.CreatorOrganization.Should().BeNull(); + } }