save development progress

This commit is contained in:
StellaOps Bot
2025-12-25 23:09:58 +02:00
parent d71853ad7e
commit aa70af062e
351 changed files with 37683 additions and 150156 deletions

View File

@@ -0,0 +1,90 @@
// -----------------------------------------------------------------------------
// ISbomParser.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Task: SBOM-8200-005
// Description: Interface for SBOM parsing and PURL extraction
// -----------------------------------------------------------------------------
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Concelier.SbomIntegration.Parsing;
/// <summary>
/// Service for parsing SBOM content and extracting package identifiers.
/// </summary>
public interface ISbomParser
{
/// <summary>
/// Extracts PURLs from SBOM content.
/// </summary>
/// <param name="content">SBOM content stream.</param>
/// <param name="format">SBOM format (CycloneDX or SPDX).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Parsing result with extracted PURLs.</returns>
Task<SbomParseResult> ParseAsync(
Stream content,
SbomFormat format,
CancellationToken cancellationToken = default);
/// <summary>
/// Detects the SBOM format from content.
/// </summary>
/// <param name="content">SBOM content stream.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Detected format and spec version.</returns>
Task<SbomFormatInfo> DetectFormatAsync(
Stream content,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of SBOM parsing.
/// </summary>
public sealed record SbomParseResult
{
/// <summary>List of extracted PURLs.</summary>
public required IReadOnlyList<string> Purls { get; init; }
/// <summary>List of extracted CPEs (for OS packages).</summary>
public IReadOnlyList<string> Cpes { get; init; } = [];
/// <summary>Primary component name (e.g., image name).</summary>
public string? PrimaryName { get; init; }
/// <summary>Primary component version.</summary>
public string? PrimaryVersion { get; init; }
/// <summary>Total component count in SBOM.</summary>
public int TotalComponents { get; init; }
/// <summary>Components without PURL (name/version only).</summary>
public IReadOnlyList<ComponentInfo> UnresolvedComponents { get; init; } = [];
/// <summary>Parsing warnings (non-fatal issues).</summary>
public IReadOnlyList<string> Warnings { get; init; } = [];
}
/// <summary>
/// Information about a component without PURL.
/// </summary>
public sealed record ComponentInfo
{
public required string Name { get; init; }
public string? Version { get; init; }
public string? Type { get; init; }
}
/// <summary>
/// Detected SBOM format information.
/// </summary>
public sealed record SbomFormatInfo
{
/// <summary>SBOM format.</summary>
public SbomFormat Format { get; init; }
/// <summary>Specification version (e.g., "1.5" for CycloneDX).</summary>
public string? SpecVersion { get; init; }
/// <summary>Whether format was successfully detected.</summary>
public bool IsDetected { get; init; }
}

View File

@@ -0,0 +1,517 @@
// -----------------------------------------------------------------------------
// SbomParser.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Task: SBOM-8200-005
// Description: SBOM parser for CycloneDX and SPDX formats
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Concelier.SbomIntegration.Parsing;
/// <summary>
/// Parser for extracting PURLs and metadata from SBOM documents.
/// Supports CycloneDX (1.4-1.6) and SPDX (2.2-2.3, 3.0).
/// </summary>
public sealed class SbomParser : ISbomParser
{
private readonly ILogger<SbomParser> _logger;
public SbomParser(ILogger<SbomParser> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<SbomParseResult> ParseAsync(
Stream content,
SbomFormat format,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(content);
// Ensure stream is at beginning
if (content.CanSeek)
{
content.Position = 0;
}
return format switch
{
SbomFormat.CycloneDX => await ParseCycloneDxAsync(content, cancellationToken).ConfigureAwait(false),
SbomFormat.SPDX => await ParseSpdxAsync(content, cancellationToken).ConfigureAwait(false),
_ => throw new ArgumentException($"Unsupported SBOM format: {format}", nameof(format))
};
}
/// <inheritdoc />
public async Task<SbomFormatInfo> DetectFormatAsync(
Stream content,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(content);
if (content.CanSeek)
{
content.Position = 0;
}
try
{
using var doc = await JsonDocument.ParseAsync(content, cancellationToken: cancellationToken)
.ConfigureAwait(false);
var root = doc.RootElement;
// Check for CycloneDX
if (root.TryGetProperty("bomFormat", out var bomFormat) &&
bomFormat.GetString()?.Equals("CycloneDX", StringComparison.OrdinalIgnoreCase) == true)
{
var specVersion = root.TryGetProperty("specVersion", out var sv) ? sv.GetString() : null;
return new SbomFormatInfo
{
Format = SbomFormat.CycloneDX,
SpecVersion = specVersion,
IsDetected = true
};
}
// Check for SPDX 2.x
if (root.TryGetProperty("spdxVersion", out var spdxVersion))
{
return new SbomFormatInfo
{
Format = SbomFormat.SPDX,
SpecVersion = spdxVersion.GetString(),
IsDetected = true
};
}
// Check for SPDX 3.0 (@context indicates JSON-LD)
if (root.TryGetProperty("@context", out var context))
{
var contextStr = context.ValueKind == JsonValueKind.String
? context.GetString()
: context.ToString();
if (contextStr?.Contains("spdx", StringComparison.OrdinalIgnoreCase) == true)
{
return new SbomFormatInfo
{
Format = SbomFormat.SPDX,
SpecVersion = "3.0",
IsDetected = true
};
}
}
return new SbomFormatInfo { IsDetected = false };
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse SBOM content as JSON");
return new SbomFormatInfo { IsDetected = false };
}
}
private async Task<SbomParseResult> ParseCycloneDxAsync(
Stream content,
CancellationToken cancellationToken)
{
using var doc = await JsonDocument.ParseAsync(content, cancellationToken: cancellationToken)
.ConfigureAwait(false);
var root = doc.RootElement;
var purls = new List<string>();
var cpes = new List<string>();
var unresolvedComponents = new List<ComponentInfo>();
var warnings = new List<string>();
string? primaryName = null;
string? primaryVersion = null;
int totalComponents = 0;
// Get primary component from metadata
if (root.TryGetProperty("metadata", out var metadata) &&
metadata.TryGetProperty("component", out var primaryComponent))
{
primaryName = primaryComponent.TryGetProperty("name", out var name) ? name.GetString() : null;
primaryVersion = primaryComponent.TryGetProperty("version", out var version) ? version.GetString() : null;
// Primary component may also have a PURL
if (primaryComponent.TryGetProperty("purl", out var primaryPurl))
{
var purlStr = primaryPurl.GetString();
if (!string.IsNullOrWhiteSpace(purlStr))
{
purls.Add(purlStr);
}
}
}
// Parse components array
if (root.TryGetProperty("components", out var components))
{
foreach (var component in components.EnumerateArray())
{
totalComponents++;
ParseCycloneDxComponent(component, purls, cpes, unresolvedComponents, warnings);
}
}
// Parse nested components (CycloneDX supports component hierarchy)
ParseNestedComponents(root, purls, cpes, unresolvedComponents, warnings, ref totalComponents);
_logger.LogDebug(
"Parsed CycloneDX SBOM: {PurlCount} PURLs, {CpeCount} CPEs, {UnresolvedCount} unresolved from {TotalCount} components",
purls.Count, cpes.Count, unresolvedComponents.Count, totalComponents);
return new SbomParseResult
{
Purls = purls.Distinct().ToList(),
Cpes = cpes.Distinct().ToList(),
PrimaryName = primaryName,
PrimaryVersion = primaryVersion,
TotalComponents = totalComponents,
UnresolvedComponents = unresolvedComponents,
Warnings = warnings
};
}
private void ParseCycloneDxComponent(
JsonElement component,
List<string> purls,
List<string> cpes,
List<ComponentInfo> unresolved,
List<string> warnings)
{
var hasPurl = false;
// Extract PURL
if (component.TryGetProperty("purl", out var purl))
{
var purlStr = purl.GetString();
if (!string.IsNullOrWhiteSpace(purlStr))
{
purls.Add(purlStr);
hasPurl = true;
}
}
// Extract CPE (from cpe property or externalReferences)
if (component.TryGetProperty("cpe", out var cpe))
{
var cpeStr = cpe.GetString();
if (!string.IsNullOrWhiteSpace(cpeStr))
{
cpes.Add(cpeStr);
}
}
// Check externalReferences for additional CPEs
if (component.TryGetProperty("externalReferences", out var extRefs))
{
foreach (var extRef in extRefs.EnumerateArray())
{
if (extRef.TryGetProperty("type", out var type) &&
type.GetString()?.Equals("cpe", StringComparison.OrdinalIgnoreCase) == true &&
extRef.TryGetProperty("url", out var url))
{
var cpeStr = url.GetString();
if (!string.IsNullOrWhiteSpace(cpeStr))
{
cpes.Add(cpeStr);
}
}
}
}
// Track unresolved components (no PURL)
if (!hasPurl)
{
var name = component.TryGetProperty("name", out var n) ? n.GetString() : null;
var version = component.TryGetProperty("version", out var v) ? v.GetString() : null;
var componentType = component.TryGetProperty("type", out var t) ? t.GetString() : null;
if (!string.IsNullOrWhiteSpace(name))
{
unresolved.Add(new ComponentInfo
{
Name = name,
Version = version,
Type = componentType
});
}
}
// Recursively parse nested components
if (component.TryGetProperty("components", out var nestedComponents))
{
foreach (var nested in nestedComponents.EnumerateArray())
{
ParseCycloneDxComponent(nested, purls, cpes, unresolved, warnings);
}
}
}
private void ParseNestedComponents(
JsonElement root,
List<string> purls,
List<string> cpes,
List<ComponentInfo> unresolved,
List<string> warnings,
ref int totalComponents)
{
// CycloneDX 1.5+ supports dependencies with nested refs
if (root.TryGetProperty("dependencies", out var dependencies))
{
// Dependencies section doesn't contain component data, just refs
// Already handled through components traversal
}
// Check for compositions (CycloneDX 1.4+)
if (root.TryGetProperty("compositions", out var compositions))
{
// Compositions define relationships but don't add new components
}
}
private async Task<SbomParseResult> ParseSpdxAsync(
Stream content,
CancellationToken cancellationToken)
{
using var doc = await JsonDocument.ParseAsync(content, cancellationToken: cancellationToken)
.ConfigureAwait(false);
var root = doc.RootElement;
var purls = new List<string>();
var cpes = new List<string>();
var unresolvedComponents = new List<ComponentInfo>();
var warnings = new List<string>();
string? primaryName = null;
string? primaryVersion = null;
int totalComponents = 0;
// Detect SPDX version
var isSpdx3 = root.TryGetProperty("@context", out _);
if (isSpdx3)
{
return await ParseSpdx3Async(root, cancellationToken).ConfigureAwait(false);
}
// SPDX 2.x parsing
// Get document name as primary
if (root.TryGetProperty("name", out var docName))
{
primaryName = docName.GetString();
}
// Parse packages
if (root.TryGetProperty("packages", out var packages))
{
foreach (var package in packages.EnumerateArray())
{
totalComponents++;
ParseSpdxPackage(package, purls, cpes, unresolvedComponents, warnings);
// First package is often the primary
if (primaryName is null && package.TryGetProperty("name", out var pkgName))
{
primaryName = pkgName.GetString();
primaryVersion = package.TryGetProperty("versionInfo", out var v) ? v.GetString() : null;
}
}
}
_logger.LogDebug(
"Parsed SPDX SBOM: {PurlCount} PURLs, {CpeCount} CPEs, {UnresolvedCount} unresolved from {TotalCount} packages",
purls.Count, cpes.Count, unresolvedComponents.Count, totalComponents);
return new SbomParseResult
{
Purls = purls.Distinct().ToList(),
Cpes = cpes.Distinct().ToList(),
PrimaryName = primaryName,
PrimaryVersion = primaryVersion,
TotalComponents = totalComponents,
UnresolvedComponents = unresolvedComponents,
Warnings = warnings
};
}
private void ParseSpdxPackage(
JsonElement package,
List<string> purls,
List<string> cpes,
List<ComponentInfo> unresolved,
List<string> warnings)
{
var hasPurl = false;
// Extract from externalRefs
if (package.TryGetProperty("externalRefs", out var extRefs))
{
foreach (var extRef in extRefs.EnumerateArray())
{
var refType = extRef.TryGetProperty("referenceType", out var rt) ? rt.GetString() : null;
var refCategory = extRef.TryGetProperty("referenceCategory", out var rc) ? rc.GetString() : null;
var locator = extRef.TryGetProperty("referenceLocator", out var loc) ? loc.GetString() : null;
if (string.IsNullOrWhiteSpace(locator))
continue;
// PURL reference
if (refType?.Equals("purl", StringComparison.OrdinalIgnoreCase) == true ||
refCategory?.Equals("PACKAGE-MANAGER", StringComparison.OrdinalIgnoreCase) == true)
{
if (locator.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
purls.Add(locator);
hasPurl = true;
}
}
// CPE reference
if (refType?.StartsWith("cpe", StringComparison.OrdinalIgnoreCase) == true ||
refCategory?.Equals("SECURITY", StringComparison.OrdinalIgnoreCase) == true)
{
if (locator.StartsWith("cpe:", StringComparison.OrdinalIgnoreCase))
{
cpes.Add(locator);
}
}
}
}
// Track unresolved packages (no PURL)
if (!hasPurl)
{
var name = package.TryGetProperty("name", out var n) ? n.GetString() : null;
var version = package.TryGetProperty("versionInfo", out var v) ? v.GetString() : null;
if (!string.IsNullOrWhiteSpace(name))
{
unresolved.Add(new ComponentInfo
{
Name = name,
Version = version,
Type = "package"
});
}
}
}
private Task<SbomParseResult> ParseSpdx3Async(
JsonElement root,
CancellationToken cancellationToken)
{
var purls = new List<string>();
var cpes = new List<string>();
var unresolvedComponents = new List<ComponentInfo>();
var warnings = new List<string>();
string? primaryName = null;
string? primaryVersion = null;
int totalComponents = 0;
// SPDX 3.0 uses "@graph" for elements
if (root.TryGetProperty("@graph", out var graph))
{
foreach (var element in graph.EnumerateArray())
{
var elementType = element.TryGetProperty("@type", out var t) ? t.GetString() : null;
// Skip non-package elements
if (elementType is null ||
(!elementType.Contains("Package", StringComparison.OrdinalIgnoreCase) &&
!elementType.Contains("Software", StringComparison.OrdinalIgnoreCase)))
{
continue;
}
totalComponents++;
var hasPurl = false;
// SPDX 3.0 uses packageUrl property directly
if (element.TryGetProperty("packageUrl", out var purl))
{
var purlStr = purl.GetString();
if (!string.IsNullOrWhiteSpace(purlStr))
{
purls.Add(purlStr);
hasPurl = true;
}
}
// Check externalIdentifier array
if (element.TryGetProperty("externalIdentifier", out var extIds))
{
foreach (var extId in extIds.EnumerateArray())
{
var idType = extId.TryGetProperty("externalIdentifierType", out var eit)
? eit.GetString()
: null;
var idValue = extId.TryGetProperty("identifier", out var id) ? id.GetString() : null;
if (string.IsNullOrWhiteSpace(idValue))
continue;
if (idType?.Equals("purl", StringComparison.OrdinalIgnoreCase) == true)
{
purls.Add(idValue);
hasPurl = true;
}
else if (idType?.StartsWith("cpe", StringComparison.OrdinalIgnoreCase) == true)
{
cpes.Add(idValue);
}
}
}
// Track unresolved
if (!hasPurl)
{
var name = element.TryGetProperty("name", out var n) ? n.GetString() : null;
var version = element.TryGetProperty("packageVersion", out var v)
? v.GetString()
: element.TryGetProperty("softwareVersion", out var sv)
? sv.GetString()
: null;
if (!string.IsNullOrWhiteSpace(name))
{
unresolvedComponents.Add(new ComponentInfo
{
Name = name,
Version = version,
Type = elementType
});
}
}
// Get primary from first package
if (primaryName is null)
{
primaryName = element.TryGetProperty("name", out var n) ? n.GetString() : null;
primaryVersion = element.TryGetProperty("packageVersion", out var v) ? v.GetString() : null;
}
}
}
_logger.LogDebug(
"Parsed SPDX 3.0 SBOM: {PurlCount} PURLs, {CpeCount} CPEs, {UnresolvedCount} unresolved from {TotalCount} elements",
purls.Count, cpes.Count, unresolvedComponents.Count, totalComponents);
return Task.FromResult(new SbomParseResult
{
Purls = purls.Distinct().ToList(),
Cpes = cpes.Distinct().ToList(),
PrimaryName = primaryName,
PrimaryVersion = primaryVersion,
TotalComponents = totalComponents,
UnresolvedComponents = unresolvedComponents,
Warnings = warnings
});
}
}