Files
git.stella-ops.org/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Postgres/PostgresFindingProjectionRepository.cs
StellaOps Bot b6b9ffc050
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Add PHP Analyzer Plugin and Composer Lock Data Handling
- Implemented the PhpAnalyzerPlugin to analyze PHP projects.
- Created ComposerLockData class to represent data from composer.lock files.
- Developed ComposerLockReader to load and parse composer.lock files asynchronously.
- Introduced ComposerPackage class to encapsulate package details.
- Added PhpPackage class to represent PHP packages with metadata and evidence.
- Implemented PhpPackageCollector to gather packages from ComposerLockData.
- Created PhpLanguageAnalyzer to perform analysis and emit results.
- Added capability signals for known PHP frameworks and CMS.
- Developed unit tests for the PHP language analyzer and its components.
- Included sample composer.lock and expected output for testing.
- Updated project files for the new PHP analyzer library and tests.
2025-11-22 14:02:49 +02:00

354 lines
15 KiB
C#

using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Hashing;
namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepository
{
private const string GetProjectionSql = """
SELECT status,
severity,
risk_score,
risk_severity,
risk_profile_version,
risk_explanation_id,
risk_event_sequence,
labels,
current_event_id,
explain_ref,
policy_rationale,
updated_at,
cycle_hash
FROM findings_projection
WHERE tenant_id = @tenant_id
AND finding_id = @finding_id
AND policy_version = @policy_version
""";
private const string UpsertProjectionSql = """
INSERT INTO findings_projection (
tenant_id,
finding_id,
policy_version,
status,
severity,
risk_score,
risk_severity,
risk_profile_version,
risk_explanation_id,
risk_event_sequence,
labels,
current_event_id,
explain_ref,
policy_rationale,
updated_at,
cycle_hash)
VALUES (
@tenant_id,
@finding_id,
@policy_version,
@status,
@severity,
@risk_score,
@risk_severity,
@risk_profile_version,
@risk_explanation_id,
@risk_event_sequence,
@labels,
@current_event_id,
@explain_ref,
@policy_rationale,
@updated_at,
@cycle_hash)
ON CONFLICT (tenant_id, finding_id, policy_version)
DO UPDATE SET
status = EXCLUDED.status,
severity = EXCLUDED.severity,
risk_score = EXCLUDED.risk_score,
risk_severity = EXCLUDED.risk_severity,
risk_profile_version = EXCLUDED.risk_profile_version,
risk_explanation_id = EXCLUDED.risk_explanation_id,
risk_event_sequence = EXCLUDED.risk_event_sequence,
labels = EXCLUDED.labels,
current_event_id = EXCLUDED.current_event_id,
explain_ref = EXCLUDED.explain_ref,
policy_rationale = EXCLUDED.policy_rationale,
updated_at = EXCLUDED.updated_at,
cycle_hash = EXCLUDED.cycle_hash;
""";
private const string InsertHistorySql = """
INSERT INTO finding_history (
tenant_id,
finding_id,
policy_version,
event_id,
status,
severity,
actor_id,
comment,
occurred_at)
VALUES (
@tenant_id,
@finding_id,
@policy_version,
@event_id,
@status,
@severity,
@actor_id,
@comment,
@occurred_at)
ON CONFLICT (tenant_id, finding_id, event_id)
DO NOTHING;
""";
private const string InsertActionSql = """
INSERT INTO triage_actions (
tenant_id,
action_id,
event_id,
finding_id,
action_type,
payload,
created_at,
created_by)
VALUES (
@tenant_id,
@action_id,
@event_id,
@finding_id,
@action_type,
@payload,
@created_at,
@created_by)
ON CONFLICT (tenant_id, action_id)
DO NOTHING;
""";
private const string SelectCheckpointSql = """
SELECT last_recorded_at,
last_event_id,
updated_at
FROM ledger_projection_offsets
WHERE worker_id = @worker_id
""";
private const string UpsertCheckpointSql = """
INSERT INTO ledger_projection_offsets (
worker_id,
last_recorded_at,
last_event_id,
updated_at)
VALUES (
@worker_id,
@last_recorded_at,
@last_event_id,
@updated_at)
ON CONFLICT (worker_id)
DO UPDATE SET
last_recorded_at = EXCLUDED.last_recorded_at,
last_event_id = EXCLUDED.last_event_id,
updated_at = EXCLUDED.updated_at;
""";
private const string DefaultWorkerId = "default";
private readonly LedgerDataSource _dataSource;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PostgresFindingProjectionRepository> _logger;
public PostgresFindingProjectionRepository(
LedgerDataSource dataSource,
TimeProvider timeProvider,
ILogger<PostgresFindingProjectionRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<FindingProjection?> GetAsync(string tenantId, string findingId, string policyVersion, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "projector", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(GetProjectionSql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("finding_id", findingId);
command.Parameters.AddWithValue("policy_version", policyVersion);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
var status = reader.GetString(0);
var severity = reader.IsDBNull(1) ? (decimal?)null : reader.GetDecimal(1);
var riskScore = reader.IsDBNull(2) ? (decimal?)null : reader.GetDecimal(2);
var riskSeverity = reader.IsDBNull(3) ? null : reader.GetString(3);
var riskProfileVersion = reader.IsDBNull(4) ? null : reader.GetString(4);
var riskExplanationId = reader.IsDBNull(5) ? (Guid?)null : reader.GetGuid(5);
var riskEventSequence = reader.IsDBNull(6) ? (long?)null : reader.GetInt64(6);
var labelsJson = reader.GetFieldValue<string>(7);
var labels = JsonNode.Parse(labelsJson)?.AsObject() ?? new JsonObject();
var currentEventId = reader.GetGuid(8);
var explainRef = reader.IsDBNull(9) ? null : reader.GetString(9);
var rationaleJson = reader.IsDBNull(10) ? string.Empty : reader.GetFieldValue<string>(10);
JsonArray rationale;
if (string.IsNullOrWhiteSpace(rationaleJson))
{
rationale = new JsonArray();
}
else
{
rationale = JsonNode.Parse(rationaleJson) as JsonArray ?? new JsonArray();
}
var updatedAt = reader.GetFieldValue<DateTimeOffset>(11);
var cycleHash = reader.GetString(12);
return new FindingProjection(
tenantId,
findingId,
policyVersion,
status,
severity,
riskScore,
riskSeverity,
riskProfileVersion,
riskExplanationId,
riskEventSequence,
labels,
currentEventId,
explainRef,
rationale,
updatedAt,
cycleHash);
}
public async Task UpsertAsync(FindingProjection projection, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(projection);
await using var connection = await _dataSource.OpenConnectionAsync(projection.TenantId, "projector", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(UpsertProjectionSql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", projection.TenantId);
command.Parameters.AddWithValue("finding_id", projection.FindingId);
command.Parameters.AddWithValue("policy_version", projection.PolicyVersion);
command.Parameters.AddWithValue("status", projection.Status);
command.Parameters.AddWithValue("severity", projection.Severity.HasValue ? projection.Severity.Value : (object)DBNull.Value);
command.Parameters.AddWithValue("risk_score", projection.RiskScore.HasValue ? projection.RiskScore.Value : (object)DBNull.Value);
command.Parameters.AddWithValue("risk_severity", projection.RiskSeverity ?? (object)DBNull.Value);
command.Parameters.AddWithValue("risk_profile_version", projection.RiskProfileVersion ?? (object)DBNull.Value);
command.Parameters.AddWithValue("risk_explanation_id", projection.RiskExplanationId ?? (object)DBNull.Value);
command.Parameters.AddWithValue("risk_event_sequence", projection.RiskEventSequence.HasValue ? projection.RiskEventSequence.Value : (object)DBNull.Value);
var labelsCanonical = LedgerCanonicalJsonSerializer.Canonicalize(projection.Labels);
var labelsJson = labelsCanonical.ToJsonString();
command.Parameters.Add(new NpgsqlParameter<string>("labels", NpgsqlDbType.Jsonb) { TypedValue = labelsJson });
command.Parameters.AddWithValue("current_event_id", projection.CurrentEventId);
command.Parameters.AddWithValue("explain_ref", projection.ExplainRef ?? (object)DBNull.Value);
var rationaleCanonical = LedgerCanonicalJsonSerializer.Canonicalize(projection.PolicyRationale);
var rationaleJson = rationaleCanonical.ToJsonString();
command.Parameters.Add(new NpgsqlParameter<string>("policy_rationale", NpgsqlDbType.Jsonb) { TypedValue = rationaleJson });
command.Parameters.AddWithValue("updated_at", projection.UpdatedAt);
command.Parameters.AddWithValue("cycle_hash", projection.CycleHash);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
public async Task InsertHistoryAsync(FindingHistoryEntry entry, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(entry);
await using var connection = await _dataSource.OpenConnectionAsync(entry.TenantId, "projector", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(InsertHistorySql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", entry.TenantId);
command.Parameters.AddWithValue("finding_id", entry.FindingId);
command.Parameters.AddWithValue("policy_version", entry.PolicyVersion);
command.Parameters.AddWithValue("event_id", entry.EventId);
command.Parameters.AddWithValue("status", entry.Status);
command.Parameters.AddWithValue("severity", entry.Severity.HasValue ? entry.Severity.Value : (object)DBNull.Value);
command.Parameters.AddWithValue("actor_id", entry.ActorId);
command.Parameters.AddWithValue("comment", entry.Comment ?? (object)DBNull.Value);
command.Parameters.AddWithValue("occurred_at", entry.OccurredAt);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
public async Task InsertActionAsync(TriageActionEntry entry, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(entry);
await using var connection = await _dataSource.OpenConnectionAsync(entry.TenantId, "projector", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(InsertActionSql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", entry.TenantId);
command.Parameters.AddWithValue("action_id", entry.ActionId);
command.Parameters.AddWithValue("event_id", entry.EventId);
command.Parameters.AddWithValue("finding_id", entry.FindingId);
command.Parameters.AddWithValue("action_type", entry.ActionType);
var payloadJson = entry.Payload.ToJsonString();
command.Parameters.Add(new NpgsqlParameter<string>("payload", NpgsqlDbType.Jsonb) { TypedValue = payloadJson });
command.Parameters.AddWithValue("created_at", entry.CreatedAt);
command.Parameters.AddWithValue("created_by", entry.CreatedBy);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<ProjectionCheckpoint> GetCheckpointAsync(CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(string.Empty, "projector", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(SelectCheckpointSql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("worker_id", DefaultWorkerId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return ProjectionCheckpoint.Initial(_timeProvider);
}
var lastRecordedAt = reader.GetFieldValue<DateTimeOffset>(0);
var lastEventId = reader.GetGuid(1);
var updatedAt = reader.GetFieldValue<DateTimeOffset>(2);
return new ProjectionCheckpoint(lastRecordedAt, lastEventId, updatedAt);
}
public async Task SaveCheckpointAsync(ProjectionCheckpoint checkpoint, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(checkpoint);
await using var connection = await _dataSource.OpenConnectionAsync(string.Empty, "projector", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(UpsertCheckpointSql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("worker_id", DefaultWorkerId);
command.Parameters.AddWithValue("last_recorded_at", checkpoint.LastRecordedAt);
command.Parameters.AddWithValue("last_event_id", checkpoint.LastEventId);
command.Parameters.AddWithValue("updated_at", checkpoint.UpdatedAt);
try
{
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
catch (PostgresException ex)
{
_logger.LogError(ex, "Failed to persist projection checkpoint.");
throw;
}
}
}