feat: Initialize Zastava Webhook service with TLS and Authority authentication
- Added Program.cs to set up the web application with Serilog for logging, health check endpoints, and a placeholder admission endpoint. - Configured Kestrel server to use TLS 1.3 and handle client certificates appropriately. - Created StellaOps.Zastava.Webhook.csproj with necessary dependencies including Serilog and Polly. - Documented tasks in TASKS.md for the Zastava Webhook project, outlining current work and exit criteria for each task.
This commit is contained in:
@@ -0,0 +1,581 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.MSRC.CSAF;
|
||||
|
||||
public sealed class MsrcCsafConnector : VexConnectorBase
|
||||
{
|
||||
private const string QuarantineMetadataKey = "excititor.quarantine.reason";
|
||||
private const string FormatMetadataKey = "msrc.csaf.format";
|
||||
private const string VulnerabilityMetadataKey = "msrc.vulnerabilityId";
|
||||
private const string AdvisoryIdMetadataKey = "msrc.advisoryId";
|
||||
private const string LastModifiedMetadataKey = "msrc.lastModified";
|
||||
private const string ReleaseDateMetadataKey = "msrc.releaseDate";
|
||||
private const string CvssSeverityMetadataKey = "msrc.severity";
|
||||
private const string CvrfUrlMetadataKey = "msrc.cvrfUrl";
|
||||
|
||||
private static readonly VexConnectorDescriptor DescriptorInstance = new(
|
||||
id: "excititor:msrc",
|
||||
kind: VexProviderKind.Vendor,
|
||||
displayName: "Microsoft MSRC CSAF")
|
||||
{
|
||||
Description = "Authenticated connector for Microsoft Security Response Center CSAF advisories.",
|
||||
SupportedFormats = ImmutableArray.Create(VexDocumentFormat.Csaf),
|
||||
Tags = ImmutableArray.Create("microsoft", "csaf", "vendor"),
|
||||
};
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMsrcTokenProvider _tokenProvider;
|
||||
private readonly IVexConnectorStateRepository _stateRepository;
|
||||
private readonly IOptions<MsrcConnectorOptions> _options;
|
||||
private readonly ILogger<MsrcCsafConnector> _logger;
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
private MsrcConnectorOptions? _validatedOptions;
|
||||
|
||||
public MsrcCsafConnector(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMsrcTokenProvider tokenProvider,
|
||||
IVexConnectorStateRepository stateRepository,
|
||||
IOptions<MsrcConnectorOptions> options,
|
||||
ILogger<MsrcCsafConnector> logger,
|
||||
TimeProvider timeProvider)
|
||||
: base(DescriptorInstance, logger, timeProvider)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
|
||||
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public override ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
|
||||
{
|
||||
var options = _options.Value ?? throw new InvalidOperationException("MSRC connector options were not registered.");
|
||||
options.Validate();
|
||||
_validatedOptions = options;
|
||||
|
||||
LogConnectorEvent(
|
||||
LogLevel.Information,
|
||||
"validate",
|
||||
"Validated MSRC CSAF connector options.",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["baseUri"] = options.BaseUri.ToString(),
|
||||
["locale"] = options.Locale,
|
||||
["apiVersion"] = options.ApiVersion,
|
||||
["pageSize"] = options.PageSize,
|
||||
["maxAdvisories"] = options.MaxAdvisoriesPerFetch,
|
||||
});
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<VexRawDocument> FetchAsync(
|
||||
VexConnectorContext context,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var options = EnsureOptionsValidated();
|
||||
var state = await _stateRepository.GetAsync(Descriptor.Id, cancellationToken).ConfigureAwait(false);
|
||||
var (from, to) = CalculateWindow(context.Since, state, options);
|
||||
|
||||
LogConnectorEvent(
|
||||
LogLevel.Information,
|
||||
"fetch.window",
|
||||
$"Fetching MSRC CSAF advisories updated between {from:O} and {to:O}.",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["from"] = from,
|
||||
["to"] = to,
|
||||
["cursorOverlapSeconds"] = options.CursorOverlap.TotalSeconds,
|
||||
});
|
||||
|
||||
var client = await CreateAuthenticatedClientAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var knownDigests = state?.DocumentDigests ?? ImmutableArray<string>.Empty;
|
||||
var digestSet = new HashSet<string>(knownDigests, StringComparer.OrdinalIgnoreCase);
|
||||
var digestList = new List<string>(knownDigests);
|
||||
var latest = state?.LastUpdated ?? from;
|
||||
var fetched = 0;
|
||||
var stateChanged = false;
|
||||
|
||||
await foreach (var summary in EnumerateSummariesAsync(client, options, from, to, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (fetched >= options.MaxAdvisoriesPerFetch)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(summary.CvrfUrl))
|
||||
{
|
||||
LogConnectorEvent(LogLevel.Debug, "skip.no-cvrf", $"Skipping MSRC advisory {summary.Id} because no CSAF URL was provided.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var documentUri = ResolveCvrfUri(options.BaseUri, summary.CvrfUrl);
|
||||
|
||||
VexRawDocument? rawDocument = null;
|
||||
try
|
||||
{
|
||||
rawDocument = await DownloadCsafAsync(client, summary, documentUri, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
LogConnectorEvent(LogLevel.Warning, "fetch.error", $"Failed to download MSRC CSAF package {documentUri}.", new Dictionary<string, object?>
|
||||
{
|
||||
["advisoryId"] = summary.Id,
|
||||
["vulnerabilityId"] = summary.VulnerabilityId ?? summary.Id,
|
||||
}, ex);
|
||||
|
||||
await Task.Delay(GetRetryDelay(options, 1), cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!digestSet.Add(rawDocument.Digest))
|
||||
{
|
||||
LogConnectorEvent(LogLevel.Debug, "skip.duplicate", $"Skipping MSRC CSAF package {documentUri} because it was already processed.");
|
||||
continue;
|
||||
}
|
||||
|
||||
await context.RawSink.StoreAsync(rawDocument, cancellationToken).ConfigureAwait(false);
|
||||
digestList.Add(rawDocument.Digest);
|
||||
stateChanged = true;
|
||||
fetched++;
|
||||
|
||||
latest = DetermineLatest(summary, latest) ?? latest;
|
||||
|
||||
var quarantineReason = rawDocument.Metadata.TryGetValue(QuarantineMetadataKey, out var reason) ? reason : null;
|
||||
if (quarantineReason is not null)
|
||||
{
|
||||
LogConnectorEvent(LogLevel.Warning, "quarantine", $"Quarantined MSRC CSAF package {documentUri} ({quarantineReason}).");
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return rawDocument;
|
||||
|
||||
if (options.RequestDelay > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(options.RequestDelay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (stateChanged)
|
||||
{
|
||||
if (digestList.Count > options.MaxTrackedDigests)
|
||||
{
|
||||
var trimmed = digestList.Count - options.MaxTrackedDigests;
|
||||
digestList.RemoveRange(0, trimmed);
|
||||
}
|
||||
|
||||
var baseState = state ?? new VexConnectorState(
|
||||
Descriptor.Id,
|
||||
null,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
null);
|
||||
var newState = baseState with
|
||||
{
|
||||
LastUpdated = latest == DateTimeOffset.MinValue ? state?.LastUpdated : latest,
|
||||
DocumentDigests = digestList.ToImmutableArray(),
|
||||
};
|
||||
|
||||
await _stateRepository.SaveAsync(newState, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
LogConnectorEvent(
|
||||
LogLevel.Information,
|
||||
"fetch.completed",
|
||||
$"MSRC CSAF fetch completed with {fetched} new documents.",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["fetched"] = fetched,
|
||||
["stateChanged"] = stateChanged,
|
||||
["lastUpdated"] = latest,
|
||||
});
|
||||
}
|
||||
|
||||
public override ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException("MSRC CSAF connector relies on CSAF normalizers for document processing.");
|
||||
|
||||
private async Task<VexRawDocument> DownloadCsafAsync(
|
||||
HttpClient client,
|
||||
MsrcVulnerabilitySummary summary,
|
||||
Uri documentUri,
|
||||
MsrcConnectorOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await SendWithRetryAsync(
|
||||
client,
|
||||
() => new HttpRequestMessage(HttpMethod.Get, documentUri),
|
||||
options,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var payload = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var validation = ValidateCsafPayload(payload);
|
||||
var metadata = BuildMetadata(builder =>
|
||||
{
|
||||
builder.Add(AdvisoryIdMetadataKey, summary.Id);
|
||||
builder.Add(VulnerabilityMetadataKey, summary.VulnerabilityId ?? summary.Id);
|
||||
builder.Add(CvrfUrlMetadataKey, documentUri.ToString());
|
||||
builder.Add(FormatMetadataKey, validation.Format);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(summary.Severity))
|
||||
{
|
||||
builder.Add(CvssSeverityMetadataKey, summary.Severity);
|
||||
}
|
||||
|
||||
if (summary.LastModifiedDate is not null)
|
||||
{
|
||||
builder.Add(LastModifiedMetadataKey, summary.LastModifiedDate.Value.ToString("O"));
|
||||
}
|
||||
|
||||
if (summary.ReleaseDate is not null)
|
||||
{
|
||||
builder.Add(ReleaseDateMetadataKey, summary.ReleaseDate.Value.ToString("O"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(validation.QuarantineReason))
|
||||
{
|
||||
builder.Add(QuarantineMetadataKey, validation.QuarantineReason);
|
||||
}
|
||||
|
||||
if (response.Headers.ETag is not null)
|
||||
{
|
||||
builder.Add("http.etag", response.Headers.ETag.Tag);
|
||||
}
|
||||
|
||||
if (response.Content.Headers.LastModified is { } lastModified)
|
||||
{
|
||||
builder.Add("http.lastModified", lastModified.ToString("O"));
|
||||
}
|
||||
});
|
||||
|
||||
return CreateRawDocument(VexDocumentFormat.Csaf, documentUri, payload, metadata);
|
||||
}
|
||||
|
||||
private async Task<HttpClient> CreateAuthenticatedClientAsync(MsrcConnectorOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await _tokenProvider.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
var client = _httpClientFactory.CreateClient(MsrcConnectorOptions.ApiClientName);
|
||||
|
||||
client.DefaultRequestHeaders.Remove("Authorization");
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"{token.Type} {token.Value}");
|
||||
client.DefaultRequestHeaders.Remove("Accept-Language");
|
||||
client.DefaultRequestHeaders.Add("Accept-Language", options.Locale);
|
||||
client.DefaultRequestHeaders.Remove("api-version");
|
||||
client.DefaultRequestHeaders.Add("api-version", options.ApiVersion);
|
||||
client.DefaultRequestHeaders.Remove("Accept");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendWithRetryAsync(
|
||||
HttpClient client,
|
||||
Func<HttpRequestMessage> requestFactory,
|
||||
MsrcConnectorOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Exception? lastError = null;
|
||||
HttpResponseMessage? response = null;
|
||||
|
||||
for (var attempt = 1; attempt <= options.MaxRetryAttempts; attempt++)
|
||||
{
|
||||
response?.Dispose();
|
||||
using var request = requestFactory();
|
||||
try
|
||||
{
|
||||
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
if (!ShouldRetry(response.StatusCode) || attempt == options.MaxRetryAttempts)
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (IsTransient(ex) && attempt < options.MaxRetryAttempts)
|
||||
{
|
||||
lastError = ex;
|
||||
LogConnectorEvent(LogLevel.Warning, "retry", $"Retrying MSRC request (attempt {attempt}/{options.MaxRetryAttempts}).", exception: ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
response?.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
await Task.Delay(GetRetryDelay(options, attempt), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
response?.Dispose();
|
||||
throw lastError ?? new InvalidOperationException("MSRC request retries exhausted.");
|
||||
}
|
||||
|
||||
private TimeSpan GetRetryDelay(MsrcConnectorOptions options, int attempt)
|
||||
{
|
||||
var baseDelay = options.RetryBaseDelay.TotalMilliseconds;
|
||||
var multiplier = Math.Pow(2, Math.Max(0, attempt - 1));
|
||||
var jitter = Random.Shared.NextDouble() * baseDelay * 0.25;
|
||||
var delayMs = Math.Min(baseDelay * multiplier + jitter, TimeSpan.FromMinutes(5).TotalMilliseconds);
|
||||
return TimeSpan.FromMilliseconds(delayMs);
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<MsrcVulnerabilitySummary> EnumerateSummariesAsync(
|
||||
HttpClient client,
|
||||
MsrcConnectorOptions options,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var fetched = 0;
|
||||
var requestUri = BuildSummaryUri(options, from, to);
|
||||
|
||||
while (requestUri is not null && fetched < options.MaxAdvisoriesPerFetch)
|
||||
{
|
||||
using var response = await SendWithRetryAsync(
|
||||
client,
|
||||
() => new HttpRequestMessage(HttpMethod.Get, requestUri),
|
||||
options,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var payload = await JsonSerializer.DeserializeAsync<MsrcSummaryResponse>(stream, _serializerOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new MsrcSummaryResponse();
|
||||
|
||||
foreach (var summary in payload.Value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(summary.CvrfUrl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return summary;
|
||||
fetched++;
|
||||
|
||||
if (fetched >= options.MaxAdvisoriesPerFetch)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.NextLink))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(payload.NextLink, UriKind.Absolute, out requestUri))
|
||||
{
|
||||
LogConnectorEvent(LogLevel.Warning, "pagination.invalid", $"MSRC pagination returned invalid next link '{payload.NextLink}'.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Uri BuildSummaryUri(MsrcConnectorOptions options, DateTimeOffset from, DateTimeOffset to)
|
||||
{
|
||||
var baseText = options.BaseUri.ToString().TrimEnd('/');
|
||||
var builder = new StringBuilder(baseText.Length + 128);
|
||||
builder.Append(baseText);
|
||||
if (!baseText.EndsWith("/vulnerabilities", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.Append("/vulnerabilities");
|
||||
}
|
||||
|
||||
builder.Append("?");
|
||||
builder.Append("$top=").Append(options.PageSize);
|
||||
builder.Append("&lastModifiedStartDateTime=").Append(Uri.EscapeDataString(from.ToUniversalTime().ToString("O")));
|
||||
builder.Append("&lastModifiedEndDateTime=").Append(Uri.EscapeDataString(to.ToUniversalTime().ToString("O")));
|
||||
builder.Append("&$orderby=lastModifiedDate");
|
||||
builder.Append("&locale=").Append(Uri.EscapeDataString(options.Locale));
|
||||
builder.Append("&api-version=").Append(Uri.EscapeDataString(options.ApiVersion));
|
||||
|
||||
return new Uri(builder.ToString(), UriKind.Absolute);
|
||||
}
|
||||
|
||||
private (DateTimeOffset From, DateTimeOffset To) CalculateWindow(
|
||||
DateTimeOffset? contextSince,
|
||||
VexConnectorState? state,
|
||||
MsrcConnectorOptions options)
|
||||
{
|
||||
var now = UtcNow();
|
||||
var since = contextSince ?? state?.LastUpdated ?? options.InitialLastModified ?? now.AddDays(-30);
|
||||
|
||||
if (state?.LastUpdated is { } persisted && persisted > since)
|
||||
{
|
||||
since = persisted;
|
||||
}
|
||||
|
||||
if (options.CursorOverlap > TimeSpan.Zero)
|
||||
{
|
||||
since = since.Add(-options.CursorOverlap);
|
||||
}
|
||||
|
||||
if (since < now.AddYears(-20))
|
||||
{
|
||||
since = now.AddYears(-20);
|
||||
}
|
||||
|
||||
return (since, now);
|
||||
}
|
||||
|
||||
private static bool ShouldRetry(HttpStatusCode statusCode)
|
||||
=> statusCode == HttpStatusCode.TooManyRequests ||
|
||||
(int)statusCode >= 500;
|
||||
|
||||
private static bool IsTransient(Exception exception)
|
||||
=> exception is HttpRequestException or IOException or TaskCanceledException;
|
||||
|
||||
private static Uri ResolveCvrfUri(Uri baseUri, string cvrfUrl)
|
||||
=> Uri.TryCreate(cvrfUrl, UriKind.Absolute, out var absolute)
|
||||
? absolute
|
||||
: new Uri(baseUri, cvrfUrl);
|
||||
|
||||
private static CsafValidationResult ValidateCsafPayload(ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsZip(payload.Span))
|
||||
{
|
||||
using var zipStream = new MemoryStream(payload.ToArray(), writable: false);
|
||||
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
var entry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
?? archive.Entries.FirstOrDefault();
|
||||
if (entry is null)
|
||||
{
|
||||
return new CsafValidationResult("zip", "Zip archive did not contain any entries.");
|
||||
}
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
using var reader = new StreamReader(entryStream, Encoding.UTF8);
|
||||
using var json = JsonDocument.Parse(reader.ReadToEnd());
|
||||
return CsafValidationResult.Valid("zip");
|
||||
}
|
||||
|
||||
if (IsGzip(payload.Span))
|
||||
{
|
||||
using var input = new MemoryStream(payload.ToArray(), writable: false);
|
||||
using var gzip = new GZipStream(input, CompressionMode.Decompress);
|
||||
using var reader = new StreamReader(gzip, Encoding.UTF8);
|
||||
using var json = JsonDocument.Parse(reader.ReadToEnd());
|
||||
return CsafValidationResult.Valid("gzip");
|
||||
}
|
||||
|
||||
using var jsonDocument = JsonDocument.Parse(payload.Span);
|
||||
return CsafValidationResult.Valid("json");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return new CsafValidationResult("json", $"JSON parse failed: {ex.Message}");
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
return new CsafValidationResult("invalid", ex.Message);
|
||||
}
|
||||
catch (EndOfStreamException ex)
|
||||
{
|
||||
return new CsafValidationResult("invalid", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsZip(ReadOnlySpan<byte> content)
|
||||
=> content.Length > 3 && content[0] == 0x50 && content[1] == 0x4B;
|
||||
|
||||
private static bool IsGzip(ReadOnlySpan<byte> content)
|
||||
=> content.Length > 2 && content[0] == 0x1F && content[1] == 0x8B;
|
||||
|
||||
private static DateTimeOffset? DetermineLatest(MsrcVulnerabilitySummary summary, DateTimeOffset? current)
|
||||
{
|
||||
var candidate = summary.LastModifiedDate ?? summary.ReleaseDate;
|
||||
if (candidate is null)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
if (current is null || candidate > current)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private MsrcConnectorOptions EnsureOptionsValidated()
|
||||
{
|
||||
if (_validatedOptions is not null)
|
||||
{
|
||||
return _validatedOptions;
|
||||
}
|
||||
|
||||
var options = _options.Value ?? throw new InvalidOperationException("MSRC connector options were not registered.");
|
||||
options.Validate();
|
||||
_validatedOptions = options;
|
||||
return options;
|
||||
}
|
||||
|
||||
private sealed record CsafValidationResult(string Format, string? QuarantineReason)
|
||||
{
|
||||
public static CsafValidationResult Valid(string format) => new(format, null);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record MsrcSummaryResponse
|
||||
{
|
||||
[JsonPropertyName("value")]
|
||||
public List<MsrcVulnerabilitySummary> Value { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("@odata.nextLink")]
|
||||
public string? NextLink { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record MsrcVulnerabilitySummary
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("releaseDate")]
|
||||
public DateTimeOffset? ReleaseDate { get; init; }
|
||||
|
||||
[JsonPropertyName("lastModifiedDate")]
|
||||
public DateTimeOffset? LastModifiedDate { get; init; }
|
||||
|
||||
[JsonPropertyName("cvrfUrl")]
|
||||
public string? CvrfUrl { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user