Files
git.stella-ops.org/src/StellaOps.Vexer.Formats.CycloneDX/CycloneDxNormalizer.cs

460 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Vexer.Core;
namespace StellaOps.Vexer.Formats.CycloneDX;
public sealed class CycloneDxNormalizer : IVexNormalizer
{
private static readonly ImmutableDictionary<string, VexClaimStatus> StateMap = new Dictionary<string, VexClaimStatus>(StringComparer.OrdinalIgnoreCase)
{
["not_affected"] = VexClaimStatus.NotAffected,
["resolved"] = VexClaimStatus.Fixed,
["resolved_with_patches"] = VexClaimStatus.Fixed,
["resolved_no_fix"] = VexClaimStatus.Fixed,
["fixed"] = VexClaimStatus.Fixed,
["affected"] = VexClaimStatus.Affected,
["known_affected"] = VexClaimStatus.Affected,
["exploitable"] = VexClaimStatus.Affected,
["in_triage"] = VexClaimStatus.UnderInvestigation,
["under_investigation"] = VexClaimStatus.UnderInvestigation,
["unknown"] = VexClaimStatus.UnderInvestigation,
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
private static readonly ImmutableDictionary<string, VexJustification> JustificationMap = new Dictionary<string, VexJustification>(StringComparer.OrdinalIgnoreCase)
{
["code_not_present"] = VexJustification.CodeNotPresent,
["code_not_reachable"] = VexJustification.CodeNotReachable,
["component_not_present"] = VexJustification.ComponentNotPresent,
["component_not_configured"] = VexJustification.ComponentNotConfigured,
["vulnerable_code_not_present"] = VexJustification.VulnerableCodeNotPresent,
["vulnerable_code_not_in_execute_path"] = VexJustification.VulnerableCodeNotInExecutePath,
["vulnerable_code_cannot_be_controlled_by_adversary"] = VexJustification.VulnerableCodeCannotBeControlledByAdversary,
["inline_mitigations_already_exist"] = VexJustification.InlineMitigationsAlreadyExist,
["protected_by_mitigating_control"] = VexJustification.ProtectedByMitigatingControl,
["protected_by_compensating_control"] = VexJustification.ProtectedByCompensatingControl,
["requires_configuration"] = VexJustification.RequiresConfiguration,
["requires_dependency"] = VexJustification.RequiresDependency,
["requires_environment"] = VexJustification.RequiresEnvironment,
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
private readonly ILogger<CycloneDxNormalizer> _logger;
public CycloneDxNormalizer(ILogger<CycloneDxNormalizer> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string Format => VexDocumentFormat.CycloneDx.ToString().ToLowerInvariant();
public bool CanHandle(VexRawDocument document)
=> document is not null && document.Format == VexDocumentFormat.CycloneDx;
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, VexProvider provider, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(provider);
cancellationToken.ThrowIfCancellationRequested();
try
{
var parseResult = CycloneDxParser.Parse(document);
var baseMetadata = parseResult.Metadata;
var claimsBuilder = ImmutableArray.CreateBuilder<VexClaim>();
foreach (var vulnerability in parseResult.Vulnerabilities)
{
cancellationToken.ThrowIfCancellationRequested();
var state = MapState(vulnerability.AnalysisState, out var stateRaw);
var justification = MapJustification(vulnerability.AnalysisJustification);
var responses = vulnerability.AnalysisResponses;
foreach (var affect in vulnerability.Affects)
{
var productInfo = parseResult.ResolveProduct(affect.ComponentRef);
var product = new VexProduct(
productInfo.Key,
productInfo.Name,
productInfo.Version,
productInfo.Purl,
productInfo.Cpe);
var metadata = baseMetadata;
if (!string.IsNullOrWhiteSpace(stateRaw))
{
metadata = metadata.SetItem("cyclonedx.analysis.state", stateRaw);
}
if (!string.IsNullOrWhiteSpace(vulnerability.AnalysisJustification))
{
metadata = metadata.SetItem("cyclonedx.analysis.justification", vulnerability.AnalysisJustification);
}
if (responses.Length > 0)
{
metadata = metadata.SetItem("cyclonedx.analysis.response", string.Join(",", responses));
}
if (!string.IsNullOrWhiteSpace(affect.ComponentRef))
{
metadata = metadata.SetItem("cyclonedx.affects.ref", affect.ComponentRef);
}
var claimDocument = new VexClaimDocument(
VexDocumentFormat.CycloneDx,
document.Digest,
document.SourceUri,
parseResult.BomVersion,
signature: null);
var claim = new VexClaim(
vulnerability.VulnerabilityId,
provider.Id,
product,
state,
claimDocument,
parseResult.FirstObserved,
parseResult.LastObserved,
justification,
vulnerability.Detail,
confidence: null,
additionalMetadata: metadata);
claimsBuilder.Add(claim);
}
}
var orderedClaims = claimsBuilder
.ToImmutable()
.OrderBy(static c => c.VulnerabilityId, StringComparer.Ordinal)
.ThenBy(static c => c.Product.Key, StringComparer.Ordinal)
.ToImmutableArray();
_logger.LogInformation(
"Normalized CycloneDX document {Source} into {ClaimCount} claim(s).",
document.SourceUri,
orderedClaims.Length);
return ValueTask.FromResult(new VexClaimBatch(
document,
orderedClaims,
ImmutableDictionary<string, string>.Empty));
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse CycloneDX VEX document {SourceUri}", document.SourceUri);
throw;
}
}
private static VexClaimStatus MapState(string? state, out string? raw)
{
raw = state?.Trim();
if (!string.IsNullOrWhiteSpace(state) && StateMap.TryGetValue(state.Trim(), out var mapped))
{
return mapped;
}
return VexClaimStatus.UnderInvestigation;
}
private static VexJustification? MapJustification(string? justification)
{
if (string.IsNullOrWhiteSpace(justification))
{
return null;
}
return JustificationMap.TryGetValue(justification.Trim(), out var mapped)
? mapped
: null;
}
private sealed class CycloneDxParser
{
public static CycloneDxParseResult Parse(VexRawDocument document)
{
using var json = JsonDocument.Parse(document.Content.ToArray());
var root = json.RootElement;
var specVersion = TryGetString(root, "specVersion");
var bomVersion = TryGetString(root, "version");
var serialNumber = TryGetString(root, "serialNumber");
var metadataTimestamp = ParseDate(TryGetProperty(root, "metadata"), "timestamp");
var observedTimestamp = metadataTimestamp ?? document.RetrievedAt;
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(specVersion))
{
metadataBuilder["cyclonedx.specVersion"] = specVersion!;
}
if (!string.IsNullOrWhiteSpace(bomVersion))
{
metadataBuilder["cyclonedx.version"] = bomVersion!;
}
if (!string.IsNullOrWhiteSpace(serialNumber))
{
metadataBuilder["cyclonedx.serialNumber"] = serialNumber!;
}
var components = CollectComponents(root);
var vulnerabilities = CollectVulnerabilities(root);
return new CycloneDxParseResult(
metadataBuilder.ToImmutable(),
bomVersion,
observedTimestamp,
observedTimestamp,
components,
vulnerabilities);
}
private static ImmutableDictionary<string, CycloneDxComponent> CollectComponents(JsonElement root)
{
var builder = ImmutableDictionary.CreateBuilder<string, CycloneDxComponent>(StringComparer.Ordinal);
if (root.TryGetProperty("components", out var components) && components.ValueKind == JsonValueKind.Array)
{
foreach (var component in components.EnumerateArray())
{
if (component.ValueKind != JsonValueKind.Object)
{
continue;
}
var reference = TryGetString(component, "bom-ref") ?? TryGetString(component, "bomRef");
if (string.IsNullOrWhiteSpace(reference))
{
continue;
}
var name = TryGetString(component, "name") ?? reference;
var version = TryGetString(component, "version");
var purl = TryGetString(component, "purl");
string? cpe = null;
if (component.TryGetProperty("externalReferences", out var externalRefs) && externalRefs.ValueKind == JsonValueKind.Array)
{
foreach (var referenceEntry in externalRefs.EnumerateArray())
{
if (referenceEntry.ValueKind != JsonValueKind.Object)
{
continue;
}
var type = TryGetString(referenceEntry, "type");
if (!string.Equals(type, "cpe", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (referenceEntry.TryGetProperty("url", out var url) && url.ValueKind == JsonValueKind.String)
{
cpe = url.GetString();
break;
}
}
}
builder[reference!] = new CycloneDxComponent(reference!, name ?? reference!, version, purl, cpe);
}
}
return builder.ToImmutable();
}
private static ImmutableArray<CycloneDxVulnerability> CollectVulnerabilities(JsonElement root)
{
if (!root.TryGetProperty("vulnerabilities", out var vulnerabilitiesElement) ||
vulnerabilitiesElement.ValueKind != JsonValueKind.Array)
{
return ImmutableArray<CycloneDxVulnerability>.Empty;
}
var builder = ImmutableArray.CreateBuilder<CycloneDxVulnerability>();
foreach (var vulnerability in vulnerabilitiesElement.EnumerateArray())
{
if (vulnerability.ValueKind != JsonValueKind.Object)
{
continue;
}
var vulnerabilityId =
TryGetString(vulnerability, "id") ??
TryGetString(vulnerability, "bom-ref") ??
TryGetString(vulnerability, "bomRef") ??
TryGetString(vulnerability, "cve");
if (string.IsNullOrWhiteSpace(vulnerabilityId))
{
continue;
}
var detail = TryGetString(vulnerability, "detail") ?? TryGetString(vulnerability, "description");
var analysis = TryGetProperty(vulnerability, "analysis");
var analysisState = TryGetString(analysis, "state");
var analysisJustification = TryGetString(analysis, "justification");
var analysisResponses = CollectResponses(analysis);
var affects = CollectAffects(vulnerability);
if (affects.Length == 0)
{
continue;
}
builder.Add(new CycloneDxVulnerability(
vulnerabilityId.Trim(),
detail?.Trim(),
analysisState,
analysisJustification,
analysisResponses,
affects));
}
return builder.ToImmutable();
}
private static ImmutableArray<string> CollectResponses(JsonElement analysis)
{
if (analysis.ValueKind != JsonValueKind.Object ||
!analysis.TryGetProperty("response", out var responseElement) ||
responseElement.ValueKind != JsonValueKind.Array)
{
return ImmutableArray<string>.Empty;
}
var responses = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var response in responseElement.EnumerateArray())
{
if (response.ValueKind == JsonValueKind.String)
{
var value = response.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
responses.Add(value.Trim());
}
}
}
return responses.Count == 0 ? ImmutableArray<string>.Empty : responses.ToImmutableArray();
}
private static ImmutableArray<CycloneDxAffect> CollectAffects(JsonElement vulnerability)
{
if (!vulnerability.TryGetProperty("affects", out var affectsElement) ||
affectsElement.ValueKind != JsonValueKind.Array)
{
return ImmutableArray<CycloneDxAffect>.Empty;
}
var builder = ImmutableArray.CreateBuilder<CycloneDxAffect>();
foreach (var affect in affectsElement.EnumerateArray())
{
if (affect.ValueKind != JsonValueKind.Object)
{
continue;
}
var reference = TryGetString(affect, "ref");
if (string.IsNullOrWhiteSpace(reference))
{
continue;
}
builder.Add(new CycloneDxAffect(reference.Trim()));
}
return builder.ToImmutable();
}
private static JsonElement TryGetProperty(JsonElement element, string propertyName)
=> element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var value)
? value
: default;
private static string? TryGetString(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
if (!element.TryGetProperty(propertyName, out var value))
{
return null;
}
return value.ValueKind == JsonValueKind.String ? value.GetString() : null;
}
private static DateTimeOffset? ParseDate(JsonElement element, string propertyName)
{
var value = TryGetString(element, propertyName);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
}
}
private sealed record CycloneDxParseResult(
ImmutableDictionary<string, string> Metadata,
string? BomVersion,
DateTimeOffset FirstObserved,
DateTimeOffset LastObserved,
ImmutableDictionary<string, CycloneDxComponent> Components,
ImmutableArray<CycloneDxVulnerability> Vulnerabilities)
{
public CycloneDxProductInfo ResolveProduct(string? componentRef)
{
if (!string.IsNullOrWhiteSpace(componentRef) &&
Components.TryGetValue(componentRef.Trim(), out var component))
{
return new CycloneDxProductInfo(component.Reference, component.Name, component.Version, component.Purl, component.Cpe);
}
var key = string.IsNullOrWhiteSpace(componentRef) ? "unknown-component" : componentRef.Trim();
return new CycloneDxProductInfo(key, key, null, null, null);
}
}
private sealed record CycloneDxComponent(
string Reference,
string Name,
string? Version,
string? Purl,
string? Cpe);
private sealed record CycloneDxVulnerability(
string VulnerabilityId,
string? Detail,
string? AnalysisState,
string? AnalysisJustification,
ImmutableArray<string> AnalysisResponses,
ImmutableArray<CycloneDxAffect> Affects);
private sealed record CycloneDxAffect(string ComponentRef);
private sealed record CycloneDxProductInfo(
string Key,
string Name,
string? Version,
string? Purl,
string? Cpe);
}