Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Spdx30ComponentDetector> logger)
{
this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory;
this.Scanner = walkerFactory;
this.Logger = logger;
}

public override string Id => "SPDX30SBOM";

public override IEnumerable<string> Categories => [nameof(DetectorClass.Spdx)];

public override IEnumerable<ComponentType> SupportedComponentTypes => [ComponentType.Spdx];

public override int Version => 1;

public override IList<string> SearchPatterns => ["*.spdx.json"];

protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary<string, string> 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);
Comment thread
sfoslund marked this conversation as resolved.
Resolved
var component = this.ReadDocument(json.RootElement, checksum, file.Location);
if (component != null)
{
processRequest.SingleFileComponentRecorder.RegisterUsage(new DetectedComponent(component));
}
}
catch (JsonException e)
Comment thread
sfoslund marked this conversation as resolved.
Resolved
{
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<string, JsonElement> elements)
{
var id = ReadId(element);
return id != null && elements.TryGetValue(id, out var resolved) ? resolved : element;
}

private static IEnumerable<JsonElement> 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<string, JsonElement> 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<string, JsonElement>(StringComparer.Ordinal);
foreach (var element in graph.EnumerateArray())
{
var id = ReadId(element);
if (id != null && !elements.TryAdd(id, element))
Comment thread
sfoslund marked this conversation as resolved.
Resolved
{
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);
Comment thread
sfoslund marked this conversation as resolved.
Resolved
}
else if (roots.Count == 0)
{
this.Logger.LogWarning("SPDX file at {ManifestLocation} has no rootElement, considering the SpdxDocument as the root element.", path);
Comment thread
sfoslund marked this conversation as resolved.
Resolved
}

// 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),
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s

// SPDX
services.AddSingleton<IComponentDetector, Spdx22ComponentDetector>();
services.AddSingleton<IComponentDetector, Spdx30ComponentDetector>();

// VCPKG
services.AddSingleton<IComponentDetector, VcpkgComponentDetector>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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<ILogger<Spdx22ComponentDetector>>();
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<EventId>(),
It.Is<It.IsAnyType>((value, _) => value.ToString().Contains("not SPDX-2.2")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once);
logger.Verify(
log => log.Log(
It.Is<LogLevel>(level => level >= LogLevel.Warning),
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Never);
}

[TestMethod]
public async Task TestSbomDetector_BlankJsonAsync()
{
Expand Down
Loading
Loading