diff --git a/src/Microsoft.ComponentDetection.Detectors/spdx/Spdx22ComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/spdx/Spdx22ComponentDetector.cs index 77de63730..a49bed04a 100644 --- a/src/Microsoft.ComponentDetection.Detectors/spdx/Spdx22ComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/spdx/Spdx22ComponentDetector.cs @@ -67,7 +67,7 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID } else { - this.Logger.LogWarning("Discovered SPDX at {ManifestLocation} is not SPDX-2.2 document, skipping", processRequest.ComponentStream.Location); + this.Logger.LogDebug("Discovered SPDX at {ManifestLocation} is not SPDX-2.2 document, skipping", processRequest.ComponentStream.Location); } } catch (JsonException) diff --git a/src/Microsoft.ComponentDetection.Detectors/spdx/Spdx30ComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/spdx/Spdx30ComponentDetector.cs new file mode 100644 index 000000000..384e3ac80 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/spdx/Spdx30ComponentDetector.cs @@ -0,0 +1,216 @@ +namespace Microsoft.ComponentDetection.Detectors.Spdx; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.Internal; +using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.Extensions.Logging; + +public class Spdx30ComponentDetector : FileComponentDetector, IDefaultOffComponentDetector +{ + public Spdx30ComponentDetector( + IComponentStreamEnumerableFactory componentStreamEnumerableFactory, + IObservableDirectoryWalkerFactory walkerFactory, + ILogger logger) + { + this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; + this.Scanner = walkerFactory; + this.Logger = logger; + } + + public override string Id => "SPDX30SBOM"; + + public override IEnumerable Categories => [nameof(DetectorClass.Spdx)]; + + public override IEnumerable SupportedComponentTypes => [ComponentType.Spdx]; + + public override int Version => 1; + + public override IList SearchPatterns => ["*.spdx.json"]; + + protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary detectorArgs, CancellationToken cancellationToken = default) + { + var file = processRequest.ComponentStream; + this.Logger.LogDebug("Discovered SPDX 3.0 manifest file at: {ManifestLocation}", file.Location); + + try + { + // Keep the SHA1 document fingerprint used by SpdxComponent and existing SBOM reference consumers. +#pragma warning disable CA5350, CA1308 // Existing SPDX document references require lowercase SHA1 checksums. + var checksum = Convert.ToHexString(await SHA1.HashDataAsync(file.Stream, cancellationToken)).ToLowerInvariant(); +#pragma warning restore CA5350, CA1308 + file.Stream.Seek(0, SeekOrigin.Begin); + + using var json = await JsonDocument.ParseAsync(file.Stream, cancellationToken: cancellationToken); + var component = this.ReadDocument(json.RootElement, checksum, file.Location); + if (component != null) + { + processRequest.SingleFileComponentRecorder.RegisterUsage(new DetectedComponent(component)); + } + } + catch (JsonException e) + { + this.Logger.LogWarning(e, "Unable to parse SPDX 3.0 file at {ManifestLocation}, skipping", file.Location); + } + catch (IOException e) + { + this.Logger.LogError(e, "Error while reading SPDX 3.0 file at {ManifestLocation}", file.Location); + } + catch (Exception e) when (e is OutOfMemoryException or OverflowException) + { + this.Logger.LogWarning(e, "SPDX 3.0 file at {ManifestLocation} exceeds available memory or supported size, skipping", file.Location); + } + } + + private static bool IsSupportedVersion(string version) + { + const string prefix = "3.0."; + if (!version.StartsWith(prefix, StringComparison.Ordinal) || version.Length == prefix.Length) + { + return false; + } + + var patchVersion = version[prefix.Length..]; + return (patchVersion.Length == 1 || patchVersion[0] != '0') + && patchVersion.All(character => character is >= '0' and <= '9'); + } + + private static string? ReadString(JsonElement element, string property) + { + if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(property, out var value)) + { + return null; + } + + if (value.ValueKind != JsonValueKind.String) + { + throw new JsonException($"Expected a string for {property}."); + } + + return value.GetString(); + } + + private static bool HasType(JsonElement element, string type) + { + if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty("type", out var value)) + { + return false; + } + + return value.ValueKind == JsonValueKind.String + ? value.GetString() == type + : value.ValueKind == JsonValueKind.Array && value.EnumerateArray().Any(item => item.ValueKind == JsonValueKind.String && item.GetString() == type); + } + + private static string? ReadId(JsonElement element) => + element.ValueKind == JsonValueKind.String ? element.GetString() : ReadString(element, "spdxId") ?? ReadString(element, "@id"); + + private static JsonElement Resolve(JsonElement element, IDictionary elements) + { + var id = ReadId(element); + return id != null && elements.TryGetValue(id, out var resolved) ? resolved : element; + } + + private static IEnumerable ReadValues(JsonElement element, string property) + { + if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(property, out var value)) + { + return []; + } + + return value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().ToArray() : [value]; + } + + private static string? ReadCreator(JsonElement creationInfo, string property, string type, IDictionary elements) => + ReadValues(creationInfo, property) + .Select(element => Resolve(element, elements)) + .Where(element => HasType(element, type)) + .Select(element => ReadString(element, "name")) + .FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)); + + private SpdxComponent? ReadDocument(JsonElement root, string checksum, string path) + { + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("@graph", out var graph) + || graph.ValueKind != JsonValueKind.Array) + { + this.Logger.LogDebug("Discovered file at {ManifestLocation} is not an SPDX 3.0 JSON-LD graph, skipping", path); + return null; + } + + var documents = graph.EnumerateArray().Where(element => HasType(element, "SpdxDocument")).ToList(); + if (documents.Count != 1) + { + this.Logger.LogWarning("SPDX 3.0 file at {ManifestLocation} must contain exactly one SpdxDocument, skipping", path); + return null; + } + + var elements = new Dictionary(StringComparer.Ordinal); + foreach (var element in graph.EnumerateArray()) + { + var id = ReadId(element); + if (id != null && !elements.TryAdd(id, element)) + { + throw new JsonException($"Duplicate SPDX element identifier: {id}."); + } + } + + var document = documents[0]; + if (!document.TryGetProperty("creationInfo", out var creationInfo)) + { + throw new JsonException("SpdxDocument is missing creationInfo."); + } + + creationInfo = Resolve(creationInfo, elements); + var version = ReadString(creationInfo, "specVersion") + ?? throw new JsonException("SpdxDocument creationInfo is missing specVersion."); + + if (!IsSupportedVersion(version)) + { + this.Logger.LogDebug("SPDX file at {ManifestLocation} has unsupported specVersion {SpecVersion}, skipping", path, version); + return null; + } + + var documentId = ReadId(document); + if (string.IsNullOrWhiteSpace(documentId) || !Uri.TryCreate(documentId, UriKind.Absolute, out var documentUri)) + { + throw new JsonException("SpdxDocument must have an absolute spdxId."); + } + + var roots = ReadValues(document, "rootElement").Select(ReadId).ToList(); + if (roots.Any(id => string.IsNullOrWhiteSpace(id))) + { + throw new JsonException("SpdxDocument contains an invalid rootElement."); + } + + if (roots.Count > 1) + { + this.Logger.LogWarning("SPDX file at {ManifestLocation} has more than one rootElement, first will be selected as root element.", path); + } + else if (roots.Count == 0) + { + this.Logger.LogWarning("SPDX file at {ManifestLocation} has no rootElement, considering the SpdxDocument as the root element.", path); + } + + // SPDX 3 has no documentNamespace, and name is optional; the document IRI identifies the document. + var name = ReadString(document, "name"); + return new SpdxComponent( + $"SPDX-{version}", + documentUri, + string.IsNullOrWhiteSpace(name) ? documentId : name, + checksum, + roots.FirstOrDefault() ?? documentId, + path) + { + CreatorTool = ReadCreator(creationInfo, "createdUsing", "Tool", elements), + CreatorOrganization = ReadCreator(creationInfo, "createdBy", "Organization", elements), + }; + } +} diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs index dd33c8a83..8e7eaa8ef 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs @@ -160,6 +160,7 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s // SPDX services.AddSingleton(); + services.AddSingleton(); // VCPKG services.AddSingleton(); diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/SPDX22ComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/SPDX22ComponentDetectorTests.cs index c53bfedb2..b12c1240c 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/SPDX22ComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/SPDX22ComponentDetectorTests.cs @@ -14,7 +14,9 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using Microsoft.ComponentDetection.Contracts.TypedComponent; using Microsoft.ComponentDetection.Detectors.Spdx; using Microsoft.ComponentDetection.TestsUtilities; +using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; [TestClass] [TestCategory("Governance/All")] @@ -132,6 +134,37 @@ public async Task TestSbomDetector_SimpleSbomAsync() sbomComponent.CreatorOrganization.Should().Be("Microsoft"); } + [TestMethod] + [DataRow("{\"@context\":\"https://spdx.org/rdf/3.0.1/spdx-context.jsonld\",\"@graph\":[]}")] + [DataRow("{\"spdxVersion\":\"SPDX-2.3\"}")] + public async Task TestSbomDetector_OtherVersionsLogAtDebugAsync(string content) + { + var logger = new Mock>(); + var (result, recorder) = await this.detectorTestUtility + .AddServiceMock(logger) + .WithFile("manifest.spdx.json", content) + .ExecuteDetectorAsync(); + + result.ResultCode.Should().Be(ProcessingResultCode.Success); + recorder.GetDetectedComponents().Should().BeEmpty(); + logger.Verify( + log => log.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((value, _) => value.ToString().Contains("not SPDX-2.2")), + It.IsAny(), + It.IsAny>()), + Times.Once); + logger.Verify( + log => log.Log( + It.Is(level => level >= LogLevel.Warning), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Never); + } + [TestMethod] public async Task TestSbomDetector_BlankJsonAsync() { diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/Spdx30ComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/Spdx30ComponentDetectorTests.cs new file mode 100644 index 000000000..07358cbd2 --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/Spdx30ComponentDetectorTests.cs @@ -0,0 +1,286 @@ +namespace Microsoft.ComponentDetection.Detectors.Tests; + +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.ComponentDetection.Detectors.Spdx; +using Microsoft.ComponentDetection.TestsUtilities; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +[TestClass] +[TestCategory("Governance/All")] +[TestCategory("Governance/ComponentDetection")] +public class Spdx30ComponentDetectorTests : BaseDetectorTest +{ + private const string DocumentId = "https://example.org/sbom/document"; + private const string RootId = "https://example.org/sbom/package"; + private const string Manifest = """ + { + "@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld", + "@graph": [ + { + "type": "SpdxDocument", + "spdxId": "https://example.org/sbom/document", + "name": "Example SBOM", + "creationInfo": "_:creation", + "rootElement": ["https://example.org/sbom/package"] + }, + { + "@id": "_:creation", + "type": "CreationInfo", + "specVersion": "3.0.1", + "created": "2026-01-01T00:00:00Z", + "createdBy": ["https://example.org/sbom/organization"], + "createdUsing": ["https://example.org/sbom/tool"] + }, + { + "type": "Organization", + "spdxId": "https://example.org/sbom/organization", + "name": "Example Organization", + "creationInfo": "_:creation" + }, + { + "type": "Tool", + "spdxId": "https://example.org/sbom/tool", + "name": "Example Tool", + "creationInfo": "_:creation" + }, + { + "type": "software_Package", + "spdxId": "https://example.org/sbom/package", + "name": "Example Package", + "creationInfo": "_:creation" + } + ] + } + """; + + [TestMethod] + [DataRow("3.0.0")] + [DataRow("3.0.1")] + [DataRow("3.0.2")] + [DataRow("3.0.123")] + public async Task DetectsDocumentWithoutImportingPackagesAsync(string version) + { + const string fileName = "manifest.spdx.json"; + var content = Manifest.Replace("3.0.1", version, StringComparison.Ordinal); + var (result, recorder) = await this.DetectorTestUtility + .WithFile(fileName, content) + .ExecuteDetectorAsync(); + + result.ResultCode.Should().Be(ProcessingResultCode.Success); + var component = recorder.GetDetectedComponents().Should().ContainSingle().Subject.Component + .Should().BeOfType().Subject; + +#pragma warning disable CA5350, CA1308 // Match the lowercase SHA1 fingerprint required by existing SPDX reference consumers. + var checksum = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(content))).ToLowerInvariant(); +#pragma warning restore CA5350, CA1308 + + component.Name.Should().Be("Example SBOM"); + component.SpdxVersion.Should().Be($"SPDX-{version}"); + component.DocumentNamespace.Should().Be(new Uri(DocumentId)); + component.RootElementId.Should().Be(RootId); + component.Checksum.Should().Be(checksum); + component.Path.Should().Be(Path.Combine(Path.GetTempPath(), fileName)); + component.CreatorTool.Should().Be("Example Tool"); + component.CreatorOrganization.Should().Be("Example Organization"); + } + + [TestMethod] + public async Task ResolvesEmbeddedCreationInfoAndCreatorsAsync() + { + var json = JsonNode.Parse(Manifest)!; + var graph = json["@graph"]!.AsArray(); + var creation = graph[1]!.DeepClone(); + creation["createdBy"] = new JsonArray(graph[2]!.DeepClone()); + creation["createdUsing"] = new JsonArray(graph[3]!.DeepClone()); + graph[0]!["creationInfo"] = creation; + graph.RemoveAt(1); + + var (_, recorder) = await this.DetectorTestUtility + .WithFile("manifest.spdx.json", json.ToJsonString()) + .ExecuteDetectorAsync(); + + var component = (SpdxComponent)recorder.GetDetectedComponents().Single().Component; + component.CreatorTool.Should().Be("Example Tool"); + component.CreatorOrganization.Should().Be("Example Organization"); + } + + [TestMethod] + [DataRow("[]", DocumentId)] + [DataRow("[\"https://example.org/sbom/package\", \"https://example.org/sbom/other\"]", RootId)] + [DataRow("\"https://example.org/sbom/package\"", RootId)] + [DataRow("[{\"spdxId\":\"https://example.org/sbom/package\"}]", RootId)] + public async Task SelectsRootAndUsesDocumentIdForUnnamedDocumentsAsync(string roots, string expectedRoot) + { + var json = JsonNode.Parse(Manifest)!; + var document = json["@graph"]![0]!.AsObject(); + document.Remove("name"); + document["rootElement"] = JsonNode.Parse(roots); + + var (_, recorder) = await this.DetectorTestUtility + .WithFile("manifest.spdx.json", json.ToJsonString()) + .ExecuteDetectorAsync(); + + var component = (SpdxComponent)recorder.GetDetectedComponents().Single().Component; + component.Name.Should().Be(DocumentId); + component.RootElementId.Should().Be(expectedRoot); + } + + [TestMethod] + public async Task UsesDocumentAsRootWhenRootElementIsAbsentAsync() + { + var json = JsonNode.Parse(Manifest)!; + json["@graph"]![0]!.AsObject().Remove("rootElement"); + var creation = json["@graph"]![1]!.AsObject(); + creation.Remove("createdUsing"); + creation["createdBy"] = new JsonArray("https://example.org/unresolved-agent"); + + var (_, recorder) = await this.DetectorTestUtility + .WithFile("manifest.spdx.json", json.ToJsonString()) + .ExecuteDetectorAsync(); + + var component = (SpdxComponent)recorder.GetDetectedComponents().Single().Component; + component.RootElementId.Should().Be(DocumentId); + component.CreatorTool.Should().BeNull(); + component.CreatorOrganization.Should().BeNull(); + } + + [TestMethod] + [DataRow("[]")] + [DataRow("{}")] + [DataRow("{\"spdxVersion\":\"SPDX-2.2\"}")] + [DataRow("{\"@graph\":{}}")] + public async Task SkipsNonSpdx30DocumentsWithDebugLogAsync(string content) + { + await this.AssertSkippedAsync(content, LogLevel.Debug); + } + + [TestMethod] + [DataRow("not json")] + [DataRow("{\"@graph\":[]}")] + [DataRow("{\"@graph\":[{\"type\":\"software_Package\"}]}")] + public async Task SkipsNonSpdx30DocumentsWithWarningAsync(string content) + { + await this.AssertSkippedWithWarningAsync(content); + } + + [TestMethod] + [DataRow(0, "spdxId", "\"relative-document\"")] + [DataRow(0, "spdxId", "null")] + [DataRow(0, "creationInfo", "\"_:unresolved\"")] + [DataRow(0, "rootElement", "[42]")] + [DataRow(0, "name", "42")] + [DataRow(1, "specVersion", "null")] + public async Task SkipsInvalidDocumentsWithWarningAsync(int index, string property, string value) + { + var json = JsonNode.Parse(Manifest)!; + json["@graph"]![index]![property] = JsonNode.Parse(value); + await this.AssertSkippedWithWarningAsync(json.ToJsonString()); + } + + [TestMethod] + [DataRow(1, "specVersion", "\"2.2\"")] + [DataRow(1, "specVersion", "\"3.1.0\"")] + [DataRow(1, "specVersion", "\"3.0.1-draft\"")] + [DataRow(1, "specVersion", "\"3.0\"")] + [DataRow(1, "specVersion", "\"3.0.\"")] + [DataRow(1, "specVersion", "\"3.0.\\u0661\"")] + [DataRow(1, "specVersion", "\"3.0.01\"")] + [DataRow(1, "specVersion", "\"3.0.-1\"")] + [DataRow(1, "specVersion", "\"3.0.1.0\"")] + [DataRow(1, "specVersion", "\"3.0.1\\n\"")] + public async Task SkipsUnsupportedVersionsWithDebugLogAsync(int index, string property, string value) + { + var json = JsonNode.Parse(Manifest)!; + json["@graph"]![index]![property] = JsonNode.Parse(value); + await this.AssertSkippedAsync(json.ToJsonString(), LogLevel.Debug); + } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task SkipsResourceFailuresWithWarningAndContinuesAsync(bool outOfMemory) + { +#pragma warning disable CA2201 // Simulate allocation failure without exhausting the test process's memory. + Exception exception = outOfMemory ? new OutOfMemoryException() : new OverflowException(); +#pragma warning restore CA2201 + var logger = new Mock>(); + var stream = new Mock(); + stream.SetupGet(value => value.CanRead).Returns(true); + stream.Setup(value => value.ReadAsync(It.IsAny>(), It.IsAny())) + .Throws(exception); + + var (result, recorder) = await this.DetectorTestUtility + .AddServiceMock(logger) + .WithFile("large.spdx.json", stream.Object) + .WithFile("manifest.spdx.json", Manifest) + .ExecuteDetectorAsync(); + + result.ResultCode.Should().Be(ProcessingResultCode.Success); + recorder.GetDetectedComponents().Should().ContainSingle(); + logger.Verify( + log => log.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((value, _) => value.ToString()!.Contains("large.spdx.json", StringComparison.Ordinal)), + exception, + It.IsAny>()), + Times.Once); + } + + [TestMethod] + [DataRow(0)] + [DataRow(1)] + public async Task RejectsMultipleDocumentsOrDuplicateIdentifiersAsync(int index) + { + var json = JsonNode.Parse(Manifest)!; + var graph = json["@graph"]!.AsArray(); + graph.Add(graph[index]!.DeepClone()); + await this.AssertSkippedWithWarningAsync(json.ToJsonString()); + } + + private Task AssertSkippedWithWarningAsync(string content) => this.AssertSkippedAsync(content, LogLevel.Warning); + + private async Task AssertSkippedAsync(string content, LogLevel expectedLevel) + { + var logger = new Mock>(); + var (result, recorder) = await this.DetectorTestUtility + .AddServiceMock(logger) + .WithFile("manifest.spdx.json", content) + .ExecuteDetectorAsync(); + + result.ResultCode.Should().Be(ProcessingResultCode.Success); + recorder.GetDetectedComponents().Should().BeEmpty(); + logger.Verify( + log => log.Log( + expectedLevel, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.AtLeastOnce); + + if (expectedLevel == LogLevel.Debug) + { + logger.Verify( + log => log.Log( + It.Is(level => level >= LogLevel.Warning), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Never); + } + } +} diff --git a/test/Microsoft.ComponentDetection.VerificationTests/resources/spdx30/manifest.spdx.json b/test/Microsoft.ComponentDetection.VerificationTests/resources/spdx30/manifest.spdx.json new file mode 100644 index 000000000..7eceb5063 --- /dev/null +++ b/test/Microsoft.ComponentDetection.VerificationTests/resources/spdx30/manifest.spdx.json @@ -0,0 +1,45 @@ +{ + "@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld", + "@graph": [ + { + "@id": "_:creation", + "type": "CreationInfo", + "specVersion": "3.0.1", + "created": "2026-01-01T00:00:00Z", + "createdBy": ["https://example.org/spdx3/organization"], + "createdUsing": ["https://example.org/spdx3/tool"] + }, + { + "type": "Organization", + "spdxId": "https://example.org/spdx3/organization", + "name": "Example Organization", + "creationInfo": "_:creation" + }, + { + "type": "Tool", + "spdxId": "https://example.org/spdx3/tool", + "name": "Example SBOM Generator", + "creationInfo": "_:creation" + }, + { + "type": "SpdxDocument", + "spdxId": "https://example.org/spdx3/document", + "name": "SPDX 3.0 verification SBOM", + "creationInfo": "_:creation", + "profileConformance": ["core", "software"], + "rootElement": ["https://example.org/spdx3/package"], + "element": [ + "https://example.org/spdx3/organization", + "https://example.org/spdx3/tool", + "https://example.org/spdx3/package" + ] + }, + { + "type": "software_Package", + "spdxId": "https://example.org/spdx3/package", + "name": "Example Package", + "software_packageVersion": "1.0.0", + "creationInfo": "_:creation" + } + ] +}