Implement VEX document verification system with issuer management and signature verification
- Added IIssuerDirectory interface for managing VEX document issuers, including methods for registration, revocation, and trust validation. - Created InMemoryIssuerDirectory class as an in-memory implementation of IIssuerDirectory for testing and single-instance deployments. - Introduced ISignatureVerifier interface for verifying signatures on VEX documents, with support for multiple signature formats. - Developed SignatureVerifier class as the default implementation of ISignatureVerifier, allowing extensibility for different signature formats. - Implemented handlers for DSSE and JWS signature formats, including methods for verification and signature extraction. - Defined various records and enums for issuer and signature metadata, enhancing the structure and clarity of the verification process.
This commit is contained in:
685
src/VexLens/StellaOps.VexLens/Normalization/CsafVexNormalizer.cs
Normal file
685
src/VexLens/StellaOps.VexLens/Normalization/CsafVexNormalizer.cs
Normal file
@@ -0,0 +1,685 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Normalization;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizer for CSAF VEX format documents.
|
||||
/// CSAF VEX documents follow the OASIS CSAF 2.0 specification with profile "VEX".
|
||||
/// </summary>
|
||||
public sealed class CsafVexNormalizer : IVexNormalizer
|
||||
{
|
||||
public VexSourceFormat SourceFormat => VexSourceFormat.CsafVex;
|
||||
|
||||
public bool CanNormalize(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// CSAF documents have document.category = "csaf_vex"
|
||||
if (root.TryGetProperty("document", out var document))
|
||||
{
|
||||
if (document.TryGetProperty("category", out var category))
|
||||
{
|
||||
var categoryStr = category.GetString();
|
||||
return categoryStr?.Equals("csaf_vex", StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<NormalizationResult> NormalizeAsync(
|
||||
string content,
|
||||
NormalizationContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var warnings = new List<NormalizationWarning>();
|
||||
var statementsSkipped = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Extract document metadata
|
||||
if (!root.TryGetProperty("document", out var documentElement))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Task.FromResult(NormalizationResult.Failed(
|
||||
[new NormalizationError("ERR_CSAF_001", "Missing 'document' element", "document", null)],
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: 0,
|
||||
StatementsSkipped: 0,
|
||||
ProductsMapped: 0),
|
||||
warnings));
|
||||
}
|
||||
|
||||
// Extract document ID
|
||||
var documentId = ExtractDocumentId(documentElement);
|
||||
if (string.IsNullOrWhiteSpace(documentId))
|
||||
{
|
||||
documentId = $"csaf:{Guid.NewGuid():N}";
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CSAF_001",
|
||||
"Document tracking ID not found; generated a random ID",
|
||||
"document.tracking.id"));
|
||||
}
|
||||
|
||||
// Extract issuer from publisher
|
||||
var issuer = ExtractIssuer(documentElement, warnings);
|
||||
|
||||
// Extract timestamps
|
||||
var (issuedAt, lastUpdatedAt) = ExtractTimestamps(documentElement);
|
||||
|
||||
// Extract product tree for product resolution
|
||||
var productTree = root.TryGetProperty("product_tree", out var pt) ? pt : default;
|
||||
|
||||
// Extract vulnerabilities and convert to statements
|
||||
var statements = ExtractStatements(root, productTree, warnings, ref statementsSkipped);
|
||||
|
||||
// Calculate source digest
|
||||
var sourceDigest = ComputeDigest(content);
|
||||
|
||||
// Build provenance
|
||||
var provenance = new NormalizationProvenance(
|
||||
NormalizedAt: context.NormalizedAt,
|
||||
Normalizer: context.Normalizer,
|
||||
SourceRevision: null,
|
||||
TransformationRules: ["csaf-vex-to-normalized-v1"]);
|
||||
|
||||
var normalizedDoc = new NormalizedVexDocument(
|
||||
SchemaVersion: NormalizedVexDocument.CurrentSchemaVersion,
|
||||
DocumentId: documentId,
|
||||
SourceFormat: VexSourceFormat.CsafVex,
|
||||
SourceDigest: sourceDigest,
|
||||
SourceUri: context.SourceUri,
|
||||
Issuer: issuer,
|
||||
IssuedAt: issuedAt,
|
||||
LastUpdatedAt: lastUpdatedAt,
|
||||
Statements: statements,
|
||||
Provenance: provenance);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
return Task.FromResult(NormalizationResult.Successful(
|
||||
normalizedDoc,
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: statements.Count,
|
||||
StatementsSkipped: statementsSkipped,
|
||||
ProductsMapped: statements.Count),
|
||||
warnings));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Task.FromResult(NormalizationResult.Failed(
|
||||
[new NormalizationError("ERR_CSAF_002", "Invalid JSON", ex.Path, ex)],
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: 0,
|
||||
StatementsSkipped: 0,
|
||||
ProductsMapped: 0),
|
||||
warnings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Task.FromResult(NormalizationResult.Failed(
|
||||
[new NormalizationError("ERR_CSAF_999", "Unexpected error during normalization", null, ex)],
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: 0,
|
||||
StatementsSkipped: 0,
|
||||
ProductsMapped: 0),
|
||||
warnings));
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractDocumentId(JsonElement document)
|
||||
{
|
||||
if (document.TryGetProperty("tracking", out var tracking) &&
|
||||
tracking.TryGetProperty("id", out var id))
|
||||
{
|
||||
return id.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static VexIssuer? ExtractIssuer(JsonElement document, List<NormalizationWarning> warnings)
|
||||
{
|
||||
if (!document.TryGetProperty("publisher", out var publisher))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CSAF_002",
|
||||
"No publisher found in document",
|
||||
"document.publisher"));
|
||||
return null;
|
||||
}
|
||||
|
||||
var issuerId = publisher.TryGetProperty("namespace", out var nsProp)
|
||||
? nsProp.GetString() ?? "unknown"
|
||||
: "unknown";
|
||||
|
||||
var issuerName = publisher.TryGetProperty("name", out var nameProp)
|
||||
? nameProp.GetString() ?? issuerId
|
||||
: issuerId;
|
||||
|
||||
var categoryStr = publisher.TryGetProperty("category", out var catProp)
|
||||
? catProp.GetString()
|
||||
: null;
|
||||
|
||||
var category = MapPublisherCategory(categoryStr);
|
||||
|
||||
return new VexIssuer(
|
||||
Id: issuerId,
|
||||
Name: issuerName,
|
||||
Category: category,
|
||||
TrustTier: TrustTier.Unknown,
|
||||
KeyFingerprints: null);
|
||||
}
|
||||
|
||||
private static IssuerCategory? MapPublisherCategory(string? category)
|
||||
{
|
||||
return category?.ToLowerInvariant() switch
|
||||
{
|
||||
"vendor" => IssuerCategory.Vendor,
|
||||
"discoverer" or "coordinator" => IssuerCategory.Community,
|
||||
"user" => IssuerCategory.Internal,
|
||||
"other" => null,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static (DateTimeOffset? IssuedAt, DateTimeOffset? LastUpdatedAt) ExtractTimestamps(JsonElement document)
|
||||
{
|
||||
DateTimeOffset? issuedAt = null;
|
||||
DateTimeOffset? lastUpdatedAt = null;
|
||||
|
||||
if (document.TryGetProperty("tracking", out var tracking))
|
||||
{
|
||||
if (tracking.TryGetProperty("initial_release_date", out var initialRelease) &&
|
||||
initialRelease.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(initialRelease.GetString(), out var parsed))
|
||||
{
|
||||
issuedAt = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (tracking.TryGetProperty("current_release_date", out var currentRelease) &&
|
||||
currentRelease.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(currentRelease.GetString(), out var parsed))
|
||||
{
|
||||
lastUpdatedAt = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (issuedAt, lastUpdatedAt);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NormalizedStatement> ExtractStatements(
|
||||
JsonElement root,
|
||||
JsonElement productTree,
|
||||
List<NormalizationWarning> warnings,
|
||||
ref int skipped)
|
||||
{
|
||||
if (!root.TryGetProperty("vulnerabilities", out var vulnerabilities) ||
|
||||
vulnerabilities.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CSAF_003",
|
||||
"No vulnerabilities array found",
|
||||
"vulnerabilities"));
|
||||
return [];
|
||||
}
|
||||
|
||||
var statements = new List<NormalizedStatement>();
|
||||
var statementIndex = 0;
|
||||
|
||||
foreach (var vuln in vulnerabilities.EnumerateArray())
|
||||
{
|
||||
var vulnStatements = ExtractVulnerabilityStatements(
|
||||
vuln, productTree, statementIndex, warnings, ref skipped);
|
||||
statements.AddRange(vulnStatements);
|
||||
statementIndex += vulnStatements.Count;
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static List<NormalizedStatement> ExtractVulnerabilityStatements(
|
||||
JsonElement vuln,
|
||||
JsonElement productTree,
|
||||
int startIndex,
|
||||
List<NormalizationWarning> warnings,
|
||||
ref int skipped)
|
||||
{
|
||||
var statements = new List<NormalizedStatement>();
|
||||
|
||||
// Extract vulnerability ID (CVE or other identifier)
|
||||
string? vulnerabilityId = null;
|
||||
var aliases = new List<string>();
|
||||
|
||||
if (vuln.TryGetProperty("cve", out var cve))
|
||||
{
|
||||
vulnerabilityId = cve.GetString();
|
||||
}
|
||||
|
||||
if (vuln.TryGetProperty("ids", out var ids) && ids.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var id in ids.EnumerateArray())
|
||||
{
|
||||
if (id.TryGetProperty("text", out var text))
|
||||
{
|
||||
var idStr = text.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(idStr))
|
||||
{
|
||||
if (vulnerabilityId == null)
|
||||
{
|
||||
vulnerabilityId = idStr;
|
||||
}
|
||||
else if (idStr != vulnerabilityId)
|
||||
{
|
||||
aliases.Add(idStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CSAF_004",
|
||||
"Vulnerability missing CVE or ID; skipped",
|
||||
"vulnerabilities[].cve"));
|
||||
skipped++;
|
||||
return statements;
|
||||
}
|
||||
|
||||
// Extract product_status for VEX statements
|
||||
if (!vuln.TryGetProperty("product_status", out var productStatus))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CSAF_005",
|
||||
$"Vulnerability {vulnerabilityId} has no product_status",
|
||||
"vulnerabilities[].product_status"));
|
||||
return statements;
|
||||
}
|
||||
|
||||
// Process each status category
|
||||
var localIndex = 0;
|
||||
|
||||
// Known not affected
|
||||
if (productStatus.TryGetProperty("known_not_affected", out var knownNotAffected) &&
|
||||
knownNotAffected.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var productRef in knownNotAffected.EnumerateArray())
|
||||
{
|
||||
var product = ResolveProduct(productRef, productTree);
|
||||
if (product != null)
|
||||
{
|
||||
var justification = ExtractJustification(vuln, productRef.GetString());
|
||||
statements.Add(CreateStatement(
|
||||
startIndex + localIndex++,
|
||||
vulnerabilityId,
|
||||
aliases,
|
||||
product,
|
||||
VexStatus.NotAffected,
|
||||
justification,
|
||||
vuln));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed
|
||||
if (productStatus.TryGetProperty("fixed", out var fixedProducts) &&
|
||||
fixedProducts.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var productRef in fixedProducts.EnumerateArray())
|
||||
{
|
||||
var product = ResolveProduct(productRef, productTree);
|
||||
if (product != null)
|
||||
{
|
||||
statements.Add(CreateStatement(
|
||||
startIndex + localIndex++,
|
||||
vulnerabilityId,
|
||||
aliases,
|
||||
product,
|
||||
VexStatus.Fixed,
|
||||
null,
|
||||
vuln));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Known affected
|
||||
if (productStatus.TryGetProperty("known_affected", out var knownAffected) &&
|
||||
knownAffected.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var productRef in knownAffected.EnumerateArray())
|
||||
{
|
||||
var product = ResolveProduct(productRef, productTree);
|
||||
if (product != null)
|
||||
{
|
||||
statements.Add(CreateStatement(
|
||||
startIndex + localIndex++,
|
||||
vulnerabilityId,
|
||||
aliases,
|
||||
product,
|
||||
VexStatus.Affected,
|
||||
null,
|
||||
vuln));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Under investigation
|
||||
if (productStatus.TryGetProperty("under_investigation", out var underInvestigation) &&
|
||||
underInvestigation.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var productRef in underInvestigation.EnumerateArray())
|
||||
{
|
||||
var product = ResolveProduct(productRef, productTree);
|
||||
if (product != null)
|
||||
{
|
||||
statements.Add(CreateStatement(
|
||||
startIndex + localIndex++,
|
||||
vulnerabilityId,
|
||||
aliases,
|
||||
product,
|
||||
VexStatus.UnderInvestigation,
|
||||
null,
|
||||
vuln));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static NormalizedProduct? ResolveProduct(JsonElement productRef, JsonElement productTree)
|
||||
{
|
||||
if (productRef.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var productId = productRef.GetString();
|
||||
if (string.IsNullOrWhiteSpace(productId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to find product details in product_tree
|
||||
string? name = null;
|
||||
string? version = null;
|
||||
string? purl = null;
|
||||
string? cpe = null;
|
||||
|
||||
if (productTree.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// Search in full_product_names
|
||||
if (productTree.TryGetProperty("full_product_names", out var fullNames) &&
|
||||
fullNames.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var fpn in fullNames.EnumerateArray())
|
||||
{
|
||||
if (fpn.TryGetProperty("product_id", out var pid) &&
|
||||
pid.GetString() == productId)
|
||||
{
|
||||
name = fpn.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||
|
||||
if (fpn.TryGetProperty("product_identification_helper", out var pih))
|
||||
{
|
||||
purl = pih.TryGetProperty("purl", out var p) ? p.GetString() : null;
|
||||
cpe = pih.TryGetProperty("cpe", out var c) ? c.GetString() : null;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in branches recursively
|
||||
if (name == null && productTree.TryGetProperty("branches", out var branches))
|
||||
{
|
||||
var result = SearchBranches(branches, productId);
|
||||
if (result.HasValue)
|
||||
{
|
||||
name = result.Value.Name;
|
||||
version = result.Value.Version;
|
||||
purl = result.Value.Purl;
|
||||
cpe = result.Value.Cpe;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new NormalizedProduct(
|
||||
Key: productId,
|
||||
Name: name,
|
||||
Version: version,
|
||||
Purl: purl,
|
||||
Cpe: cpe,
|
||||
Hashes: null);
|
||||
}
|
||||
|
||||
private static (string? Name, string? Version, string? Purl, string? Cpe)? SearchBranches(
|
||||
JsonElement branches,
|
||||
string productId)
|
||||
{
|
||||
if (branches.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var branch in branches.EnumerateArray())
|
||||
{
|
||||
// Check product in this branch
|
||||
if (branch.TryGetProperty("product", out var product) &&
|
||||
product.TryGetProperty("product_id", out var pid) &&
|
||||
pid.GetString() == productId)
|
||||
{
|
||||
var name = product.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||
var version = branch.TryGetProperty("name", out var bn) &&
|
||||
branch.TryGetProperty("category", out var bc) &&
|
||||
bc.GetString() == "product_version"
|
||||
? bn.GetString()
|
||||
: null;
|
||||
|
||||
string? purl = null;
|
||||
string? cpe = null;
|
||||
|
||||
if (product.TryGetProperty("product_identification_helper", out var pih))
|
||||
{
|
||||
purl = pih.TryGetProperty("purl", out var p) ? p.GetString() : null;
|
||||
cpe = pih.TryGetProperty("cpe", out var c) ? c.GetString() : null;
|
||||
}
|
||||
|
||||
return (name, version, purl, cpe);
|
||||
}
|
||||
|
||||
// Recurse into sub-branches
|
||||
if (branch.TryGetProperty("branches", out var subBranches))
|
||||
{
|
||||
var result = SearchBranches(subBranches, productId);
|
||||
if (result.HasValue)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static VexJustification? ExtractJustification(JsonElement vuln, string? productId)
|
||||
{
|
||||
// Look for flags that indicate justification
|
||||
if (!vuln.TryGetProperty("flags", out var flags) ||
|
||||
flags.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var flag in flags.EnumerateArray())
|
||||
{
|
||||
// Check if this flag applies to our product
|
||||
if (flag.TryGetProperty("product_ids", out var productIds) &&
|
||||
productIds.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var applies = false;
|
||||
foreach (var pid in productIds.EnumerateArray())
|
||||
{
|
||||
if (pid.GetString() == productId)
|
||||
{
|
||||
applies = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!applies)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (flag.TryGetProperty("label", out var label))
|
||||
{
|
||||
var labelStr = label.GetString();
|
||||
var justification = MapCsafFlagToJustification(labelStr);
|
||||
if (justification.HasValue)
|
||||
{
|
||||
return justification;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static VexJustification? MapCsafFlagToJustification(string? label)
|
||||
{
|
||||
return label?.ToLowerInvariant() switch
|
||||
{
|
||||
"component_not_present" => VexJustification.ComponentNotPresent,
|
||||
"vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent,
|
||||
"vulnerable_code_not_in_execute_path" or "vulnerable_code_cannot_be_controlled_by_adversary" =>
|
||||
VexJustification.VulnerableCodeNotInExecutePath,
|
||||
"inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static NormalizedStatement CreateStatement(
|
||||
int index,
|
||||
string vulnerabilityId,
|
||||
List<string> aliases,
|
||||
NormalizedProduct product,
|
||||
VexStatus status,
|
||||
VexJustification? justification,
|
||||
JsonElement vuln)
|
||||
{
|
||||
// Extract notes for status notes
|
||||
string? statusNotes = null;
|
||||
if (vuln.TryGetProperty("notes", out var notes) && notes.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var note in notes.EnumerateArray())
|
||||
{
|
||||
if (note.TryGetProperty("category", out var cat) &&
|
||||
cat.GetString() == "description" &&
|
||||
note.TryGetProperty("text", out var text))
|
||||
{
|
||||
statusNotes = text.GetString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract action statement from remediations
|
||||
string? actionStatement = null;
|
||||
DateTimeOffset? actionTimestamp = null;
|
||||
|
||||
if (vuln.TryGetProperty("remediations", out var remediations) &&
|
||||
remediations.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var rem in remediations.EnumerateArray())
|
||||
{
|
||||
if (rem.TryGetProperty("details", out var details))
|
||||
{
|
||||
actionStatement = details.GetString();
|
||||
}
|
||||
|
||||
if (rem.TryGetProperty("date", out var date) &&
|
||||
date.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(date.GetString(), out var parsed))
|
||||
{
|
||||
actionTimestamp = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
break; // Take first remediation
|
||||
}
|
||||
}
|
||||
|
||||
// Extract release date as timestamp
|
||||
DateTimeOffset? timestamp = null;
|
||||
if (vuln.TryGetProperty("release_date", out var releaseDate) &&
|
||||
releaseDate.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(releaseDate.GetString(), out var parsed))
|
||||
{
|
||||
timestamp = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return new NormalizedStatement(
|
||||
StatementId: $"stmt-{index}",
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
VulnerabilityAliases: aliases.Count > 0 ? aliases : null,
|
||||
Product: product,
|
||||
Status: status,
|
||||
StatusNotes: statusNotes,
|
||||
Justification: justification,
|
||||
ImpactStatement: null,
|
||||
ActionStatement: actionStatement,
|
||||
ActionStatementTimestamp: actionTimestamp,
|
||||
Versions: null,
|
||||
Subcomponents: null,
|
||||
FirstSeen: timestamp,
|
||||
LastSeen: timestamp);
|
||||
}
|
||||
|
||||
private static string ComputeDigest(string content)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Normalization;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizer for CycloneDX VEX format documents.
|
||||
/// CycloneDX VEX uses the vulnerabilities array in CycloneDX BOM format.
|
||||
/// </summary>
|
||||
public sealed class CycloneDxVexNormalizer : IVexNormalizer
|
||||
{
|
||||
public VexSourceFormat SourceFormat => VexSourceFormat.CycloneDxVex;
|
||||
|
||||
public bool CanNormalize(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// CycloneDX documents have bomFormat = "CycloneDX" and must have vulnerabilities
|
||||
if (root.TryGetProperty("bomFormat", out var bomFormat))
|
||||
{
|
||||
var formatStr = bomFormat.GetString();
|
||||
if (formatStr?.Equals("CycloneDX", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
// Must have vulnerabilities array to be a VEX document
|
||||
return root.TryGetProperty("vulnerabilities", out var vulns) &&
|
||||
vulns.ValueKind == JsonValueKind.Array &&
|
||||
vulns.GetArrayLength() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<NormalizationResult> NormalizeAsync(
|
||||
string content,
|
||||
NormalizationContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var warnings = new List<NormalizationWarning>();
|
||||
var statementsSkipped = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Extract document ID from serialNumber or metadata
|
||||
var documentId = ExtractDocumentId(root);
|
||||
if (string.IsNullOrWhiteSpace(documentId))
|
||||
{
|
||||
documentId = $"cyclonedx:{Guid.NewGuid():N}";
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CDX_001",
|
||||
"Serial number not found; generated a random ID",
|
||||
"serialNumber"));
|
||||
}
|
||||
|
||||
// Extract issuer from metadata
|
||||
var issuer = ExtractIssuer(root, warnings);
|
||||
|
||||
// Extract timestamps
|
||||
var (issuedAt, lastUpdatedAt) = ExtractTimestamps(root);
|
||||
|
||||
// Build component lookup for product resolution
|
||||
var componentLookup = BuildComponentLookup(root);
|
||||
|
||||
// Extract vulnerabilities and convert to statements
|
||||
var statements = ExtractStatements(root, componentLookup, warnings, ref statementsSkipped);
|
||||
|
||||
// Calculate source digest
|
||||
var sourceDigest = ComputeDigest(content);
|
||||
|
||||
// Build provenance
|
||||
var provenance = new NormalizationProvenance(
|
||||
NormalizedAt: context.NormalizedAt,
|
||||
Normalizer: context.Normalizer,
|
||||
SourceRevision: null,
|
||||
TransformationRules: ["cyclonedx-vex-to-normalized-v1"]);
|
||||
|
||||
var normalizedDoc = new NormalizedVexDocument(
|
||||
SchemaVersion: NormalizedVexDocument.CurrentSchemaVersion,
|
||||
DocumentId: documentId,
|
||||
SourceFormat: VexSourceFormat.CycloneDxVex,
|
||||
SourceDigest: sourceDigest,
|
||||
SourceUri: context.SourceUri,
|
||||
Issuer: issuer,
|
||||
IssuedAt: issuedAt,
|
||||
LastUpdatedAt: lastUpdatedAt,
|
||||
Statements: statements,
|
||||
Provenance: provenance);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
return Task.FromResult(NormalizationResult.Successful(
|
||||
normalizedDoc,
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: statements.Count,
|
||||
StatementsSkipped: statementsSkipped,
|
||||
ProductsMapped: statements.Count),
|
||||
warnings));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Task.FromResult(NormalizationResult.Failed(
|
||||
[new NormalizationError("ERR_CDX_001", "Invalid JSON", ex.Path, ex)],
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: 0,
|
||||
StatementsSkipped: 0,
|
||||
ProductsMapped: 0),
|
||||
warnings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Task.FromResult(NormalizationResult.Failed(
|
||||
[new NormalizationError("ERR_CDX_999", "Unexpected error during normalization", null, ex)],
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: 0,
|
||||
StatementsSkipped: 0,
|
||||
ProductsMapped: 0),
|
||||
warnings));
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractDocumentId(JsonElement root)
|
||||
{
|
||||
// Try serialNumber first
|
||||
if (root.TryGetProperty("serialNumber", out var serialNumber))
|
||||
{
|
||||
return serialNumber.GetString();
|
||||
}
|
||||
|
||||
// Fall back to metadata.component.bom-ref
|
||||
if (root.TryGetProperty("metadata", out var metadata) &&
|
||||
metadata.TryGetProperty("component", out var component) &&
|
||||
component.TryGetProperty("bom-ref", out var bomRef))
|
||||
{
|
||||
return bomRef.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static VexIssuer? ExtractIssuer(JsonElement root, List<NormalizationWarning> warnings)
|
||||
{
|
||||
if (!root.TryGetProperty("metadata", out var metadata))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CDX_002",
|
||||
"No metadata found in document",
|
||||
"metadata"));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to extract from authors or supplier
|
||||
string? issuerId = null;
|
||||
string? issuerName = null;
|
||||
|
||||
if (metadata.TryGetProperty("authors", out var authors) &&
|
||||
authors.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var author in authors.EnumerateArray())
|
||||
{
|
||||
issuerName = author.TryGetProperty("name", out var name) ? name.GetString() : null;
|
||||
issuerId = author.TryGetProperty("email", out var email) ? email.GetString() : issuerName;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(issuerName))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(issuerName) &&
|
||||
metadata.TryGetProperty("supplier", out var supplier))
|
||||
{
|
||||
issuerName = supplier.TryGetProperty("name", out var name) ? name.GetString() : null;
|
||||
issuerId = supplier.TryGetProperty("url", out var url)
|
||||
? url.ValueKind == JsonValueKind.Array
|
||||
? url.EnumerateArray().FirstOrDefault().GetString()
|
||||
: url.GetString()
|
||||
: issuerName;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(issuerName) &&
|
||||
metadata.TryGetProperty("manufacture", out var manufacture))
|
||||
{
|
||||
issuerName = manufacture.TryGetProperty("name", out var name) ? name.GetString() : null;
|
||||
issuerId = issuerName;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(issuerName))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CDX_003",
|
||||
"No author/supplier found in metadata",
|
||||
"metadata.authors"));
|
||||
return null;
|
||||
}
|
||||
|
||||
return new VexIssuer(
|
||||
Id: issuerId ?? "unknown",
|
||||
Name: issuerName ?? "unknown",
|
||||
Category: null,
|
||||
TrustTier: TrustTier.Unknown,
|
||||
KeyFingerprints: null);
|
||||
}
|
||||
|
||||
private static (DateTimeOffset? IssuedAt, DateTimeOffset? LastUpdatedAt) ExtractTimestamps(JsonElement root)
|
||||
{
|
||||
DateTimeOffset? issuedAt = null;
|
||||
|
||||
if (root.TryGetProperty("metadata", out var metadata) &&
|
||||
metadata.TryGetProperty("timestamp", out var timestamp) &&
|
||||
timestamp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(timestamp.GetString(), out var parsed))
|
||||
{
|
||||
issuedAt = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return (issuedAt, null);
|
||||
}
|
||||
|
||||
private static Dictionary<string, ComponentInfo> BuildComponentLookup(JsonElement root)
|
||||
{
|
||||
var lookup = new Dictionary<string, ComponentInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Add metadata component
|
||||
if (root.TryGetProperty("metadata", out var metadata) &&
|
||||
metadata.TryGetProperty("component", out var metaComponent))
|
||||
{
|
||||
AddComponentToLookup(lookup, metaComponent);
|
||||
}
|
||||
|
||||
// Add all components
|
||||
if (root.TryGetProperty("components", out var components) &&
|
||||
components.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
AddComponentsRecursively(lookup, components);
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private static void AddComponentsRecursively(Dictionary<string, ComponentInfo> lookup, JsonElement components)
|
||||
{
|
||||
foreach (var component in components.EnumerateArray())
|
||||
{
|
||||
AddComponentToLookup(lookup, component);
|
||||
|
||||
// Handle nested components
|
||||
if (component.TryGetProperty("components", out var nested) &&
|
||||
nested.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
AddComponentsRecursively(lookup, nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddComponentToLookup(Dictionary<string, ComponentInfo> lookup, JsonElement component)
|
||||
{
|
||||
var bomRef = component.TryGetProperty("bom-ref", out var br) ? br.GetString() : null;
|
||||
var name = component.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||
var version = component.TryGetProperty("version", out var v) ? v.GetString() : null;
|
||||
var purl = component.TryGetProperty("purl", out var p) ? p.GetString() : null;
|
||||
var cpe = component.TryGetProperty("cpe", out var c) ? c.GetString() : null;
|
||||
|
||||
// Extract hashes
|
||||
Dictionary<string, string>? hashes = null;
|
||||
if (component.TryGetProperty("hashes", out var hashArray) &&
|
||||
hashArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
hashes = [];
|
||||
foreach (var hash in hashArray.EnumerateArray())
|
||||
{
|
||||
var alg = hash.TryGetProperty("alg", out var a) ? a.GetString() : null;
|
||||
var content = hash.TryGetProperty("content", out var cont) ? cont.GetString() : null;
|
||||
if (!string.IsNullOrWhiteSpace(alg) && !string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
hashes[alg] = content;
|
||||
}
|
||||
}
|
||||
|
||||
if (hashes.Count == 0)
|
||||
{
|
||||
hashes = null;
|
||||
}
|
||||
}
|
||||
|
||||
var info = new ComponentInfo(name, version, purl, cpe, hashes);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bomRef))
|
||||
{
|
||||
lookup[bomRef] = info;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(purl) && !lookup.ContainsKey(purl))
|
||||
{
|
||||
lookup[purl] = info;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NormalizedStatement> ExtractStatements(
|
||||
JsonElement root,
|
||||
Dictionary<string, ComponentInfo> componentLookup,
|
||||
List<NormalizationWarning> warnings,
|
||||
ref int skipped)
|
||||
{
|
||||
if (!root.TryGetProperty("vulnerabilities", out var vulnerabilities) ||
|
||||
vulnerabilities.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CDX_004",
|
||||
"No vulnerabilities array found",
|
||||
"vulnerabilities"));
|
||||
return [];
|
||||
}
|
||||
|
||||
var statements = new List<NormalizedStatement>();
|
||||
var index = 0;
|
||||
|
||||
foreach (var vuln in vulnerabilities.EnumerateArray())
|
||||
{
|
||||
var vulnStatements = ExtractVulnerabilityStatements(
|
||||
vuln, componentLookup, index, warnings, ref skipped);
|
||||
statements.AddRange(vulnStatements);
|
||||
index += vulnStatements.Count > 0 ? vulnStatements.Count : 1;
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static List<NormalizedStatement> ExtractVulnerabilityStatements(
|
||||
JsonElement vuln,
|
||||
Dictionary<string, ComponentInfo> componentLookup,
|
||||
int startIndex,
|
||||
List<NormalizationWarning> warnings,
|
||||
ref int skipped)
|
||||
{
|
||||
var statements = new List<NormalizedStatement>();
|
||||
|
||||
// Extract vulnerability ID
|
||||
var vulnerabilityId = vuln.TryGetProperty("id", out var id) ? id.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CDX_005",
|
||||
"Vulnerability missing ID; skipped",
|
||||
"vulnerabilities[].id"));
|
||||
skipped++;
|
||||
return statements;
|
||||
}
|
||||
|
||||
// Extract aliases from references with type = "advisory"
|
||||
var aliases = new List<string>();
|
||||
if (vuln.TryGetProperty("references", out var refs) &&
|
||||
refs.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var reference in refs.EnumerateArray())
|
||||
{
|
||||
if (reference.TryGetProperty("id", out var refId))
|
||||
{
|
||||
var refIdStr = refId.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(refIdStr) && refIdStr != vulnerabilityId)
|
||||
{
|
||||
aliases.Add(refIdStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract affected components
|
||||
if (!vuln.TryGetProperty("affects", out var affects) ||
|
||||
affects.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CDX_006",
|
||||
$"Vulnerability {vulnerabilityId} has no affects array",
|
||||
"vulnerabilities[].affects"));
|
||||
skipped++;
|
||||
return statements;
|
||||
}
|
||||
|
||||
var localIndex = 0;
|
||||
foreach (var affect in affects.EnumerateArray())
|
||||
{
|
||||
var refStr = affect.TryGetProperty("ref", out var refProp) ? refProp.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(refStr))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var product = ResolveProduct(refStr, componentLookup);
|
||||
if (product == null)
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CDX_007",
|
||||
$"Could not resolve component ref '{refStr}'",
|
||||
"vulnerabilities[].affects[].ref"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract analysis/status
|
||||
var status = VexStatus.UnderInvestigation;
|
||||
VexJustification? justification = null;
|
||||
string? statusNotes = null;
|
||||
string? actionStatement = null;
|
||||
|
||||
if (vuln.TryGetProperty("analysis", out var analysis))
|
||||
{
|
||||
var stateStr = analysis.TryGetProperty("state", out var state) ? state.GetString() : null;
|
||||
status = MapAnalysisState(stateStr) ?? VexStatus.UnderInvestigation;
|
||||
|
||||
var justificationStr = analysis.TryGetProperty("justification", out var just) ? just.GetString() : null;
|
||||
justification = MapJustification(justificationStr);
|
||||
|
||||
statusNotes = analysis.TryGetProperty("detail", out var detail) ? detail.GetString() : null;
|
||||
|
||||
if (analysis.TryGetProperty("response", out var response) &&
|
||||
response.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var responses = new List<string>();
|
||||
foreach (var r in response.EnumerateArray())
|
||||
{
|
||||
var rStr = r.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(rStr))
|
||||
{
|
||||
responses.Add(rStr);
|
||||
}
|
||||
}
|
||||
|
||||
if (responses.Count > 0)
|
||||
{
|
||||
actionStatement = string.Join(", ", responses);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract timestamps
|
||||
DateTimeOffset? firstSeen = null;
|
||||
DateTimeOffset? lastSeen = null;
|
||||
|
||||
if (vuln.TryGetProperty("created", out var created) &&
|
||||
created.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(created.GetString(), out var parsed))
|
||||
{
|
||||
firstSeen = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (vuln.TryGetProperty("updated", out var updated) &&
|
||||
updated.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(updated.GetString(), out var parsed))
|
||||
{
|
||||
lastSeen = parsed;
|
||||
}
|
||||
}
|
||||
else if (vuln.TryGetProperty("published", out var published) &&
|
||||
published.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(published.GetString(), out var parsed))
|
||||
{
|
||||
lastSeen = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract version ranges if specified
|
||||
VersionRange? versions = null;
|
||||
if (affect.TryGetProperty("versions", out var versionsArray) &&
|
||||
versionsArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var affectedVersions = new List<string>();
|
||||
var fixedVersions = new List<string>();
|
||||
|
||||
foreach (var ver in versionsArray.EnumerateArray())
|
||||
{
|
||||
var verStr = ver.TryGetProperty("version", out var v) ? v.GetString() : null;
|
||||
var statusStr = ver.TryGetProperty("status", out var s) ? s.GetString() : null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(verStr))
|
||||
{
|
||||
if (statusStr?.Equals("affected", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
affectedVersions.Add(verStr);
|
||||
}
|
||||
else if (statusStr?.Equals("unaffected", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
fixedVersions.Add(verStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (affectedVersions.Count > 0 || fixedVersions.Count > 0)
|
||||
{
|
||||
versions = new VersionRange(
|
||||
Affected: affectedVersions.Count > 0 ? affectedVersions : null,
|
||||
Fixed: fixedVersions.Count > 0 ? fixedVersions : null,
|
||||
Unaffected: null);
|
||||
}
|
||||
}
|
||||
|
||||
statements.Add(new NormalizedStatement(
|
||||
StatementId: $"stmt-{startIndex + localIndex}",
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
VulnerabilityAliases: aliases.Count > 0 ? aliases : null,
|
||||
Product: product,
|
||||
Status: status,
|
||||
StatusNotes: statusNotes,
|
||||
Justification: justification,
|
||||
ImpactStatement: null,
|
||||
ActionStatement: actionStatement,
|
||||
ActionStatementTimestamp: null,
|
||||
Versions: versions,
|
||||
Subcomponents: null,
|
||||
FirstSeen: firstSeen,
|
||||
LastSeen: lastSeen ?? firstSeen));
|
||||
|
||||
localIndex++;
|
||||
}
|
||||
|
||||
if (statements.Count == 0)
|
||||
{
|
||||
skipped++;
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static NormalizedProduct? ResolveProduct(string refStr, Dictionary<string, ComponentInfo> componentLookup)
|
||||
{
|
||||
if (componentLookup.TryGetValue(refStr, out var info))
|
||||
{
|
||||
return new NormalizedProduct(
|
||||
Key: info.Purl ?? refStr,
|
||||
Name: info.Name,
|
||||
Version: info.Version,
|
||||
Purl: info.Purl,
|
||||
Cpe: info.Cpe,
|
||||
Hashes: info.Hashes);
|
||||
}
|
||||
|
||||
// If not found in lookup, create a basic product entry
|
||||
if (refStr.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new NormalizedProduct(
|
||||
Key: refStr,
|
||||
Name: null,
|
||||
Version: null,
|
||||
Purl: refStr,
|
||||
Cpe: null,
|
||||
Hashes: null);
|
||||
}
|
||||
|
||||
return new NormalizedProduct(
|
||||
Key: refStr,
|
||||
Name: null,
|
||||
Version: null,
|
||||
Purl: null,
|
||||
Cpe: null,
|
||||
Hashes: null);
|
||||
}
|
||||
|
||||
private static VexStatus? MapAnalysisState(string? state)
|
||||
{
|
||||
return state?.ToLowerInvariant() switch
|
||||
{
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"exploitable" or "in_triage" => VexStatus.Affected,
|
||||
"resolved" or "resolved_with_pedigree" => VexStatus.Fixed,
|
||||
"false_positive" => VexStatus.NotAffected,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static VexJustification? MapJustification(string? justification)
|
||||
{
|
||||
return justification?.ToLowerInvariant() switch
|
||||
{
|
||||
"code_not_present" => VexJustification.ComponentNotPresent,
|
||||
"code_not_reachable" => VexJustification.VulnerableCodeNotInExecutePath,
|
||||
"requires_configuration" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
|
||||
"requires_dependency" => VexJustification.ComponentNotPresent,
|
||||
"requires_environment" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
|
||||
"protected_by_compiler" or "protected_by_mitigating_control" or "protected_at_runtime" or "protected_at_perimeter" =>
|
||||
VexJustification.InlineMitigationsAlreadyExist,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDigest(string content)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private sealed record ComponentInfo(
|
||||
string? Name,
|
||||
string? Version,
|
||||
string? Purl,
|
||||
string? Cpe,
|
||||
IReadOnlyDictionary<string, string>? Hashes);
|
||||
}
|
||||
164
src/VexLens/StellaOps.VexLens/Normalization/IVexNormalizer.cs
Normal file
164
src/VexLens/StellaOps.VexLens/Normalization/IVexNormalizer.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Normalization;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for VEX document normalizers.
|
||||
/// Each normalizer handles a specific source format (OpenVEX, CSAF, CycloneDX, etc.)
|
||||
/// </summary>
|
||||
public interface IVexNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the source format this normalizer handles.
|
||||
/// </summary>
|
||||
VexSourceFormat SourceFormat { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this normalizer can handle the given document.
|
||||
/// </summary>
|
||||
bool CanNormalize(string content);
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a VEX document to the standard format.
|
||||
/// </summary>
|
||||
Task<NormalizationResult> NormalizeAsync(
|
||||
string content,
|
||||
NormalizationContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for normalization operation.
|
||||
/// </summary>
|
||||
public sealed record NormalizationContext(
|
||||
string? SourceUri,
|
||||
DateTimeOffset NormalizedAt,
|
||||
string Normalizer,
|
||||
IReadOnlyDictionary<string, object?>? Options);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a normalization operation.
|
||||
/// </summary>
|
||||
public sealed record NormalizationResult(
|
||||
bool Success,
|
||||
NormalizedVexDocument? Document,
|
||||
IReadOnlyList<NormalizationError> Errors,
|
||||
IReadOnlyList<NormalizationWarning> Warnings,
|
||||
NormalizationMetrics Metrics)
|
||||
{
|
||||
public static NormalizationResult Successful(
|
||||
NormalizedVexDocument document,
|
||||
NormalizationMetrics metrics,
|
||||
IEnumerable<NormalizationWarning>? warnings = null)
|
||||
{
|
||||
return new NormalizationResult(
|
||||
Success: true,
|
||||
Document: document,
|
||||
Errors: [],
|
||||
Warnings: warnings?.ToList() ?? [],
|
||||
Metrics: metrics);
|
||||
}
|
||||
|
||||
public static NormalizationResult Failed(
|
||||
IEnumerable<NormalizationError> errors,
|
||||
NormalizationMetrics metrics,
|
||||
IEnumerable<NormalizationWarning>? warnings = null)
|
||||
{
|
||||
return new NormalizationResult(
|
||||
Success: false,
|
||||
Document: null,
|
||||
Errors: errors.ToList(),
|
||||
Warnings: warnings?.ToList() ?? [],
|
||||
Metrics: metrics);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error during normalization.
|
||||
/// </summary>
|
||||
public sealed record NormalizationError(
|
||||
string Code,
|
||||
string Message,
|
||||
string? Path,
|
||||
Exception? Exception);
|
||||
|
||||
/// <summary>
|
||||
/// Warning during normalization.
|
||||
/// </summary>
|
||||
public sealed record NormalizationWarning(
|
||||
string Code,
|
||||
string Message,
|
||||
string? Path);
|
||||
|
||||
/// <summary>
|
||||
/// Metrics from normalization operation.
|
||||
/// </summary>
|
||||
public sealed record NormalizationMetrics(
|
||||
TimeSpan Duration,
|
||||
int SourceBytes,
|
||||
int StatementsExtracted,
|
||||
int StatementsSkipped,
|
||||
int ProductsMapped);
|
||||
|
||||
/// <summary>
|
||||
/// Registry for VEX normalizers.
|
||||
/// </summary>
|
||||
public interface IVexNormalizerRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all registered normalizers.
|
||||
/// </summary>
|
||||
IReadOnlyList<IVexNormalizer> Normalizers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the normalizer for a specific source format.
|
||||
/// </summary>
|
||||
IVexNormalizer? GetNormalizer(VexSourceFormat format);
|
||||
|
||||
/// <summary>
|
||||
/// Detects the format and returns the appropriate normalizer.
|
||||
/// </summary>
|
||||
IVexNormalizer? DetectNormalizer(string content);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a normalizer.
|
||||
/// </summary>
|
||||
void Register(IVexNormalizer normalizer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of the normalizer registry.
|
||||
/// </summary>
|
||||
public sealed class VexNormalizerRegistry : IVexNormalizerRegistry
|
||||
{
|
||||
private readonly Dictionary<VexSourceFormat, IVexNormalizer> _normalizers = [];
|
||||
private readonly List<IVexNormalizer> _orderedNormalizers = [];
|
||||
|
||||
public IReadOnlyList<IVexNormalizer> Normalizers => _orderedNormalizers;
|
||||
|
||||
public IVexNormalizer? GetNormalizer(VexSourceFormat format)
|
||||
{
|
||||
return _normalizers.GetValueOrDefault(format);
|
||||
}
|
||||
|
||||
public IVexNormalizer? DetectNormalizer(string content)
|
||||
{
|
||||
foreach (var normalizer in _orderedNormalizers)
|
||||
{
|
||||
if (normalizer.CanNormalize(content))
|
||||
{
|
||||
return normalizer;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Register(IVexNormalizer normalizer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(normalizer);
|
||||
|
||||
_normalizers[normalizer.SourceFormat] = normalizer;
|
||||
_orderedNormalizers.Add(normalizer);
|
||||
}
|
||||
}
|
||||
479
src/VexLens/StellaOps.VexLens/Normalization/OpenVexNormalizer.cs
Normal file
479
src/VexLens/StellaOps.VexLens/Normalization/OpenVexNormalizer.cs
Normal file
@@ -0,0 +1,479 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Normalization;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizer for OpenVEX format documents.
|
||||
/// </summary>
|
||||
public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
{
|
||||
public VexSourceFormat SourceFormat => VexSourceFormat.OpenVex;
|
||||
|
||||
public bool CanNormalize(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// OpenVEX documents have @context with openvex
|
||||
if (root.TryGetProperty("@context", out var context))
|
||||
{
|
||||
var contextStr = context.GetString();
|
||||
return contextStr?.Contains("openvex", StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<NormalizationResult> NormalizeAsync(
|
||||
string content,
|
||||
NormalizationContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var warnings = new List<NormalizationWarning>();
|
||||
var statementsSkipped = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Extract document ID
|
||||
var documentId = ExtractDocumentId(root);
|
||||
if (string.IsNullOrWhiteSpace(documentId))
|
||||
{
|
||||
documentId = $"openvex:{Guid.NewGuid():N}";
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_OPENVEX_001",
|
||||
"Document ID not found; generated a random ID",
|
||||
"@id"));
|
||||
}
|
||||
|
||||
// Extract issuer
|
||||
var issuer = ExtractIssuer(root, warnings);
|
||||
|
||||
// Extract timestamps
|
||||
var issuedAt = ExtractTimestamp(root, "timestamp");
|
||||
var lastUpdatedAt = ExtractTimestamp(root, "last_updated");
|
||||
|
||||
// Extract statements
|
||||
var statements = ExtractStatements(root, warnings, ref statementsSkipped);
|
||||
|
||||
// Calculate source digest
|
||||
var sourceDigest = ComputeDigest(content);
|
||||
|
||||
// Build provenance
|
||||
var provenance = new NormalizationProvenance(
|
||||
NormalizedAt: context.NormalizedAt,
|
||||
Normalizer: context.Normalizer,
|
||||
SourceRevision: null,
|
||||
TransformationRules: ["openvex-to-normalized-v1"]);
|
||||
|
||||
var normalizedDoc = new NormalizedVexDocument(
|
||||
SchemaVersion: NormalizedVexDocument.CurrentSchemaVersion,
|
||||
DocumentId: documentId,
|
||||
SourceFormat: VexSourceFormat.OpenVex,
|
||||
SourceDigest: sourceDigest,
|
||||
SourceUri: context.SourceUri,
|
||||
Issuer: issuer,
|
||||
IssuedAt: issuedAt,
|
||||
LastUpdatedAt: lastUpdatedAt,
|
||||
Statements: statements,
|
||||
Provenance: provenance);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
return Task.FromResult(NormalizationResult.Successful(
|
||||
normalizedDoc,
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: statements.Count,
|
||||
StatementsSkipped: statementsSkipped,
|
||||
ProductsMapped: statements.Count),
|
||||
warnings));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Task.FromResult(NormalizationResult.Failed(
|
||||
[new NormalizationError("ERR_OPENVEX_001", "Invalid JSON", ex.Path, ex)],
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: 0,
|
||||
StatementsSkipped: 0,
|
||||
ProductsMapped: 0),
|
||||
warnings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Task.FromResult(NormalizationResult.Failed(
|
||||
[new NormalizationError("ERR_OPENVEX_999", "Unexpected error during normalization", null, ex)],
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: 0,
|
||||
StatementsSkipped: 0,
|
||||
ProductsMapped: 0),
|
||||
warnings));
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractDocumentId(JsonElement root)
|
||||
{
|
||||
if (root.TryGetProperty("@id", out var id))
|
||||
{
|
||||
return id.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static VexIssuer? ExtractIssuer(JsonElement root, List<NormalizationWarning> warnings)
|
||||
{
|
||||
if (!root.TryGetProperty("author", out var author))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_OPENVEX_002",
|
||||
"No author/issuer found in document",
|
||||
"author"));
|
||||
return null;
|
||||
}
|
||||
|
||||
var issuerId = author.TryGetProperty("@id", out var idProp)
|
||||
? idProp.GetString() ?? "unknown"
|
||||
: "unknown";
|
||||
|
||||
var issuerName = author.TryGetProperty("name", out var nameProp)
|
||||
? nameProp.GetString() ?? issuerId
|
||||
: issuerId;
|
||||
|
||||
var role = author.TryGetProperty("role", out var roleProp)
|
||||
? roleProp.GetString()
|
||||
: null;
|
||||
|
||||
var category = MapRoleToCategory(role);
|
||||
|
||||
return new VexIssuer(
|
||||
Id: issuerId,
|
||||
Name: issuerName,
|
||||
Category: category,
|
||||
TrustTier: TrustTier.Unknown,
|
||||
KeyFingerprints: null);
|
||||
}
|
||||
|
||||
private static IssuerCategory? MapRoleToCategory(string? role)
|
||||
{
|
||||
return role?.ToLowerInvariant() switch
|
||||
{
|
||||
"vendor" => IssuerCategory.Vendor,
|
||||
"distributor" => IssuerCategory.Distributor,
|
||||
"maintainer" or "community" => IssuerCategory.Community,
|
||||
"aggregator" => IssuerCategory.Aggregator,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ExtractTimestamp(JsonElement root, string propertyName)
|
||||
{
|
||||
if (root.TryGetProperty(propertyName, out var prop) &&
|
||||
prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var str = prop.GetString();
|
||||
if (DateTimeOffset.TryParse(str, out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NormalizedStatement> ExtractStatements(
|
||||
JsonElement root,
|
||||
List<NormalizationWarning> warnings,
|
||||
ref int skipped)
|
||||
{
|
||||
if (!root.TryGetProperty("statements", out var statementsArray) ||
|
||||
statementsArray.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_OPENVEX_003",
|
||||
"No statements array found",
|
||||
"statements"));
|
||||
return [];
|
||||
}
|
||||
|
||||
var statements = new List<NormalizedStatement>();
|
||||
var index = 0;
|
||||
|
||||
foreach (var stmt in statementsArray.EnumerateArray())
|
||||
{
|
||||
var statement = ExtractStatement(stmt, index, warnings, ref skipped);
|
||||
if (statement != null)
|
||||
{
|
||||
statements.Add(statement);
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static NormalizedStatement? ExtractStatement(
|
||||
JsonElement stmt,
|
||||
int index,
|
||||
List<NormalizationWarning> warnings,
|
||||
ref int skipped)
|
||||
{
|
||||
// Extract vulnerability
|
||||
string? vulnerabilityId = null;
|
||||
var aliases = new List<string>();
|
||||
|
||||
if (stmt.TryGetProperty("vulnerability", out var vuln))
|
||||
{
|
||||
if (vuln.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
vulnerabilityId = vuln.GetString();
|
||||
}
|
||||
else if (vuln.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
vulnerabilityId = vuln.TryGetProperty("@id", out var vulnId)
|
||||
? vulnId.GetString()
|
||||
: vuln.TryGetProperty("name", out var vulnName)
|
||||
? vulnName.GetString()
|
||||
: null;
|
||||
|
||||
if (vuln.TryGetProperty("aliases", out var aliasArray) &&
|
||||
aliasArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var alias in aliasArray.EnumerateArray())
|
||||
{
|
||||
if (alias.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var aliasStr = alias.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(aliasStr))
|
||||
{
|
||||
aliases.Add(aliasStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_OPENVEX_004",
|
||||
"Statement missing vulnerability ID; skipped",
|
||||
$"statements[{index}].vulnerability"));
|
||||
skipped++;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract products
|
||||
var products = new List<NormalizedProduct>();
|
||||
if (stmt.TryGetProperty("products", out var productsArray) &&
|
||||
productsArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var prod in productsArray.EnumerateArray())
|
||||
{
|
||||
var product = ExtractProduct(prod);
|
||||
if (product != null)
|
||||
{
|
||||
products.Add(product);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (products.Count == 0)
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_OPENVEX_005",
|
||||
"Statement has no valid products; skipped",
|
||||
$"statements[{index}].products"));
|
||||
skipped++;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract status
|
||||
var statusStr = stmt.TryGetProperty("status", out var statusProp)
|
||||
? statusProp.GetString()
|
||||
: null;
|
||||
|
||||
var status = MapStatus(statusStr);
|
||||
if (!status.HasValue)
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_OPENVEX_006",
|
||||
$"Unknown status '{statusStr}'; defaulting to under_investigation",
|
||||
$"statements[{index}].status"));
|
||||
status = VexStatus.UnderInvestigation;
|
||||
}
|
||||
|
||||
// Extract justification
|
||||
var justificationStr = stmt.TryGetProperty("justification", out var justProp)
|
||||
? justProp.GetString()
|
||||
: null;
|
||||
|
||||
var justification = MapJustification(justificationStr);
|
||||
|
||||
// Extract other fields
|
||||
var statusNotes = stmt.TryGetProperty("status_notes", out var notesProp)
|
||||
? notesProp.GetString()
|
||||
: null;
|
||||
|
||||
var impactStatement = stmt.TryGetProperty("impact_statement", out var impactProp)
|
||||
? impactProp.GetString()
|
||||
: null;
|
||||
|
||||
var actionStatement = stmt.TryGetProperty("action_statement", out var actionProp)
|
||||
? actionProp.GetString()
|
||||
: null;
|
||||
|
||||
var actionTimestamp = stmt.TryGetProperty("action_statement_timestamp", out var actionTsProp)
|
||||
? ExtractTimestamp(actionTsProp)
|
||||
: null;
|
||||
|
||||
var timestamp = ExtractTimestamp(stmt, "timestamp");
|
||||
|
||||
// For OpenVEX, create one statement per product
|
||||
var primaryProduct = products[0];
|
||||
var subcomponents = products.Count > 1 ? products.Skip(1).ToList() : null;
|
||||
|
||||
return new NormalizedStatement(
|
||||
StatementId: $"stmt-{index}",
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
VulnerabilityAliases: aliases.Count > 0 ? aliases : null,
|
||||
Product: primaryProduct,
|
||||
Status: status.Value,
|
||||
StatusNotes: statusNotes,
|
||||
Justification: justification,
|
||||
ImpactStatement: impactStatement,
|
||||
ActionStatement: actionStatement,
|
||||
ActionStatementTimestamp: actionTimestamp,
|
||||
Versions: null,
|
||||
Subcomponents: subcomponents,
|
||||
FirstSeen: timestamp,
|
||||
LastSeen: timestamp);
|
||||
}
|
||||
|
||||
private static NormalizedProduct? ExtractProduct(JsonElement prod)
|
||||
{
|
||||
string? key = null;
|
||||
string? name = null;
|
||||
string? version = null;
|
||||
string? purl = null;
|
||||
string? cpe = null;
|
||||
|
||||
if (prod.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
key = prod.GetString();
|
||||
if (key?.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
purl = key;
|
||||
}
|
||||
else if (key?.StartsWith("cpe:", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
cpe = key;
|
||||
}
|
||||
}
|
||||
else if (prod.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
key = prod.TryGetProperty("@id", out var idProp) ? idProp.GetString() : null;
|
||||
name = prod.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null;
|
||||
version = prod.TryGetProperty("version", out var versionProp) ? versionProp.GetString() : null;
|
||||
|
||||
if (prod.TryGetProperty("identifiers", out var identifiers) &&
|
||||
identifiers.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
purl = identifiers.TryGetProperty("purl", out var purlProp) ? purlProp.GetString() : null;
|
||||
cpe = identifiers.TryGetProperty("cpe23", out var cpeProp) ? cpeProp.GetString() : null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(purl) &&
|
||||
prod.TryGetProperty("purl", out var directPurl))
|
||||
{
|
||||
purl = directPurl.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key) && string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new NormalizedProduct(
|
||||
Key: key ?? purl ?? cpe ?? $"unknown-{Guid.NewGuid():N}",
|
||||
Name: name,
|
||||
Version: version,
|
||||
Purl: purl,
|
||||
Cpe: cpe,
|
||||
Hashes: null);
|
||||
}
|
||||
|
||||
private static VexStatus? MapStatus(string? status)
|
||||
{
|
||||
return status?.ToLowerInvariant() switch
|
||||
{
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"affected" => VexStatus.Affected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static VexJustification? MapJustification(string? justification)
|
||||
{
|
||||
return justification?.ToLowerInvariant() switch
|
||||
{
|
||||
"component_not_present" => VexJustification.ComponentNotPresent,
|
||||
"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,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ExtractTimestamp(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var str = element.GetString();
|
||||
if (DateTimeOffset.TryParse(str, out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ComputeDigest(string content)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user