Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- 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.
354 lines
15 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|