feat: Add tests for RichGraphPublisher and RichGraphWriter
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled

- Implement unit tests for RichGraphPublisher to verify graph publishing to CAS.
- Implement unit tests for RichGraphWriter to ensure correct writing of canonical graphs and metadata.

feat: Implement AOC Guard validation logic

- Add AOC Guard validation logic to enforce document structure and field constraints.
- Introduce violation codes for various validation errors.
- Implement tests for AOC Guard to validate expected behavior.

feat: Create Console Status API client and service

- Implement ConsoleStatusClient for fetching console status and streaming run events.
- Create ConsoleStatusService to manage console status polling and event subscriptions.
- Add tests for ConsoleStatusClient to verify API interactions.

feat: Develop Console Status component

- Create ConsoleStatusComponent for displaying console status and run events.
- Implement UI for showing status metrics and handling user interactions.
- Add styles for console status display.

test: Add tests for Console Status store

- Implement tests for ConsoleStatusStore to verify event handling and state management.
This commit is contained in:
StellaOps Bot
2025-12-01 07:34:50 +02:00
parent 7df0677e34
commit c11d87d252
108 changed files with 4773 additions and 351 deletions

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Concelier.Storage.Postgres.Models;
/// <summary>
/// Represents an affected package entry for an advisory.
/// </summary>
public sealed class AdvisoryAffectedEntity
{
public Guid Id { get; init; }
public Guid AdvisoryId { get; init; }
public required string Ecosystem { get; init; }
public required string PackageName { get; init; }
public string? Purl { get; init; }
public string VersionRange { get; init; } = "{}";
public string[]? VersionsAffected { get; init; }
public string[]? VersionsFixed { get; init; }
public string? DatabaseSpecific { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Concelier.Storage.Postgres.Models;
/// <summary>
/// Represents an advisory alias (e.g., CVE, GHSA).
/// </summary>
public sealed class AdvisoryAliasEntity
{
public Guid Id { get; init; }
public Guid AdvisoryId { get; init; }
public required string AliasType { get; init; }
public required string AliasValue { get; init; }
public bool IsPrimary { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Concelier.Storage.Postgres.Models;
/// <summary>
/// Represents a credit entry for an advisory.
/// </summary>
public sealed class AdvisoryCreditEntity
{
public Guid Id { get; init; }
public Guid AdvisoryId { get; init; }
public required string Name { get; init; }
public string? Contact { get; init; }
public string? CreditType { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,20 @@
namespace StellaOps.Concelier.Storage.Postgres.Models;
/// <summary>
/// Represents a CVSS score for an advisory.
/// </summary>
public sealed class AdvisoryCvssEntity
{
public Guid Id { get; init; }
public Guid AdvisoryId { get; init; }
public required string CvssVersion { get; init; }
public required string VectorString { get; init; }
public decimal BaseScore { get; init; }
public string? BaseSeverity { get; init; }
public decimal? ExploitabilityScore { get; init; }
public decimal? ImpactScore { get; init; }
public string? Source { get; init; }
public bool IsPrimary { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,14 @@
namespace StellaOps.Concelier.Storage.Postgres.Models;
/// <summary>
/// Represents an advisory reference URL.
/// </summary>
public sealed class AdvisoryReferenceEntity
{
public Guid Id { get; init; }
public Guid AdvisoryId { get; init; }
public required string RefType { get; init; }
public required string Url { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,14 @@
namespace StellaOps.Concelier.Storage.Postgres.Models;
/// <summary>
/// Represents a snapshot of an advisory at a point in time.
/// </summary>
public sealed class AdvisorySnapshotEntity
{
public Guid Id { get; init; }
public Guid FeedSnapshotId { get; init; }
public required string AdvisoryKey { get; init; }
public required string ContentHash { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Concelier.Storage.Postgres.Models;
/// <summary>
/// Represents a CWE weakness linked to an advisory.
/// </summary>
public sealed class AdvisoryWeaknessEntity
{
public Guid Id { get; init; }
public Guid AdvisoryId { get; init; }
public required string CweId { get; init; }
public string? Description { get; init; }
public string? Source { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.Concelier.Storage.Postgres.Models;
/// <summary>
/// Represents a feed snapshot record.
/// </summary>
public sealed class FeedSnapshotEntity
{
public Guid Id { get; init; }
public Guid SourceId { get; init; }
public required string SnapshotId { get; init; }
public int AdvisoryCount { get; init; }
public string? Checksum { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,20 @@
namespace StellaOps.Concelier.Storage.Postgres.Models;
/// <summary>
/// Represents a Known Exploited Vulnerability flag entry.
/// </summary>
public sealed class KevFlagEntity
{
public Guid Id { get; init; }
public Guid AdvisoryId { get; init; }
public required string CveId { get; init; }
public string? VendorProject { get; init; }
public string? Product { get; init; }
public string? VulnerabilityName { get; init; }
public DateOnly DateAdded { get; init; }
public DateOnly? DueDate { get; init; }
public bool KnownRansomwareUse { get; init; }
public string? Notes { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.Concelier.Storage.Postgres.Models;
/// <summary>
/// Represents a merge event audit record.
/// </summary>
public sealed class MergeEventEntity
{
public long Id { get; init; }
public Guid AdvisoryId { get; init; }
public Guid? SourceId { get; init; }
public required string EventType { get; init; }
public string? OldValue { get; init; }
public string? NewValue { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Concelier.Storage.Postgres.Models;
/// <summary>
/// Tracks source ingestion cursors and metrics.
/// </summary>
public sealed class SourceStateEntity
{
public Guid Id { get; init; }
public Guid SourceId { get; init; }
public string? Cursor { get; init; }
public DateTimeOffset? LastSyncAt { get; init; }
public DateTimeOffset? LastSuccessAt { get; init; }
public string? LastError { get; init; }
public long SyncCount { get; init; }
public int ErrorCount { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,143 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for advisory affected packages.
/// </summary>
public sealed class AdvisoryAffectedRepository : RepositoryBase<ConcelierDataSource>, IAdvisoryAffectedRepository
{
private const string SystemTenantId = "_system";
public AdvisoryAffectedRepository(ConcelierDataSource dataSource, ILogger<AdvisoryAffectedRepository> logger)
: base(dataSource, logger)
{
}
public async Task ReplaceAsync(Guid advisoryId, IEnumerable<AdvisoryAffectedEntity> affected, CancellationToken cancellationToken = default)
{
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
const string deleteSql = "DELETE FROM vuln.advisory_affected WHERE advisory_id = @advisory_id";
await using (var deleteCmd = CreateCommand(deleteSql, connection))
{
deleteCmd.Transaction = transaction;
AddParameter(deleteCmd, "advisory_id", advisoryId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
const string insertSql = """
INSERT INTO vuln.advisory_affected
(id, advisory_id, ecosystem, package_name, purl, version_range, versions_affected,
versions_fixed, database_specific)
VALUES
(@id, @advisory_id, @ecosystem, @package_name, @purl, @version_range::jsonb,
@versions_affected, @versions_fixed, @database_specific::jsonb)
""";
foreach (var entry in affected)
{
await using var insertCmd = CreateCommand(insertSql, connection);
insertCmd.Transaction = transaction;
AddParameter(insertCmd, "id", entry.Id);
AddParameter(insertCmd, "advisory_id", advisoryId);
AddParameter(insertCmd, "ecosystem", entry.Ecosystem);
AddParameter(insertCmd, "package_name", entry.PackageName);
AddParameter(insertCmd, "purl", entry.Purl);
AddJsonbParameter(insertCmd, "version_range", entry.VersionRange);
AddTextArrayParameter(insertCmd, "versions_affected", entry.VersionsAffected);
AddTextArrayParameter(insertCmd, "versions_fixed", entry.VersionsFixed);
AddJsonbParameter(insertCmd, "database_specific", entry.DatabaseSpecific);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
public Task<IReadOnlyList<AdvisoryAffectedEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_id, ecosystem, package_name, purl, version_range::text,
versions_affected, versions_fixed, database_specific::text, created_at
FROM vuln.advisory_affected
WHERE advisory_id = @advisory_id
ORDER BY ecosystem, package_name, purl NULLS LAST
""";
return QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "advisory_id", advisoryId),
MapAffected,
cancellationToken);
}
public Task<IReadOnlyList<AdvisoryAffectedEntity>> GetByPurlAsync(string purl, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_id, ecosystem, package_name, purl, version_range::text,
versions_affected, versions_fixed, database_specific::text, created_at
FROM vuln.advisory_affected
WHERE purl = @purl
ORDER BY advisory_id, id
LIMIT @limit OFFSET @offset
""";
return QueryAsync(
SystemTenantId,
sql,
cmd =>
{
AddParameter(cmd, "purl", purl);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapAffected,
cancellationToken);
}
public Task<IReadOnlyList<AdvisoryAffectedEntity>> GetByPackageNameAsync(string ecosystem, string packageName, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_id, ecosystem, package_name, purl, version_range::text,
versions_affected, versions_fixed, database_specific::text, created_at
FROM vuln.advisory_affected
WHERE ecosystem = @ecosystem AND package_name = @package_name
ORDER BY advisory_id, id
LIMIT @limit OFFSET @offset
""";
return QueryAsync(
SystemTenantId,
sql,
cmd =>
{
AddParameter(cmd, "ecosystem", ecosystem);
AddParameter(cmd, "package_name", packageName);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapAffected,
cancellationToken);
}
private static AdvisoryAffectedEntity MapAffected(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
AdvisoryId = reader.GetGuid(1),
Ecosystem = reader.GetString(2),
PackageName = reader.GetString(3),
Purl = GetNullableString(reader, 4),
VersionRange = reader.GetString(5),
VersionsAffected = reader.IsDBNull(6) ? null : reader.GetFieldValue<string[]>(6),
VersionsFixed = reader.IsDBNull(7) ? null : reader.GetFieldValue<string[]>(7),
DatabaseSpecific = GetNullableString(reader, 8),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9)
};
}

View File

@@ -0,0 +1,100 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for advisory aliases.
/// </summary>
public sealed class AdvisoryAliasRepository : RepositoryBase<ConcelierDataSource>, IAdvisoryAliasRepository
{
private const string SystemTenantId = "_system";
public AdvisoryAliasRepository(ConcelierDataSource dataSource, ILogger<AdvisoryAliasRepository> logger)
: base(dataSource, logger)
{
}
public async Task ReplaceAsync(Guid advisoryId, IEnumerable<AdvisoryAliasEntity> aliases, CancellationToken cancellationToken = default)
{
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
const string deleteSql = "DELETE FROM vuln.advisory_aliases WHERE advisory_id = @advisory_id";
await using (var deleteCmd = CreateCommand(deleteSql, connection))
{
deleteCmd.Transaction = transaction;
AddParameter(deleteCmd, "advisory_id", advisoryId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
const string insertSql = """
INSERT INTO vuln.advisory_aliases
(id, advisory_id, alias_type, alias_value, is_primary)
VALUES
(@id, @advisory_id, @alias_type, @alias_value, @is_primary)
""";
foreach (var alias in aliases)
{
await using var insertCmd = CreateCommand(insertSql, connection);
insertCmd.Transaction = transaction;
AddParameter(insertCmd, "id", alias.Id);
AddParameter(insertCmd, "advisory_id", advisoryId);
AddParameter(insertCmd, "alias_type", alias.AliasType);
AddParameter(insertCmd, "alias_value", alias.AliasValue);
AddParameter(insertCmd, "is_primary", alias.IsPrimary);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
public Task<IReadOnlyList<AdvisoryAliasEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_id, alias_type, alias_value, is_primary, created_at
FROM vuln.advisory_aliases
WHERE advisory_id = @advisory_id
ORDER BY is_primary DESC, alias_type, alias_value
""";
return QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "advisory_id", advisoryId),
MapAlias,
cancellationToken);
}
public Task<IReadOnlyList<AdvisoryAliasEntity>> GetByAliasAsync(string aliasValue, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_id, alias_type, alias_value, is_primary, created_at
FROM vuln.advisory_aliases
WHERE alias_value = @alias_value
ORDER BY is_primary DESC
""";
return QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "alias_value", aliasValue),
MapAlias,
cancellationToken);
}
private static AdvisoryAliasEntity MapAlias(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
AdvisoryId = reader.GetGuid(1),
AliasType = reader.GetString(2),
AliasValue = reader.GetString(3),
IsPrimary = reader.GetBoolean(4),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(5)
};
}

View File

@@ -0,0 +1,83 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for advisory credits.
/// </summary>
public sealed class AdvisoryCreditRepository : RepositoryBase<ConcelierDataSource>, IAdvisoryCreditRepository
{
private const string SystemTenantId = "_system";
public AdvisoryCreditRepository(ConcelierDataSource dataSource, ILogger<AdvisoryCreditRepository> logger)
: base(dataSource, logger)
{
}
public async Task ReplaceAsync(Guid advisoryId, IEnumerable<AdvisoryCreditEntity> credits, CancellationToken cancellationToken = default)
{
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
const string deleteSql = "DELETE FROM vuln.advisory_credits WHERE advisory_id = @advisory_id";
await using (var deleteCmd = CreateCommand(deleteSql, connection))
{
deleteCmd.Transaction = transaction;
AddParameter(deleteCmd, "advisory_id", advisoryId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
const string insertSql = """
INSERT INTO vuln.advisory_credits
(id, advisory_id, name, contact, credit_type)
VALUES
(@id, @advisory_id, @name, @contact, @credit_type)
""";
foreach (var credit in credits)
{
await using var insertCmd = CreateCommand(insertSql, connection);
insertCmd.Transaction = transaction;
AddParameter(insertCmd, "id", credit.Id);
AddParameter(insertCmd, "advisory_id", advisoryId);
AddParameter(insertCmd, "name", credit.Name);
AddParameter(insertCmd, "contact", credit.Contact);
AddParameter(insertCmd, "credit_type", credit.CreditType);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
public Task<IReadOnlyList<AdvisoryCreditEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_id, name, contact, credit_type, created_at
FROM vuln.advisory_credits
WHERE advisory_id = @advisory_id
ORDER BY name
""";
return QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "advisory_id", advisoryId),
MapCredit,
cancellationToken);
}
private static AdvisoryCreditEntity MapCredit(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
AdvisoryId = reader.GetGuid(1),
Name = reader.GetString(2),
Contact = GetNullableString(reader, 3),
CreditType = GetNullableString(reader, 4),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(5)
};
}

View File

@@ -0,0 +1,96 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for advisory CVSS scores.
/// </summary>
public sealed class AdvisoryCvssRepository : RepositoryBase<ConcelierDataSource>, IAdvisoryCvssRepository
{
private const string SystemTenantId = "_system";
public AdvisoryCvssRepository(ConcelierDataSource dataSource, ILogger<AdvisoryCvssRepository> logger)
: base(dataSource, logger)
{
}
public async Task ReplaceAsync(Guid advisoryId, IEnumerable<AdvisoryCvssEntity> scores, CancellationToken cancellationToken = default)
{
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
const string deleteSql = "DELETE FROM vuln.advisory_cvss WHERE advisory_id = @advisory_id";
await using (var deleteCmd = CreateCommand(deleteSql, connection))
{
deleteCmd.Transaction = transaction;
AddParameter(deleteCmd, "advisory_id", advisoryId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
const string insertSql = """
INSERT INTO vuln.advisory_cvss
(id, advisory_id, cvss_version, vector_string, base_score, base_severity,
exploitability_score, impact_score, source, is_primary)
VALUES
(@id, @advisory_id, @cvss_version, @vector_string, @base_score, @base_severity,
@exploitability_score, @impact_score, @source, @is_primary)
""";
foreach (var score in scores)
{
await using var insertCmd = CreateCommand(insertSql, connection);
insertCmd.Transaction = transaction;
AddParameter(insertCmd, "id", score.Id);
AddParameter(insertCmd, "advisory_id", advisoryId);
AddParameter(insertCmd, "cvss_version", score.CvssVersion);
AddParameter(insertCmd, "vector_string", score.VectorString);
AddParameter(insertCmd, "base_score", score.BaseScore);
AddParameter(insertCmd, "base_severity", score.BaseSeverity);
AddParameter(insertCmd, "exploitability_score", score.ExploitabilityScore);
AddParameter(insertCmd, "impact_score", score.ImpactScore);
AddParameter(insertCmd, "source", score.Source);
AddParameter(insertCmd, "is_primary", score.IsPrimary);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
public Task<IReadOnlyList<AdvisoryCvssEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_id, cvss_version, vector_string, base_score, base_severity,
exploitability_score, impact_score, source, is_primary, created_at
FROM vuln.advisory_cvss
WHERE advisory_id = @advisory_id
ORDER BY is_primary DESC, cvss_version
""";
return QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "advisory_id", advisoryId),
MapCvss,
cancellationToken);
}
private static AdvisoryCvssEntity MapCvss(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
AdvisoryId = reader.GetGuid(1),
CvssVersion = reader.GetString(2),
VectorString = reader.GetString(3),
BaseScore = reader.GetDecimal(4),
BaseSeverity = GetNullableString(reader, 5),
ExploitabilityScore = GetNullableDecimal(reader, 6),
ImpactScore = GetNullableDecimal(reader, 7),
Source = GetNullableString(reader, 8),
IsPrimary = reader.GetBoolean(9),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(10)
};
}

View File

@@ -0,0 +1,81 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for advisory references.
/// </summary>
public sealed class AdvisoryReferenceRepository : RepositoryBase<ConcelierDataSource>, IAdvisoryReferenceRepository
{
private const string SystemTenantId = "_system";
public AdvisoryReferenceRepository(ConcelierDataSource dataSource, ILogger<AdvisoryReferenceRepository> logger)
: base(dataSource, logger)
{
}
public async Task ReplaceAsync(Guid advisoryId, IEnumerable<AdvisoryReferenceEntity> references, CancellationToken cancellationToken = default)
{
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
const string deleteSql = "DELETE FROM vuln.advisory_references WHERE advisory_id = @advisory_id";
await using (var deleteCmd = CreateCommand(deleteSql, connection))
{
deleteCmd.Transaction = transaction;
AddParameter(deleteCmd, "advisory_id", advisoryId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
const string insertSql = """
INSERT INTO vuln.advisory_references
(id, advisory_id, ref_type, url)
VALUES
(@id, @advisory_id, @ref_type, @url)
""";
foreach (var reference in references)
{
await using var insertCmd = CreateCommand(insertSql, connection);
insertCmd.Transaction = transaction;
AddParameter(insertCmd, "id", reference.Id);
AddParameter(insertCmd, "advisory_id", advisoryId);
AddParameter(insertCmd, "ref_type", reference.RefType);
AddParameter(insertCmd, "url", reference.Url);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
public Task<IReadOnlyList<AdvisoryReferenceEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_id, ref_type, url, created_at
FROM vuln.advisory_references
WHERE advisory_id = @advisory_id
ORDER BY ref_type, url
""";
return QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "advisory_id", advisoryId),
MapReference,
cancellationToken);
}
private static AdvisoryReferenceEntity MapReference(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
AdvisoryId = reader.GetGuid(1),
RefType = reader.GetString(2),
Url = reader.GetString(3),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(4)
};
}

View File

@@ -25,55 +25,24 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
}
/// <inheritdoc />
public async Task<AdvisoryEntity> UpsertAsync(AdvisoryEntity advisory, CancellationToken cancellationToken = default)
public Task<AdvisoryEntity> UpsertAsync(
AdvisoryEntity advisory,
IEnumerable<AdvisoryAliasEntity>? aliases,
IEnumerable<AdvisoryCvssEntity>? cvss,
IEnumerable<AdvisoryAffectedEntity>? affected,
IEnumerable<AdvisoryReferenceEntity>? references,
IEnumerable<AdvisoryCreditEntity>? credits,
IEnumerable<AdvisoryWeaknessEntity>? weaknesses,
IEnumerable<KevFlagEntity>? kevFlags,
CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO vuln.advisories (
id, advisory_key, primary_vuln_id, source_id, title, summary, description,
severity, published_at, modified_at, withdrawn_at, provenance, raw_payload
)
VALUES (
@id, @advisory_key, @primary_vuln_id, @source_id, @title, @summary, @description,
@severity, @published_at, @modified_at, @withdrawn_at, @provenance::jsonb, @raw_payload::jsonb
)
ON CONFLICT (advisory_key) DO UPDATE SET
primary_vuln_id = EXCLUDED.primary_vuln_id,
source_id = COALESCE(EXCLUDED.source_id, vuln.advisories.source_id),
title = COALESCE(EXCLUDED.title, vuln.advisories.title),
summary = COALESCE(EXCLUDED.summary, vuln.advisories.summary),
description = COALESCE(EXCLUDED.description, vuln.advisories.description),
severity = COALESCE(EXCLUDED.severity, vuln.advisories.severity),
published_at = COALESCE(EXCLUDED.published_at, vuln.advisories.published_at),
modified_at = COALESCE(EXCLUDED.modified_at, vuln.advisories.modified_at),
withdrawn_at = EXCLUDED.withdrawn_at,
provenance = vuln.advisories.provenance || EXCLUDED.provenance,
raw_payload = EXCLUDED.raw_payload
RETURNING id, advisory_key, primary_vuln_id, source_id, title, summary, description,
severity, published_at, modified_at, withdrawn_at, provenance::text, raw_payload::text,
created_at, updated_at
""";
return UpsertInternalAsync(advisory, aliases, cvss, affected, references, credits, weaknesses, kevFlags, cancellationToken);
}
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", advisory.Id);
AddParameter(command, "advisory_key", advisory.AdvisoryKey);
AddParameter(command, "primary_vuln_id", advisory.PrimaryVulnId);
AddParameter(command, "source_id", advisory.SourceId);
AddParameter(command, "title", advisory.Title);
AddParameter(command, "summary", advisory.Summary);
AddParameter(command, "description", advisory.Description);
AddParameter(command, "severity", advisory.Severity);
AddParameter(command, "published_at", advisory.PublishedAt);
AddParameter(command, "modified_at", advisory.ModifiedAt);
AddParameter(command, "withdrawn_at", advisory.WithdrawnAt);
AddJsonbParameter(command, "provenance", advisory.Provenance);
AddJsonbParameter(command, "raw_payload", advisory.RawPayload);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapAdvisory(reader);
/// <inheritdoc />
public Task<AdvisoryEntity> UpsertAsync(AdvisoryEntity advisory, CancellationToken cancellationToken = default)
{
return UpsertInternalAsync(advisory, null, null, null, null, null, null, null, cancellationToken);
}
/// <inheritdoc />
@@ -133,6 +102,90 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<AdvisoryEntity>> GetByAliasAsync(string aliasValue, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT a.id, a.advisory_key, a.primary_vuln_id, a.source_id, a.title, a.summary, a.description,
a.severity, a.published_at, a.modified_at, a.withdrawn_at, a.provenance::text, a.raw_payload::text,
a.created_at, a.updated_at
FROM vuln.advisories a
JOIN vuln.advisory_aliases al ON al.advisory_id = a.id
WHERE al.alias_value = @alias_value
ORDER BY a.modified_at DESC, a.id
""";
return await QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "alias_value", aliasValue),
MapAdvisory,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<AdvisoryEntity>> GetAffectingPackageAsync(
string purl,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT a.id, a.advisory_key, a.primary_vuln_id, a.source_id, a.title, a.summary, a.description,
a.severity, a.published_at, a.modified_at, a.withdrawn_at, a.provenance::text, a.raw_payload::text,
a.created_at, a.updated_at
FROM vuln.advisories a
JOIN vuln.advisory_affected af ON af.advisory_id = a.id
WHERE af.purl = @purl
ORDER BY a.modified_at DESC, a.id
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
SystemTenantId,
sql,
cmd =>
{
AddParameter(cmd, "purl", purl);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapAdvisory,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<AdvisoryEntity>> GetAffectingPackageNameAsync(
string ecosystem,
string packageName,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT a.id, a.advisory_key, a.primary_vuln_id, a.source_id, a.title, a.summary, a.description,
a.severity, a.published_at, a.modified_at, a.withdrawn_at, a.provenance::text, a.raw_payload::text,
a.created_at, a.updated_at
FROM vuln.advisories a
JOIN vuln.advisory_affected af ON af.advisory_id = a.id
WHERE af.ecosystem = @ecosystem AND af.package_name = @package_name
ORDER BY a.modified_at DESC, a.id
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
SystemTenantId,
sql,
cmd =>
{
AddParameter(cmd, "ecosystem", ecosystem);
AddParameter(cmd, "package_name", packageName);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapAdvisory,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<AdvisoryEntity>> SearchAsync(
string query,
@@ -299,6 +352,358 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
return results.ToDictionary(r => r.Severity, r => r.Count);
}
private async Task<AdvisoryEntity> UpsertInternalAsync(
AdvisoryEntity advisory,
IEnumerable<AdvisoryAliasEntity>? aliases,
IEnumerable<AdvisoryCvssEntity>? cvss,
IEnumerable<AdvisoryAffectedEntity>? affected,
IEnumerable<AdvisoryReferenceEntity>? references,
IEnumerable<AdvisoryCreditEntity>? credits,
IEnumerable<AdvisoryWeaknessEntity>? weaknesses,
IEnumerable<KevFlagEntity>? kevFlags,
CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO vuln.advisories (
id, advisory_key, primary_vuln_id, source_id, title, summary, description,
severity, published_at, modified_at, withdrawn_at, provenance, raw_payload
)
VALUES (
@id, @advisory_key, @primary_vuln_id, @source_id, @title, @summary, @description,
@severity, @published_at, @modified_at, @withdrawn_at, @provenance::jsonb, @raw_payload::jsonb
)
ON CONFLICT (advisory_key) DO UPDATE SET
primary_vuln_id = EXCLUDED.primary_vuln_id,
source_id = COALESCE(EXCLUDED.source_id, vuln.advisories.source_id),
title = COALESCE(EXCLUDED.title, vuln.advisories.title),
summary = COALESCE(EXCLUDED.summary, vuln.advisories.summary),
description = COALESCE(EXCLUDED.description, vuln.advisories.description),
severity = COALESCE(EXCLUDED.severity, vuln.advisories.severity),
published_at = COALESCE(EXCLUDED.published_at, vuln.advisories.published_at),
modified_at = COALESCE(EXCLUDED.modified_at, vuln.advisories.modified_at),
withdrawn_at = EXCLUDED.withdrawn_at,
provenance = vuln.advisories.provenance || EXCLUDED.provenance,
raw_payload = EXCLUDED.raw_payload,
updated_at = NOW()
RETURNING id, advisory_key, primary_vuln_id, source_id, title, summary, description,
severity, published_at, modified_at, withdrawn_at, provenance::text, raw_payload::text,
created_at, updated_at
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
AdvisoryEntity result;
await using (var command = CreateCommand(sql, connection))
{
command.Transaction = transaction;
AddParameter(command, "id", advisory.Id);
AddParameter(command, "advisory_key", advisory.AdvisoryKey);
AddParameter(command, "primary_vuln_id", advisory.PrimaryVulnId);
AddParameter(command, "source_id", advisory.SourceId);
AddParameter(command, "title", advisory.Title);
AddParameter(command, "summary", advisory.Summary);
AddParameter(command, "description", advisory.Description);
AddParameter(command, "severity", advisory.Severity);
AddParameter(command, "published_at", advisory.PublishedAt);
AddParameter(command, "modified_at", advisory.ModifiedAt);
AddParameter(command, "withdrawn_at", advisory.WithdrawnAt);
AddJsonbParameter(command, "provenance", advisory.Provenance);
AddJsonbParameter(command, "raw_payload", advisory.RawPayload);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
result = MapAdvisory(reader);
}
// Replace child tables only when collections are provided (null = leave existing).
if (aliases is not null)
{
await ReplaceAliasesAsync(result.Id, aliases, connection, transaction, cancellationToken).ConfigureAwait(false);
}
if (cvss is not null)
{
await ReplaceCvssAsync(result.Id, cvss, connection, transaction, cancellationToken).ConfigureAwait(false);
}
if (affected is not null)
{
await ReplaceAffectedAsync(result.Id, affected, connection, transaction, cancellationToken).ConfigureAwait(false);
}
if (references is not null)
{
await ReplaceReferencesAsync(result.Id, references, connection, transaction, cancellationToken).ConfigureAwait(false);
}
if (credits is not null)
{
await ReplaceCreditsAsync(result.Id, credits, connection, transaction, cancellationToken).ConfigureAwait(false);
}
if (weaknesses is not null)
{
await ReplaceWeaknessesAsync(result.Id, weaknesses, connection, transaction, cancellationToken).ConfigureAwait(false);
}
if (kevFlags is not null)
{
await ReplaceKevFlagsAsync(result.Id, kevFlags, connection, transaction, cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return result;
}
private static async Task ReplaceAliasesAsync(
Guid advisoryId,
IEnumerable<AdvisoryAliasEntity> aliases,
NpgsqlConnection connection,
NpgsqlTransaction transaction,
CancellationToken cancellationToken)
{
const string deleteSql = "DELETE FROM vuln.advisory_aliases WHERE advisory_id = @advisory_id";
await using (var deleteCmd = new NpgsqlCommand(deleteSql, connection, transaction))
{
deleteCmd.Parameters.AddWithValue("advisory_id", advisoryId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
const string insertSql = """
INSERT INTO vuln.advisory_aliases (id, advisory_id, alias_type, alias_value, is_primary)
VALUES (@id, @advisory_id, @alias_type, @alias_value, @is_primary)
""";
foreach (var alias in aliases)
{
await using var insertCmd = new NpgsqlCommand(insertSql, connection, transaction);
insertCmd.Parameters.AddWithValue("id", alias.Id);
insertCmd.Parameters.AddWithValue("advisory_id", advisoryId);
insertCmd.Parameters.AddWithValue("alias_type", alias.AliasType);
insertCmd.Parameters.AddWithValue("alias_value", alias.AliasValue);
insertCmd.Parameters.AddWithValue("is_primary", alias.IsPrimary);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
private static async Task ReplaceCvssAsync(
Guid advisoryId,
IEnumerable<AdvisoryCvssEntity> scores,
NpgsqlConnection connection,
NpgsqlTransaction transaction,
CancellationToken cancellationToken)
{
const string deleteSql = "DELETE FROM vuln.advisory_cvss WHERE advisory_id = @advisory_id";
await using (var deleteCmd = new NpgsqlCommand(deleteSql, connection, transaction))
{
deleteCmd.Parameters.AddWithValue("advisory_id", advisoryId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
const string insertSql = """
INSERT INTO vuln.advisory_cvss
(id, advisory_id, cvss_version, vector_string, base_score, base_severity,
exploitability_score, impact_score, source, is_primary)
VALUES
(@id, @advisory_id, @cvss_version, @vector_string, @base_score, @base_severity,
@exploitability_score, @impact_score, @source, @is_primary)
""";
foreach (var score in scores)
{
await using var insertCmd = new NpgsqlCommand(insertSql, connection, transaction);
insertCmd.Parameters.AddWithValue("id", score.Id);
insertCmd.Parameters.AddWithValue("advisory_id", advisoryId);
insertCmd.Parameters.AddWithValue("cvss_version", score.CvssVersion);
insertCmd.Parameters.AddWithValue("vector_string", score.VectorString);
insertCmd.Parameters.AddWithValue("base_score", score.BaseScore);
insertCmd.Parameters.AddWithValue("base_severity", (object?)score.BaseSeverity ?? DBNull.Value);
insertCmd.Parameters.AddWithValue("exploitability_score", (object?)score.ExploitabilityScore ?? DBNull.Value);
insertCmd.Parameters.AddWithValue("impact_score", (object?)score.ImpactScore ?? DBNull.Value);
insertCmd.Parameters.AddWithValue("source", (object?)score.Source ?? DBNull.Value);
insertCmd.Parameters.AddWithValue("is_primary", score.IsPrimary);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
private static async Task ReplaceAffectedAsync(
Guid advisoryId,
IEnumerable<AdvisoryAffectedEntity> affected,
NpgsqlConnection connection,
NpgsqlTransaction transaction,
CancellationToken cancellationToken)
{
const string deleteSql = "DELETE FROM vuln.advisory_affected WHERE advisory_id = @advisory_id";
await using (var deleteCmd = new NpgsqlCommand(deleteSql, connection, transaction))
{
deleteCmd.Parameters.AddWithValue("advisory_id", advisoryId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
const string insertSql = """
INSERT INTO vuln.advisory_affected
(id, advisory_id, ecosystem, package_name, purl, version_range, versions_affected,
versions_fixed, database_specific)
VALUES
(@id, @advisory_id, @ecosystem, @package_name, @purl, @version_range::jsonb,
@versions_affected, @versions_fixed, @database_specific::jsonb)
""";
foreach (var entry in affected)
{
await using var insertCmd = new NpgsqlCommand(insertSql, connection, transaction);
insertCmd.Parameters.AddWithValue("id", entry.Id);
insertCmd.Parameters.AddWithValue("advisory_id", advisoryId);
insertCmd.Parameters.AddWithValue("ecosystem", entry.Ecosystem);
insertCmd.Parameters.AddWithValue("package_name", entry.PackageName);
insertCmd.Parameters.AddWithValue("purl", (object?)entry.Purl ?? DBNull.Value);
insertCmd.Parameters.Add(new NpgsqlParameter<string?>("version_range", NpgsqlTypes.NpgsqlDbType.Jsonb)
{
TypedValue = entry.VersionRange
});
insertCmd.Parameters.Add(new NpgsqlParameter<string[]?>("versions_affected", NpgsqlTypes.NpgsqlDbType.Array | NpgsqlTypes.NpgsqlDbType.Text)
{
TypedValue = entry.VersionsAffected
});
insertCmd.Parameters.Add(new NpgsqlParameter<string[]?>("versions_fixed", NpgsqlTypes.NpgsqlDbType.Array | NpgsqlTypes.NpgsqlDbType.Text)
{
TypedValue = entry.VersionsFixed
});
insertCmd.Parameters.Add(new NpgsqlParameter<string?>("database_specific", NpgsqlTypes.NpgsqlDbType.Jsonb)
{
TypedValue = entry.DatabaseSpecific
});
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
private static async Task ReplaceReferencesAsync(
Guid advisoryId,
IEnumerable<AdvisoryReferenceEntity> references,
NpgsqlConnection connection,
NpgsqlTransaction transaction,
CancellationToken cancellationToken)
{
const string deleteSql = "DELETE FROM vuln.advisory_references WHERE advisory_id = @advisory_id";
await using (var deleteCmd = new NpgsqlCommand(deleteSql, connection, transaction))
{
deleteCmd.Parameters.AddWithValue("advisory_id", advisoryId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
const string insertSql = """
INSERT INTO vuln.advisory_references (id, advisory_id, ref_type, url)
VALUES (@id, @advisory_id, @ref_type, @url)
""";
foreach (var reference in references)
{
await using var insertCmd = new NpgsqlCommand(insertSql, connection, transaction);
insertCmd.Parameters.AddWithValue("id", reference.Id);
insertCmd.Parameters.AddWithValue("advisory_id", advisoryId);
insertCmd.Parameters.AddWithValue("ref_type", reference.RefType);
insertCmd.Parameters.AddWithValue("url", reference.Url);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
private static async Task ReplaceCreditsAsync(
Guid advisoryId,
IEnumerable<AdvisoryCreditEntity> credits,
NpgsqlConnection connection,
NpgsqlTransaction transaction,
CancellationToken cancellationToken)
{
const string deleteSql = "DELETE FROM vuln.advisory_credits WHERE advisory_id = @advisory_id";
await using (var deleteCmd = new NpgsqlCommand(deleteSql, connection, transaction))
{
deleteCmd.Parameters.AddWithValue("advisory_id", advisoryId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
const string insertSql = """
INSERT INTO vuln.advisory_credits (id, advisory_id, name, contact, credit_type)
VALUES (@id, @advisory_id, @name, @contact, @credit_type)
""";
foreach (var credit in credits)
{
await using var insertCmd = new NpgsqlCommand(insertSql, connection, transaction);
insertCmd.Parameters.AddWithValue("id", credit.Id);
insertCmd.Parameters.AddWithValue("advisory_id", advisoryId);
insertCmd.Parameters.AddWithValue("name", credit.Name);
insertCmd.Parameters.AddWithValue("contact", (object?)credit.Contact ?? DBNull.Value);
insertCmd.Parameters.AddWithValue("credit_type", (object?)credit.CreditType ?? DBNull.Value);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
private static async Task ReplaceWeaknessesAsync(
Guid advisoryId,
IEnumerable<AdvisoryWeaknessEntity> weaknesses,
NpgsqlConnection connection,
NpgsqlTransaction transaction,
CancellationToken cancellationToken)
{
const string deleteSql = "DELETE FROM vuln.advisory_weaknesses WHERE advisory_id = @advisory_id";
await using (var deleteCmd = new NpgsqlCommand(deleteSql, connection, transaction))
{
deleteCmd.Parameters.AddWithValue("advisory_id", advisoryId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
const string insertSql = """
INSERT INTO vuln.advisory_weaknesses (id, advisory_id, cwe_id, description, source)
VALUES (@id, @advisory_id, @cwe_id, @description, @source)
""";
foreach (var weakness in weaknesses)
{
await using var insertCmd = new NpgsqlCommand(insertSql, connection, transaction);
insertCmd.Parameters.AddWithValue("id", weakness.Id);
insertCmd.Parameters.AddWithValue("advisory_id", advisoryId);
insertCmd.Parameters.AddWithValue("cwe_id", weakness.CweId);
insertCmd.Parameters.AddWithValue("description", (object?)weakness.Description ?? DBNull.Value);
insertCmd.Parameters.AddWithValue("source", (object?)weakness.Source ?? DBNull.Value);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
private static async Task ReplaceKevFlagsAsync(
Guid advisoryId,
IEnumerable<KevFlagEntity> kevFlags,
NpgsqlConnection connection,
NpgsqlTransaction transaction,
CancellationToken cancellationToken)
{
const string deleteSql = "DELETE FROM vuln.kev_flags WHERE advisory_id = @advisory_id";
await using (var deleteCmd = new NpgsqlCommand(deleteSql, connection, transaction))
{
deleteCmd.Parameters.AddWithValue("advisory_id", advisoryId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
const string insertSql = """
INSERT INTO vuln.kev_flags
(id, advisory_id, cve_id, vendor_project, product, vulnerability_name,
date_added, due_date, known_ransomware_use, notes)
VALUES
(@id, @advisory_id, @cve_id, @vendor_project, @product, @vulnerability_name,
@date_added, @due_date, @known_ransomware_use, @notes)
""";
foreach (var flag in kevFlags)
{
await using var insertCmd = new NpgsqlCommand(insertSql, connection, transaction);
insertCmd.Parameters.AddWithValue("id", flag.Id);
insertCmd.Parameters.AddWithValue("advisory_id", advisoryId);
insertCmd.Parameters.AddWithValue("cve_id", flag.CveId);
insertCmd.Parameters.AddWithValue("vendor_project", (object?)flag.VendorProject ?? DBNull.Value);
insertCmd.Parameters.AddWithValue("product", (object?)flag.Product ?? DBNull.Value);
insertCmd.Parameters.AddWithValue("vulnerability_name", (object?)flag.VulnerabilityName ?? DBNull.Value);
insertCmd.Parameters.AddWithValue("date_added", flag.DateAdded);
insertCmd.Parameters.AddWithValue("due_date", (object?)flag.DueDate ?? DBNull.Value);
insertCmd.Parameters.AddWithValue("known_ransomware_use", flag.KnownRansomwareUse);
insertCmd.Parameters.AddWithValue("notes", (object?)flag.Notes ?? DBNull.Value);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
private static AdvisoryEntity MapAdvisory(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),

View File

@@ -0,0 +1,72 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for advisory snapshots.
/// </summary>
public sealed class AdvisorySnapshotRepository : RepositoryBase<ConcelierDataSource>, IAdvisorySnapshotRepository
{
private const string SystemTenantId = "_system";
public AdvisorySnapshotRepository(ConcelierDataSource dataSource, ILogger<AdvisorySnapshotRepository> logger)
: base(dataSource, logger)
{
}
public async Task<AdvisorySnapshotEntity> InsertAsync(AdvisorySnapshotEntity snapshot, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO vuln.advisory_snapshots
(id, feed_snapshot_id, advisory_key, content_hash)
VALUES
(@id, @feed_snapshot_id, @advisory_key, @content_hash)
ON CONFLICT (feed_snapshot_id, advisory_key) DO UPDATE SET
content_hash = EXCLUDED.content_hash
RETURNING id, feed_snapshot_id, advisory_key, content_hash, created_at
""";
return await QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd =>
{
AddParameter(cmd, "id", snapshot.Id);
AddParameter(cmd, "feed_snapshot_id", snapshot.FeedSnapshotId);
AddParameter(cmd, "advisory_key", snapshot.AdvisoryKey);
AddParameter(cmd, "content_hash", snapshot.ContentHash);
},
MapSnapshot!,
cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Insert returned null");
}
public Task<IReadOnlyList<AdvisorySnapshotEntity>> GetByFeedSnapshotAsync(Guid feedSnapshotId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, feed_snapshot_id, advisory_key, content_hash, created_at
FROM vuln.advisory_snapshots
WHERE feed_snapshot_id = @feed_snapshot_id
ORDER BY advisory_key
""";
return QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "feed_snapshot_id", feedSnapshotId),
MapSnapshot,
cancellationToken);
}
private static AdvisorySnapshotEntity MapSnapshot(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
FeedSnapshotId = reader.GetGuid(1),
AdvisoryKey = reader.GetString(2),
ContentHash = reader.GetString(3),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(4)
};
}

View File

@@ -0,0 +1,83 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for advisory weaknesses (CWE).
/// </summary>
public sealed class AdvisoryWeaknessRepository : RepositoryBase<ConcelierDataSource>, IAdvisoryWeaknessRepository
{
private const string SystemTenantId = "_system";
public AdvisoryWeaknessRepository(ConcelierDataSource dataSource, ILogger<AdvisoryWeaknessRepository> logger)
: base(dataSource, logger)
{
}
public async Task ReplaceAsync(Guid advisoryId, IEnumerable<AdvisoryWeaknessEntity> weaknesses, CancellationToken cancellationToken = default)
{
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
const string deleteSql = "DELETE FROM vuln.advisory_weaknesses WHERE advisory_id = @advisory_id";
await using (var deleteCmd = CreateCommand(deleteSql, connection))
{
deleteCmd.Transaction = transaction;
AddParameter(deleteCmd, "advisory_id", advisoryId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
const string insertSql = """
INSERT INTO vuln.advisory_weaknesses
(id, advisory_id, cwe_id, description, source)
VALUES
(@id, @advisory_id, @cwe_id, @description, @source)
""";
foreach (var weakness in weaknesses)
{
await using var insertCmd = CreateCommand(insertSql, connection);
insertCmd.Transaction = transaction;
AddParameter(insertCmd, "id", weakness.Id);
AddParameter(insertCmd, "advisory_id", advisoryId);
AddParameter(insertCmd, "cwe_id", weakness.CweId);
AddParameter(insertCmd, "description", weakness.Description);
AddParameter(insertCmd, "source", weakness.Source);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
public Task<IReadOnlyList<AdvisoryWeaknessEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_id, cwe_id, description, source, created_at
FROM vuln.advisory_weaknesses
WHERE advisory_id = @advisory_id
ORDER BY cwe_id
""";
return QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "advisory_id", advisoryId),
MapWeakness,
cancellationToken);
}
private static AdvisoryWeaknessEntity MapWeakness(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
AdvisoryId = reader.GetGuid(1),
CweId = reader.GetString(2),
Description = GetNullableString(reader, 3),
Source = GetNullableString(reader, 4),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(5)
};
}

View File

@@ -0,0 +1,78 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for feed snapshots.
/// </summary>
public sealed class FeedSnapshotRepository : RepositoryBase<ConcelierDataSource>, IFeedSnapshotRepository
{
private const string SystemTenantId = "_system";
public FeedSnapshotRepository(ConcelierDataSource dataSource, ILogger<FeedSnapshotRepository> logger)
: base(dataSource, logger)
{
}
public async Task<FeedSnapshotEntity> InsertAsync(FeedSnapshotEntity snapshot, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO vuln.feed_snapshots
(id, source_id, snapshot_id, advisory_count, checksum, metadata)
VALUES
(@id, @source_id, @snapshot_id, @advisory_count, @checksum, @metadata::jsonb)
ON CONFLICT (source_id, snapshot_id) DO NOTHING
RETURNING id, source_id, snapshot_id, advisory_count, checksum, metadata::text, created_at
""";
return await QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd =>
{
AddParameter(cmd, "id", snapshot.Id);
AddParameter(cmd, "source_id", snapshot.SourceId);
AddParameter(cmd, "snapshot_id", snapshot.SnapshotId);
AddParameter(cmd, "advisory_count", snapshot.AdvisoryCount);
AddParameter(cmd, "checksum", snapshot.Checksum);
AddJsonbParameter(cmd, "metadata", snapshot.Metadata);
},
MapSnapshot!,
cancellationToken).ConfigureAwait(false) ?? snapshot;
}
public Task<FeedSnapshotEntity?> GetBySourceAndIdAsync(Guid sourceId, string snapshotId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, source_id, snapshot_id, advisory_count, checksum, metadata::text, created_at
FROM vuln.feed_snapshots
WHERE source_id = @source_id AND snapshot_id = @snapshot_id
""";
return QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd =>
{
AddParameter(cmd, "source_id", sourceId);
AddParameter(cmd, "snapshot_id", snapshotId);
},
MapSnapshot,
cancellationToken);
}
private static FeedSnapshotEntity MapSnapshot(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
SourceId = reader.GetGuid(1),
SnapshotId = reader.GetString(2),
AdvisoryCount = reader.GetInt32(3),
Checksum = GetNullableString(reader, 4),
Metadata = reader.GetString(5),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(6)
};
}

View File

@@ -0,0 +1,15 @@
using StellaOps.Concelier.Storage.Postgres.Models;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// Repository for advisory affected package rows.
/// </summary>
public interface IAdvisoryAffectedRepository
{
Task ReplaceAsync(Guid advisoryId, IEnumerable<AdvisoryAffectedEntity> affected, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AdvisoryAffectedEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AdvisoryAffectedEntity>> GetByPurlAsync(string purl, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AdvisoryAffectedEntity>> GetByPackageNameAsync(string ecosystem, string packageName, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,14 @@
using StellaOps.Concelier.Storage.Postgres.Models;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// Repository for advisory aliases.
/// </summary>
public interface IAdvisoryAliasRepository
{
Task ReplaceAsync(Guid advisoryId, IEnumerable<AdvisoryAliasEntity> aliases, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AdvisoryAliasEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AdvisoryAliasEntity>> GetByAliasAsync(string aliasValue, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Concelier.Storage.Postgres.Models;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// Repository for advisory credits.
/// </summary>
public interface IAdvisoryCreditRepository
{
Task ReplaceAsync(Guid advisoryId, IEnumerable<AdvisoryCreditEntity> credits, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AdvisoryCreditEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Concelier.Storage.Postgres.Models;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// Repository for advisory CVSS scores.
/// </summary>
public interface IAdvisoryCvssRepository
{
Task ReplaceAsync(Guid advisoryId, IEnumerable<AdvisoryCvssEntity> scores, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AdvisoryCvssEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Concelier.Storage.Postgres.Models;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// Repository for advisory references.
/// </summary>
public interface IAdvisoryReferenceRepository
{
Task ReplaceAsync(Guid advisoryId, IEnumerable<AdvisoryReferenceEntity> references, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AdvisoryReferenceEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default);
}

View File

@@ -27,6 +27,30 @@ public interface IAdvisoryRepository
/// </summary>
Task<AdvisoryEntity?> GetByVulnIdAsync(string vulnId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets advisories that include the provided alias (e.g., CVE, GHSA).
/// </summary>
Task<IReadOnlyList<AdvisoryEntity>> GetByAliasAsync(string aliasValue, CancellationToken cancellationToken = default);
/// <summary>
/// Gets advisories affecting a package identified by full PURL.
/// </summary>
Task<IReadOnlyList<AdvisoryEntity>> GetAffectingPackageAsync(
string purl,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets advisories affecting a package by ecosystem/name (when PURL missing).
/// </summary>
Task<IReadOnlyList<AdvisoryEntity>> GetAffectingPackageNameAsync(
string ecosystem,
string packageName,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Searches advisories by full-text search.
/// </summary>
@@ -72,4 +96,19 @@ public interface IAdvisoryRepository
/// Counts advisories by severity.
/// </summary>
Task<IDictionary<string, long>> CountBySeverityAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Upserts an advisory and optional child records in a single transaction.
/// Passing null collections leaves existing child rows untouched; pass empty collections to replace with none.
/// </summary>
Task<AdvisoryEntity> UpsertAsync(
AdvisoryEntity advisory,
IEnumerable<Models.AdvisoryAliasEntity>? aliases,
IEnumerable<Models.AdvisoryCvssEntity>? cvss,
IEnumerable<Models.AdvisoryAffectedEntity>? affected,
IEnumerable<Models.AdvisoryReferenceEntity>? references,
IEnumerable<Models.AdvisoryCreditEntity>? credits,
IEnumerable<Models.AdvisoryWeaknessEntity>? weaknesses,
IEnumerable<Models.KevFlagEntity>? kevFlags,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Concelier.Storage.Postgres.Models;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// Repository for advisory snapshots.
/// </summary>
public interface IAdvisorySnapshotRepository
{
Task<AdvisorySnapshotEntity> InsertAsync(AdvisorySnapshotEntity snapshot, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AdvisorySnapshotEntity>> GetByFeedSnapshotAsync(Guid feedSnapshotId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Concelier.Storage.Postgres.Models;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// Repository for advisory weaknesses (CWE).
/// </summary>
public interface IAdvisoryWeaknessRepository
{
Task ReplaceAsync(Guid advisoryId, IEnumerable<AdvisoryWeaknessEntity> weaknesses, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AdvisoryWeaknessEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Concelier.Storage.Postgres.Models;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// Repository for feed snapshots.
/// </summary>
public interface IFeedSnapshotRepository
{
Task<FeedSnapshotEntity> InsertAsync(FeedSnapshotEntity snapshot, CancellationToken cancellationToken = default);
Task<FeedSnapshotEntity?> GetBySourceAndIdAsync(Guid sourceId, string snapshotId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,14 @@
using StellaOps.Concelier.Storage.Postgres.Models;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// Repository for KEV flag records.
/// </summary>
public interface IKevFlagRepository
{
Task ReplaceAsync(Guid advisoryId, IEnumerable<KevFlagEntity> flags, CancellationToken cancellationToken = default);
Task<IReadOnlyList<KevFlagEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<KevFlagEntity>> GetByCveAsync(string cveId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Concelier.Storage.Postgres.Models;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// Repository for merge event audit records.
/// </summary>
public interface IMergeEventRepository
{
Task<MergeEventEntity> InsertAsync(MergeEventEntity evt, CancellationToken cancellationToken = default);
Task<IReadOnlyList<MergeEventEntity>> GetByAdvisoryAsync(Guid advisoryId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,15 @@
using StellaOps.Concelier.Storage.Postgres.Models;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// Repository for vulnerability feed sources.
/// </summary>
public interface ISourceRepository
{
Task<SourceEntity> UpsertAsync(SourceEntity source, CancellationToken cancellationToken = default);
Task<SourceEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<SourceEntity?> GetByKeyAsync(string key, CancellationToken cancellationToken = default);
Task<IReadOnlyList<SourceEntity>> ListAsync(bool? enabled = null, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Concelier.Storage.Postgres.Models;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// Repository for source ingestion state.
/// </summary>
public interface ISourceStateRepository
{
Task<SourceStateEntity> UpsertAsync(SourceStateEntity state, CancellationToken cancellationToken = default);
Task<SourceStateEntity?> GetBySourceIdAsync(Guid sourceId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,114 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for KEV flags.
/// </summary>
public sealed class KevFlagRepository : RepositoryBase<ConcelierDataSource>, IKevFlagRepository
{
private const string SystemTenantId = "_system";
public KevFlagRepository(ConcelierDataSource dataSource, ILogger<KevFlagRepository> logger)
: base(dataSource, logger)
{
}
public async Task ReplaceAsync(Guid advisoryId, IEnumerable<KevFlagEntity> flags, CancellationToken cancellationToken = default)
{
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
const string deleteSql = "DELETE FROM vuln.kev_flags WHERE advisory_id = @advisory_id";
await using (var deleteCmd = CreateCommand(deleteSql, connection))
{
deleteCmd.Transaction = transaction;
AddParameter(deleteCmd, "advisory_id", advisoryId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
const string insertSql = """
INSERT INTO vuln.kev_flags
(id, advisory_id, cve_id, vendor_project, product, vulnerability_name,
date_added, due_date, known_ransomware_use, notes)
VALUES
(@id, @advisory_id, @cve_id, @vendor_project, @product, @vulnerability_name,
@date_added, @due_date, @known_ransomware_use, @notes)
""";
foreach (var flag in flags)
{
await using var insertCmd = CreateCommand(insertSql, connection);
insertCmd.Transaction = transaction;
AddParameter(insertCmd, "id", flag.Id);
AddParameter(insertCmd, "advisory_id", advisoryId);
AddParameter(insertCmd, "cve_id", flag.CveId);
AddParameter(insertCmd, "vendor_project", flag.VendorProject);
AddParameter(insertCmd, "product", flag.Product);
AddParameter(insertCmd, "vulnerability_name", flag.VulnerabilityName);
AddParameter(insertCmd, "date_added", flag.DateAdded);
AddParameter(insertCmd, "due_date", flag.DueDate);
AddParameter(insertCmd, "known_ransomware_use", flag.KnownRansomwareUse);
AddParameter(insertCmd, "notes", flag.Notes);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
public Task<IReadOnlyList<KevFlagEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_id, cve_id, vendor_project, product, vulnerability_name,
date_added, due_date, known_ransomware_use, notes, created_at
FROM vuln.kev_flags
WHERE advisory_id = @advisory_id
ORDER BY date_added DESC, cve_id
""";
return QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "advisory_id", advisoryId),
MapKev,
cancellationToken);
}
public Task<IReadOnlyList<KevFlagEntity>> GetByCveAsync(string cveId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_id, cve_id, vendor_project, product, vulnerability_name,
date_added, due_date, known_ransomware_use, notes, created_at
FROM vuln.kev_flags
WHERE cve_id = @cve_id
ORDER BY date_added DESC, advisory_id
""";
return QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "cve_id", cveId),
MapKev,
cancellationToken);
}
private static KevFlagEntity MapKev(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
AdvisoryId = reader.GetGuid(1),
CveId = reader.GetString(2),
VendorProject = GetNullableString(reader, 3),
Product = GetNullableString(reader, 4),
VulnerabilityName = GetNullableString(reader, 5),
DateAdded = DateOnly.FromDateTime(reader.GetDateTime(6)),
DueDate = reader.IsDBNull(7) ? null : DateOnly.FromDateTime(reader.GetDateTime(7)),
KnownRansomwareUse = reader.GetBoolean(8),
Notes = GetNullableString(reader, 9),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(10)
};
}

View File

@@ -0,0 +1,79 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for merge event audit records.
/// </summary>
public sealed class MergeEventRepository : RepositoryBase<ConcelierDataSource>, IMergeEventRepository
{
private const string SystemTenantId = "_system";
public MergeEventRepository(ConcelierDataSource dataSource, ILogger<MergeEventRepository> logger)
: base(dataSource, logger)
{
}
public async Task<MergeEventEntity> InsertAsync(MergeEventEntity evt, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO vuln.merge_events
(advisory_id, source_id, event_type, old_value, new_value)
VALUES
(@advisory_id, @source_id, @event_type, @old_value::jsonb, @new_value::jsonb)
RETURNING id, advisory_id, source_id, event_type, old_value::text, new_value::text, created_at
""";
return await QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd =>
{
AddParameter(cmd, "advisory_id", evt.AdvisoryId);
AddParameter(cmd, "source_id", evt.SourceId);
AddParameter(cmd, "event_type", evt.EventType);
AddJsonbParameter(cmd, "old_value", evt.OldValue);
AddJsonbParameter(cmd, "new_value", evt.NewValue);
},
MapEvent!,
cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Insert returned null");
}
public Task<IReadOnlyList<MergeEventEntity>> GetByAdvisoryAsync(Guid advisoryId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_id, source_id, event_type, old_value::text, new_value::text, created_at
FROM vuln.merge_events
WHERE advisory_id = @advisory_id
ORDER BY created_at DESC, id DESC
LIMIT @limit OFFSET @offset
""";
return QueryAsync(
SystemTenantId,
sql,
cmd =>
{
AddParameter(cmd, "advisory_id", advisoryId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapEvent,
cancellationToken);
}
private static MergeEventEntity MapEvent(NpgsqlDataReader reader) => new()
{
Id = reader.GetInt64(0),
AdvisoryId = reader.GetGuid(1),
SourceId = GetNullableGuid(reader, 2),
EventType = reader.GetString(3),
OldValue = GetNullableString(reader, 4),
NewValue = GetNullableString(reader, 5),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(6)
};
}

View File

@@ -0,0 +1,136 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for feed sources.
/// </summary>
public sealed class SourceRepository : RepositoryBase<ConcelierDataSource>, ISourceRepository
{
private const string SystemTenantId = "_system";
public SourceRepository(ConcelierDataSource dataSource, ILogger<SourceRepository> logger)
: base(dataSource, logger)
{
}
public async Task<SourceEntity> UpsertAsync(SourceEntity source, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO vuln.sources
(id, key, name, source_type, url, priority, enabled, config, metadata)
VALUES
(@id, @key, @name, @source_type, @url, @priority, @enabled, @config::jsonb, @metadata::jsonb)
ON CONFLICT (key) DO UPDATE SET
name = EXCLUDED.name,
source_type = EXCLUDED.source_type,
url = EXCLUDED.url,
priority = EXCLUDED.priority,
enabled = EXCLUDED.enabled,
config = EXCLUDED.config,
metadata = EXCLUDED.metadata,
updated_at = NOW()
RETURNING id, key, name, source_type, url, priority, enabled,
config::text, metadata::text, created_at, updated_at
""";
return await QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd =>
{
AddParameter(cmd, "id", source.Id);
AddParameter(cmd, "key", source.Key);
AddParameter(cmd, "name", source.Name);
AddParameter(cmd, "source_type", source.SourceType);
AddParameter(cmd, "url", source.Url);
AddParameter(cmd, "priority", source.Priority);
AddParameter(cmd, "enabled", source.Enabled);
AddJsonbParameter(cmd, "config", source.Config);
AddJsonbParameter(cmd, "metadata", source.Metadata);
},
MapSource!,
cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Upsert returned null");
}
public Task<SourceEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, key, name, source_type, url, priority, enabled,
config::text, metadata::text, created_at, updated_at
FROM vuln.sources
WHERE id = @id
""";
return QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "id", id),
MapSource,
cancellationToken);
}
public Task<SourceEntity?> GetByKeyAsync(string key, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, key, name, source_type, url, priority, enabled,
config::text, metadata::text, created_at, updated_at
FROM vuln.sources
WHERE key = @key
""";
return QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "key", key),
MapSource,
cancellationToken);
}
public Task<IReadOnlyList<SourceEntity>> ListAsync(bool? enabled = null, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, key, name, source_type, url, priority, enabled,
config::text, metadata::text, created_at, updated_at
FROM vuln.sources
""";
if (enabled.HasValue)
{
sql += " WHERE enabled = @enabled";
}
sql += " ORDER BY priority DESC, key";
return QueryAsync(
SystemTenantId,
sql,
cmd =>
{
if (enabled.HasValue)
{
AddParameter(cmd, "enabled", enabled.Value);
}
},
MapSource,
cancellationToken);
}
private static SourceEntity MapSource(Npgsql.NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
Key = reader.GetString(1),
Name = reader.GetString(2),
SourceType = reader.GetString(3),
Url = GetNullableString(reader, 4),
Priority = reader.GetInt32(5),
Enabled = reader.GetBoolean(6),
Config = reader.GetString(7),
Metadata = reader.GetString(8),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(10)
};
}

View File

@@ -0,0 +1,92 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for source ingestion state.
/// </summary>
public sealed class SourceStateRepository : RepositoryBase<ConcelierDataSource>, ISourceStateRepository
{
private const string SystemTenantId = "_system";
public SourceStateRepository(ConcelierDataSource dataSource, ILogger<SourceStateRepository> logger)
: base(dataSource, logger)
{
}
public async Task<SourceStateEntity> UpsertAsync(SourceStateEntity state, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO vuln.source_states
(id, source_id, cursor, last_sync_at, last_success_at, last_error,
sync_count, error_count, metadata)
VALUES
(@id, @source_id, @cursor, @last_sync_at, @last_success_at, @last_error,
@sync_count, @error_count, @metadata::jsonb)
ON CONFLICT (source_id) DO UPDATE SET
cursor = EXCLUDED.cursor,
last_sync_at = EXCLUDED.last_sync_at,
last_success_at = EXCLUDED.last_success_at,
last_error = EXCLUDED.last_error,
sync_count = EXCLUDED.sync_count,
error_count = EXCLUDED.error_count,
metadata = EXCLUDED.metadata,
updated_at = NOW()
RETURNING id, source_id, cursor, last_sync_at, last_success_at, last_error,
sync_count, error_count, metadata::text, updated_at
""";
return await QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd =>
{
AddParameter(cmd, "id", state.Id);
AddParameter(cmd, "source_id", state.SourceId);
AddParameter(cmd, "cursor", state.Cursor);
AddParameter(cmd, "last_sync_at", state.LastSyncAt);
AddParameter(cmd, "last_success_at", state.LastSuccessAt);
AddParameter(cmd, "last_error", state.LastError);
AddParameter(cmd, "sync_count", state.SyncCount);
AddParameter(cmd, "error_count", state.ErrorCount);
AddJsonbParameter(cmd, "metadata", state.Metadata);
},
MapState!,
cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Upsert returned null");
}
public Task<SourceStateEntity?> GetBySourceIdAsync(Guid sourceId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, source_id, cursor, last_sync_at, last_success_at, last_error,
sync_count, error_count, metadata::text, updated_at
FROM vuln.source_states
WHERE source_id = @source_id
""";
return QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "source_id", sourceId),
MapState,
cancellationToken);
}
private static SourceStateEntity MapState(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
SourceId = reader.GetGuid(1),
Cursor = GetNullableString(reader, 2),
LastSyncAt = GetNullableDateTimeOffset(reader, 3),
LastSuccessAt = GetNullableDateTimeOffset(reader, 4),
LastError = GetNullableString(reader, 5),
SyncCount = reader.GetInt64(6),
ErrorCount = reader.GetInt32(7),
Metadata = reader.GetString(8),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(9)
};
}

View File

@@ -28,6 +28,18 @@ public static class ServiceCollectionExtensions
// Register repositories
services.AddScoped<IAdvisoryRepository, AdvisoryRepository>();
services.AddScoped<ISourceRepository, SourceRepository>();
services.AddScoped<IAdvisoryAliasRepository, AdvisoryAliasRepository>();
services.AddScoped<IAdvisoryCvssRepository, AdvisoryCvssRepository>();
services.AddScoped<IAdvisoryAffectedRepository, AdvisoryAffectedRepository>();
services.AddScoped<IAdvisoryReferenceRepository, AdvisoryReferenceRepository>();
services.AddScoped<IAdvisoryCreditRepository, AdvisoryCreditRepository>();
services.AddScoped<IAdvisoryWeaknessRepository, AdvisoryWeaknessRepository>();
services.AddScoped<IKevFlagRepository, KevFlagRepository>();
services.AddScoped<ISourceStateRepository, SourceStateRepository>();
services.AddScoped<IFeedSnapshotRepository, FeedSnapshotRepository>();
services.AddScoped<IAdvisorySnapshotRepository, AdvisorySnapshotRepository>();
services.AddScoped<IMergeEventRepository, MergeEventRepository>();
return services;
}
@@ -47,6 +59,18 @@ public static class ServiceCollectionExtensions
// Register repositories
services.AddScoped<IAdvisoryRepository, AdvisoryRepository>();
services.AddScoped<ISourceRepository, SourceRepository>();
services.AddScoped<IAdvisoryAliasRepository, AdvisoryAliasRepository>();
services.AddScoped<IAdvisoryCvssRepository, AdvisoryCvssRepository>();
services.AddScoped<IAdvisoryAffectedRepository, AdvisoryAffectedRepository>();
services.AddScoped<IAdvisoryReferenceRepository, AdvisoryReferenceRepository>();
services.AddScoped<IAdvisoryCreditRepository, AdvisoryCreditRepository>();
services.AddScoped<IAdvisoryWeaknessRepository, AdvisoryWeaknessRepository>();
services.AddScoped<IKevFlagRepository, KevFlagRepository>();
services.AddScoped<ISourceStateRepository, SourceStateRepository>();
services.AddScoped<IFeedSnapshotRepository, FeedSnapshotRepository>();
services.AddScoped<IAdvisorySnapshotRepository, AdvisorySnapshotRepository>();
services.AddScoped<IMergeEventRepository, MergeEventRepository>();
return services;
}