up
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
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
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
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.AirGap.Storage.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for AirGap module.
|
||||
/// </summary>
|
||||
public sealed class AirGapDataSource : DataSourceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Default schema name for AirGap tables.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "airgap";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new AirGap data source.
|
||||
/// </summary>
|
||||
public AirGapDataSource(IOptions<PostgresOptions> options, ILogger<AirGapDataSource> logger)
|
||||
: base(CreateOptions(options.Value), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ModuleName => "AirGap";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
|
||||
{
|
||||
base.ConfigureDataSourceBuilder(builder);
|
||||
}
|
||||
|
||||
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
|
||||
{
|
||||
baseOptions.SchemaName = DefaultSchemaName;
|
||||
}
|
||||
return baseOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
using StellaOps.AirGap.Controller.Stores;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.AirGap.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed store for AirGap sealing state.
|
||||
/// </summary>
|
||||
public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>, IAirGapStateStore
|
||||
{
|
||||
private volatile bool _initialized;
|
||||
private readonly SemaphoreSlim _initLock = new(1, 1);
|
||||
|
||||
public PostgresAirGapStateStore(AirGapDataSource dataSource, ILogger<PostgresAirGapStateStore> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<AirGapState> GetAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
|
||||
staleness_budget, drift_baseline_seconds, content_budgets
|
||||
FROM airgap.state
|
||||
WHERE LOWER(tenant_id) = LOWER(@tenant_id);
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Return default state for tenant if not found
|
||||
return new AirGapState { TenantId = tenantId };
|
||||
}
|
||||
|
||||
return Map(reader);
|
||||
}
|
||||
|
||||
public async Task SetAsync(AirGapState state, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
INSERT INTO airgap.state (
|
||||
id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
|
||||
staleness_budget, drift_baseline_seconds, content_budgets
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @sealed, @policy_hash, @time_anchor, @last_transition_at,
|
||||
@staleness_budget, @drift_baseline_seconds, @content_budgets
|
||||
)
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
id = EXCLUDED.id,
|
||||
sealed = EXCLUDED.sealed,
|
||||
policy_hash = EXCLUDED.policy_hash,
|
||||
time_anchor = EXCLUDED.time_anchor,
|
||||
last_transition_at = EXCLUDED.last_transition_at,
|
||||
staleness_budget = EXCLUDED.staleness_budget,
|
||||
drift_baseline_seconds = EXCLUDED.drift_baseline_seconds,
|
||||
content_budgets = EXCLUDED.content_budgets,
|
||||
updated_at = NOW();
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "id", state.Id);
|
||||
AddParameter(command, "tenant_id", state.TenantId);
|
||||
AddParameter(command, "sealed", state.Sealed);
|
||||
AddParameter(command, "policy_hash", (object?)state.PolicyHash ?? DBNull.Value);
|
||||
AddJsonbParameter(command, "time_anchor", SerializeTimeAnchor(state.TimeAnchor));
|
||||
AddParameter(command, "last_transition_at", state.LastTransitionAt);
|
||||
AddJsonbParameter(command, "staleness_budget", SerializeStalenessBudget(state.StalenessBudget));
|
||||
AddParameter(command, "drift_baseline_seconds", state.DriftBaselineSeconds);
|
||||
AddJsonbParameter(command, "content_budgets", SerializeContentBudgets(state.ContentBudgets));
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static AirGapState Map(NpgsqlDataReader reader)
|
||||
{
|
||||
var id = reader.GetString(0);
|
||||
var tenantId = reader.GetString(1);
|
||||
var sealed_ = reader.GetBoolean(2);
|
||||
var policyHash = reader.IsDBNull(3) ? null : reader.GetString(3);
|
||||
var timeAnchorJson = reader.GetFieldValue<string>(4);
|
||||
var lastTransitionAt = reader.GetFieldValue<DateTimeOffset>(5);
|
||||
var stalenessBudgetJson = reader.GetFieldValue<string>(6);
|
||||
var driftBaselineSeconds = reader.GetInt64(7);
|
||||
var contentBudgetsJson = reader.IsDBNull(8) ? null : reader.GetFieldValue<string>(8);
|
||||
|
||||
var timeAnchor = DeserializeTimeAnchor(timeAnchorJson);
|
||||
var stalenessBudget = DeserializeStalenessBudget(stalenessBudgetJson);
|
||||
var contentBudgets = DeserializeContentBudgets(contentBudgetsJson);
|
||||
|
||||
return new AirGapState
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
Sealed = sealed_,
|
||||
PolicyHash = policyHash,
|
||||
TimeAnchor = timeAnchor,
|
||||
LastTransitionAt = lastTransitionAt,
|
||||
StalenessBudget = stalenessBudget,
|
||||
DriftBaselineSeconds = driftBaselineSeconds,
|
||||
ContentBudgets = contentBudgets
|
||||
};
|
||||
}
|
||||
|
||||
#region Serialization
|
||||
|
||||
private static string SerializeTimeAnchor(TimeAnchor anchor)
|
||||
{
|
||||
var obj = new
|
||||
{
|
||||
anchorTime = anchor.AnchorTime,
|
||||
source = anchor.Source,
|
||||
format = anchor.Format,
|
||||
signatureFingerprint = anchor.SignatureFingerprint,
|
||||
tokenDigest = anchor.TokenDigest
|
||||
};
|
||||
return JsonSerializer.Serialize(obj);
|
||||
}
|
||||
|
||||
private static TimeAnchor DeserializeTimeAnchor(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var anchorTime = root.GetProperty("anchorTime").GetDateTimeOffset();
|
||||
var source = root.GetProperty("source").GetString() ?? "unknown";
|
||||
var format = root.GetProperty("format").GetString() ?? "unknown";
|
||||
var signatureFingerprint = root.TryGetProperty("signatureFingerprint", out var sf) && sf.ValueKind == JsonValueKind.String
|
||||
? sf.GetString() ?? ""
|
||||
: "";
|
||||
var tokenDigest = root.TryGetProperty("tokenDigest", out var td) && td.ValueKind == JsonValueKind.String
|
||||
? td.GetString() ?? ""
|
||||
: "";
|
||||
|
||||
return new TimeAnchor(anchorTime, source, format, signatureFingerprint, tokenDigest);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return TimeAnchor.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeStalenessBudget(StalenessBudget budget)
|
||||
{
|
||||
var obj = new
|
||||
{
|
||||
warningSeconds = budget.WarningSeconds,
|
||||
breachSeconds = budget.BreachSeconds
|
||||
};
|
||||
return JsonSerializer.Serialize(obj);
|
||||
}
|
||||
|
||||
private static StalenessBudget DeserializeStalenessBudget(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var warningSeconds = root.GetProperty("warningSeconds").GetInt64();
|
||||
var breachSeconds = root.GetProperty("breachSeconds").GetInt64();
|
||||
|
||||
return new StalenessBudget(warningSeconds, breachSeconds);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return StalenessBudget.Default;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeContentBudgets(IReadOnlyDictionary<string, StalenessBudget> budgets)
|
||||
{
|
||||
if (budgets.Count == 0)
|
||||
{
|
||||
return "{}";
|
||||
}
|
||||
|
||||
var dict = budgets.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => new { warningSeconds = kv.Value.WarningSeconds, breachSeconds = kv.Value.BreachSeconds });
|
||||
|
||||
return JsonSerializer.Serialize(dict);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, StalenessBudget> DeserializeContentBudgets(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var result = new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
var warningSeconds = property.Value.GetProperty("warningSeconds").GetInt64();
|
||||
var breachSeconds = property.Value.GetProperty("breachSeconds").GetInt64();
|
||||
result[property.Name] = new StalenessBudget(warningSeconds, breachSeconds);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async ValueTask EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
CREATE SCHEMA IF NOT EXISTS airgap;
|
||||
CREATE TABLE IF NOT EXISTS airgap.state (
|
||||
id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL PRIMARY KEY,
|
||||
sealed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
policy_hash TEXT,
|
||||
time_anchor JSONB NOT NULL DEFAULT '{}',
|
||||
last_transition_at TIMESTAMPTZ NOT NULL DEFAULT '0001-01-01T00:00:00Z',
|
||||
staleness_budget JSONB NOT NULL DEFAULT '{"warningSeconds":3600,"breachSeconds":7200}',
|
||||
drift_baseline_seconds BIGINT NOT NULL DEFAULT 0,
|
||||
content_budgets JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_airgap_state_sealed ON airgap.state(sealed) WHERE sealed = TRUE;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
_initialized = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.AirGap.Controller.Stores;
|
||||
using StellaOps.AirGap.Storage.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.AirGap.Storage.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring AirGap PostgreSQL storage services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds AirGap PostgreSQL storage services.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration root.</param>
|
||||
/// <param name="sectionName">Configuration section name for PostgreSQL options.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAirGapPostgresStorage(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName = "Postgres:AirGap")
|
||||
{
|
||||
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
|
||||
services.AddSingleton<AirGapDataSource>();
|
||||
services.AddScoped<IAirGapStateStore, PostgresAirGapStateStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds AirGap PostgreSQL storage services with explicit options.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configureOptions">Options configuration action.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAirGapPostgresStorage(
|
||||
this IServiceCollection services,
|
||||
Action<PostgresOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
services.AddSingleton<AirGapDataSource>();
|
||||
services.AddScoped<IAirGapStateStore, PostgresAirGapStateStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>StellaOps.AirGap.Storage.Postgres</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.AirGap.Controller/StellaOps.AirGap.Controller.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -5,6 +5,9 @@ java_demo_archive,5,1,13.6363,49.4627,61.3100
|
||||
java_fat_archive,5,2,3.5181,8.1467,9.4927
|
||||
go_buildinfo_fixture,5,2,6.9861,25.8818,32.1304
|
||||
dotnet_multirid_fixture,5,2,11.8266,38.9340,47.8401
|
||||
dotnet_declared_source_tree,5,2,6.2100,21.2400,26.1600
|
||||
dotnet_declared_lockfile,5,2,1.7700,4.7600,5.7300
|
||||
dotnet_declared_packages_config,5,2,1.4100,2.9200,3.3700
|
||||
python_site_packages_scan,5,3,36.7930,105.6978,128.4211
|
||||
python_pip_cache_fixture,5,1,20.1829,30.9147,34.3257
|
||||
python_layered_editable_fixture,5,3,31.8757,39.7647,41.5656
|
||||
|
||||
|
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"thresholdMs": 2000,
|
||||
"iterations": 5,
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "dotnet_multirid_fixture",
|
||||
"label": ".NET analyzer on multi-RID fixture (deps.json)",
|
||||
"root": "src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/dotnet/multi",
|
||||
"analyzers": [
|
||||
"dotnet"
|
||||
],
|
||||
"thresholdMs": 1000
|
||||
},
|
||||
{
|
||||
"id": "dotnet_declared_source_tree",
|
||||
"label": ".NET analyzer declared-only (source-tree, no deps.json)",
|
||||
"root": "src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/dotnet/source-tree-only",
|
||||
"analyzers": [
|
||||
"dotnet"
|
||||
],
|
||||
"thresholdMs": 1000
|
||||
},
|
||||
{
|
||||
"id": "dotnet_declared_lockfile",
|
||||
"label": ".NET analyzer declared-only (lockfile-only, no deps.json)",
|
||||
"root": "src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/dotnet/lockfile-only",
|
||||
"analyzers": [
|
||||
"dotnet"
|
||||
],
|
||||
"thresholdMs": 1000
|
||||
},
|
||||
{
|
||||
"id": "dotnet_declared_packages_config",
|
||||
"label": ".NET analyzer declared-only (packages.config legacy)",
|
||||
"root": "src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/dotnet/packages-config-only",
|
||||
"analyzers": [
|
||||
"dotnet"
|
||||
],
|
||||
"thresholdMs": 1000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -85,6 +85,33 @@
|
||||
"bun"
|
||||
],
|
||||
"thresholdMs": 1000
|
||||
},
|
||||
{
|
||||
"id": "dotnet_declared_source_tree",
|
||||
"label": ".NET analyzer declared-only (source-tree, no deps.json)",
|
||||
"root": "src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/dotnet/source-tree-only",
|
||||
"analyzers": [
|
||||
"dotnet"
|
||||
],
|
||||
"thresholdMs": 1000
|
||||
},
|
||||
{
|
||||
"id": "dotnet_declared_lockfile",
|
||||
"label": ".NET analyzer declared-only (lockfile-only, no deps.json)",
|
||||
"root": "src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/dotnet/lockfile-only",
|
||||
"analyzers": [
|
||||
"dotnet"
|
||||
],
|
||||
"thresholdMs": 1000
|
||||
},
|
||||
{
|
||||
"id": "dotnet_declared_packages_config",
|
||||
"label": ".NET analyzer declared-only (packages.config legacy)",
|
||||
"root": "src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/dotnet/packages-config-only",
|
||||
"analyzers": [
|
||||
"dotnet"
|
||||
],
|
||||
"thresholdMs": 1000
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -268,16 +268,22 @@ internal static class CommandFactory
|
||||
{
|
||||
Description = "Include raw NDJSON output."
|
||||
};
|
||||
var includeSemanticOption = new Option<bool>("--semantic")
|
||||
{
|
||||
Description = "Include semantic entrypoint analysis (intent, capabilities, threats)."
|
||||
};
|
||||
|
||||
entryTrace.Add(scanIdOption);
|
||||
entryTrace.Add(includeNdjsonOption);
|
||||
entryTrace.Add(includeSemanticOption);
|
||||
|
||||
entryTrace.SetAction((parseResult, _) =>
|
||||
{
|
||||
var id = parseResult.GetValue(scanIdOption) ?? string.Empty;
|
||||
var includeNdjson = parseResult.GetValue(includeNdjsonOption);
|
||||
var includeSemantic = parseResult.GetValue(includeSemanticOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleScanEntryTraceAsync(services, id, includeNdjson, verbose, cancellationToken);
|
||||
return CommandHandlers.HandleScanEntryTraceAsync(services, id, includeNdjson, includeSemantic, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
scan.Add(entryTrace);
|
||||
@@ -8845,7 +8851,7 @@ internal static class CommandFactory
|
||||
var runOutputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Path to write the export bundle.",
|
||||
IsRequired = true
|
||||
Required = true
|
||||
};
|
||||
var runOverwriteOption = new Option<bool>("--overwrite")
|
||||
{
|
||||
@@ -8895,7 +8901,7 @@ internal static class CommandFactory
|
||||
var startProfileOption = new Option<string>("--profile-id")
|
||||
{
|
||||
Description = "Export profile identifier.",
|
||||
IsRequired = true
|
||||
Required = true
|
||||
};
|
||||
var startSelectorOption = new Option<string[]?>("--selector", new[] { "-s" })
|
||||
{
|
||||
|
||||
@@ -509,7 +509,7 @@ internal static class CommandHandlers
|
||||
}
|
||||
}
|
||||
|
||||
private static void RenderEntryTrace(EntryTraceResponseModel result, bool includeNdjson)
|
||||
private static void RenderEntryTrace(EntryTraceResponseModel result, bool includeNdjson, bool includeSemantic)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
@@ -570,6 +570,69 @@ internal static class CommandHandlers
|
||||
console.Write(diagTable);
|
||||
}
|
||||
|
||||
// Semantic entrypoint analysis
|
||||
if (includeSemantic && result.Semantic is not null)
|
||||
{
|
||||
console.WriteLine();
|
||||
console.MarkupLine("[bold]Semantic Entrypoint Analysis[/]");
|
||||
console.MarkupLine($"Intent: [green]{Markup.Escape(result.Semantic.Intent)}[/]");
|
||||
console.MarkupLine($"Language: {Markup.Escape(result.Semantic.Language ?? "unknown")}");
|
||||
console.MarkupLine($"Framework: {Markup.Escape(result.Semantic.Framework ?? "none")}");
|
||||
console.MarkupLine($"Confidence: {result.Semantic.ConfidenceScore:P0} ({Markup.Escape(result.Semantic.ConfidenceTier)})");
|
||||
|
||||
if (result.Semantic.Capabilities.Count > 0)
|
||||
{
|
||||
console.MarkupLine($"Capabilities: [cyan]{Markup.Escape(string.Join(", ", result.Semantic.Capabilities))}[/]");
|
||||
}
|
||||
|
||||
if (result.Semantic.Threats.Count > 0)
|
||||
{
|
||||
console.WriteLine();
|
||||
console.MarkupLine("[bold]Threat Vectors[/]");
|
||||
var threatTable = new Table()
|
||||
.AddColumn("Threat")
|
||||
.AddColumn("CWE")
|
||||
.AddColumn("OWASP")
|
||||
.AddColumn("Confidence");
|
||||
|
||||
foreach (var threat in result.Semantic.Threats)
|
||||
{
|
||||
threatTable.AddRow(
|
||||
threat.Type,
|
||||
threat.CweId ?? "-",
|
||||
threat.OwaspCategory ?? "-",
|
||||
threat.Confidence.ToString("P0", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
console.Write(threatTable);
|
||||
}
|
||||
|
||||
if (result.Semantic.DataBoundaries.Count > 0)
|
||||
{
|
||||
console.WriteLine();
|
||||
console.MarkupLine("[bold]Data Flow Boundaries[/]");
|
||||
var boundaryTable = new Table()
|
||||
.AddColumn("Type")
|
||||
.AddColumn("Direction")
|
||||
.AddColumn("Sensitivity");
|
||||
|
||||
foreach (var boundary in result.Semantic.DataBoundaries)
|
||||
{
|
||||
boundaryTable.AddRow(
|
||||
boundary.Type,
|
||||
boundary.Direction,
|
||||
boundary.Sensitivity);
|
||||
}
|
||||
|
||||
console.Write(boundaryTable);
|
||||
}
|
||||
}
|
||||
else if (includeSemantic && result.Semantic is null)
|
||||
{
|
||||
console.WriteLine();
|
||||
console.MarkupLine("[italic yellow]Semantic analysis not available for this scan.[/]");
|
||||
}
|
||||
|
||||
if (includeNdjson && result.Ndjson.Count > 0)
|
||||
{
|
||||
console.MarkupLine("[bold]NDJSON Output[/]");
|
||||
@@ -685,6 +748,7 @@ internal static class CommandHandlers
|
||||
IServiceProvider services,
|
||||
string scanId,
|
||||
bool includeNdjson,
|
||||
bool includeSemantic,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -697,6 +761,7 @@ internal static class CommandHandlers
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.entrytrace", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "scan entrytrace");
|
||||
activity?.SetTag("stellaops.cli.scan_id", scanId);
|
||||
activity?.SetTag("stellaops.cli.include_semantic", includeSemantic);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scan entrytrace");
|
||||
|
||||
try
|
||||
@@ -713,7 +778,7 @@ internal static class CommandHandlers
|
||||
return;
|
||||
}
|
||||
|
||||
RenderEntryTrace(result, includeNdjson);
|
||||
RenderEntryTrace(result, includeNdjson, includeSemantic);
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -6362,6 +6427,8 @@ internal static class CommandHandlers
|
||||
table.AddColumn("Status");
|
||||
table.AddColumn("Severity");
|
||||
table.AddColumn("Score");
|
||||
table.AddColumn("Tier");
|
||||
table.AddColumn("Risk");
|
||||
table.AddColumn("SBOM");
|
||||
table.AddColumn("Advisories");
|
||||
table.AddColumn("Updated (UTC)");
|
||||
@@ -6373,6 +6440,8 @@ internal static class CommandHandlers
|
||||
Markup.Escape(item.Status),
|
||||
Markup.Escape(item.Severity.Normalized),
|
||||
Markup.Escape(FormatScore(item.Severity.Score)),
|
||||
FormatUncertaintyTier(item.Uncertainty?.AggregateTier),
|
||||
Markup.Escape(FormatScore(item.Uncertainty?.RiskScore)),
|
||||
Markup.Escape(item.SbomId),
|
||||
Markup.Escape(FormatListPreview(item.AdvisoryIds)),
|
||||
Markup.Escape(FormatUpdatedAt(item.UpdatedAt)));
|
||||
@@ -6385,11 +6454,13 @@ internal static class CommandHandlers
|
||||
foreach (var item in items)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"{Finding} — Status {Status}, Severity {Severity} ({Score}), SBOM {Sbom}, Updated {Updated}",
|
||||
"{Finding} — Status {Status}, Severity {Severity} ({Score}), Tier {Tier} (Risk {Risk}), SBOM {Sbom}, Updated {Updated}",
|
||||
item.FindingId,
|
||||
item.Status,
|
||||
item.Severity.Normalized,
|
||||
item.Severity.Score?.ToString("0.00", CultureInfo.InvariantCulture) ?? "n/a",
|
||||
FormatUncertaintyTierPlain(item.Uncertainty?.AggregateTier),
|
||||
item.Uncertainty?.RiskScore?.ToString("0.00", CultureInfo.InvariantCulture) ?? "n/a",
|
||||
item.SbomId,
|
||||
FormatUpdatedAt(item.UpdatedAt));
|
||||
}
|
||||
@@ -6420,6 +6491,8 @@ internal static class CommandHandlers
|
||||
table.AddRow("Finding", Markup.Escape(finding.FindingId));
|
||||
table.AddRow("Status", Markup.Escape(finding.Status));
|
||||
table.AddRow("Severity", Markup.Escape(FormatSeverity(finding.Severity)));
|
||||
table.AddRow("Uncertainty Tier", FormatUncertaintyTier(finding.Uncertainty?.AggregateTier));
|
||||
table.AddRow("Risk Score", Markup.Escape(FormatScore(finding.Uncertainty?.RiskScore)));
|
||||
table.AddRow("SBOM", Markup.Escape(finding.SbomId));
|
||||
table.AddRow("Policy Version", Markup.Escape(finding.PolicyVersion.ToString(CultureInfo.InvariantCulture)));
|
||||
table.AddRow("Updated (UTC)", Markup.Escape(FormatUpdatedAt(finding.UpdatedAt)));
|
||||
@@ -6427,6 +6500,11 @@ internal static class CommandHandlers
|
||||
table.AddRow("Advisories", Markup.Escape(FormatListPreview(finding.AdvisoryIds)));
|
||||
table.AddRow("VEX", Markup.Escape(FormatVexMetadata(finding.Vex)));
|
||||
|
||||
if (finding.Uncertainty?.States is { Count: > 0 })
|
||||
{
|
||||
table.AddRow("Uncertainty States", Markup.Escape(FormatUncertaintyStates(finding.Uncertainty.States)));
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
else
|
||||
@@ -6434,6 +6512,9 @@ internal static class CommandHandlers
|
||||
logger.LogInformation("Finding {Finding}", finding.FindingId);
|
||||
logger.LogInformation(" Status: {Status}", finding.Status);
|
||||
logger.LogInformation(" Severity: {Severity}", FormatSeverity(finding.Severity));
|
||||
logger.LogInformation(" Uncertainty: {Tier} (Risk {Risk})",
|
||||
FormatUncertaintyTierPlain(finding.Uncertainty?.AggregateTier),
|
||||
finding.Uncertainty?.RiskScore?.ToString("0.00", CultureInfo.InvariantCulture) ?? "n/a");
|
||||
logger.LogInformation(" SBOM: {Sbom}", finding.SbomId);
|
||||
logger.LogInformation(" Policy version: {Version}", finding.PolicyVersion);
|
||||
logger.LogInformation(" Updated (UTC): {Updated}", FormatUpdatedAt(finding.UpdatedAt));
|
||||
@@ -6449,6 +6530,10 @@ internal static class CommandHandlers
|
||||
{
|
||||
logger.LogInformation(" VEX: {Vex}", FormatVexMetadata(finding.Vex));
|
||||
}
|
||||
if (finding.Uncertainty?.States is { Count: > 0 })
|
||||
{
|
||||
logger.LogInformation(" Uncertainty States: {States}", FormatUncertaintyStates(finding.Uncertainty.States));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6569,6 +6654,54 @@ internal static class CommandHandlers
|
||||
private static string FormatScore(double? score)
|
||||
=> score.HasValue ? score.Value.ToString("0.00", CultureInfo.InvariantCulture) : "-";
|
||||
|
||||
private static string FormatUncertaintyTier(string? tier)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tier))
|
||||
{
|
||||
return "[grey]-[/]";
|
||||
}
|
||||
|
||||
var (color, display) = tier.ToUpperInvariant() switch
|
||||
{
|
||||
"T1" => ("red", "T1 (High)"),
|
||||
"T2" => ("yellow", "T2 (Medium)"),
|
||||
"T3" => ("blue", "T3 (Low)"),
|
||||
"T4" => ("green", "T4 (Negligible)"),
|
||||
_ => ("grey", tier)
|
||||
};
|
||||
|
||||
return $"[{color}]{Markup.Escape(display)}[/]";
|
||||
}
|
||||
|
||||
private static string FormatUncertaintyTierPlain(string? tier)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tier))
|
||||
{
|
||||
return "-";
|
||||
}
|
||||
|
||||
return tier.ToUpperInvariant() switch
|
||||
{
|
||||
"T1" => "T1 (High)",
|
||||
"T2" => "T2 (Medium)",
|
||||
"T3" => "T3 (Low)",
|
||||
"T4" => "T4 (Negligible)",
|
||||
_ => tier
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatUncertaintyStates(IReadOnlyList<PolicyFindingUncertaintyState>? states)
|
||||
{
|
||||
if (states is null || states.Count == 0)
|
||||
{
|
||||
return "-";
|
||||
}
|
||||
|
||||
return string.Join(", ", states
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.Code))
|
||||
.Select(s => $"{s.Code}={s.Entropy?.ToString("0.00", CultureInfo.InvariantCulture) ?? "?"}"));
|
||||
}
|
||||
|
||||
private static string FormatKeyValuePairs(IReadOnlyDictionary<string, string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
|
||||
@@ -2443,6 +2443,29 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
|
||||
var updatedAt = document.UpdatedAt ?? DateTimeOffset.MinValue;
|
||||
|
||||
PolicyFindingUncertainty? uncertainty = null;
|
||||
if (document.Uncertainty is not null)
|
||||
{
|
||||
IReadOnlyList<PolicyFindingUncertaintyState>? states = null;
|
||||
if (document.Uncertainty.States is not null)
|
||||
{
|
||||
states = document.Uncertainty.States
|
||||
.Where(s => s is not null)
|
||||
.Select(s => new PolicyFindingUncertaintyState(
|
||||
string.IsNullOrWhiteSpace(s!.Code) ? null : s.Code,
|
||||
string.IsNullOrWhiteSpace(s.Name) ? null : s.Name,
|
||||
s.Entropy,
|
||||
string.IsNullOrWhiteSpace(s.Tier) ? null : s.Tier))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
uncertainty = new PolicyFindingUncertainty(
|
||||
string.IsNullOrWhiteSpace(document.Uncertainty.AggregateTier) ? null : document.Uncertainty.AggregateTier,
|
||||
document.Uncertainty.RiskScore,
|
||||
states,
|
||||
document.Uncertainty.ComputedAt);
|
||||
}
|
||||
|
||||
return new PolicyFindingDocument(
|
||||
findingId,
|
||||
status,
|
||||
@@ -2450,6 +2473,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
sbomId,
|
||||
advisoryIds,
|
||||
vex,
|
||||
uncertainty,
|
||||
document.PolicyVersion ?? 0,
|
||||
updatedAt,
|
||||
string.IsNullOrWhiteSpace(document.RunId) ? null : document.RunId);
|
||||
|
||||
@@ -10,4 +10,36 @@ internal sealed record EntryTraceResponseModel(
|
||||
DateTimeOffset GeneratedAt,
|
||||
EntryTraceGraph Graph,
|
||||
IReadOnlyList<string> Ndjson,
|
||||
EntryTracePlan? BestPlan);
|
||||
EntryTracePlan? BestPlan,
|
||||
SemanticEntrypointSummary? Semantic = null);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of semantic entrypoint analysis for CLI display.
|
||||
/// </summary>
|
||||
internal sealed record SemanticEntrypointSummary
|
||||
{
|
||||
public string Intent { get; init; } = "Unknown";
|
||||
public IReadOnlyList<string> Capabilities { get; init; } = Array.Empty<string>();
|
||||
public IReadOnlyList<ThreatVectorSummary> Threats { get; init; } = Array.Empty<ThreatVectorSummary>();
|
||||
public IReadOnlyList<DataBoundarySummary> DataBoundaries { get; init; } = Array.Empty<DataBoundarySummary>();
|
||||
public string? Framework { get; init; }
|
||||
public string? Language { get; init; }
|
||||
public double ConfidenceScore { get; init; }
|
||||
public string ConfidenceTier { get; init; } = "Unknown";
|
||||
public string AnalyzedAt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed record ThreatVectorSummary
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public double Confidence { get; init; }
|
||||
public string? CweId { get; init; }
|
||||
public string? OwaspCategory { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record DataBoundarySummary
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public string Direction { get; init; } = string.Empty;
|
||||
public string Sensitivity { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ internal sealed record PolicyFindingDocument(
|
||||
string SbomId,
|
||||
IReadOnlyList<string> AdvisoryIds,
|
||||
PolicyFindingVexMetadata? Vex,
|
||||
PolicyFindingUncertainty? Uncertainty,
|
||||
int PolicyVersion,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? RunId);
|
||||
@@ -33,6 +34,18 @@ internal sealed record PolicyFindingSeverity(string Normalized, double? Score);
|
||||
|
||||
internal sealed record PolicyFindingVexMetadata(string? WinningStatementId, string? Source, string? Status);
|
||||
|
||||
internal sealed record PolicyFindingUncertainty(
|
||||
string? AggregateTier,
|
||||
double? RiskScore,
|
||||
IReadOnlyList<PolicyFindingUncertaintyState>? States,
|
||||
DateTimeOffset? ComputedAt);
|
||||
|
||||
internal sealed record PolicyFindingUncertaintyState(
|
||||
string? Code,
|
||||
string? Name,
|
||||
double? Entropy,
|
||||
string? Tier);
|
||||
|
||||
internal sealed record PolicyFindingExplainResult(
|
||||
string FindingId,
|
||||
int PolicyVersion,
|
||||
|
||||
@@ -27,6 +27,8 @@ internal sealed class PolicyFindingDocumentDocument
|
||||
|
||||
public PolicyFindingVexDocument? Vex { get; set; }
|
||||
|
||||
public PolicyFindingUncertaintyDocument? Uncertainty { get; set; }
|
||||
|
||||
public int? PolicyVersion { get; set; }
|
||||
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
@@ -34,6 +36,28 @@ internal sealed class PolicyFindingDocumentDocument
|
||||
public string? RunId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingUncertaintyDocument
|
||||
{
|
||||
public string? AggregateTier { get; set; }
|
||||
|
||||
public double? RiskScore { get; set; }
|
||||
|
||||
public List<PolicyFindingUncertaintyStateDocument>? States { get; set; }
|
||||
|
||||
public DateTimeOffset? ComputedAt { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingUncertaintyStateDocument
|
||||
{
|
||||
public string? Code { get; set; }
|
||||
|
||||
public string? Name { get; set; }
|
||||
|
||||
public double? Entropy { get; set; }
|
||||
|
||||
public string? Tier { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingSeverityDocument
|
||||
{
|
||||
public string? Normalized { get; set; }
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
|
||||
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" />
|
||||
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="3.1.0" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.48.0" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Excititor.Core.Evidence;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed store for VEX attestations.
|
||||
/// </summary>
|
||||
public sealed class PostgresVexAttestationStore : RepositoryBase<ExcititorDataSource>, IVexAttestationStore
|
||||
{
|
||||
private volatile bool _initialized;
|
||||
private readonly SemaphoreSlim _initLock = new(1, 1);
|
||||
|
||||
public PostgresVexAttestationStore(ExcititorDataSource dataSource, ILogger<PostgresVexAttestationStore> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async ValueTask SaveAsync(VexStoredAttestation attestation, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
INSERT INTO vex.attestations (
|
||||
attestation_id, tenant, manifest_id, merkle_root, dsse_envelope_json,
|
||||
dsse_envelope_hash, item_count, attested_at, metadata
|
||||
)
|
||||
VALUES (
|
||||
@attestation_id, @tenant, @manifest_id, @merkle_root, @dsse_envelope_json,
|
||||
@dsse_envelope_hash, @item_count, @attested_at, @metadata
|
||||
)
|
||||
ON CONFLICT (tenant, attestation_id) DO UPDATE SET
|
||||
manifest_id = EXCLUDED.manifest_id,
|
||||
merkle_root = EXCLUDED.merkle_root,
|
||||
dsse_envelope_json = EXCLUDED.dsse_envelope_json,
|
||||
dsse_envelope_hash = EXCLUDED.dsse_envelope_hash,
|
||||
item_count = EXCLUDED.item_count,
|
||||
attested_at = EXCLUDED.attested_at,
|
||||
metadata = EXCLUDED.metadata;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "attestation_id", attestation.AttestationId);
|
||||
AddParameter(command, "tenant", attestation.Tenant);
|
||||
AddParameter(command, "manifest_id", attestation.ManifestId);
|
||||
AddParameter(command, "merkle_root", attestation.MerkleRoot);
|
||||
AddParameter(command, "dsse_envelope_json", attestation.DsseEnvelopeJson);
|
||||
AddParameter(command, "dsse_envelope_hash", attestation.DsseEnvelopeHash);
|
||||
AddParameter(command, "item_count", attestation.ItemCount);
|
||||
AddParameter(command, "attested_at", attestation.AttestedAt);
|
||||
AddJsonbParameter(command, "metadata", SerializeMetadata(attestation.Metadata));
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<VexStoredAttestation?> FindByIdAsync(string tenant, string attestationId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant) || string.IsNullOrWhiteSpace(attestationId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
SELECT attestation_id, tenant, manifest_id, merkle_root, dsse_envelope_json,
|
||||
dsse_envelope_hash, item_count, attested_at, metadata
|
||||
FROM vex.attestations
|
||||
WHERE LOWER(tenant) = LOWER(@tenant) AND attestation_id = @attestation_id;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant.Trim());
|
||||
AddParameter(command, "attestation_id", attestationId.Trim());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Map(reader);
|
||||
}
|
||||
|
||||
public async ValueTask<VexStoredAttestation?> FindByManifestIdAsync(string tenant, string manifestId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant) || string.IsNullOrWhiteSpace(manifestId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
SELECT attestation_id, tenant, manifest_id, merkle_root, dsse_envelope_json,
|
||||
dsse_envelope_hash, item_count, attested_at, metadata
|
||||
FROM vex.attestations
|
||||
WHERE LOWER(tenant) = LOWER(@tenant) AND LOWER(manifest_id) = LOWER(@manifest_id)
|
||||
ORDER BY attested_at DESC
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant.Trim());
|
||||
AddParameter(command, "manifest_id", manifestId.Trim());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Map(reader);
|
||||
}
|
||||
|
||||
public async ValueTask<VexAttestationListResult> ListAsync(VexAttestationQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get total count
|
||||
var countSql = "SELECT COUNT(*) FROM vex.attestations WHERE LOWER(tenant) = LOWER(@tenant)";
|
||||
var whereClauses = new List<string>();
|
||||
|
||||
if (query.Since.HasValue)
|
||||
{
|
||||
whereClauses.Add("attested_at >= @since");
|
||||
}
|
||||
|
||||
if (query.Until.HasValue)
|
||||
{
|
||||
whereClauses.Add("attested_at <= @until");
|
||||
}
|
||||
|
||||
if (whereClauses.Count > 0)
|
||||
{
|
||||
countSql += " AND " + string.Join(" AND ", whereClauses);
|
||||
}
|
||||
|
||||
await using var countCommand = CreateCommand(countSql, connection);
|
||||
AddParameter(countCommand, "tenant", query.Tenant);
|
||||
|
||||
if (query.Since.HasValue)
|
||||
{
|
||||
AddParameter(countCommand, "since", query.Since.Value);
|
||||
}
|
||||
|
||||
if (query.Until.HasValue)
|
||||
{
|
||||
AddParameter(countCommand, "until", query.Until.Value);
|
||||
}
|
||||
|
||||
var totalCount = Convert.ToInt32(await countCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false));
|
||||
|
||||
// Get items
|
||||
var selectSql = """
|
||||
SELECT attestation_id, tenant, manifest_id, merkle_root, dsse_envelope_json,
|
||||
dsse_envelope_hash, item_count, attested_at, metadata
|
||||
FROM vex.attestations
|
||||
WHERE LOWER(tenant) = LOWER(@tenant)
|
||||
""";
|
||||
|
||||
if (whereClauses.Count > 0)
|
||||
{
|
||||
selectSql += " AND " + string.Join(" AND ", whereClauses);
|
||||
}
|
||||
|
||||
selectSql += " ORDER BY attested_at DESC, attestation_id ASC LIMIT @limit OFFSET @offset;";
|
||||
|
||||
await using var selectCommand = CreateCommand(selectSql, connection);
|
||||
AddParameter(selectCommand, "tenant", query.Tenant);
|
||||
AddParameter(selectCommand, "limit", query.Limit);
|
||||
AddParameter(selectCommand, "offset", query.Offset);
|
||||
|
||||
if (query.Since.HasValue)
|
||||
{
|
||||
AddParameter(selectCommand, "since", query.Since.Value);
|
||||
}
|
||||
|
||||
if (query.Until.HasValue)
|
||||
{
|
||||
AddParameter(selectCommand, "until", query.Until.Value);
|
||||
}
|
||||
|
||||
var items = new List<VexStoredAttestation>();
|
||||
await using var reader = await selectCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(Map(reader));
|
||||
}
|
||||
|
||||
var hasMore = query.Offset + items.Count < totalCount;
|
||||
|
||||
return new VexAttestationListResult(items, totalCount, hasMore);
|
||||
}
|
||||
|
||||
public async ValueTask<int> CountAsync(string tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = "SELECT COUNT(*) FROM vex.attestations WHERE LOWER(tenant) = LOWER(@tenant);";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant.Trim());
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static VexStoredAttestation Map(NpgsqlDataReader reader)
|
||||
{
|
||||
var attestationId = reader.GetString(0);
|
||||
var tenant = reader.GetString(1);
|
||||
var manifestId = reader.GetString(2);
|
||||
var merkleRoot = reader.GetString(3);
|
||||
var dsseEnvelopeJson = reader.GetString(4);
|
||||
var dsseEnvelopeHash = reader.GetString(5);
|
||||
var itemCount = reader.GetInt32(6);
|
||||
var attestedAt = reader.GetFieldValue<DateTimeOffset>(7);
|
||||
var metadataJson = reader.IsDBNull(8) ? null : reader.GetFieldValue<string>(8);
|
||||
|
||||
var metadata = DeserializeMetadata(metadataJson);
|
||||
|
||||
return new VexStoredAttestation(
|
||||
attestationId,
|
||||
tenant,
|
||||
manifestId,
|
||||
merkleRoot,
|
||||
dsseEnvelopeJson,
|
||||
dsseEnvelopeHash,
|
||||
itemCount,
|
||||
attestedAt,
|
||||
metadata);
|
||||
}
|
||||
|
||||
private static string SerializeMetadata(ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
if (metadata.IsEmpty)
|
||||
{
|
||||
return "{}";
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(metadata);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> DeserializeMetadata(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = property.Value.GetString();
|
||||
if (value is not null)
|
||||
{
|
||||
builder[property.Name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
CREATE TABLE IF NOT EXISTS vex.attestations (
|
||||
attestation_id TEXT NOT NULL,
|
||||
tenant TEXT NOT NULL,
|
||||
manifest_id TEXT NOT NULL,
|
||||
merkle_root TEXT NOT NULL,
|
||||
dsse_envelope_json TEXT NOT NULL,
|
||||
dsse_envelope_hash TEXT NOT NULL,
|
||||
item_count INTEGER NOT NULL,
|
||||
attested_at TIMESTAMPTZ NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant, attestation_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_attestations_tenant ON vex.attestations(tenant);
|
||||
CREATE INDEX IF NOT EXISTS idx_attestations_manifest_id ON vex.attestations(tenant, manifest_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attestations_attested_at ON vex.attestations(tenant, attested_at DESC);
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
_initialized = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed store for VEX observations with complex nested structures.
|
||||
/// </summary>
|
||||
public sealed class PostgresVexObservationStore : RepositoryBase<ExcititorDataSource>, IVexObservationStore
|
||||
{
|
||||
private volatile bool _initialized;
|
||||
private readonly SemaphoreSlim _initLock = new(1, 1);
|
||||
|
||||
public PostgresVexObservationStore(ExcititorDataSource dataSource, ILogger<PostgresVexObservationStore> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async ValueTask<bool> InsertAsync(VexObservation observation, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observation);
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
INSERT INTO vex.observations (
|
||||
observation_id, tenant, provider_id, stream_id, upstream, statements,
|
||||
content, linkset, created_at, supersedes, attributes
|
||||
)
|
||||
VALUES (
|
||||
@observation_id, @tenant, @provider_id, @stream_id, @upstream, @statements,
|
||||
@content, @linkset, @created_at, @supersedes, @attributes
|
||||
)
|
||||
ON CONFLICT (tenant, observation_id) DO NOTHING;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddObservationParameters(command, observation);
|
||||
|
||||
var affected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
public async ValueTask<bool> UpsertAsync(VexObservation observation, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observation);
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
INSERT INTO vex.observations (
|
||||
observation_id, tenant, provider_id, stream_id, upstream, statements,
|
||||
content, linkset, created_at, supersedes, attributes
|
||||
)
|
||||
VALUES (
|
||||
@observation_id, @tenant, @provider_id, @stream_id, @upstream, @statements,
|
||||
@content, @linkset, @created_at, @supersedes, @attributes
|
||||
)
|
||||
ON CONFLICT (tenant, observation_id) DO UPDATE SET
|
||||
provider_id = EXCLUDED.provider_id,
|
||||
stream_id = EXCLUDED.stream_id,
|
||||
upstream = EXCLUDED.upstream,
|
||||
statements = EXCLUDED.statements,
|
||||
content = EXCLUDED.content,
|
||||
linkset = EXCLUDED.linkset,
|
||||
created_at = EXCLUDED.created_at,
|
||||
supersedes = EXCLUDED.supersedes,
|
||||
attributes = EXCLUDED.attributes;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddObservationParameters(command, observation);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async ValueTask<int> InsertManyAsync(string tenant, IEnumerable<VexObservation> observations, CancellationToken cancellationToken)
|
||||
{
|
||||
if (observations is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var observationsList = observations
|
||||
.Where(o => string.Equals(o.Tenant, tenant, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (observationsList.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var count = 0;
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var observation in observationsList)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO vex.observations (
|
||||
observation_id, tenant, provider_id, stream_id, upstream, statements,
|
||||
content, linkset, created_at, supersedes, attributes
|
||||
)
|
||||
VALUES (
|
||||
@observation_id, @tenant, @provider_id, @stream_id, @upstream, @statements,
|
||||
@content, @linkset, @created_at, @supersedes, @attributes
|
||||
)
|
||||
ON CONFLICT (tenant, observation_id) DO NOTHING;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddObservationParameters(command, observation);
|
||||
|
||||
var affected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (affected > 0)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async ValueTask<VexObservation?> GetByIdAsync(string tenant, string observationId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant) || string.IsNullOrWhiteSpace(observationId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
SELECT observation_id, tenant, provider_id, stream_id, upstream, statements,
|
||||
content, linkset, created_at, supersedes, attributes
|
||||
FROM vex.observations
|
||||
WHERE LOWER(tenant) = LOWER(@tenant) AND observation_id = @observation_id;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant.Trim());
|
||||
AddParameter(command, "observation_id", observationId.Trim());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Map(reader);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<VexObservation>> FindByVulnerabilityAndProductAsync(
|
||||
string tenant,
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
// Use JSONB containment to query nested statements array
|
||||
const string sql = """
|
||||
SELECT observation_id, tenant, provider_id, stream_id, upstream, statements,
|
||||
content, linkset, created_at, supersedes, attributes
|
||||
FROM vex.observations
|
||||
WHERE LOWER(tenant) = LOWER(@tenant)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(statements) AS stmt
|
||||
WHERE LOWER(stmt->>'vulnerabilityId') = LOWER(@vulnerability_id)
|
||||
AND LOWER(stmt->>'productKey') = LOWER(@product_key)
|
||||
)
|
||||
ORDER BY created_at DESC;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant);
|
||||
AddParameter(command, "vulnerability_id", vulnerabilityId);
|
||||
AddParameter(command, "product_key", productKey);
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<VexObservation>> FindByProviderAsync(
|
||||
string tenant,
|
||||
string providerId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
SELECT observation_id, tenant, provider_id, stream_id, upstream, statements,
|
||||
content, linkset, created_at, supersedes, attributes
|
||||
FROM vex.observations
|
||||
WHERE LOWER(tenant) = LOWER(@tenant) AND LOWER(provider_id) = LOWER(@provider_id)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant);
|
||||
AddParameter(command, "provider_id", providerId);
|
||||
AddParameter(command, "limit", limit);
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<bool> DeleteAsync(string tenant, string observationId, CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
DELETE FROM vex.observations
|
||||
WHERE LOWER(tenant) = LOWER(@tenant) AND observation_id = @observation_id;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant);
|
||||
AddParameter(command, "observation_id", observationId);
|
||||
|
||||
var affected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
public async ValueTask<long> CountAsync(string tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = "SELECT COUNT(*) FROM vex.observations WHERE LOWER(tenant) = LOWER(@tenant);";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt64(result);
|
||||
}
|
||||
|
||||
private void AddObservationParameters(NpgsqlCommand command, VexObservation observation)
|
||||
{
|
||||
AddParameter(command, "observation_id", observation.ObservationId);
|
||||
AddParameter(command, "tenant", observation.Tenant);
|
||||
AddParameter(command, "provider_id", observation.ProviderId);
|
||||
AddParameter(command, "stream_id", observation.StreamId);
|
||||
AddJsonbParameter(command, "upstream", SerializeUpstream(observation.Upstream));
|
||||
AddJsonbParameter(command, "statements", SerializeStatements(observation.Statements));
|
||||
AddJsonbParameter(command, "content", SerializeContent(observation.Content));
|
||||
AddJsonbParameter(command, "linkset", SerializeLinkset(observation.Linkset));
|
||||
AddParameter(command, "created_at", observation.CreatedAt);
|
||||
AddParameter(command, "supersedes", observation.Supersedes.IsDefaultOrEmpty ? Array.Empty<string>() : observation.Supersedes.ToArray());
|
||||
AddJsonbParameter(command, "attributes", SerializeAttributes(observation.Attributes));
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<VexObservation>> ExecuteQueryAsync(NpgsqlCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<VexObservation>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(Map(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static VexObservation Map(NpgsqlDataReader reader)
|
||||
{
|
||||
var observationId = reader.GetString(0);
|
||||
var tenant = reader.GetString(1);
|
||||
var providerId = reader.GetString(2);
|
||||
var streamId = reader.GetString(3);
|
||||
var upstreamJson = reader.GetFieldValue<string>(4);
|
||||
var statementsJson = reader.GetFieldValue<string>(5);
|
||||
var contentJson = reader.GetFieldValue<string>(6);
|
||||
var linksetJson = reader.GetFieldValue<string>(7);
|
||||
var createdAt = reader.GetFieldValue<DateTimeOffset>(8);
|
||||
var supersedes = reader.IsDBNull(9) ? Array.Empty<string>() : reader.GetFieldValue<string[]>(9);
|
||||
var attributesJson = reader.IsDBNull(10) ? null : reader.GetFieldValue<string>(10);
|
||||
|
||||
var upstream = DeserializeUpstream(upstreamJson);
|
||||
var statements = DeserializeStatements(statementsJson);
|
||||
var content = DeserializeContent(contentJson);
|
||||
var linkset = DeserializeLinkset(linksetJson);
|
||||
var attributes = DeserializeAttributes(attributesJson);
|
||||
|
||||
return new VexObservation(
|
||||
observationId,
|
||||
tenant,
|
||||
providerId,
|
||||
streamId,
|
||||
upstream,
|
||||
statements,
|
||||
content,
|
||||
linkset,
|
||||
createdAt,
|
||||
supersedes.Length == 0 ? null : supersedes.ToImmutableArray(),
|
||||
attributes);
|
||||
}
|
||||
|
||||
#region Serialization
|
||||
|
||||
private static string SerializeUpstream(VexObservationUpstream upstream)
|
||||
{
|
||||
var obj = new
|
||||
{
|
||||
upstreamId = upstream.UpstreamId,
|
||||
documentVersion = upstream.DocumentVersion,
|
||||
fetchedAt = upstream.FetchedAt,
|
||||
receivedAt = upstream.ReceivedAt,
|
||||
contentHash = upstream.ContentHash,
|
||||
signature = new
|
||||
{
|
||||
present = upstream.Signature.Present,
|
||||
format = upstream.Signature.Format,
|
||||
keyId = upstream.Signature.KeyId,
|
||||
signature = upstream.Signature.Signature
|
||||
},
|
||||
metadata = upstream.Metadata
|
||||
};
|
||||
return JsonSerializer.Serialize(obj);
|
||||
}
|
||||
|
||||
private static VexObservationUpstream DeserializeUpstream(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var upstreamId = root.GetProperty("upstreamId").GetString()!;
|
||||
var documentVersion = root.TryGetProperty("documentVersion", out var dv) && dv.ValueKind == JsonValueKind.String
|
||||
? dv.GetString()
|
||||
: null;
|
||||
var fetchedAt = root.GetProperty("fetchedAt").GetDateTimeOffset();
|
||||
var receivedAt = root.GetProperty("receivedAt").GetDateTimeOffset();
|
||||
var contentHash = root.GetProperty("contentHash").GetString()!;
|
||||
|
||||
var sigElem = root.GetProperty("signature");
|
||||
var signature = new VexObservationSignature(
|
||||
sigElem.GetProperty("present").GetBoolean(),
|
||||
sigElem.TryGetProperty("format", out var f) && f.ValueKind == JsonValueKind.String ? f.GetString() : null,
|
||||
sigElem.TryGetProperty("keyId", out var k) && k.ValueKind == JsonValueKind.String ? k.GetString() : null,
|
||||
sigElem.TryGetProperty("signature", out var s) && s.ValueKind == JsonValueKind.String ? s.GetString() : null);
|
||||
|
||||
var metadata = DeserializeStringDict(root, "metadata");
|
||||
|
||||
return new VexObservationUpstream(upstreamId, documentVersion, fetchedAt, receivedAt, contentHash, signature, metadata);
|
||||
}
|
||||
|
||||
private static string SerializeStatements(ImmutableArray<VexObservationStatement> statements)
|
||||
{
|
||||
var list = statements.Select(s => new
|
||||
{
|
||||
vulnerabilityId = s.VulnerabilityId,
|
||||
productKey = s.ProductKey,
|
||||
status = s.Status.ToString(),
|
||||
lastObserved = s.LastObserved,
|
||||
locator = s.Locator,
|
||||
justification = s.Justification?.ToString(),
|
||||
introducedVersion = s.IntroducedVersion,
|
||||
fixedVersion = s.FixedVersion,
|
||||
purl = s.Purl,
|
||||
cpe = s.Cpe,
|
||||
evidence = s.Evidence.Select(e => e?.ToJsonString()),
|
||||
metadata = s.Metadata
|
||||
}).ToArray();
|
||||
|
||||
return JsonSerializer.Serialize(list);
|
||||
}
|
||||
|
||||
private static ImmutableArray<VexObservationStatement> DeserializeStatements(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var builder = ImmutableArray.CreateBuilder<VexObservationStatement>();
|
||||
|
||||
foreach (var elem in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var vulnId = elem.GetProperty("vulnerabilityId").GetString()!;
|
||||
var productKey = elem.GetProperty("productKey").GetString()!;
|
||||
var statusStr = elem.GetProperty("status").GetString()!;
|
||||
var status = Enum.TryParse<VexClaimStatus>(statusStr, ignoreCase: true, out var st) ? st : VexClaimStatus.Affected;
|
||||
|
||||
DateTimeOffset? lastObserved = null;
|
||||
if (elem.TryGetProperty("lastObserved", out var lo) && lo.ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
lastObserved = lo.GetDateTimeOffset();
|
||||
}
|
||||
|
||||
var locator = GetOptionalString(elem, "locator");
|
||||
VexJustification? justification = null;
|
||||
if (elem.TryGetProperty("justification", out var jElem) && jElem.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var justStr = jElem.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(justStr) && Enum.TryParse<VexJustification>(justStr, ignoreCase: true, out var j))
|
||||
{
|
||||
justification = j;
|
||||
}
|
||||
}
|
||||
|
||||
var introducedVersion = GetOptionalString(elem, "introducedVersion");
|
||||
var fixedVersion = GetOptionalString(elem, "fixedVersion");
|
||||
var purl = GetOptionalString(elem, "purl");
|
||||
var cpe = GetOptionalString(elem, "cpe");
|
||||
|
||||
ImmutableArray<JsonNode>? evidence = null;
|
||||
if (elem.TryGetProperty("evidence", out var evElem) && evElem.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var evBuilder = ImmutableArray.CreateBuilder<JsonNode>();
|
||||
foreach (var evItem in evElem.EnumerateArray())
|
||||
{
|
||||
if (evItem.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var evStr = evItem.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(evStr))
|
||||
{
|
||||
var node = JsonNode.Parse(evStr);
|
||||
if (node is not null)
|
||||
{
|
||||
evBuilder.Add(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (evBuilder.Count > 0)
|
||||
{
|
||||
evidence = evBuilder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
var metadata = DeserializeStringDict(elem, "metadata");
|
||||
|
||||
builder.Add(new VexObservationStatement(
|
||||
vulnId, productKey, status, lastObserved, locator, justification,
|
||||
introducedVersion, fixedVersion, purl, cpe, evidence, metadata));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static string SerializeContent(VexObservationContent content)
|
||||
{
|
||||
var obj = new
|
||||
{
|
||||
format = content.Format,
|
||||
specVersion = content.SpecVersion,
|
||||
raw = content.Raw.ToJsonString(),
|
||||
metadata = content.Metadata
|
||||
};
|
||||
return JsonSerializer.Serialize(obj);
|
||||
}
|
||||
|
||||
private static VexObservationContent DeserializeContent(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var format = root.GetProperty("format").GetString()!;
|
||||
var specVersion = GetOptionalString(root, "specVersion");
|
||||
var rawStr = root.GetProperty("raw").GetString()!;
|
||||
var raw = JsonNode.Parse(rawStr)!;
|
||||
var metadata = DeserializeStringDict(root, "metadata");
|
||||
|
||||
return new VexObservationContent(format, specVersion, raw, metadata);
|
||||
}
|
||||
|
||||
private static string SerializeLinkset(VexObservationLinkset linkset)
|
||||
{
|
||||
var obj = new
|
||||
{
|
||||
aliases = linkset.Aliases.ToArray(),
|
||||
purls = linkset.Purls.ToArray(),
|
||||
cpes = linkset.Cpes.ToArray(),
|
||||
references = linkset.References.Select(r => new { type = r.Type, url = r.Url }).ToArray(),
|
||||
reconciledFrom = linkset.ReconciledFrom.ToArray(),
|
||||
disagreements = linkset.Disagreements.Select(d => new
|
||||
{
|
||||
providerId = d.ProviderId,
|
||||
status = d.Status,
|
||||
justification = d.Justification,
|
||||
confidence = d.Confidence
|
||||
}).ToArray(),
|
||||
observations = linkset.Observations.Select(o => new
|
||||
{
|
||||
observationId = o.ObservationId,
|
||||
providerId = o.ProviderId,
|
||||
status = o.Status,
|
||||
confidence = o.Confidence
|
||||
}).ToArray()
|
||||
};
|
||||
return JsonSerializer.Serialize(obj);
|
||||
}
|
||||
|
||||
private static VexObservationLinkset DeserializeLinkset(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var aliases = GetStringArray(root, "aliases");
|
||||
var purls = GetStringArray(root, "purls");
|
||||
var cpes = GetStringArray(root, "cpes");
|
||||
var reconciledFrom = GetStringArray(root, "reconciledFrom");
|
||||
|
||||
var references = new List<VexObservationReference>();
|
||||
if (root.TryGetProperty("references", out var refsElem) && refsElem.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var refElem in refsElem.EnumerateArray())
|
||||
{
|
||||
var type = refElem.GetProperty("type").GetString()!;
|
||||
var url = refElem.GetProperty("url").GetString()!;
|
||||
references.Add(new VexObservationReference(type, url));
|
||||
}
|
||||
}
|
||||
|
||||
var disagreements = new List<VexObservationDisagreement>();
|
||||
if (root.TryGetProperty("disagreements", out var disElem) && disElem.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var dElem in disElem.EnumerateArray())
|
||||
{
|
||||
var providerId = dElem.GetProperty("providerId").GetString()!;
|
||||
var status = dElem.GetProperty("status").GetString()!;
|
||||
var justification = GetOptionalString(dElem, "justification");
|
||||
double? confidence = null;
|
||||
if (dElem.TryGetProperty("confidence", out var c) && c.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
confidence = c.GetDouble();
|
||||
}
|
||||
|
||||
disagreements.Add(new VexObservationDisagreement(providerId, status, justification, confidence));
|
||||
}
|
||||
}
|
||||
|
||||
var observationRefs = new List<VexLinksetObservationRefModel>();
|
||||
if (root.TryGetProperty("observations", out var obsElem) && obsElem.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var oElem in obsElem.EnumerateArray())
|
||||
{
|
||||
var obsId = oElem.GetProperty("observationId").GetString()!;
|
||||
var providerId = oElem.GetProperty("providerId").GetString()!;
|
||||
var status = oElem.GetProperty("status").GetString()!;
|
||||
double? confidence = null;
|
||||
if (oElem.TryGetProperty("confidence", out var c) && c.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
confidence = c.GetDouble();
|
||||
}
|
||||
|
||||
observationRefs.Add(new VexLinksetObservationRefModel(obsId, providerId, status, confidence));
|
||||
}
|
||||
}
|
||||
|
||||
return new VexObservationLinkset(aliases, purls, cpes, references, reconciledFrom, disagreements, observationRefs);
|
||||
}
|
||||
|
||||
private static string SerializeAttributes(ImmutableDictionary<string, string> attributes)
|
||||
{
|
||||
if (attributes.IsEmpty)
|
||||
{
|
||||
return "{}";
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(attributes);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> DeserializeAttributes(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = property.Value.GetString();
|
||||
if (value is not null)
|
||||
{
|
||||
builder[property.Name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> DeserializeStringDict(JsonElement elem, string propertyName)
|
||||
{
|
||||
if (!elem.TryGetProperty(propertyName, out var prop) || prop.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var p in prop.EnumerateObject())
|
||||
{
|
||||
if (p.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var val = p.Value.GetString();
|
||||
if (val is not null)
|
||||
{
|
||||
builder[p.Name] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static string? GetOptionalString(JsonElement elem, string propertyName)
|
||||
{
|
||||
if (elem.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return prop.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetStringArray(JsonElement elem, string propertyName)
|
||||
{
|
||||
if (!elem.TryGetProperty(propertyName, out var prop) || prop.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Enumerable.Empty<string>();
|
||||
}
|
||||
|
||||
return prop.EnumerateArray()
|
||||
.Where(e => e.ValueKind == JsonValueKind.String)
|
||||
.Select(e => e.GetString()!)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async ValueTask EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
CREATE TABLE IF NOT EXISTS vex.observations (
|
||||
observation_id TEXT NOT NULL,
|
||||
tenant TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
stream_id TEXT NOT NULL,
|
||||
upstream JSONB NOT NULL,
|
||||
statements JSONB NOT NULL DEFAULT '[]',
|
||||
content JSONB NOT NULL,
|
||||
linkset JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
supersedes TEXT[] NOT NULL DEFAULT '{}',
|
||||
attributes JSONB NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (tenant, observation_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_tenant ON vex.observations(tenant);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_provider ON vex.observations(tenant, provider_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_created_at ON vex.observations(tenant, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_statements ON vex.observations USING GIN (statements);
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
_initialized = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Storage;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed provider store for VEX provider registry.
|
||||
/// </summary>
|
||||
public sealed class PostgresVexProviderStore : RepositoryBase<ExcititorDataSource>, IVexProviderStore
|
||||
{
|
||||
private volatile bool _initialized;
|
||||
private readonly SemaphoreSlim _initLock = new(1, 1);
|
||||
|
||||
public PostgresVexProviderStore(ExcititorDataSource dataSource, ILogger<PostgresVexProviderStore> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async ValueTask<VexProvider?> FindAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
SELECT id, display_name, kind, base_uris, discovery, trust, enabled
|
||||
FROM vex.providers
|
||||
WHERE LOWER(id) = LOWER(@id);
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "id", id);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Map(reader);
|
||||
}
|
||||
|
||||
public async ValueTask SaveAsync(VexProvider provider, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
INSERT INTO vex.providers (id, display_name, kind, base_uris, discovery, trust, enabled)
|
||||
VALUES (@id, @display_name, @kind, @base_uris, @discovery, @trust, @enabled)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
kind = EXCLUDED.kind,
|
||||
base_uris = EXCLUDED.base_uris,
|
||||
discovery = EXCLUDED.discovery,
|
||||
trust = EXCLUDED.trust,
|
||||
enabled = EXCLUDED.enabled;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "id", provider.Id);
|
||||
AddParameter(command, "display_name", provider.DisplayName);
|
||||
AddParameter(command, "kind", provider.Kind.ToString().ToLowerInvariant());
|
||||
AddParameter(command, "base_uris", provider.BaseUris.IsDefault ? Array.Empty<string>() : provider.BaseUris.Select(u => u.ToString()).ToArray());
|
||||
AddJsonbParameter(command, "discovery", SerializeDiscovery(provider.Discovery));
|
||||
AddJsonbParameter(command, "trust", SerializeTrust(provider.Trust));
|
||||
AddParameter(command, "enabled", provider.Enabled);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyCollection<VexProvider>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT id, display_name, kind, base_uris, discovery, trust, enabled
|
||||
FROM vex.providers
|
||||
ORDER BY id;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<VexProvider>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(Map(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private VexProvider Map(NpgsqlDataReader reader)
|
||||
{
|
||||
var id = reader.GetString(0);
|
||||
var displayName = reader.GetString(1);
|
||||
var kindStr = reader.GetString(2);
|
||||
var baseUrisArr = reader.IsDBNull(3) ? Array.Empty<string>() : reader.GetFieldValue<string[]>(3);
|
||||
var discoveryJson = reader.IsDBNull(4) ? null : reader.GetFieldValue<string>(4);
|
||||
var trustJson = reader.IsDBNull(5) ? null : reader.GetFieldValue<string>(5);
|
||||
var enabled = reader.IsDBNull(6) || reader.GetBoolean(6);
|
||||
|
||||
var kind = Enum.TryParse<VexProviderKind>(kindStr, ignoreCase: true, out var k) ? k : VexProviderKind.Vendor;
|
||||
var baseUris = baseUrisArr.Select(s => new Uri(s)).ToArray();
|
||||
var discovery = DeserializeDiscovery(discoveryJson);
|
||||
var trust = DeserializeTrust(trustJson);
|
||||
|
||||
return new VexProvider(id, displayName, kind, baseUris, discovery, trust, enabled);
|
||||
}
|
||||
|
||||
private static string SerializeDiscovery(VexProviderDiscovery discovery)
|
||||
{
|
||||
var obj = new
|
||||
{
|
||||
wellKnownMetadata = discovery.WellKnownMetadata?.ToString(),
|
||||
rolieService = discovery.RolIeService?.ToString()
|
||||
};
|
||||
return JsonSerializer.Serialize(obj);
|
||||
}
|
||||
|
||||
private static VexProviderDiscovery DeserializeDiscovery(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return VexProviderDiscovery.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Uri? wellKnown = null;
|
||||
Uri? rolie = null;
|
||||
|
||||
if (root.TryGetProperty("wellKnownMetadata", out var wkProp) && wkProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var wkStr = wkProp.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(wkStr))
|
||||
{
|
||||
wellKnown = new Uri(wkStr);
|
||||
}
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("rolieService", out var rsProp) && rsProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var rsStr = rsProp.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(rsStr))
|
||||
{
|
||||
rolie = new Uri(rsStr);
|
||||
}
|
||||
}
|
||||
|
||||
return new VexProviderDiscovery(wellKnown, rolie);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return VexProviderDiscovery.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeTrust(VexProviderTrust trust)
|
||||
{
|
||||
var obj = new
|
||||
{
|
||||
weight = trust.Weight,
|
||||
cosign = trust.Cosign is null ? null : new { issuer = trust.Cosign.Issuer, identityPattern = trust.Cosign.IdentityPattern },
|
||||
pgpFingerprints = trust.PgpFingerprints.IsDefault ? Array.Empty<string>() : trust.PgpFingerprints.ToArray()
|
||||
};
|
||||
return JsonSerializer.Serialize(obj);
|
||||
}
|
||||
|
||||
private static VexProviderTrust DeserializeTrust(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return VexProviderTrust.Default;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var weight = 1.0;
|
||||
if (root.TryGetProperty("weight", out var wProp) && wProp.TryGetDouble(out var w))
|
||||
{
|
||||
weight = w;
|
||||
}
|
||||
|
||||
VexCosignTrust? cosign = null;
|
||||
if (root.TryGetProperty("cosign", out var cProp) && cProp.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var issuer = cProp.TryGetProperty("issuer", out var iProp) ? iProp.GetString() : null;
|
||||
var pattern = cProp.TryGetProperty("identityPattern", out var pProp) ? pProp.GetString() : null;
|
||||
if (!string.IsNullOrWhiteSpace(issuer) && !string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
cosign = new VexCosignTrust(issuer, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerable<string>? fingerprints = null;
|
||||
if (root.TryGetProperty("pgpFingerprints", out var fProp) && fProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
fingerprints = fProp.EnumerateArray()
|
||||
.Where(e => e.ValueKind == JsonValueKind.String)
|
||||
.Select(e => e.GetString()!)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
}
|
||||
|
||||
return new VexProviderTrust(weight, cosign, fingerprints);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return VexProviderTrust.Default;
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
CREATE TABLE IF NOT EXISTS vex.providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('vendor', 'distro', 'hub', 'platform', 'attestation')),
|
||||
base_uris TEXT[] NOT NULL DEFAULT '{}',
|
||||
discovery JSONB NOT NULL DEFAULT '{}',
|
||||
trust JSONB NOT NULL DEFAULT '{}',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_providers_kind ON vex.providers(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_providers_enabled ON vex.providers(enabled) WHERE enabled = TRUE;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
_initialized = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed store for VEX timeline events.
|
||||
/// </summary>
|
||||
public sealed class PostgresVexTimelineEventStore : RepositoryBase<ExcititorDataSource>, IVexTimelineEventStore
|
||||
{
|
||||
private volatile bool _initialized;
|
||||
private readonly SemaphoreSlim _initLock = new(1, 1);
|
||||
|
||||
public PostgresVexTimelineEventStore(ExcititorDataSource dataSource, ILogger<PostgresVexTimelineEventStore> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async ValueTask<string> InsertAsync(TimelineEvent evt, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
INSERT INTO vex.timeline_events (
|
||||
event_id, tenant, provider_id, stream_id, event_type, trace_id,
|
||||
justification_summary, evidence_hash, payload_hash, created_at, attributes
|
||||
)
|
||||
VALUES (
|
||||
@event_id, @tenant, @provider_id, @stream_id, @event_type, @trace_id,
|
||||
@justification_summary, @evidence_hash, @payload_hash, @created_at, @attributes
|
||||
)
|
||||
ON CONFLICT (tenant, event_id) DO NOTHING
|
||||
RETURNING event_id;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "event_id", evt.EventId);
|
||||
AddParameter(command, "tenant", evt.Tenant);
|
||||
AddParameter(command, "provider_id", evt.ProviderId);
|
||||
AddParameter(command, "stream_id", evt.StreamId);
|
||||
AddParameter(command, "event_type", evt.EventType);
|
||||
AddParameter(command, "trace_id", evt.TraceId);
|
||||
AddParameter(command, "justification_summary", evt.JustificationSummary);
|
||||
AddParameter(command, "evidence_hash", (object?)evt.EvidenceHash ?? DBNull.Value);
|
||||
AddParameter(command, "payload_hash", (object?)evt.PayloadHash ?? DBNull.Value);
|
||||
AddParameter(command, "created_at", evt.CreatedAt);
|
||||
AddJsonbParameter(command, "attributes", SerializeAttributes(evt.Attributes));
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result?.ToString() ?? evt.EventId;
|
||||
}
|
||||
|
||||
public async ValueTask<int> InsertManyAsync(string tenant, IEnumerable<TimelineEvent> events, CancellationToken cancellationToken)
|
||||
{
|
||||
if (events is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var eventsList = events.Where(e => string.Equals(e.Tenant, tenant, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
if (eventsList.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var count = 0;
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var evt in eventsList)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO vex.timeline_events (
|
||||
event_id, tenant, provider_id, stream_id, event_type, trace_id,
|
||||
justification_summary, evidence_hash, payload_hash, created_at, attributes
|
||||
)
|
||||
VALUES (
|
||||
@event_id, @tenant, @provider_id, @stream_id, @event_type, @trace_id,
|
||||
@justification_summary, @evidence_hash, @payload_hash, @created_at, @attributes
|
||||
)
|
||||
ON CONFLICT (tenant, event_id) DO NOTHING;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "event_id", evt.EventId);
|
||||
AddParameter(command, "tenant", evt.Tenant);
|
||||
AddParameter(command, "provider_id", evt.ProviderId);
|
||||
AddParameter(command, "stream_id", evt.StreamId);
|
||||
AddParameter(command, "event_type", evt.EventType);
|
||||
AddParameter(command, "trace_id", evt.TraceId);
|
||||
AddParameter(command, "justification_summary", evt.JustificationSummary);
|
||||
AddParameter(command, "evidence_hash", (object?)evt.EvidenceHash ?? DBNull.Value);
|
||||
AddParameter(command, "payload_hash", (object?)evt.PayloadHash ?? DBNull.Value);
|
||||
AddParameter(command, "created_at", evt.CreatedAt);
|
||||
AddJsonbParameter(command, "attributes", SerializeAttributes(evt.Attributes));
|
||||
|
||||
var affected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (affected > 0)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<TimelineEvent>> FindByTimeRangeAsync(
|
||||
string tenant,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
SELECT event_id, tenant, provider_id, stream_id, event_type, trace_id,
|
||||
justification_summary, evidence_hash, payload_hash, created_at, attributes
|
||||
FROM vex.timeline_events
|
||||
WHERE LOWER(tenant) = LOWER(@tenant)
|
||||
AND created_at >= @from
|
||||
AND created_at <= @to
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant);
|
||||
AddParameter(command, "from", from);
|
||||
AddParameter(command, "to", to);
|
||||
AddParameter(command, "limit", limit);
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<TimelineEvent>> FindByTraceIdAsync(
|
||||
string tenant,
|
||||
string traceId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
SELECT event_id, tenant, provider_id, stream_id, event_type, trace_id,
|
||||
justification_summary, evidence_hash, payload_hash, created_at, attributes
|
||||
FROM vex.timeline_events
|
||||
WHERE LOWER(tenant) = LOWER(@tenant) AND trace_id = @trace_id
|
||||
ORDER BY created_at DESC;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant);
|
||||
AddParameter(command, "trace_id", traceId);
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<TimelineEvent>> FindByProviderAsync(
|
||||
string tenant,
|
||||
string providerId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
SELECT event_id, tenant, provider_id, stream_id, event_type, trace_id,
|
||||
justification_summary, evidence_hash, payload_hash, created_at, attributes
|
||||
FROM vex.timeline_events
|
||||
WHERE LOWER(tenant) = LOWER(@tenant) AND LOWER(provider_id) = LOWER(@provider_id)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant);
|
||||
AddParameter(command, "provider_id", providerId);
|
||||
AddParameter(command, "limit", limit);
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<TimelineEvent>> FindByEventTypeAsync(
|
||||
string tenant,
|
||||
string eventType,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
SELECT event_id, tenant, provider_id, stream_id, event_type, trace_id,
|
||||
justification_summary, evidence_hash, payload_hash, created_at, attributes
|
||||
FROM vex.timeline_events
|
||||
WHERE LOWER(tenant) = LOWER(@tenant) AND LOWER(event_type) = LOWER(@event_type)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant);
|
||||
AddParameter(command, "event_type", eventType);
|
||||
AddParameter(command, "limit", limit);
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<TimelineEvent>> GetRecentAsync(
|
||||
string tenant,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
SELECT event_id, tenant, provider_id, stream_id, event_type, trace_id,
|
||||
justification_summary, evidence_hash, payload_hash, created_at, attributes
|
||||
FROM vex.timeline_events
|
||||
WHERE LOWER(tenant) = LOWER(@tenant)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant);
|
||||
AddParameter(command, "limit", limit);
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<TimelineEvent?> GetByIdAsync(
|
||||
string tenant,
|
||||
string eventId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
SELECT event_id, tenant, provider_id, stream_id, event_type, trace_id,
|
||||
justification_summary, evidence_hash, payload_hash, created_at, attributes
|
||||
FROM vex.timeline_events
|
||||
WHERE LOWER(tenant) = LOWER(@tenant) AND event_id = @event_id;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant);
|
||||
AddParameter(command, "event_id", eventId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Map(reader);
|
||||
}
|
||||
|
||||
public async ValueTask<long> CountAsync(string tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = "SELECT COUNT(*) FROM vex.timeline_events WHERE LOWER(tenant) = LOWER(@tenant);";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt64(result);
|
||||
}
|
||||
|
||||
public async ValueTask<long> CountInRangeAsync(
|
||||
string tenant,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
SELECT COUNT(*)
|
||||
FROM vex.timeline_events
|
||||
WHERE LOWER(tenant) = LOWER(@tenant)
|
||||
AND created_at >= @from
|
||||
AND created_at <= @to;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant", tenant);
|
||||
AddParameter(command, "from", from);
|
||||
AddParameter(command, "to", to);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt64(result);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<TimelineEvent>> ExecuteQueryAsync(NpgsqlCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<TimelineEvent>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(Map(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static TimelineEvent Map(NpgsqlDataReader reader)
|
||||
{
|
||||
var eventId = reader.GetString(0);
|
||||
var tenant = reader.GetString(1);
|
||||
var providerId = reader.GetString(2);
|
||||
var streamId = reader.GetString(3);
|
||||
var eventType = reader.GetString(4);
|
||||
var traceId = reader.GetString(5);
|
||||
var justificationSummary = reader.GetString(6);
|
||||
var evidenceHash = reader.IsDBNull(7) ? null : reader.GetString(7);
|
||||
var payloadHash = reader.IsDBNull(8) ? null : reader.GetString(8);
|
||||
var createdAt = reader.GetFieldValue<DateTimeOffset>(9);
|
||||
var attributesJson = reader.IsDBNull(10) ? null : reader.GetFieldValue<string>(10);
|
||||
|
||||
var attributes = DeserializeAttributes(attributesJson);
|
||||
|
||||
return new TimelineEvent(
|
||||
eventId,
|
||||
tenant,
|
||||
providerId,
|
||||
streamId,
|
||||
eventType,
|
||||
traceId,
|
||||
justificationSummary,
|
||||
createdAt,
|
||||
evidenceHash,
|
||||
payloadHash,
|
||||
attributes);
|
||||
}
|
||||
|
||||
private static string SerializeAttributes(ImmutableDictionary<string, string> attributes)
|
||||
{
|
||||
if (attributes.IsEmpty)
|
||||
{
|
||||
return "{}";
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(attributes);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> DeserializeAttributes(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = property.Value.GetString();
|
||||
if (value is not null)
|
||||
{
|
||||
builder[property.Name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
CREATE TABLE IF NOT EXISTS vex.timeline_events (
|
||||
event_id TEXT NOT NULL,
|
||||
tenant TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
stream_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
trace_id TEXT NOT NULL,
|
||||
justification_summary TEXT NOT NULL DEFAULT '',
|
||||
evidence_hash TEXT,
|
||||
payload_hash TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
attributes JSONB NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (tenant, event_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_timeline_events_tenant ON vex.timeline_events(tenant);
|
||||
CREATE INDEX IF NOT EXISTS idx_timeline_events_trace_id ON vex.timeline_events(tenant, trace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_timeline_events_provider ON vex.timeline_events(tenant, provider_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_timeline_events_type ON vex.timeline_events(tenant, event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_timeline_events_created_at ON vex.timeline_events(tenant, created_at DESC);
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
_initialized = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Excititor.Core.Evidence;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.Core.Storage;
|
||||
using StellaOps.Excititor.Storage.Postgres.Repositories;
|
||||
@@ -39,6 +40,12 @@ public static class ServiceCollectionExtensions
|
||||
// Register append-only checkpoint store for deterministic persistence (EXCITITOR-ORCH-32/33)
|
||||
services.AddScoped<IAppendOnlyCheckpointStore, PostgresAppendOnlyCheckpointStore>();
|
||||
|
||||
// Register VEX auxiliary stores (SPRINT-3412: PostgreSQL durability)
|
||||
services.AddScoped<IVexProviderStore, PostgresVexProviderStore>();
|
||||
services.AddScoped<IVexObservationStore, PostgresVexObservationStore>();
|
||||
services.AddScoped<IVexAttestationStore, PostgresVexAttestationStore>();
|
||||
services.AddScoped<IVexTimelineEventStore, PostgresVexTimelineEventStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -65,6 +72,12 @@ public static class ServiceCollectionExtensions
|
||||
// Register append-only checkpoint store for deterministic persistence (EXCITITOR-ORCH-32/33)
|
||||
services.AddScoped<IAppendOnlyCheckpointStore, PostgresAppendOnlyCheckpointStore>();
|
||||
|
||||
// Register VEX auxiliary stores (SPRINT-3412: PostgreSQL durability)
|
||||
services.AddScoped<IVexProviderStore, PostgresVexProviderStore>();
|
||||
services.AddScoped<IVexObservationStore, PostgresVexObservationStore>();
|
||||
services.AddScoped<IVexAttestationStore, PostgresVexAttestationStore>();
|
||||
services.AddScoped<IVexTimelineEventStore, PostgresVexTimelineEventStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Gateway.WebService.OpenApi;
|
||||
|
||||
namespace StellaOps.Gateway.WebService;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring the gateway middleware pipeline.
|
||||
/// </summary>
|
||||
public static class ApplicationBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the gateway router middleware pipeline.
|
||||
/// </summary>
|
||||
/// <param name="app">The application builder.</param>
|
||||
/// <returns>The application builder for chaining.</returns>
|
||||
public static IApplicationBuilder UseGatewayRouter(this IApplicationBuilder app)
|
||||
{
|
||||
// Resolve endpoints from routing state
|
||||
app.UseMiddleware<EndpointResolutionMiddleware>();
|
||||
|
||||
// Make routing decisions (select instance)
|
||||
app.UseMiddleware<RoutingDecisionMiddleware>();
|
||||
|
||||
// Dispatch to transport and return response
|
||||
app.UseMiddleware<TransportDispatchMiddleware>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps OpenAPI endpoints to the application.
|
||||
/// Should be called before UseGatewayRouter so OpenAPI requests are handled first.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint route builder.</param>
|
||||
/// <returns>The endpoint route builder for chaining.</returns>
|
||||
public static IEndpointRouteBuilder MapGatewayOpenApi(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
return endpoints.MapGatewayOpenApiEndpoints();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using StellaOps.Gateway.WebService;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register gateway routing services
|
||||
builder.Services.AddGatewayRouting(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Health check endpoint (not routed through gateway middleware)
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
|
||||
|
||||
// Gateway router middleware pipeline
|
||||
// All other requests are routed through the gateway
|
||||
app.UseGatewayRouter();
|
||||
|
||||
app.Run();
|
||||
|
||||
// Make Program class accessible for integration tests
|
||||
public partial class Program { }
|
||||
@@ -1,20 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="YamlDotNet" Version="16.2.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Config\StellaOps.Router.Config.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Gateway.WebService.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,270 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AuthorityClaimsRefreshService"/>.
|
||||
/// </summary>
|
||||
public sealed class AuthorityClaimsRefreshServiceTests
|
||||
{
|
||||
private readonly Mock<IAuthorityClaimsProvider> _claimsProviderMock;
|
||||
private readonly Mock<IEffectiveClaimsStore> _claimsStoreMock;
|
||||
private readonly AuthorityConnectionOptions _options;
|
||||
|
||||
public AuthorityClaimsRefreshServiceTests()
|
||||
{
|
||||
_claimsProviderMock = new Mock<IAuthorityClaimsProvider>();
|
||||
_claimsStoreMock = new Mock<IEffectiveClaimsStore>();
|
||||
_options = new AuthorityConnectionOptions
|
||||
{
|
||||
AuthorityUrl = "http://authority.local",
|
||||
Enabled = true,
|
||||
RefreshInterval = TimeSpan.FromMilliseconds(100),
|
||||
WaitForAuthorityOnStartup = false,
|
||||
StartupTimeout = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
|
||||
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>());
|
||||
}
|
||||
|
||||
private AuthorityClaimsRefreshService CreateService()
|
||||
{
|
||||
return new AuthorityClaimsRefreshService(
|
||||
_claimsProviderMock.Object,
|
||||
_claimsStoreMock.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<AuthorityClaimsRefreshService>.Instance);
|
||||
}
|
||||
|
||||
#region ExecuteAsync Tests - Disabled
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenDisabled_DoesNotFetchClaims()
|
||||
{
|
||||
// Arrange
|
||||
_options.Enabled = false;
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await service.StopAsync(cts.Token);
|
||||
|
||||
// Assert
|
||||
_claimsProviderMock.Verify(
|
||||
p => p.GetOverridesAsync(It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenNoAuthorityUrl_DoesNotFetchClaims()
|
||||
{
|
||||
// Arrange
|
||||
_options.AuthorityUrl = string.Empty;
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await service.StopAsync(cts.Token);
|
||||
|
||||
// Assert
|
||||
_claimsProviderMock.Verify(
|
||||
p => p.GetOverridesAsync(It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync Tests - Enabled
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenEnabled_FetchesClaims()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_claimsProviderMock.Verify(
|
||||
p => p.GetOverridesAsync(It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_UpdatesStoreWithOverrides()
|
||||
{
|
||||
// Arrange
|
||||
var key = EndpointKey.Create("service", "GET", "/api/test");
|
||||
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key] = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
};
|
||||
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(overrides);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_claimsStoreMock.Verify(
|
||||
s => s.UpdateFromAuthority(It.Is<IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>>(
|
||||
d => d.ContainsKey(key))),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync Tests - Wait for Authority
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WaitForAuthority_FetchesOnStartup()
|
||||
{
|
||||
// Arrange
|
||||
_options.WaitForAuthorityOnStartup = true;
|
||||
_options.StartupTimeout = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
// Authority is immediately available
|
||||
_claimsProviderMock.Setup(p => p.IsAvailable).Returns(true);
|
||||
|
||||
var fetchCalled = false;
|
||||
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
|
||||
.Callback(() => fetchCalled = true)
|
||||
.ReturnsAsync(new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>());
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - fetch was called during startup
|
||||
fetchCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WaitForAuthority_StopsAfterTimeout()
|
||||
{
|
||||
// Arrange
|
||||
_options.WaitForAuthorityOnStartup = true;
|
||||
_options.StartupTimeout = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
_claimsProviderMock.Setup(p => p.IsAvailable).Returns(false);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act - should not block forever
|
||||
var startTask = service.StartAsync(cts.Token);
|
||||
await Task.Delay(300);
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - should complete even if Authority never becomes available
|
||||
startTask.IsCompleted.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Push Notification Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithPushNotifications_SubscribesToEvent()
|
||||
{
|
||||
// Arrange
|
||||
_options.UseAuthorityPushNotifications = true;
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - verify event subscription by checking it doesn't throw
|
||||
_claimsProviderMock.VerifyAdd(
|
||||
p => p.OverridesChanged += It.IsAny<EventHandler<ClaimsOverrideChangedEventArgs>>(),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_WithPushNotifications_UnsubscribesFromEvent()
|
||||
{
|
||||
// Arrange
|
||||
_options.UseAuthorityPushNotifications = true;
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
|
||||
// Act
|
||||
await cts.CancelAsync();
|
||||
service.Dispose();
|
||||
|
||||
// Assert
|
||||
_claimsProviderMock.VerifyRemove(
|
||||
p => p.OverridesChanged -= It.IsAny<EventHandler<ClaimsOverrideChangedEventArgs>>(),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ProviderThrows_ContinuesRefreshLoop()
|
||||
{
|
||||
// Arrange
|
||||
var callCount = 0;
|
||||
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
throw new HttpRequestException("Test error");
|
||||
}
|
||||
return new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>();
|
||||
});
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(250); // Wait for at least 2 refresh cycles
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - should have continued after error
|
||||
callCount.Should().BeGreaterThan(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AuthorizationMiddleware"/>.
|
||||
/// </summary>
|
||||
public sealed class AuthorizationMiddlewareTests
|
||||
{
|
||||
private readonly Mock<IEffectiveClaimsStore> _claimsStoreMock;
|
||||
private readonly Mock<RequestDelegate> _nextMock;
|
||||
private bool _nextCalled;
|
||||
|
||||
public AuthorizationMiddlewareTests()
|
||||
{
|
||||
_claimsStoreMock = new Mock<IEffectiveClaimsStore>();
|
||||
_nextMock = new Mock<RequestDelegate>();
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.Callback(() => _nextCalled = true)
|
||||
.Returns(Task.CompletedTask);
|
||||
}
|
||||
|
||||
private AuthorizationMiddleware CreateMiddleware()
|
||||
{
|
||||
return new AuthorizationMiddleware(
|
||||
_nextMock.Object,
|
||||
_claimsStoreMock.Object,
|
||||
NullLogger<AuthorizationMiddleware>.Instance);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext(
|
||||
EndpointDescriptor? endpoint = null,
|
||||
ClaimsPrincipal? user = null)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
if (endpoint is not null)
|
||||
{
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor] = endpoint;
|
||||
}
|
||||
|
||||
if (user is not null)
|
||||
{
|
||||
context.User = user;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(
|
||||
string serviceName = "test-service",
|
||||
string method = "GET",
|
||||
string path = "/api/test",
|
||||
ClaimRequirement[]? claims = null)
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
ServiceName = serviceName,
|
||||
Version = "1.0.0",
|
||||
Method = method,
|
||||
Path = path,
|
||||
RequiringClaims = claims ?? []
|
||||
};
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreateUserWithClaims(params (string Type, string Value)[] claims)
|
||||
{
|
||||
var identity = new ClaimsIdentity(
|
||||
claims.Select(c => new Claim(c.Type, c.Value)),
|
||||
"TestAuth");
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
#region No Endpoint Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithNoEndpoint_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(endpoint: null);
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Empty Claims Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithEmptyRequiringClaims_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>());
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Matching Claims Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMatchingClaims_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(("role", "admin"));
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithClaimTypeOnly_MatchesAnyValue()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(("role", "any-value"));
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = null } // Any value matches
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMultipleMatchingClaims_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(
|
||||
("role", "admin"),
|
||||
("department", "engineering"),
|
||||
("level", "senior"));
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" },
|
||||
new() { Type = "department", Value = "engineering" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Missing Claims Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMissingClaim_Returns403()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(("role", "user")); // Has role, but wrong value
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMissingClaimType_Returns403()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(("department", "engineering"));
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithNoClaims_Returns403()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(); // No claims at all
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithPartialMatchingClaims_Returns403()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(("role", "admin")); // Has one, missing another
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" },
|
||||
new() { Type = "department", Value = "engineering" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Response Body Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMissingClaim_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims();
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
context.Response.ContentType.Should().StartWith("application/json");
|
||||
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Forbidden");
|
||||
responseBody.Should().Contain("role");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Microservice;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
public class CancellationTests
|
||||
{
|
||||
private readonly InMemoryConnectionRegistry _registry = new();
|
||||
private readonly InMemoryTransportOptions _options = new() { SimulatedLatency = TimeSpan.Zero };
|
||||
|
||||
private InMemoryTransportClient CreateClient()
|
||||
{
|
||||
return new InMemoryTransportClient(
|
||||
_registry,
|
||||
Options.Create(_options),
|
||||
NullLogger<InMemoryTransportClient>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelReasons_HasAllExpectedConstants()
|
||||
{
|
||||
Assert.Equal("ClientDisconnected", CancelReasons.ClientDisconnected);
|
||||
Assert.Equal("Timeout", CancelReasons.Timeout);
|
||||
Assert.Equal("PayloadLimitExceeded", CancelReasons.PayloadLimitExceeded);
|
||||
Assert.Equal("Shutdown", CancelReasons.Shutdown);
|
||||
Assert.Equal("ConnectionClosed", CancelReasons.ConnectionClosed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_RegistersWithRegistry()
|
||||
{
|
||||
// Arrange
|
||||
using var client = CreateClient();
|
||||
var instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "test-instance",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "us-east-1"
|
||||
};
|
||||
|
||||
// Act
|
||||
await client.ConnectAsync(instance, [], CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var connectionIdField = client.GetType()
|
||||
.GetField("_connectionId", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
var connectionId = connectionIdField?.GetValue(client)?.ToString();
|
||||
Assert.NotNull(connectionId);
|
||||
|
||||
var channel = _registry.GetChannel(connectionId!);
|
||||
Assert.NotNull(channel);
|
||||
Assert.Equal(instance.InstanceId, channel!.Instance?.InstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelAllInflight_DoesNotThrowWhenEmpty()
|
||||
{
|
||||
// Arrange
|
||||
using var client = CreateClient();
|
||||
|
||||
// Act & Assert - should not throw
|
||||
client.CancelAllInflight(CancelReasons.Shutdown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateClient();
|
||||
|
||||
// Act & Assert - should not throw
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectAsync_CancelsAllInflightWithShutdownReason()
|
||||
{
|
||||
// Arrange
|
||||
using var client = CreateClient();
|
||||
var instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "test-instance",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "us-east-1"
|
||||
};
|
||||
|
||||
await client.ConnectAsync(instance, [], CancellationToken.None);
|
||||
|
||||
// Act
|
||||
await client.DisconnectAsync();
|
||||
|
||||
// Assert - no exception means success
|
||||
}
|
||||
}
|
||||
|
||||
public class InflightRequestTrackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Track_ReturnsCancellationToken()
|
||||
{
|
||||
// Arrange
|
||||
using var tracker = new InflightRequestTracker(
|
||||
NullLogger<InflightRequestTracker>.Instance);
|
||||
var correlationId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var token = tracker.Track(correlationId);
|
||||
|
||||
// Assert
|
||||
Assert.False(token.IsCancellationRequested);
|
||||
Assert.Equal(1, tracker.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_ThrowsIfAlreadyTracked()
|
||||
{
|
||||
// Arrange
|
||||
using var tracker = new InflightRequestTracker(
|
||||
NullLogger<InflightRequestTracker>.Instance);
|
||||
var correlationId = Guid.NewGuid();
|
||||
tracker.Track(correlationId);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() => tracker.Track(correlationId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_TriggersCancellationToken()
|
||||
{
|
||||
// Arrange
|
||||
using var tracker = new InflightRequestTracker(
|
||||
NullLogger<InflightRequestTracker>.Instance);
|
||||
var correlationId = Guid.NewGuid();
|
||||
var token = tracker.Track(correlationId);
|
||||
|
||||
// Act
|
||||
var result = tracker.Cancel(correlationId, "TestReason");
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.True(token.IsCancellationRequested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_ReturnsFalseForUnknownRequest()
|
||||
{
|
||||
// Arrange
|
||||
using var tracker = new InflightRequestTracker(
|
||||
NullLogger<InflightRequestTracker>.Instance);
|
||||
var correlationId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var result = tracker.Cancel(correlationId, "TestReason");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_RemovesFromTracking()
|
||||
{
|
||||
// Arrange
|
||||
using var tracker = new InflightRequestTracker(
|
||||
NullLogger<InflightRequestTracker>.Instance);
|
||||
var correlationId = Guid.NewGuid();
|
||||
tracker.Track(correlationId);
|
||||
Assert.Equal(1, tracker.Count);
|
||||
|
||||
// Act
|
||||
tracker.Complete(correlationId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, tracker.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelAll_CancelsAllTrackedRequests()
|
||||
{
|
||||
// Arrange
|
||||
using var tracker = new InflightRequestTracker(
|
||||
NullLogger<InflightRequestTracker>.Instance);
|
||||
|
||||
var tokens = new List<CancellationToken>();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
tokens.Add(tracker.Track(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
// Act
|
||||
tracker.CancelAll("TestReason");
|
||||
|
||||
// Assert
|
||||
Assert.All(tokens, t => Assert.True(t.IsCancellationRequested));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_CancelsAllTrackedRequests()
|
||||
{
|
||||
// Arrange
|
||||
var tracker = new InflightRequestTracker(
|
||||
NullLogger<InflightRequestTracker>.Instance);
|
||||
|
||||
var tokens = new List<CancellationToken>();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
tokens.Add(tracker.Track(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
// Act
|
||||
tracker.Dispose();
|
||||
|
||||
// Assert
|
||||
Assert.All(tokens, t => Assert.True(t.IsCancellationRequested));
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration-style tests for <see cref="ConnectionManager"/>.
|
||||
/// Uses real InMemoryTransportServer since it's a sealed class.
|
||||
/// </summary>
|
||||
public sealed class ConnectionManagerTests : IAsyncLifetime
|
||||
{
|
||||
private readonly InMemoryConnectionRegistry _connectionRegistry;
|
||||
private readonly InMemoryTransportServer _transportServer;
|
||||
private readonly Mock<IGlobalRoutingState> _routingStateMock;
|
||||
private readonly ConnectionManager _manager;
|
||||
|
||||
public ConnectionManagerTests()
|
||||
{
|
||||
_connectionRegistry = new InMemoryConnectionRegistry();
|
||||
|
||||
var options = Options.Create(new InMemoryTransportOptions());
|
||||
_transportServer = new InMemoryTransportServer(
|
||||
_connectionRegistry,
|
||||
options,
|
||||
NullLogger<InMemoryTransportServer>.Instance);
|
||||
|
||||
_routingStateMock = new Mock<IGlobalRoutingState>(MockBehavior.Loose);
|
||||
|
||||
_manager = new ConnectionManager(
|
||||
_transportServer,
|
||||
_connectionRegistry,
|
||||
_routingStateMock.Object,
|
||||
NullLogger<ConnectionManager>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _manager.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _manager.StopAsync(CancellationToken.None);
|
||||
_transportServer.Dispose();
|
||||
}
|
||||
|
||||
#region StartAsync/StopAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_ShouldStartSuccessfully()
|
||||
{
|
||||
// The manager starts in InitializeAsync
|
||||
// Just verify it can be started without exception
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_ShouldStopSuccessfully()
|
||||
{
|
||||
// This is tested in DisposeAsync
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Connection Registration Tests via Channel Simulation
|
||||
|
||||
[Fact]
|
||||
public async Task WhenHelloReceived_AddsConnectionToRoutingState()
|
||||
{
|
||||
// Arrange
|
||||
var channel = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
|
||||
|
||||
// Simulate sending a HELLO frame through the channel
|
||||
var helloFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
CorrelationId = Guid.NewGuid().ToString()
|
||||
};
|
||||
|
||||
// Act
|
||||
await channel.ToGateway.Writer.WriteAsync(helloFrame);
|
||||
|
||||
// Give time for the frame to be processed
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(
|
||||
s => s.AddConnection(It.Is<ConnectionState>(c => c.ConnectionId == "conn-1")),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WhenHeartbeatReceived_UpdatesConnectionState()
|
||||
{
|
||||
// Arrange
|
||||
var channel = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
|
||||
|
||||
// First send HELLO to register the connection
|
||||
var helloFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
CorrelationId = Guid.NewGuid().ToString()
|
||||
};
|
||||
await channel.ToGateway.Writer.WriteAsync(helloFrame);
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act - send heartbeat
|
||||
var heartbeatFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Heartbeat,
|
||||
CorrelationId = Guid.NewGuid().ToString()
|
||||
};
|
||||
await channel.ToGateway.Writer.WriteAsync(heartbeatFrame);
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(
|
||||
s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WhenConnectionClosed_RemovesConnectionFromRoutingState()
|
||||
{
|
||||
// Arrange
|
||||
var channel = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
|
||||
|
||||
// First send HELLO to register the connection
|
||||
var helloFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
CorrelationId = Guid.NewGuid().ToString()
|
||||
};
|
||||
await channel.ToGateway.Writer.WriteAsync(helloFrame);
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act - close the channel
|
||||
await channel.LifetimeToken.CancelAsync();
|
||||
|
||||
// Give time for the close to be processed
|
||||
await Task.Delay(200);
|
||||
|
||||
// Assert - may be called multiple times (on close and on stop)
|
||||
_routingStateMock.Verify(
|
||||
s => s.RemoveConnection("conn-1"),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WhenMultipleConnectionsRegister_AllAreTracked()
|
||||
{
|
||||
// Arrange
|
||||
var channel1 = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
|
||||
var channel2 = CreateAndRegisterChannel("conn-2", "service-b", "2.0.0");
|
||||
|
||||
// Act - send HELLO frames
|
||||
await channel1.ToGateway.Writer.WriteAsync(new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
CorrelationId = Guid.NewGuid().ToString()
|
||||
});
|
||||
await channel2.ToGateway.Writer.WriteAsync(new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
CorrelationId = Guid.NewGuid().ToString()
|
||||
});
|
||||
await Task.Delay(150);
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(
|
||||
s => s.AddConnection(It.Is<ConnectionState>(c => c.ConnectionId == "conn-1")),
|
||||
Times.Once);
|
||||
_routingStateMock.Verify(
|
||||
s => s.AddConnection(It.Is<ConnectionState>(c => c.ConnectionId == "conn-2")),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private InMemoryChannel CreateAndRegisterChannel(
|
||||
string connectionId, string serviceName, string version)
|
||||
{
|
||||
var instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"{serviceName}-{Guid.NewGuid():N}",
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = "us-east-1"
|
||||
};
|
||||
|
||||
// Create channel through the registry
|
||||
var channel = _connectionRegistry.CreateChannel(connectionId);
|
||||
channel.Instance = instance;
|
||||
|
||||
// Simulate that the transport server is listening to this connection
|
||||
_transportServer.StartListeningToConnection(connectionId);
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
public class DefaultRoutingPluginTests
|
||||
{
|
||||
private readonly RoutingOptions _options = new()
|
||||
{
|
||||
DefaultVersion = null,
|
||||
StrictVersionMatching = true,
|
||||
RoutingTimeoutMs = 30000,
|
||||
PreferLocalRegion = true,
|
||||
AllowDegradedInstances = true,
|
||||
TieBreaker = TieBreakerMode.Random,
|
||||
PingToleranceMs = 0.1
|
||||
};
|
||||
|
||||
private readonly GatewayNodeConfig _gatewayConfig = new()
|
||||
{
|
||||
Region = "us-east-1",
|
||||
NodeId = "gw-test-01",
|
||||
Environment = "test",
|
||||
NeighborRegions = ["eu-west-1", "us-west-2"]
|
||||
};
|
||||
|
||||
private DefaultRoutingPlugin CreateSut(
|
||||
Action<RoutingOptions>? configureOptions = null,
|
||||
Action<GatewayNodeConfig>? configureGateway = null)
|
||||
{
|
||||
configureOptions?.Invoke(_options);
|
||||
configureGateway?.Invoke(_gatewayConfig);
|
||||
return new DefaultRoutingPlugin(
|
||||
Options.Create(_options),
|
||||
Options.Create(_gatewayConfig));
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId = "conn-1",
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
string region = "us-east-1",
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
|
||||
double averagePingMs = 0,
|
||||
DateTime? lastHeartbeatUtc = null)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"inst-{connectionId}",
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = region
|
||||
},
|
||||
Status = status,
|
||||
TransportType = TransportType.InMemory,
|
||||
AveragePingMs = averagePingMs,
|
||||
LastHeartbeatUtc = lastHeartbeatUtc ?? DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(
|
||||
string method = "GET",
|
||||
string path = "/api/test",
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0")
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
Method = method,
|
||||
Path = path,
|
||||
ServiceName = serviceName,
|
||||
Version = version
|
||||
};
|
||||
}
|
||||
|
||||
private static RoutingContext CreateContext(
|
||||
string method = "GET",
|
||||
string path = "/api/test",
|
||||
string gatewayRegion = "us-east-1",
|
||||
string? requestedVersion = null,
|
||||
EndpointDescriptor? endpoint = null,
|
||||
params ConnectionState[] connections)
|
||||
{
|
||||
return new RoutingContext
|
||||
{
|
||||
Method = method,
|
||||
Path = path,
|
||||
GatewayRegion = gatewayRegion,
|
||||
RequestedVersion = requestedVersion,
|
||||
Endpoint = endpoint ?? CreateEndpoint(),
|
||||
AvailableConnections = connections
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenNoConnections()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenNoEndpoint()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var connection = CreateConnection();
|
||||
var context = new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
GatewayRegion = "us-east-1",
|
||||
Endpoint = null,
|
||||
AvailableConnections = [connection]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldSelectHealthyConnection()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var connection = CreateConnection(status: InstanceHealthStatus.Healthy);
|
||||
var context = CreateContext(connections: [connection]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Should().BeSameAs(connection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldPreferHealthyOverDegraded()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var degraded = CreateConnection("conn-1", status: InstanceHealthStatus.Degraded);
|
||||
var healthy = CreateConnection("conn-2", status: InstanceHealthStatus.Healthy);
|
||||
var context = CreateContext(connections: [degraded, healthy]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Status.Should().Be(InstanceHealthStatus.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldSelectDegraded_WhenNoHealthyAndAllowed()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.AllowDegradedInstances = true);
|
||||
var degraded = CreateConnection(status: InstanceHealthStatus.Degraded);
|
||||
var context = CreateContext(connections: [degraded]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Status.Should().Be(InstanceHealthStatus.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenOnlyDegradedAndNotAllowed()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.AllowDegradedInstances = false);
|
||||
var degraded = CreateConnection(status: InstanceHealthStatus.Degraded);
|
||||
var context = CreateContext(connections: [degraded]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldExcludeUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var unhealthy = CreateConnection("conn-1", status: InstanceHealthStatus.Unhealthy);
|
||||
var healthy = CreateConnection("conn-2", status: InstanceHealthStatus.Healthy);
|
||||
var context = CreateContext(connections: [unhealthy, healthy]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.ConnectionId.Should().Be("conn-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldExcludeDraining()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var draining = CreateConnection("conn-1", status: InstanceHealthStatus.Draining);
|
||||
var healthy = CreateConnection("conn-2", status: InstanceHealthStatus.Healthy);
|
||||
var context = CreateContext(connections: [draining, healthy]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.ConnectionId.Should().Be("conn-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldFilterByRequestedVersion()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var v1 = CreateConnection("conn-1", version: "1.0.0");
|
||||
var v2 = CreateConnection("conn-2", version: "2.0.0");
|
||||
var context = CreateContext(requestedVersion: "2.0.0", connections: [v1, v2]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Instance.Version.Should().Be("2.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldUseDefaultVersion_WhenNoRequestedVersion()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.DefaultVersion = "1.0.0");
|
||||
var v1 = CreateConnection("conn-1", version: "1.0.0");
|
||||
var v2 = CreateConnection("conn-2", version: "2.0.0");
|
||||
var context = CreateContext(requestedVersion: null, connections: [v1, v2]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Instance.Version.Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenNoMatchingVersion()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var v1 = CreateConnection("conn-1", version: "1.0.0");
|
||||
var context = CreateContext(requestedVersion: "2.0.0", connections: [v1]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldMatchAnyVersion_WhenNoVersionSpecified()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.DefaultVersion = null);
|
||||
var v1 = CreateConnection("conn-1", version: "1.0.0");
|
||||
var v2 = CreateConnection("conn-2", version: "2.0.0");
|
||||
var context = CreateContext(requestedVersion: null, connections: [v1, v2]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldPreferLocalRegion()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.PreferLocalRegion = true);
|
||||
var remote = CreateConnection("conn-1", region: "us-west-2");
|
||||
var local = CreateConnection("conn-2", region: "us-east-1");
|
||||
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remote, local]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Instance.Region.Should().Be("us-east-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldAllowRemoteRegion_WhenNoLocalAvailable()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.PreferLocalRegion = true);
|
||||
var remote = CreateConnection("conn-1", region: "us-west-2");
|
||||
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remote]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Instance.Region.Should().Be("us-west-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldIgnoreRegionPreference_WhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.PreferLocalRegion = false);
|
||||
// Create connections with same ping and heartbeat so they are tied
|
||||
var sameHeartbeat = DateTime.UtcNow;
|
||||
var remote = CreateConnection("conn-1", region: "us-west-2", lastHeartbeatUtc: sameHeartbeat);
|
||||
var local = CreateConnection("conn-2", region: "us-east-1", lastHeartbeatUtc: sameHeartbeat);
|
||||
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remote, local]);
|
||||
|
||||
// Act - run multiple times to verify random selection includes both
|
||||
var selectedRegions = new HashSet<string>();
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
selectedRegions.Add(result!.Connection.Instance.Region);
|
||||
}
|
||||
|
||||
// Assert - with random selection, we should see both regions selected
|
||||
// Note: This is probabilistic but should almost always pass
|
||||
selectedRegions.Should().Contain("us-west-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldSetCorrectTimeout()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.RoutingTimeoutMs = 5000);
|
||||
var connection = CreateConnection();
|
||||
var context = CreateContext(connections: [connection]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.EffectiveTimeout.Should().Be(TimeSpan.FromMilliseconds(5000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldSetCorrectTransportType()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var connection = CreateConnection();
|
||||
var context = CreateContext(connections: [connection]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.TransportType.Should().Be(TransportType.InMemory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldReturnEndpointFromContext()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var endpoint = CreateEndpoint(path: "/api/special");
|
||||
var connection = CreateConnection();
|
||||
var context = CreateContext(endpoint: endpoint, connections: [connection]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Endpoint.Path.Should().Be("/api/special");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldDistributeLoadAcrossMultipleConnections()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
// Create connections with same ping and heartbeat so they are tied
|
||||
var sameHeartbeat = DateTime.UtcNow;
|
||||
var conn1 = CreateConnection("conn-1", lastHeartbeatUtc: sameHeartbeat);
|
||||
var conn2 = CreateConnection("conn-2", lastHeartbeatUtc: sameHeartbeat);
|
||||
var conn3 = CreateConnection("conn-3", lastHeartbeatUtc: sameHeartbeat);
|
||||
var context = CreateContext(connections: [conn1, conn2, conn3]);
|
||||
|
||||
// Act - run multiple times
|
||||
var selectedConnections = new Dictionary<string, int>();
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
var connId = result!.Connection.ConnectionId;
|
||||
selectedConnections[connId] = selectedConnections.GetValueOrDefault(connId) + 1;
|
||||
}
|
||||
|
||||
// Assert - all connections should be selected at least once (probabilistic with random tie-breaker)
|
||||
selectedConnections.Should().HaveCount(3);
|
||||
selectedConnections.Keys.Should().Contain("conn-1");
|
||||
selectedConnections.Keys.Should().Contain("conn-2");
|
||||
selectedConnections.Keys.Should().Contain("conn-3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldPreferLowerPing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var sameHeartbeat = DateTime.UtcNow;
|
||||
var highPing = CreateConnection("conn-1", averagePingMs: 100, lastHeartbeatUtc: sameHeartbeat);
|
||||
var lowPing = CreateConnection("conn-2", averagePingMs: 10, lastHeartbeatUtc: sameHeartbeat);
|
||||
var context = CreateContext(connections: [highPing, lowPing]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - lower ping should be preferred
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.ConnectionId.Should().Be("conn-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldPreferMoreRecentHeartbeat_WhenPingEqual()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var now = DateTime.UtcNow;
|
||||
var oldHeartbeat = CreateConnection("conn-1", averagePingMs: 10, lastHeartbeatUtc: now.AddSeconds(-30));
|
||||
var recentHeartbeat = CreateConnection("conn-2", averagePingMs: 10, lastHeartbeatUtc: now);
|
||||
var context = CreateContext(connections: [oldHeartbeat, recentHeartbeat]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - more recent heartbeat should be preferred
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.ConnectionId.Should().Be("conn-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldPreferNeighborRegionOverRemote()
|
||||
{
|
||||
// Arrange - gateway config has NeighborRegions = ["eu-west-1", "us-west-2"]
|
||||
var sut = CreateSut();
|
||||
var sameHeartbeat = DateTime.UtcNow;
|
||||
var remoteRegion = CreateConnection("conn-1", region: "ap-south-1", lastHeartbeatUtc: sameHeartbeat);
|
||||
var neighborRegion = CreateConnection("conn-2", region: "eu-west-1", lastHeartbeatUtc: sameHeartbeat);
|
||||
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remoteRegion, neighborRegion]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - neighbor region should be preferred over remote
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Instance.Region.Should().Be("eu-west-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldUseRoundRobin_WhenConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.TieBreaker = TieBreakerMode.RoundRobin);
|
||||
var sameHeartbeat = DateTime.UtcNow;
|
||||
var conn1 = CreateConnection("conn-1", lastHeartbeatUtc: sameHeartbeat);
|
||||
var conn2 = CreateConnection("conn-2", lastHeartbeatUtc: sameHeartbeat);
|
||||
var context = CreateContext(connections: [conn1, conn2]);
|
||||
|
||||
// Act - with round-robin, we should cycle through connections
|
||||
var selections = new List<string>();
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
selections.Add(result!.Connection.ConnectionId);
|
||||
}
|
||||
|
||||
// Assert - should alternate between connections
|
||||
selections.Distinct().Count().Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldCombineFilters()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o =>
|
||||
{
|
||||
o.PreferLocalRegion = true;
|
||||
o.AllowDegradedInstances = false;
|
||||
});
|
||||
|
||||
// Create various combinations
|
||||
var wrongVersionHealthyLocal = CreateConnection("conn-1", version: "2.0.0", region: "us-east-1", status: InstanceHealthStatus.Healthy);
|
||||
var rightVersionDegradedLocal = CreateConnection("conn-2", version: "1.0.0", region: "us-east-1", status: InstanceHealthStatus.Degraded);
|
||||
var rightVersionHealthyRemote = CreateConnection("conn-3", version: "1.0.0", region: "us-west-2", status: InstanceHealthStatus.Healthy);
|
||||
var rightVersionHealthyLocal = CreateConnection("conn-4", version: "1.0.0", region: "us-east-1", status: InstanceHealthStatus.Healthy);
|
||||
|
||||
var context = CreateContext(
|
||||
gatewayRegion: "us-east-1",
|
||||
requestedVersion: "1.0.0",
|
||||
connections: [wrongVersionHealthyLocal, rightVersionDegradedLocal, rightVersionHealthyRemote, rightVersionHealthyLocal]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - should select the only connection matching all criteria
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.ConnectionId.Should().Be("conn-4");
|
||||
}
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="EffectiveClaimsStore"/>.
|
||||
/// </summary>
|
||||
public sealed class EffectiveClaimsStoreTests
|
||||
{
|
||||
private readonly EffectiveClaimsStore _store;
|
||||
|
||||
public EffectiveClaimsStoreTests()
|
||||
{
|
||||
_store = new EffectiveClaimsStore(NullLogger<EffectiveClaimsStore>.Instance);
|
||||
}
|
||||
|
||||
#region GetEffectiveClaims Tests
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveClaims_NoClaimsRegistered_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange - fresh store
|
||||
|
||||
// Act
|
||||
var claims = _store.GetEffectiveClaims("service", "GET", "/api/test");
|
||||
|
||||
// Assert
|
||||
claims.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveClaims_MicroserviceClaimsOnly_ReturnsMicroserviceClaims()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Act
|
||||
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
|
||||
|
||||
// Assert
|
||||
claims.Should().HaveCount(1);
|
||||
claims[0].Type.Should().Be("role");
|
||||
claims[0].Value.Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveClaims_AuthorityOverridesTakePrecedence()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "user" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
var key = EndpointKey.Create("test-service", "GET", "/api/users");
|
||||
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key] = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
};
|
||||
_store.UpdateFromAuthority(overrides);
|
||||
|
||||
// Act
|
||||
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
|
||||
|
||||
// Assert
|
||||
claims.Should().HaveCount(1);
|
||||
claims[0].Value.Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveClaims_MethodNormalization_MatchesCaseInsensitively()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "get",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Act
|
||||
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
|
||||
|
||||
// Assert
|
||||
claims.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveClaims_PathNormalization_MatchesCaseInsensitively()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/API/USERS",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Act
|
||||
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
|
||||
|
||||
// Assert
|
||||
claims.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateFromMicroservice Tests
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromMicroservice_MultipleEndpoints_RegistersAll()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "reader" }]
|
||||
},
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "POST",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "writer" }]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("test-service", "GET", "/api/users")[0].Value.Should().Be("reader");
|
||||
_store.GetEffectiveClaims("test-service", "POST", "/api/users")[0].Value.Should().Be("writer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromMicroservice_EmptyClaims_RemovesFromStore()
|
||||
{
|
||||
// Arrange - first add some claims
|
||||
var endpoints1 = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints1);
|
||||
|
||||
// Now update with empty claims
|
||||
var endpoints2 = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = []
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromMicroservice("test-service", endpoints2);
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromMicroservice_DefaultEmptyClaims_TreatedAsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users"
|
||||
// RequiringClaims defaults to []
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateFromAuthority Tests
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromAuthority_ClearsPreviousOverrides()
|
||||
{
|
||||
// Arrange - add initial override
|
||||
var key1 = EndpointKey.Create("service1", "GET", "/api/test1");
|
||||
var overrides1 = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key1] = [new ClaimRequirement { Type = "role", Value = "old" }]
|
||||
};
|
||||
_store.UpdateFromAuthority(overrides1);
|
||||
|
||||
// Update with new overrides (different key)
|
||||
var key2 = EndpointKey.Create("service2", "POST", "/api/test2");
|
||||
var overrides2 = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key2] = [new ClaimRequirement { Type = "role", Value = "new" }]
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromAuthority(overrides2);
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("service1", "GET", "/api/test1").Should().BeEmpty();
|
||||
_store.GetEffectiveClaims("service2", "POST", "/api/test2").Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromAuthority_EmptyClaimsNotStored()
|
||||
{
|
||||
// Arrange
|
||||
var key = EndpointKey.Create("service", "GET", "/api/test");
|
||||
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key] = []
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromAuthority(overrides);
|
||||
|
||||
// Assert - should fall back to microservice (which is empty)
|
||||
_store.GetEffectiveClaims("service", "GET", "/api/test").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromAuthority_MultipleOverrides()
|
||||
{
|
||||
// Arrange
|
||||
var key1 = EndpointKey.Create("service1", "GET", "/api/users");
|
||||
var key2 = EndpointKey.Create("service1", "POST", "/api/users");
|
||||
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key1] = [new ClaimRequirement { Type = "role", Value = "reader" }],
|
||||
[key2] = [new ClaimRequirement { Type = "role", Value = "writer" }]
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromAuthority(overrides);
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("service1", "GET", "/api/users")[0].Value.Should().Be("reader");
|
||||
_store.GetEffectiveClaims("service1", "POST", "/api/users")[0].Value.Should().Be("writer");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveService Tests
|
||||
|
||||
[Fact]
|
||||
public void RemoveService_RemovesMicroserviceClaims()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Act
|
||||
_store.RemoveService("test-service");
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveService_CaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "Test-Service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("Test-Service", endpoints);
|
||||
|
||||
// Act - remove with different case
|
||||
_store.RemoveService("TEST-SERVICE");
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveService_OnlyRemovesTargetService()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints1 = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "service-a",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/a",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "a" }]
|
||||
}
|
||||
};
|
||||
var endpoints2 = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "service-b",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/b",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "b" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("service-a", endpoints1);
|
||||
_store.UpdateFromMicroservice("service-b", endpoints2);
|
||||
|
||||
// Act
|
||||
_store.RemoveService("service-a");
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("service-a", "GET", "/api/a").Should().BeEmpty();
|
||||
_store.GetEffectiveClaims("service-b", "GET", "/api/b").Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveService_UnknownService_DoesNotThrow()
|
||||
{
|
||||
// Arrange & Act
|
||||
var action = () => _store.RemoveService("unknown-service");
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="EndpointResolutionMiddleware"/>.
|
||||
/// </summary>
|
||||
public sealed class EndpointResolutionMiddlewareTests
|
||||
{
|
||||
private readonly Mock<IGlobalRoutingState> _routingStateMock;
|
||||
private readonly Mock<RequestDelegate> _nextMock;
|
||||
private bool _nextCalled;
|
||||
|
||||
public EndpointResolutionMiddlewareTests()
|
||||
{
|
||||
_routingStateMock = new Mock<IGlobalRoutingState>();
|
||||
_nextMock = new Mock<RequestDelegate>();
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.Callback(() => _nextCalled = true)
|
||||
.Returns(Task.CompletedTask);
|
||||
}
|
||||
|
||||
private EndpointResolutionMiddleware CreateMiddleware()
|
||||
{
|
||||
return new EndpointResolutionMiddleware(_nextMock.Object);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext(string method = "GET", string path = "/api/test")
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = method;
|
||||
context.Request.Path = path;
|
||||
context.Response.Body = new MemoryStream();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(
|
||||
string serviceName = "test-service",
|
||||
string method = "GET",
|
||||
string path = "/api/test")
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
ServiceName = serviceName,
|
||||
Version = "1.0.0",
|
||||
Method = method,
|
||||
Path = path
|
||||
};
|
||||
}
|
||||
|
||||
#region Matching Endpoint Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithMatchingEndpoint_SetsHttpContextItem()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var context = CreateHttpContext();
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/test"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithMatchingEndpoint_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var context = CreateHttpContext();
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/test"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unknown Path Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithUnknownPath_Returns404()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(path: "/api/unknown");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/unknown"))
|
||||
.Returns((EndpointDescriptor?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithUnknownPath_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(path: "/api/unknown");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/unknown"))
|
||||
.Returns((EndpointDescriptor?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("not found");
|
||||
responseBody.Should().Contain("/api/unknown");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HTTP Method Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithPostMethod_ResolvesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(method: "POST");
|
||||
var context = CreateHttpContext(method: "POST");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("POST", "/api/test"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithDeleteMethod_ResolvesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(method: "DELETE", path: "/api/users/123");
|
||||
var context = CreateHttpContext(method: "DELETE", path: "/api/users/123");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("DELETE", "/api/users/123"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithWrongMethod_Returns404()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(method: "DELETE", path: "/api/test");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("DELETE", "/api/test"))
|
||||
.Returns((EndpointDescriptor?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Path Variations Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithParameterizedPath_ResolvesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(path: "/api/users/{id}");
|
||||
var context = CreateHttpContext(path: "/api/users/123");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/users/123"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithRootPath_ResolvesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(path: "/");
|
||||
var context = CreateHttpContext(path: "/");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithEmptyPath_PassesEmptyStringToRouting()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(path: "");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", ""))
|
||||
.Returns((EndpointDescriptor?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(r => r.ResolveEndpoint("GET", ""), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multiple Calls Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_MultipleCalls_EachResolvesIndependently()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint1 = CreateEndpoint(path: "/api/users");
|
||||
var endpoint2 = CreateEndpoint(path: "/api/items");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/users"))
|
||||
.Returns(endpoint1);
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/items"))
|
||||
.Returns(endpoint2);
|
||||
|
||||
var context1 = CreateHttpContext(path: "/api/users");
|
||||
var context2 = CreateHttpContext(path: "/api/items");
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context1, _routingStateMock.Object);
|
||||
await middleware.Invoke(context2, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context1.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint1);
|
||||
context2.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HealthMonitorService"/>.
|
||||
/// </summary>
|
||||
public sealed class HealthMonitorServiceTests
|
||||
{
|
||||
private readonly Mock<IGlobalRoutingState> _routingStateMock;
|
||||
private readonly HealthOptions _options;
|
||||
|
||||
public HealthMonitorServiceTests()
|
||||
{
|
||||
_routingStateMock = new Mock<IGlobalRoutingState>(MockBehavior.Loose);
|
||||
_options = new HealthOptions
|
||||
{
|
||||
StaleThreshold = TimeSpan.FromSeconds(10),
|
||||
DegradedThreshold = TimeSpan.FromSeconds(5),
|
||||
CheckInterval = TimeSpan.FromMilliseconds(100)
|
||||
};
|
||||
}
|
||||
|
||||
private HealthMonitorService CreateService()
|
||||
{
|
||||
return new HealthMonitorService(
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<HealthMonitorService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_MarksStaleConnectionsUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
var staleConnection = CreateConnection("conn-1", "service-a", "1.0.0");
|
||||
staleConnection.Status = InstanceHealthStatus.Healthy;
|
||||
staleConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-15); // Past stale threshold
|
||||
|
||||
_routingStateMock.Setup(s => s.GetAllConnections())
|
||||
.Returns([staleConnection]);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(200, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(
|
||||
s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_MarksDegradedConnectionsDegraded()
|
||||
{
|
||||
// Arrange
|
||||
var degradedConnection = CreateConnection("conn-1", "service-a", "1.0.0");
|
||||
degradedConnection.Status = InstanceHealthStatus.Healthy;
|
||||
degradedConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-7); // Past degraded but not stale
|
||||
|
||||
_routingStateMock.Setup(s => s.GetAllConnections())
|
||||
.Returns([degradedConnection]);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await service.StartAsync(cts.Token);
|
||||
// Wait enough time for at least one check cycle (CheckInterval is 100ms)
|
||||
await Task.Delay(300, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(
|
||||
s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_DoesNotChangeHealthyConnections()
|
||||
{
|
||||
// Arrange
|
||||
var healthyConnection = CreateConnection("conn-1", "service-a", "1.0.0");
|
||||
healthyConnection.Status = InstanceHealthStatus.Healthy;
|
||||
healthyConnection.LastHeartbeatUtc = DateTime.UtcNow; // Fresh heartbeat
|
||||
|
||||
_routingStateMock.Setup(s => s.GetAllConnections())
|
||||
.Returns([healthyConnection]);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(200, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Assert - should not have updated the connection
|
||||
_routingStateMock.Verify(
|
||||
s => s.UpdateConnection(It.IsAny<string>(), It.IsAny<Action<ConnectionState>>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_DoesNotChangeDrainingConnections()
|
||||
{
|
||||
// Arrange
|
||||
var drainingConnection = CreateConnection("conn-1", "service-a", "1.0.0");
|
||||
drainingConnection.Status = InstanceHealthStatus.Draining;
|
||||
drainingConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-30); // Very stale
|
||||
|
||||
_routingStateMock.Setup(s => s.GetAllConnections())
|
||||
.Returns([drainingConnection]);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(200, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Assert - draining connections should be left alone
|
||||
_routingStateMock.Verify(
|
||||
s => s.UpdateConnection(It.IsAny<string>(), It.IsAny<Action<ConnectionState>>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_DoesNotDoubleMarkUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
var unhealthyConnection = CreateConnection("conn-1", "service-a", "1.0.0");
|
||||
unhealthyConnection.Status = InstanceHealthStatus.Unhealthy;
|
||||
unhealthyConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-30); // Very stale
|
||||
|
||||
_routingStateMock.Setup(s => s.GetAllConnections())
|
||||
.Returns([unhealthyConnection]);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(200, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Assert - already unhealthy connections should not be updated
|
||||
_routingStateMock.Verify(
|
||||
s => s.UpdateConnection(It.IsAny<string>(), It.IsAny<Action<ConnectionState>>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAction_SetsStatusToUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection("conn-1", "service-a", "1.0.0");
|
||||
connection.Status = InstanceHealthStatus.Healthy;
|
||||
connection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-15);
|
||||
|
||||
Action<ConnectionState>? capturedAction = null;
|
||||
_routingStateMock.Setup(s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()))
|
||||
.Callback<string, Action<ConnectionState>>((id, action) => capturedAction = action);
|
||||
_routingStateMock.Setup(s => s.GetAllConnections())
|
||||
.Returns([connection]);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Act - run the service briefly
|
||||
try
|
||||
{
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(200, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Assert
|
||||
capturedAction.Should().NotBeNull();
|
||||
|
||||
// Apply the action to verify it sets Unhealthy
|
||||
var testConnection = CreateConnection("conn-1", "service-a", "1.0.0");
|
||||
testConnection.Status = InstanceHealthStatus.Healthy;
|
||||
capturedAction!(testConnection);
|
||||
|
||||
testConnection.Status.Should().Be(InstanceHealthStatus.Unhealthy);
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId, string serviceName, string version)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"{serviceName}-{Guid.NewGuid():N}",
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = "us-east-1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
LastHeartbeatUtc = DateTime.UtcNow,
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="HttpAuthorityClaimsProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class HttpAuthorityClaimsProviderTests
|
||||
{
|
||||
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly AuthorityConnectionOptions _options;
|
||||
|
||||
public HttpAuthorityClaimsProviderTests()
|
||||
{
|
||||
_httpHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClient = new HttpClient(_httpHandlerMock.Object);
|
||||
_options = new AuthorityConnectionOptions
|
||||
{
|
||||
AuthorityUrl = "http://authority.local"
|
||||
};
|
||||
}
|
||||
|
||||
private HttpAuthorityClaimsProvider CreateProvider()
|
||||
{
|
||||
return new HttpAuthorityClaimsProvider(
|
||||
_httpClient,
|
||||
Options.Create(_options),
|
||||
NullLogger<HttpAuthorityClaimsProvider>.Instance);
|
||||
}
|
||||
|
||||
#region GetOverridesAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_NoAuthorityUrl_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
_options.AuthorityUrl = string.Empty;
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_WhitespaceUrl_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
_options.AuthorityUrl = " ";
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_SuccessfulResponse_ParsesOverrides()
|
||||
{
|
||||
// Arrange
|
||||
var responseBody = JsonSerializer.Serialize(new
|
||||
{
|
||||
overrides = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
serviceName = "test-service",
|
||||
method = "GET",
|
||||
path = "/api/users",
|
||||
requiringClaims = new[]
|
||||
{
|
||||
new { type = "role", value = "admin" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
SetupHttpResponse(HttpStatusCode.OK, responseBody);
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
provider.IsAvailable.Should().BeTrue();
|
||||
|
||||
var key = result.Keys.First();
|
||||
key.ServiceName.Should().Be("test-service");
|
||||
key.Method.Should().Be("GET");
|
||||
key.Path.Should().Be("/api/users");
|
||||
|
||||
result[key].Should().HaveCount(1);
|
||||
result[key][0].Type.Should().Be("role");
|
||||
result[key][0].Value.Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_EmptyOverrides_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var responseBody = JsonSerializer.Serialize(new
|
||||
{
|
||||
overrides = Array.Empty<object>()
|
||||
});
|
||||
|
||||
SetupHttpResponse(HttpStatusCode.OK, responseBody);
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_NullOverrides_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var responseBody = "{}";
|
||||
SetupHttpResponse(HttpStatusCode.OK, responseBody);
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_HttpError_ReturnsEmptyAndSetsUnavailable()
|
||||
{
|
||||
// Arrange
|
||||
SetupHttpResponse(HttpStatusCode.InternalServerError, "Error");
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_Timeout_ReturnsEmptyAndSetsUnavailable()
|
||||
{
|
||||
// Arrange
|
||||
_httpHandlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new TaskCanceledException("Timeout"));
|
||||
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_NetworkError_ReturnsEmptyAndSetsUnavailable()
|
||||
{
|
||||
// Arrange
|
||||
_httpHandlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Connection refused"));
|
||||
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_TrimsTrailingSlash()
|
||||
{
|
||||
// Arrange
|
||||
_options.AuthorityUrl = "http://authority.local/";
|
||||
var responseBody = JsonSerializer.Serialize(new { overrides = Array.Empty<object>() });
|
||||
|
||||
string? capturedUrl = null;
|
||||
_httpHandlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync((HttpRequestMessage req, CancellationToken _) =>
|
||||
{
|
||||
capturedUrl = req.RequestUri?.ToString();
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(responseBody)
|
||||
};
|
||||
});
|
||||
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
capturedUrl.Should().Be("http://authority.local/api/v1/claims/overrides");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_MultipleOverrides_ParsesAll()
|
||||
{
|
||||
// Arrange
|
||||
var responseBody = JsonSerializer.Serialize(new
|
||||
{
|
||||
overrides = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
serviceName = "service-a",
|
||||
method = "GET",
|
||||
path = "/api/a",
|
||||
requiringClaims = new[] { new { type = "role", value = "a" } }
|
||||
},
|
||||
new
|
||||
{
|
||||
serviceName = "service-b",
|
||||
method = "POST",
|
||||
path = "/api/b",
|
||||
requiringClaims = new[]
|
||||
{
|
||||
new { type = "role", value = "b1" },
|
||||
new { type = "department", value = "b2" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
SetupHttpResponse(HttpStatusCode.OK, responseBody);
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsAvailable Tests
|
||||
|
||||
[Fact]
|
||||
public void IsAvailable_InitiallyFalse()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Assert
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsAvailable_TrueAfterSuccessfulFetch()
|
||||
{
|
||||
// Arrange
|
||||
SetupHttpResponse(HttpStatusCode.OK, "{}");
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
provider.IsAvailable.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsAvailable_FalseAfterFailedFetch()
|
||||
{
|
||||
// Arrange
|
||||
SetupHttpResponse(HttpStatusCode.ServiceUnavailable, "");
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region OverridesChanged Event Tests
|
||||
|
||||
[Fact]
|
||||
public void OverridesChanged_CanBeSubscribed()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
var eventRaised = false;
|
||||
|
||||
// Act
|
||||
provider.OverridesChanged += (_, _) => eventRaised = true;
|
||||
|
||||
// Assert - no exception during subscription, event not raised yet
|
||||
eventRaised.Should().BeFalse();
|
||||
provider.Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupHttpResponse(HttpStatusCode statusCode, string content)
|
||||
{
|
||||
_httpHandlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(content)
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
public class InMemoryRoutingStateTests
|
||||
{
|
||||
private readonly InMemoryRoutingState _sut = new();
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId = "conn-1",
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
string region = "us-east-1",
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
|
||||
params (string Method, string Path)[] endpoints)
|
||||
{
|
||||
var connection = new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"inst-{connectionId}",
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = region
|
||||
},
|
||||
Status = status,
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
|
||||
foreach (var (method, path) in endpoints)
|
||||
{
|
||||
connection.Endpoints[(method, path)] = new EndpointDescriptor
|
||||
{
|
||||
Method = method,
|
||||
Path = path,
|
||||
ServiceName = serviceName,
|
||||
Version = version
|
||||
};
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddConnection_ShouldStoreConnection()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
|
||||
|
||||
// Act
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Assert
|
||||
var result = _sut.GetConnection(connection.ConnectionId);
|
||||
result.Should().NotBeNull();
|
||||
result.Should().BeSameAs(connection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddConnection_ShouldIndexEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/users/{id}")]);
|
||||
|
||||
// Act
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Assert
|
||||
var endpoint = _sut.ResolveEndpoint("GET", "/api/users/123");
|
||||
endpoint.Should().NotBeNull();
|
||||
endpoint!.Path.Should().Be("/api/users/{id}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveConnection_ShouldRemoveConnection()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
_sut.RemoveConnection(connection.ConnectionId);
|
||||
|
||||
// Assert
|
||||
var result = _sut.GetConnection(connection.ConnectionId);
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveConnection_ShouldRemoveEndpointsWhenLastConnection()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
_sut.RemoveConnection(connection.ConnectionId);
|
||||
|
||||
// Assert
|
||||
var endpoint = _sut.ResolveEndpoint("GET", "/api/test");
|
||||
endpoint.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveConnection_ShouldKeepEndpointsWhenOtherConnectionsExist()
|
||||
{
|
||||
// Arrange
|
||||
var connection1 = CreateConnection("conn-1", endpoints: [("GET", "/api/test")]);
|
||||
var connection2 = CreateConnection("conn-2", endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection1);
|
||||
_sut.AddConnection(connection2);
|
||||
|
||||
// Act
|
||||
_sut.RemoveConnection("conn-1");
|
||||
|
||||
// Assert
|
||||
var endpoint = _sut.ResolveEndpoint("GET", "/api/test");
|
||||
endpoint.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateConnection_ShouldApplyUpdate()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
_sut.UpdateConnection(connection.ConnectionId, c => c.Status = InstanceHealthStatus.Degraded);
|
||||
|
||||
// Assert
|
||||
var result = _sut.GetConnection(connection.ConnectionId);
|
||||
result.Should().NotBeNull();
|
||||
result!.Status.Should().Be(InstanceHealthStatus.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateConnection_ShouldDoNothingForUnknownConnection()
|
||||
{
|
||||
// Act - should not throw
|
||||
_sut.UpdateConnection("unknown", c => c.Status = InstanceHealthStatus.Degraded);
|
||||
|
||||
// Assert
|
||||
var result = _sut.GetConnection("unknown");
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnection_ShouldReturnNullForUnknownConnection()
|
||||
{
|
||||
// Act
|
||||
var result = _sut.GetConnection("unknown");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllConnections_ShouldReturnAllConnections()
|
||||
{
|
||||
// Arrange
|
||||
var connection1 = CreateConnection("conn-1", endpoints: [("GET", "/api/test1")]);
|
||||
var connection2 = CreateConnection("conn-2", endpoints: [("GET", "/api/test2")]);
|
||||
_sut.AddConnection(connection1);
|
||||
_sut.AddConnection(connection2);
|
||||
|
||||
// Act
|
||||
var result = _sut.GetAllConnections();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().Contain(connection1);
|
||||
result.Should().Contain(connection2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllConnections_ShouldReturnEmptyWhenNoConnections()
|
||||
{
|
||||
// Act
|
||||
var result = _sut.GetAllConnections();
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpoint_ShouldMatchExactPath()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/health")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
var result = _sut.ResolveEndpoint("GET", "/api/health");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Path.Should().Be("/api/health");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpoint_ShouldMatchParameterizedPath()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/users/{id}/orders/{orderId}")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
var result = _sut.ResolveEndpoint("GET", "/api/users/123/orders/456");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Path.Should().Be("/api/users/{id}/orders/{orderId}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpoint_ShouldReturnNullForNonMatchingMethod()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
var result = _sut.ResolveEndpoint("POST", "/api/test");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpoint_ShouldReturnNullForNonMatchingPath()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
var result = _sut.ResolveEndpoint("GET", "/api/other");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpoint_ShouldBeCaseInsensitiveForMethod()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
var result = _sut.ResolveEndpoint("get", "/api/test");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionsFor_ShouldFilterByServiceName()
|
||||
{
|
||||
// Arrange
|
||||
var connection1 = CreateConnection("conn-1", "service-a", endpoints: [("GET", "/api/test")]);
|
||||
var connection2 = CreateConnection("conn-2", "service-b", endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection1);
|
||||
_sut.AddConnection(connection2);
|
||||
|
||||
// Act
|
||||
var result = _sut.GetConnectionsFor("service-a", "1.0.0", "GET", "/api/test");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Instance.ServiceName.Should().Be("service-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionsFor_ShouldFilterByVersion()
|
||||
{
|
||||
// Arrange
|
||||
var connection1 = CreateConnection("conn-1", "service-a", "1.0.0", endpoints: [("GET", "/api/test")]);
|
||||
var connection2 = CreateConnection("conn-2", "service-a", "2.0.0", endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection1);
|
||||
_sut.AddConnection(connection2);
|
||||
|
||||
// Act
|
||||
var result = _sut.GetConnectionsFor("service-a", "1.0.0", "GET", "/api/test");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Instance.Version.Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionsFor_ShouldReturnEmptyWhenNoMatch()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection("conn-1", "service-a", endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
var result = _sut.GetConnectionsFor("service-b", "1.0.0", "GET", "/api/test");
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionsFor_ShouldMatchParameterizedPaths()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection("conn-1", "service-a", endpoints: [("GET", "/api/users/{id}")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
var result = _sut.GetConnectionsFor("service-a", "1.0.0", "GET", "/api/users/123");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Gateway.WebService.OpenApi;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.OpenApi;
|
||||
|
||||
public class ClaimSecurityMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void GenerateSecuritySchemes_WithNoEndpoints_ReturnsBearerAuthOnly()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = Array.Empty<EndpointDescriptor>();
|
||||
|
||||
// Act
|
||||
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
|
||||
|
||||
// Assert
|
||||
schemes.Should().ContainKey("BearerAuth");
|
||||
schemes.Should().NotContainKey("OAuth2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecuritySchemes_WithClaimRequirements_ReturnsOAuth2()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/test",
|
||||
ServiceName = "test",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "test:write" }]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
|
||||
|
||||
// Assert
|
||||
schemes.Should().ContainKey("BearerAuth");
|
||||
schemes.Should().ContainKey("OAuth2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecuritySchemes_CollectsAllUniqueScopes()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "billing:write" }]
|
||||
},
|
||||
new EndpointDescriptor
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "billing:read" }]
|
||||
},
|
||||
new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/payments",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "billing:write" }] // Duplicate
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
|
||||
|
||||
// Assert
|
||||
var oauth2 = schemes["OAuth2"];
|
||||
var scopes = oauth2!["flows"]!["clientCredentials"]!["scopes"]!;
|
||||
|
||||
scopes.AsObject().Count.Should().Be(2); // Only unique scopes
|
||||
scopes["billing:write"].Should().NotBeNull();
|
||||
scopes["billing:read"].Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecuritySchemes_SetsCorrectTokenUrl()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/test",
|
||||
ServiceName = "test",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "test:write" }]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/custom/token");
|
||||
|
||||
// Assert
|
||||
var tokenUrl = schemes["OAuth2"]!["flows"]!["clientCredentials"]!["tokenUrl"]!.GetValue<string>();
|
||||
tokenUrl.Should().Be("/custom/token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecurityRequirement_WithNoClaimRequirements_ReturnsEmptyArray()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/public",
|
||||
ServiceName = "test",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = []
|
||||
};
|
||||
|
||||
// Act
|
||||
var requirement = ClaimSecurityMapper.GenerateSecurityRequirement(endpoint);
|
||||
|
||||
// Assert
|
||||
requirement.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecurityRequirement_WithClaimRequirements_ReturnsBearerAndOAuth2()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/secure",
|
||||
ServiceName = "test",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims =
|
||||
[
|
||||
new ClaimRequirement { Type = "billing:write" },
|
||||
new ClaimRequirement { Type = "billing:admin" }
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var requirement = ClaimSecurityMapper.GenerateSecurityRequirement(endpoint);
|
||||
|
||||
// Assert
|
||||
requirement.Count.Should().Be(1);
|
||||
|
||||
var req = requirement[0]!.AsObject();
|
||||
req.Should().ContainKey("BearerAuth");
|
||||
req.Should().ContainKey("OAuth2");
|
||||
|
||||
var scopes = req["OAuth2"]!.AsArray();
|
||||
scopes.Count.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecuritySchemes_BearerAuth_HasCorrectStructure()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = Array.Empty<EndpointDescriptor>();
|
||||
|
||||
// Act
|
||||
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
|
||||
|
||||
// Assert
|
||||
var bearer = schemes["BearerAuth"]!.AsObject();
|
||||
bearer["type"]!.GetValue<string>().Should().Be("http");
|
||||
bearer["scheme"]!.GetValue<string>().Should().Be("bearer");
|
||||
bearer["bearerFormat"]!.GetValue<string>().Should().Be("JWT");
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.OpenApi;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.OpenApi;
|
||||
|
||||
public class GatewayOpenApiDocumentCacheTests
|
||||
{
|
||||
private readonly Mock<IOpenApiDocumentGenerator> _generator = new();
|
||||
private readonly OpenApiAggregationOptions _options = new() { CacheTtlSeconds = 60 };
|
||||
private readonly GatewayOpenApiDocumentCache _sut;
|
||||
|
||||
public GatewayOpenApiDocumentCacheTests()
|
||||
{
|
||||
_sut = new GatewayOpenApiDocumentCache(
|
||||
_generator.Object,
|
||||
Options.Create(_options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_FirstCall_GeneratesDocument()
|
||||
{
|
||||
// Arrange
|
||||
var expectedDoc = """{"openapi":"3.1.0"}""";
|
||||
_generator.Setup(x => x.GenerateDocument()).Returns(expectedDoc);
|
||||
|
||||
// Act
|
||||
var (doc, _, _) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
doc.Should().Be(expectedDoc);
|
||||
_generator.Verify(x => x.GenerateDocument(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_SubsequentCalls_ReturnsCachedDocument()
|
||||
{
|
||||
// Arrange
|
||||
var expectedDoc = """{"openapi":"3.1.0"}""";
|
||||
_generator.Setup(x => x.GenerateDocument()).Returns(expectedDoc);
|
||||
|
||||
// Act
|
||||
var (doc1, _, _) = _sut.GetDocument();
|
||||
var (doc2, _, _) = _sut.GetDocument();
|
||||
var (doc3, _, _) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
doc1.Should().Be(expectedDoc);
|
||||
doc2.Should().Be(expectedDoc);
|
||||
doc3.Should().Be(expectedDoc);
|
||||
_generator.Verify(x => x.GenerateDocument(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_AfterInvalidate_RegeneratesDocument()
|
||||
{
|
||||
// Arrange
|
||||
var doc1 = """{"openapi":"3.1.0","version":"1"}""";
|
||||
var doc2 = """{"openapi":"3.1.0","version":"2"}""";
|
||||
|
||||
_generator.SetupSequence(x => x.GenerateDocument())
|
||||
.Returns(doc1)
|
||||
.Returns(doc2);
|
||||
|
||||
// Act
|
||||
var (result1, _, _) = _sut.GetDocument();
|
||||
_sut.Invalidate();
|
||||
var (result2, _, _) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
result1.Should().Be(doc1);
|
||||
result2.Should().Be(doc2);
|
||||
_generator.Verify(x => x.GenerateDocument(), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_ReturnsConsistentETag()
|
||||
{
|
||||
// Arrange
|
||||
var expectedDoc = """{"openapi":"3.1.0"}""";
|
||||
_generator.Setup(x => x.GenerateDocument()).Returns(expectedDoc);
|
||||
|
||||
// Act
|
||||
var (_, etag1, _) = _sut.GetDocument();
|
||||
var (_, etag2, _) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
etag1.Should().NotBeNullOrEmpty();
|
||||
etag1.Should().Be(etag2);
|
||||
etag1.Should().StartWith("\"").And.EndWith("\""); // ETag format
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_DifferentContent_DifferentETag()
|
||||
{
|
||||
// Arrange
|
||||
var doc1 = """{"openapi":"3.1.0","version":"1"}""";
|
||||
var doc2 = """{"openapi":"3.1.0","version":"2"}""";
|
||||
|
||||
_generator.SetupSequence(x => x.GenerateDocument())
|
||||
.Returns(doc1)
|
||||
.Returns(doc2);
|
||||
|
||||
// Act
|
||||
var (_, etag1, _) = _sut.GetDocument();
|
||||
_sut.Invalidate();
|
||||
var (_, etag2, _) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
etag1.Should().NotBe(etag2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_ReturnsGenerationTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
_generator.Setup(x => x.GenerateDocument()).Returns("{}");
|
||||
var beforeGeneration = DateTime.UtcNow;
|
||||
|
||||
// Act
|
||||
var (_, _, generatedAt) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
generatedAt.Should().BeOnOrAfter(beforeGeneration);
|
||||
generatedAt.Should().BeOnOrBefore(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalidate_CanBeCalledMultipleTimes()
|
||||
{
|
||||
// Arrange
|
||||
_generator.Setup(x => x.GenerateDocument()).Returns("{}");
|
||||
_sut.GetDocument();
|
||||
|
||||
// Act & Assert - should not throw
|
||||
_sut.Invalidate();
|
||||
_sut.Invalidate();
|
||||
_sut.Invalidate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_WithZeroTtl_AlwaysRegenerates()
|
||||
{
|
||||
// Arrange
|
||||
var options = new OpenApiAggregationOptions { CacheTtlSeconds = 0 };
|
||||
var sut = new GatewayOpenApiDocumentCache(
|
||||
_generator.Object,
|
||||
Options.Create(options));
|
||||
|
||||
var callCount = 0;
|
||||
_generator.Setup(x => x.GenerateDocument())
|
||||
.Returns(() => $"{{\"call\":{++callCount}}}");
|
||||
|
||||
// Act
|
||||
sut.GetDocument();
|
||||
// Wait a tiny bit to ensure TTL is exceeded
|
||||
Thread.Sleep(10);
|
||||
sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
// With 0 TTL, each call should regenerate
|
||||
_generator.Verify(x => x.GenerateDocument(), Times.Exactly(2));
|
||||
}
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.OpenApi;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.OpenApi;
|
||||
|
||||
public class OpenApiDocumentGeneratorTests
|
||||
{
|
||||
private readonly Mock<IGlobalRoutingState> _routingState = new();
|
||||
private readonly OpenApiAggregationOptions _options = new();
|
||||
private readonly OpenApiDocumentGenerator _sut;
|
||||
|
||||
public OpenApiDocumentGeneratorTests()
|
||||
{
|
||||
_sut = new OpenApiDocumentGenerator(
|
||||
_routingState.Object,
|
||||
Options.Create(_options));
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
params EndpointDescriptor[] endpoints)
|
||||
{
|
||||
var connection = new ConnectionState
|
||||
{
|
||||
ConnectionId = $"conn-{serviceName}",
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"inst-{serviceName}",
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = "us-east-1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
TransportType = TransportType.InMemory,
|
||||
Schemas = new Dictionary<string, SchemaDefinition>(),
|
||||
OpenApiInfo = new ServiceOpenApiInfo
|
||||
{
|
||||
Title = serviceName,
|
||||
Description = $"Test {serviceName} service"
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
connection.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithNoConnections_ReturnsValidOpenApiDocument()
|
||||
{
|
||||
// Arrange
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
document.Should().NotBeNullOrEmpty();
|
||||
|
||||
var doc = JsonDocument.Parse(document);
|
||||
doc.RootElement.GetProperty("openapi").GetString().Should().Be("3.1.0");
|
||||
doc.RootElement.GetProperty("info").GetProperty("title").GetString().Should().Be(_options.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_SetsCorrectInfoSection()
|
||||
{
|
||||
// Arrange
|
||||
_options.Title = "My Gateway API";
|
||||
_options.Description = "My description";
|
||||
_options.Version = "2.0.0";
|
||||
_options.LicenseName = "MIT";
|
||||
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
var info = doc.RootElement.GetProperty("info");
|
||||
|
||||
info.GetProperty("title").GetString().Should().Be("My Gateway API");
|
||||
info.GetProperty("description").GetString().Should().Be("My description");
|
||||
info.GetProperty("version").GetString().Should().Be("2.0.0");
|
||||
info.GetProperty("license").GetProperty("name").GetString().Should().Be("MIT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithConnections_GeneratesPaths()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/items",
|
||||
ServiceName = "inventory",
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
var connection = CreateConnection("inventory", "1.0.0", endpoint);
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
var paths = doc.RootElement.GetProperty("paths");
|
||||
|
||||
paths.TryGetProperty("/api/items", out var pathItem).Should().BeTrue();
|
||||
pathItem.TryGetProperty("get", out var operation).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithSchemaInfo_IncludesDocumentation()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
SchemaInfo = new EndpointSchemaInfo
|
||||
{
|
||||
Summary = "Create invoice",
|
||||
Description = "Creates a new invoice",
|
||||
Tags = ["billing", "invoices"],
|
||||
Deprecated = false
|
||||
}
|
||||
};
|
||||
|
||||
var connection = CreateConnection("billing", "1.0.0", endpoint);
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
var operation = doc.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty("/invoices")
|
||||
.GetProperty("post");
|
||||
|
||||
operation.GetProperty("summary").GetString().Should().Be("Create invoice");
|
||||
operation.GetProperty("description").GetString().Should().Be("Creates a new invoice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithSchemas_IncludesSchemaReferences()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
SchemaInfo = new EndpointSchemaInfo
|
||||
{
|
||||
RequestSchemaId = "CreateInvoiceRequest"
|
||||
}
|
||||
};
|
||||
|
||||
var connection = CreateConnection("billing", "1.0.0", endpoint);
|
||||
var connectionWithSchemas = new ConnectionState
|
||||
{
|
||||
ConnectionId = connection.ConnectionId,
|
||||
Instance = connection.Instance,
|
||||
Status = connection.Status,
|
||||
TransportType = connection.TransportType,
|
||||
Schemas = new Dictionary<string, SchemaDefinition>
|
||||
{
|
||||
["CreateInvoiceRequest"] = new SchemaDefinition
|
||||
{
|
||||
SchemaId = "CreateInvoiceRequest",
|
||||
SchemaJson = """{"type": "object", "properties": {"amount": {"type": "number"}}}""",
|
||||
ETag = "\"ABC123\""
|
||||
}
|
||||
}
|
||||
};
|
||||
connectionWithSchemas.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
|
||||
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([connectionWithSchemas]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
|
||||
// Check request body reference
|
||||
var requestBody = doc.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty("/invoices")
|
||||
.GetProperty("post")
|
||||
.GetProperty("requestBody")
|
||||
.GetProperty("content")
|
||||
.GetProperty("application/json")
|
||||
.GetProperty("schema")
|
||||
.GetProperty("$ref")
|
||||
.GetString();
|
||||
|
||||
requestBody.Should().Be("#/components/schemas/billing_CreateInvoiceRequest");
|
||||
|
||||
// Check schema exists in components
|
||||
var schemas = doc.RootElement.GetProperty("components").GetProperty("schemas");
|
||||
schemas.TryGetProperty("billing_CreateInvoiceRequest", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithClaimRequirements_IncludesSecurity()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "billing:write" }]
|
||||
};
|
||||
|
||||
var connection = CreateConnection("billing", "1.0.0", endpoint);
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
|
||||
// Check security schemes
|
||||
var securitySchemes = doc.RootElement
|
||||
.GetProperty("components")
|
||||
.GetProperty("securitySchemes");
|
||||
|
||||
securitySchemes.TryGetProperty("BearerAuth", out _).Should().BeTrue();
|
||||
securitySchemes.TryGetProperty("OAuth2", out _).Should().BeTrue();
|
||||
|
||||
// Check operation security
|
||||
var operation = doc.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty("/invoices")
|
||||
.GetProperty("post");
|
||||
|
||||
operation.TryGetProperty("security", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithMultipleServices_GeneratesTags()
|
||||
{
|
||||
// Arrange
|
||||
var billingEndpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
var inventoryEndpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/items",
|
||||
ServiceName = "inventory",
|
||||
Version = "2.0.0"
|
||||
};
|
||||
|
||||
var billingConn = CreateConnection("billing", "1.0.0", billingEndpoint);
|
||||
var inventoryConn = CreateConnection("inventory", "2.0.0", inventoryEndpoint);
|
||||
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([billingConn, inventoryConn]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
var tags = doc.RootElement.GetProperty("tags");
|
||||
|
||||
tags.GetArrayLength().Should().Be(2);
|
||||
|
||||
var tagNames = new List<string>();
|
||||
foreach (var tag in tags.EnumerateArray())
|
||||
{
|
||||
tagNames.Add(tag.GetProperty("name").GetString()!);
|
||||
}
|
||||
|
||||
tagNames.Should().Contain("billing");
|
||||
tagNames.Should().Contain("inventory");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithDeprecatedEndpoint_SetsDeprecatedFlag()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/legacy",
|
||||
ServiceName = "test",
|
||||
Version = "1.0.0",
|
||||
SchemaInfo = new EndpointSchemaInfo
|
||||
{
|
||||
Deprecated = true
|
||||
}
|
||||
};
|
||||
|
||||
var connection = CreateConnection("test", "1.0.0", endpoint);
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
var operation = doc.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty("/legacy")
|
||||
.GetProperty("get");
|
||||
|
||||
operation.GetProperty("deprecated").GetBoolean().Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="PayloadLimitsMiddleware"/>.
|
||||
/// </summary>
|
||||
public sealed class PayloadLimitsMiddlewareTests
|
||||
{
|
||||
private readonly Mock<IPayloadTracker> _trackerMock;
|
||||
private readonly Mock<RequestDelegate> _nextMock;
|
||||
private readonly PayloadLimits _defaultLimits;
|
||||
private bool _nextCalled;
|
||||
|
||||
public PayloadLimitsMiddlewareTests()
|
||||
{
|
||||
_trackerMock = new Mock<IPayloadTracker>();
|
||||
_nextMock = new Mock<RequestDelegate>();
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.Callback(() => _nextCalled = true)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_defaultLimits = new PayloadLimits
|
||||
{
|
||||
MaxRequestBytesPerCall = 10 * 1024 * 1024, // 10MB
|
||||
MaxRequestBytesPerConnection = 100 * 1024 * 1024, // 100MB
|
||||
MaxAggregateInflightBytes = 1024 * 1024 * 1024 // 1GB
|
||||
};
|
||||
}
|
||||
|
||||
private PayloadLimitsMiddleware CreateMiddleware(PayloadLimits? limits = null)
|
||||
{
|
||||
return new PayloadLimitsMiddleware(
|
||||
_nextMock.Object,
|
||||
Options.Create(limits ?? _defaultLimits),
|
||||
NullLogger<PayloadLimitsMiddleware>.Instance);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext(long? contentLength = null, string connectionId = "conn-1")
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
context.Request.Body = new MemoryStream();
|
||||
context.Connection.Id = connectionId;
|
||||
|
||||
if (contentLength.HasValue)
|
||||
{
|
||||
context.Request.ContentLength = contentLength;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
#region Within Limits Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithinLimits_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoContentLength_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: null);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 0))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithZeroContentLength_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 0);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 0))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Per-Call Limit Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsPerCallLimit_Returns413()
|
||||
{
|
||||
// Arrange
|
||||
var limits = new PayloadLimits { MaxRequestBytesPerCall = 1000 };
|
||||
var middleware = CreateMiddleware(limits);
|
||||
var context = CreateHttpContext(contentLength: 2000);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status413PayloadTooLarge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsPerCallLimit_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var limits = new PayloadLimits { MaxRequestBytesPerCall = 1000 };
|
||||
var middleware = CreateMiddleware(limits);
|
||||
var context = CreateHttpContext(contentLength: 2000);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Payload Too Large");
|
||||
responseBody.Should().Contain("1000");
|
||||
responseBody.Should().Contain("2000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExactlyAtPerCallLimit_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var limits = new PayloadLimits { MaxRequestBytesPerCall = 1000 };
|
||||
var middleware = CreateMiddleware(limits);
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Aggregate Limit Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsAggregateLimit_Returns503()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(false);
|
||||
_trackerMock.Setup(t => t.IsOverloaded)
|
||||
.Returns(true);
|
||||
_trackerMock.Setup(t => t.CurrentInflightBytes)
|
||||
.Returns(1024 * 1024 * 1024); // 1GB
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsAggregateLimit_WritesOverloadedResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(false);
|
||||
_trackerMock.Setup(t => t.IsOverloaded)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Overloaded");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Per-Connection Limit Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsPerConnectionLimit_Returns429()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(false);
|
||||
_trackerMock.Setup(t => t.IsOverloaded)
|
||||
.Returns(false); // Not aggregate limit
|
||||
_trackerMock.Setup(t => t.GetConnectionInflightBytes("conn-1"))
|
||||
.Returns(100 * 1024 * 1024); // 100MB
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status429TooManyRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsPerConnectionLimit_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(false);
|
||||
_trackerMock.Setup(t => t.IsOverloaded)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Too Many Requests");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Release Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_AfterSuccess_ReleasesReservation()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_trackerMock.Verify(t => t.Release("conn-1", It.IsAny<long>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_AfterNextThrows_StillReleasesReservation()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(true);
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Test error"));
|
||||
|
||||
// Act
|
||||
var act = async () => await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
_trackerMock.Verify(t => t.Release("conn-1", It.IsAny<long>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Different Connections Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_DifferentConnections_TrackedSeparately()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context1 = CreateHttpContext(contentLength: 1000, connectionId: "conn-1");
|
||||
var context2 = CreateHttpContext(contentLength: 2000, connectionId: "conn-2");
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve(It.IsAny<string>(), It.IsAny<long>()))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context1, _trackerMock.Object);
|
||||
await middleware.Invoke(context2, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_trackerMock.Verify(t => t.TryReserve("conn-1", 1000), Times.Once);
|
||||
_trackerMock.Verify(t => t.TryReserve("conn-2", 2000), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
public class PayloadTrackerTests
|
||||
{
|
||||
private readonly PayloadLimits _limits = new()
|
||||
{
|
||||
MaxRequestBytesPerCall = 1024,
|
||||
MaxRequestBytesPerConnection = 4096,
|
||||
MaxAggregateInflightBytes = 8192
|
||||
};
|
||||
|
||||
private PayloadTracker CreateTracker()
|
||||
{
|
||||
return new PayloadTracker(
|
||||
Options.Create(_limits),
|
||||
NullLogger<PayloadTracker>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReserve_WithinLimits_ReturnsTrue()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
var result = tracker.TryReserve("conn-1", 500);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(500, tracker.CurrentInflightBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReserve_ExceedsAggregateLimits_ReturnsFalse()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
// Reserve from multiple connections to approach aggregate limit (8192)
|
||||
// Each connection can have up to 4096 bytes
|
||||
Assert.True(tracker.TryReserve("conn-1", 4000));
|
||||
Assert.True(tracker.TryReserve("conn-2", 4000));
|
||||
// Now at 8000 bytes
|
||||
|
||||
// Another reservation that exceeds aggregate limit (8000 + 500 > 8192) should fail
|
||||
var result = tracker.TryReserve("conn-3", 500);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(8000, tracker.CurrentInflightBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReserve_ExceedsPerConnectionLimit_ReturnsFalse()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
// Reserve up to per-connection limit
|
||||
Assert.True(tracker.TryReserve("conn-1", 4000));
|
||||
|
||||
// Next reservation on same connection should fail
|
||||
var result = tracker.TryReserve("conn-1", 500);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReserve_DifferentConnections_TrackedSeparately()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
Assert.True(tracker.TryReserve("conn-1", 3000));
|
||||
Assert.True(tracker.TryReserve("conn-2", 3000));
|
||||
|
||||
Assert.Equal(3000, tracker.GetConnectionInflightBytes("conn-1"));
|
||||
Assert.Equal(3000, tracker.GetConnectionInflightBytes("conn-2"));
|
||||
Assert.Equal(6000, tracker.CurrentInflightBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Release_DecreasesInflightBytes()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.TryReserve("conn-1", 1000);
|
||||
tracker.Release("conn-1", 500);
|
||||
|
||||
Assert.Equal(500, tracker.CurrentInflightBytes);
|
||||
Assert.Equal(500, tracker.GetConnectionInflightBytes("conn-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Release_CannotGoNegative()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.TryReserve("conn-1", 100);
|
||||
tracker.Release("conn-1", 500); // More than reserved
|
||||
|
||||
Assert.Equal(0, tracker.GetConnectionInflightBytes("conn-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsOverloaded_TrueWhenExceedsLimit()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
// Reservation at limit passes (8192 <= 8192 is false for >, so not overloaded at exactly limit)
|
||||
// But we can't exceed the limit. The IsOverloaded check is for current > limit
|
||||
// So at exactly 8192, IsOverloaded should be false (8192 > 8192 is false)
|
||||
// Reserving 8193 would be rejected. So let's test that at limit, IsOverloaded is false
|
||||
tracker.TryReserve("conn-1", 8192);
|
||||
|
||||
// At exactly the limit, IsOverloaded is false (8192 > 8192 = false)
|
||||
Assert.False(tracker.IsOverloaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsOverloaded_FalseWhenWithinLimit()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.TryReserve("conn-1", 4000);
|
||||
|
||||
Assert.False(tracker.IsOverloaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionInflightBytes_ReturnsZeroForUnknownConnection()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
var result = tracker.GetConnectionInflightBytes("unknown");
|
||||
|
||||
Assert.Equal(0, result);
|
||||
}
|
||||
}
|
||||
|
||||
public class ByteCountingStreamTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadAsync_CountsBytesRead()
|
||||
{
|
||||
var data = new byte[] { 1, 2, 3, 4, 5 };
|
||||
using var inner = new MemoryStream(data);
|
||||
using var stream = new ByteCountingStream(inner, 100);
|
||||
|
||||
var buffer = new byte[10];
|
||||
var read = await stream.ReadAsync(buffer);
|
||||
|
||||
Assert.Equal(5, read);
|
||||
Assert.Equal(5, stream.BytesRead);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_ThrowsWhenLimitExceeded()
|
||||
{
|
||||
var data = new byte[100];
|
||||
using var inner = new MemoryStream(data);
|
||||
using var stream = new ByteCountingStream(inner, 50);
|
||||
|
||||
var buffer = new byte[100];
|
||||
|
||||
var ex = await Assert.ThrowsAsync<PayloadLimitExceededException>(
|
||||
() => stream.ReadAsync(buffer).AsTask());
|
||||
|
||||
Assert.Equal(100, ex.BytesRead);
|
||||
Assert.Equal(50, ex.Limit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_CallsCallbackOnLimitExceeded()
|
||||
{
|
||||
var data = new byte[100];
|
||||
using var inner = new MemoryStream(data);
|
||||
var callbackCalled = false;
|
||||
using var stream = new ByteCountingStream(inner, 50, () => callbackCalled = true);
|
||||
|
||||
var buffer = new byte[100];
|
||||
|
||||
await Assert.ThrowsAsync<PayloadLimitExceededException>(
|
||||
() => stream.ReadAsync(buffer).AsTask());
|
||||
|
||||
Assert.True(callbackCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_AccumulatesAcrossMultipleReads()
|
||||
{
|
||||
var data = new byte[100];
|
||||
using var inner = new MemoryStream(data);
|
||||
using var stream = new ByteCountingStream(inner, 60);
|
||||
|
||||
var buffer = new byte[30];
|
||||
|
||||
// First read - 30 bytes
|
||||
var read1 = await stream.ReadAsync(buffer);
|
||||
Assert.Equal(30, read1);
|
||||
Assert.Equal(30, stream.BytesRead);
|
||||
|
||||
// Second read - 30 more bytes
|
||||
var read2 = await stream.ReadAsync(buffer);
|
||||
Assert.Equal(30, read2);
|
||||
Assert.Equal(60, stream.BytesRead);
|
||||
|
||||
// Third read should exceed limit
|
||||
await Assert.ThrowsAsync<PayloadLimitExceededException>(
|
||||
() => stream.ReadAsync(buffer).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stream_Properties_AreCorrect()
|
||||
{
|
||||
using var inner = new MemoryStream();
|
||||
using var stream = new ByteCountingStream(inner, 100);
|
||||
|
||||
Assert.True(stream.CanRead);
|
||||
Assert.False(stream.CanWrite);
|
||||
Assert.False(stream.CanSeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_ThrowsNotSupported()
|
||||
{
|
||||
using var inner = new MemoryStream();
|
||||
using var stream = new ByteCountingStream(inner, 100);
|
||||
|
||||
Assert.Throws<NotSupportedException>(() => stream.Write(new byte[10], 0, 10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seek_ThrowsNotSupported()
|
||||
{
|
||||
using var inner = new MemoryStream();
|
||||
using var stream = new ByteCountingStream(inner, 100);
|
||||
|
||||
Assert.Throws<NotSupportedException>(() => stream.Seek(0, SeekOrigin.Begin));
|
||||
}
|
||||
}
|
||||
|
||||
public class PayloadLimitExceededExceptionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_SetsProperties()
|
||||
{
|
||||
var ex = new PayloadLimitExceededException(1000, 500);
|
||||
|
||||
Assert.Equal(1000, ex.BytesRead);
|
||||
Assert.Equal(500, ex.Limit);
|
||||
Assert.Contains("1000", ex.Message);
|
||||
Assert.Contains("500", ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RoutingDecisionMiddleware"/>.
|
||||
/// </summary>
|
||||
public sealed class RoutingDecisionMiddlewareTests
|
||||
{
|
||||
private readonly Mock<IRoutingPlugin> _routingPluginMock;
|
||||
private readonly Mock<IGlobalRoutingState> _routingStateMock;
|
||||
private readonly Mock<RequestDelegate> _nextMock;
|
||||
private readonly GatewayNodeConfig _gatewayConfig;
|
||||
private readonly RoutingOptions _routingOptions;
|
||||
private bool _nextCalled;
|
||||
|
||||
public RoutingDecisionMiddlewareTests()
|
||||
{
|
||||
_routingPluginMock = new Mock<IRoutingPlugin>();
|
||||
_routingStateMock = new Mock<IGlobalRoutingState>();
|
||||
_nextMock = new Mock<RequestDelegate>();
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.Callback(() => _nextCalled = true)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_gatewayConfig = new GatewayNodeConfig
|
||||
{
|
||||
Region = "us-east-1",
|
||||
NodeId = "gw-01",
|
||||
Environment = "test"
|
||||
};
|
||||
|
||||
_routingOptions = new RoutingOptions
|
||||
{
|
||||
DefaultVersion = "1.0.0"
|
||||
};
|
||||
}
|
||||
|
||||
private RoutingDecisionMiddleware CreateMiddleware()
|
||||
{
|
||||
return new RoutingDecisionMiddleware(_nextMock.Object);
|
||||
}
|
||||
|
||||
private HttpContext CreateHttpContext(EndpointDescriptor? endpoint = null)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = "GET";
|
||||
context.Request.Path = "/api/test";
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
if (endpoint is not null)
|
||||
{
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor] = endpoint;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0")
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Method = "GET",
|
||||
Path = "/api/test"
|
||||
};
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId = "conn-1",
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"inst-{connectionId}",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "us-east-1"
|
||||
},
|
||||
Status = status,
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
}
|
||||
|
||||
private static RoutingDecision CreateDecision(
|
||||
EndpointDescriptor? endpoint = null,
|
||||
ConnectionState? connection = null)
|
||||
{
|
||||
return new RoutingDecision
|
||||
{
|
||||
Endpoint = endpoint ?? CreateEndpoint(),
|
||||
Connection = connection ?? CreateConnection(),
|
||||
TransportType = TransportType.InMemory,
|
||||
EffectiveTimeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
}
|
||||
|
||||
#region Missing Endpoint Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoEndpoint_Returns500()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(endpoint: null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoEndpoint_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(endpoint: null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("descriptor missing");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Available Instance Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithAvailableInstance_SetsRoutingDecision()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var connection = CreateConnection();
|
||||
var decision = CreateDecision(endpoint, connection);
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
endpoint.ServiceName, endpoint.Version, endpoint.Method, endpoint.Path))
|
||||
.Returns([connection]);
|
||||
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Items[RouterHttpContextKeys.RoutingDecision].Should().Be(decision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithAvailableInstance_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var decision = CreateDecision(endpoint);
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([CreateConnection()]);
|
||||
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region No Instances Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoInstances_Returns503()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([]);
|
||||
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RoutingDecision?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoInstances_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([]);
|
||||
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RoutingDecision?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("No instances available");
|
||||
responseBody.Should().Contain("test-service");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Routing Context Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_PassesCorrectRoutingContext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var decision = CreateDecision(endpoint);
|
||||
var connection = CreateConnection();
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
endpoint.ServiceName, endpoint.Version, endpoint.Method, endpoint.Path))
|
||||
.Returns([connection]);
|
||||
|
||||
RoutingContext? capturedContext = null;
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.Method.Should().Be("GET");
|
||||
capturedContext.Path.Should().Be("/api/test");
|
||||
capturedContext.GatewayRegion.Should().Be("us-east-1");
|
||||
capturedContext.Endpoint.Should().Be(endpoint);
|
||||
capturedContext.AvailableConnections.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_PassesRequestHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var decision = CreateDecision(endpoint);
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
context.Request.Headers["X-Custom-Header"] = "CustomValue";
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([CreateConnection()]);
|
||||
|
||||
RoutingContext? capturedContext = null;
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
capturedContext!.Headers.Should().ContainKey("X-Custom-Header");
|
||||
capturedContext.Headers["X-Custom-Header"].Should().Be("CustomValue");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Version Extraction Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithXApiVersionHeader_ExtractsVersion()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var decision = CreateDecision(endpoint);
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
context.Request.Headers["X-Api-Version"] = "2.0.0";
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([CreateConnection()]);
|
||||
|
||||
RoutingContext? capturedContext = null;
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
capturedContext!.RequestedVersion.Should().Be("2.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoVersionHeader_UsesDefault()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var decision = CreateDecision(endpoint);
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([CreateConnection()]);
|
||||
|
||||
RoutingContext? capturedContext = null;
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
capturedContext!.RequestedVersion.Should().Be("1.0.0"); // From _routingOptions
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<!-- Disable Concelier test infrastructure - not needed for Gateway tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-preview.7.25380.108" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Gateway.WebService\StellaOps.Gateway.WebService.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,315 +0,0 @@
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Microservice.Streaming;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
public class StreamingTests
|
||||
{
|
||||
private readonly InMemoryConnectionRegistry _registry = new();
|
||||
private readonly InMemoryTransportOptions _options = new() { SimulatedLatency = TimeSpan.Zero };
|
||||
|
||||
private InMemoryTransportClient CreateClient()
|
||||
{
|
||||
return new InMemoryTransportClient(
|
||||
_registry,
|
||||
Options.Create(_options),
|
||||
NullLogger<InMemoryTransportClient>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamDataPayload_HasRequiredProperties()
|
||||
{
|
||||
var payload = new StreamDataPayload
|
||||
{
|
||||
CorrelationId = Guid.NewGuid(),
|
||||
Data = new byte[] { 1, 2, 3 },
|
||||
EndOfStream = true,
|
||||
SequenceNumber = 5
|
||||
};
|
||||
|
||||
Assert.NotEqual(Guid.Empty, payload.CorrelationId);
|
||||
Assert.Equal(3, payload.Data.Length);
|
||||
Assert.True(payload.EndOfStream);
|
||||
Assert.Equal(5, payload.SequenceNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamingOptions_HasDefaultValues()
|
||||
{
|
||||
var options = StreamingOptions.Default;
|
||||
|
||||
Assert.Equal(64 * 1024, options.ChunkSize);
|
||||
Assert.Equal(100, options.MaxConcurrentStreams);
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), options.StreamIdleTimeout);
|
||||
Assert.Equal(16, options.ChannelCapacity);
|
||||
}
|
||||
}
|
||||
|
||||
public class StreamingRequestBodyStreamTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadAsync_ReturnsDataFromChannel()
|
||||
{
|
||||
// Arrange
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
|
||||
|
||||
var testData = new byte[] { 1, 2, 3, 4, 5 };
|
||||
await channel.Writer.WriteAsync(new StreamChunk { Data = testData, SequenceNumber = 0 });
|
||||
await channel.Writer.WriteAsync(new StreamChunk { Data = [], EndOfStream = true, SequenceNumber = 1 });
|
||||
channel.Writer.Complete();
|
||||
|
||||
// Act
|
||||
var buffer = new byte[10];
|
||||
var bytesRead = await stream.ReadAsync(buffer);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, bytesRead);
|
||||
Assert.Equal(testData, buffer[..5]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_ReturnsZeroAtEndOfStream()
|
||||
{
|
||||
// Arrange
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
|
||||
|
||||
await channel.Writer.WriteAsync(new StreamChunk { Data = [], EndOfStream = true, SequenceNumber = 0 });
|
||||
channel.Writer.Complete();
|
||||
|
||||
// Act
|
||||
var buffer = new byte[10];
|
||||
var bytesRead = await stream.ReadAsync(buffer);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, bytesRead);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_HandlesMultipleChunks()
|
||||
{
|
||||
// Arrange
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
|
||||
|
||||
await channel.Writer.WriteAsync(new StreamChunk { Data = [1, 2, 3], SequenceNumber = 0 });
|
||||
await channel.Writer.WriteAsync(new StreamChunk { Data = [4, 5, 6], SequenceNumber = 1 });
|
||||
await channel.Writer.WriteAsync(new StreamChunk { Data = [], EndOfStream = true, SequenceNumber = 2 });
|
||||
channel.Writer.Complete();
|
||||
|
||||
// Act
|
||||
using var memStream = new MemoryStream();
|
||||
await stream.CopyToAsync(memStream);
|
||||
|
||||
// Assert
|
||||
var result = memStream.ToArray();
|
||||
Assert.Equal(6, result.Length);
|
||||
Assert.Equal(new byte[] { 1, 2, 3, 4, 5, 6 }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stream_Properties_AreCorrect()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
|
||||
|
||||
Assert.True(stream.CanRead);
|
||||
Assert.False(stream.CanWrite);
|
||||
Assert.False(stream.CanSeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_ThrowsNotSupported()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
|
||||
|
||||
Assert.Throws<NotSupportedException>(() => stream.Write([1, 2, 3], 0, 3));
|
||||
}
|
||||
}
|
||||
|
||||
public class StreamingResponseBodyStreamTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriteAsync_WritesToChannel()
|
||||
{
|
||||
// Arrange
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
await using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
|
||||
|
||||
var testData = new byte[] { 1, 2, 3, 4, 5 };
|
||||
|
||||
// Act
|
||||
await stream.WriteAsync(testData);
|
||||
await stream.FlushAsync();
|
||||
|
||||
// Assert
|
||||
Assert.True(channel.Reader.TryRead(out var chunk));
|
||||
Assert.Equal(testData, chunk!.Data);
|
||||
Assert.False(chunk.EndOfStream);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompleteAsync_SendsEndOfStream()
|
||||
{
|
||||
// Arrange
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
await using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
await stream.WriteAsync(new byte[] { 1, 2, 3 });
|
||||
await stream.CompleteAsync();
|
||||
|
||||
// Assert - should have data chunk + end chunk
|
||||
var chunks = new List<StreamChunk>();
|
||||
await foreach (var chunk in channel.Reader.ReadAllAsync())
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
|
||||
Assert.Equal(2, chunks.Count);
|
||||
Assert.False(chunks[0].EndOfStream);
|
||||
Assert.True(chunks[1].EndOfStream);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_ChunksLargeData()
|
||||
{
|
||||
// Arrange
|
||||
var chunkSize = 10;
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
await using var stream = new StreamingResponseBodyStream(channel.Writer, chunkSize, CancellationToken.None);
|
||||
|
||||
var testData = new byte[25]; // Will need 3 chunks
|
||||
for (var i = 0; i < testData.Length; i++)
|
||||
{
|
||||
testData[i] = (byte)i;
|
||||
}
|
||||
|
||||
// Act
|
||||
await stream.WriteAsync(testData);
|
||||
await stream.CompleteAsync();
|
||||
|
||||
// Assert
|
||||
var chunks = new List<StreamChunk>();
|
||||
await foreach (var chunk in channel.Reader.ReadAllAsync())
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
|
||||
// Should have 3 chunks (10+10+5) + 1 end-of-stream (with 0 data since remainder already flushed)
|
||||
Assert.Equal(4, chunks.Count);
|
||||
Assert.Equal(10, chunks[0].Data.Length);
|
||||
Assert.Equal(10, chunks[1].Data.Length);
|
||||
Assert.Equal(5, chunks[2].Data.Length);
|
||||
Assert.True(chunks[3].EndOfStream);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stream_Properties_AreCorrect()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
|
||||
|
||||
Assert.False(stream.CanRead);
|
||||
Assert.True(stream.CanWrite);
|
||||
Assert.False(stream.CanSeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_ThrowsNotSupported()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
|
||||
|
||||
Assert.Throws<NotSupportedException>(() => stream.Read(new byte[10], 0, 10));
|
||||
}
|
||||
}
|
||||
|
||||
public class InMemoryTransportStreamingTests
|
||||
{
|
||||
private readonly InMemoryConnectionRegistry _registry = new();
|
||||
private readonly InMemoryTransportOptions _options = new() { SimulatedLatency = TimeSpan.Zero };
|
||||
|
||||
private InMemoryTransportClient CreateClient()
|
||||
{
|
||||
return new InMemoryTransportClient(
|
||||
_registry,
|
||||
Options.Create(_options),
|
||||
NullLogger<InMemoryTransportClient>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendStreamingAsync_SendsRequestStreamDataFrames()
|
||||
{
|
||||
// Arrange
|
||||
using var client = CreateClient();
|
||||
var instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "test-instance",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "us-east-1"
|
||||
};
|
||||
|
||||
await client.ConnectAsync(instance, [], CancellationToken.None);
|
||||
|
||||
// Get connection ID via reflection
|
||||
var connectionIdField = client.GetType()
|
||||
.GetField("_connectionId", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
var connectionId = connectionIdField?.GetValue(client)?.ToString();
|
||||
Assert.NotNull(connectionId);
|
||||
|
||||
var channel = _registry.GetChannel(connectionId!);
|
||||
Assert.NotNull(channel);
|
||||
Assert.NotNull(channel!.State);
|
||||
|
||||
// Create request body stream
|
||||
var requestBody = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 });
|
||||
|
||||
// Create request frame
|
||||
var requestFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
|
||||
var limits = PayloadLimits.Default;
|
||||
|
||||
// Act - Start streaming (this will send frames to microservice)
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var sendTask = client.SendStreamingAsync(
|
||||
channel.State!,
|
||||
requestFrame,
|
||||
requestBody,
|
||||
_ => Task.CompletedTask,
|
||||
limits,
|
||||
cts.Token);
|
||||
|
||||
// Read the frames that were sent to microservice
|
||||
var frames = new List<Frame>();
|
||||
await foreach (var frame in channel.ToMicroservice.Reader.ReadAllAsync(cts.Token))
|
||||
{
|
||||
frames.Add(frame);
|
||||
if (frame.Type == FrameType.RequestStreamData && frame.Payload.Length == 0)
|
||||
{
|
||||
// End of stream - break
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert - should have REQUEST header + data chunks + end-of-stream
|
||||
Assert.True(frames.Count >= 2);
|
||||
Assert.Equal(FrameType.Request, frames[0].Type);
|
||||
Assert.Equal(FrameType.RequestStreamData, frames[^1].Type);
|
||||
Assert.Equal(0, frames[^1].Payload.Length); // End of stream marker
|
||||
}
|
||||
}
|
||||
@@ -1,786 +0,0 @@
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="TransportDispatchMiddleware"/>.
|
||||
/// </summary>
|
||||
public sealed class TransportDispatchMiddlewareTests
|
||||
{
|
||||
private readonly Mock<ITransportClient> _transportClientMock;
|
||||
private readonly Mock<IGlobalRoutingState> _routingStateMock;
|
||||
private readonly Mock<RequestDelegate> _nextMock;
|
||||
private bool _nextCalled;
|
||||
|
||||
public TransportDispatchMiddlewareTests()
|
||||
{
|
||||
_transportClientMock = new Mock<ITransportClient>();
|
||||
_routingStateMock = new Mock<IGlobalRoutingState>();
|
||||
_nextMock = new Mock<RequestDelegate>();
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.Callback(() => _nextCalled = true)
|
||||
.Returns(Task.CompletedTask);
|
||||
}
|
||||
|
||||
private TransportDispatchMiddleware CreateMiddleware()
|
||||
{
|
||||
return new TransportDispatchMiddleware(
|
||||
_nextMock.Object,
|
||||
NullLogger<TransportDispatchMiddleware>.Instance);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext(
|
||||
RoutingDecision? decision = null,
|
||||
string method = "GET",
|
||||
string path = "/api/test",
|
||||
byte[]? body = null)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = method;
|
||||
context.Request.Path = path;
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
context.Request.Body = new MemoryStream(body);
|
||||
context.Request.ContentLength = body.Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Request.Body = new MemoryStream();
|
||||
}
|
||||
|
||||
if (decision is not null)
|
||||
{
|
||||
context.Items[RouterHttpContextKeys.RoutingDecision] = decision;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
bool supportsStreaming = false)
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
SupportsStreaming = supportsStreaming
|
||||
};
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId = "conn-1",
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"inst-{connectionId}",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "us-east-1"
|
||||
},
|
||||
Status = status,
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
}
|
||||
|
||||
private static RoutingDecision CreateDecision(
|
||||
EndpointDescriptor? endpoint = null,
|
||||
ConnectionState? connection = null,
|
||||
TimeSpan? timeout = null)
|
||||
{
|
||||
return new RoutingDecision
|
||||
{
|
||||
Endpoint = endpoint ?? CreateEndpoint(),
|
||||
Connection = connection ?? CreateConnection(),
|
||||
TransportType = TransportType.InMemory,
|
||||
EffectiveTimeout = timeout ?? TimeSpan.FromSeconds(30)
|
||||
};
|
||||
}
|
||||
|
||||
private static Frame CreateResponseFrame(
|
||||
string requestId = "test-request",
|
||||
int statusCode = 200,
|
||||
Dictionary<string, string>? headers = null,
|
||||
byte[]? payload = null)
|
||||
{
|
||||
var response = new ResponseFrame
|
||||
{
|
||||
RequestId = requestId,
|
||||
StatusCode = statusCode,
|
||||
Headers = headers ?? new Dictionary<string, string>(),
|
||||
Payload = payload ?? []
|
||||
};
|
||||
|
||||
return FrameConverter.ToFrame(response);
|
||||
}
|
||||
|
||||
#region Missing Routing Decision Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoRoutingDecision_Returns500()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(decision: null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoRoutingDecision_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(decision: null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Routing decision missing");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Successful Request/Response Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithSuccessfulResponse_ForwardsStatusCode()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId, statusCode: 201);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(201);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithResponsePayload_WritesToResponseBody()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
var responsePayload = Encoding.UTF8.GetBytes("{\"result\":\"success\"}");
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId, payload: responsePayload);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Be("{\"result\":\"success\"}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithResponseHeaders_ForwardsHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
var responseHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["X-Custom-Header"] = "CustomValue",
|
||||
["Content-Type"] = "application/json"
|
||||
};
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId, headers: responseHeaders);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Headers.Should().ContainKey("X-Custom-Header");
|
||||
context.Response.Headers["X-Custom-Header"].ToString().Should().Be("CustomValue");
|
||||
context.Response.Headers["Content-Type"].ToString().Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithTransferEncodingHeader_DoesNotForward()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
var responseHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["Transfer-Encoding"] = "chunked",
|
||||
["X-Custom-Header"] = "CustomValue"
|
||||
};
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId, headers: responseHeaders);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Headers.Should().NotContainKey("Transfer-Encoding");
|
||||
context.Response.Headers.Should().ContainKey("X-Custom-Header");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithRequestBody_SendsBodyInFrame()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var requestBody = Encoding.UTF8.GetBytes("{\"data\":\"test\"}");
|
||||
var context = CreateHttpContext(decision: decision, body: requestBody);
|
||||
|
||||
byte[]? capturedPayload = null;
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ConnectionState, Frame, TimeSpan, CancellationToken>((conn, req, timeout, ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
capturedPayload = requestFrame?.Payload.ToArray();
|
||||
})
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
capturedPayload.Should().BeEquivalentTo(requestBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithRequestHeaders_ForwardsHeadersInFrame()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
context.Request.Headers["X-Request-Id"] = "req-123";
|
||||
context.Request.Headers["Accept"] = "application/json";
|
||||
|
||||
IReadOnlyDictionary<string, string>? capturedHeaders = null;
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ConnectionState, Frame, TimeSpan, CancellationToken>((conn, req, timeout, ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
capturedHeaders = requestFrame?.Headers;
|
||||
})
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
capturedHeaders.Should().NotBeNull();
|
||||
capturedHeaders.Should().ContainKey("X-Request-Id");
|
||||
capturedHeaders!["X-Request-Id"].Should().Be("req-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Timeout Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithTimeout_Returns504()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision(timeout: TimeSpan.FromMilliseconds(50));
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status504GatewayTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithTimeout_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision(timeout: TimeSpan.FromMilliseconds(50));
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Upstream timeout");
|
||||
responseBody.Should().Contain("test-service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithTimeout_SendsCancelFrame()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision(timeout: TimeSpan.FromMilliseconds(50));
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_transportClientMock.Verify(t => t.SendCancelAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Guid>(),
|
||||
CancelReasons.Timeout), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Upstream Error Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithUpstreamError_Returns502()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Connection failed"));
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status502BadGateway);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithUpstreamError_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Connection failed"));
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Upstream error");
|
||||
responseBody.Should().Contain("Connection failed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Invalid Response Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithInvalidResponseFrame_Returns502()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
// Return a malformed frame that cannot be parsed as ResponseFrame
|
||||
var invalidFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Heartbeat, // Wrong type
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(invalidFrame);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status502BadGateway);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithInvalidResponseFrame_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
var invalidFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Cancel, // Wrong type
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(invalidFrame);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Invalid upstream response");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Connection Ping Update Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithSuccessfulResponse_UpdatesConnectionPing()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(r => r.UpdateConnection(
|
||||
"conn-1",
|
||||
It.IsAny<Action<ConnectionState>>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Streaming Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithStreamingEndpoint_UsesSendStreamingAsync()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(supportsStreaming: true);
|
||||
var decision = CreateDecision(endpoint: endpoint);
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendStreamingAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<Func<Stream, Task>>(),
|
||||
It.IsAny<PayloadLimits>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ConnectionState, Frame, Stream, Func<Stream, Task>, PayloadLimits, CancellationToken>(
|
||||
async (conn, req, requestBody, readResponse, limits, ct) =>
|
||||
{
|
||||
// Simulate streaming response
|
||||
using var responseStream = new MemoryStream(Encoding.UTF8.GetBytes("streamed data"));
|
||||
await readResponse(responseStream);
|
||||
})
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_transportClientMock.Verify(t => t.SendStreamingAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<Func<Stream, Task>>(),
|
||||
It.IsAny<PayloadLimits>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_StreamingWithTimeout_Returns504()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(supportsStreaming: true);
|
||||
var decision = CreateDecision(endpoint: endpoint, timeout: TimeSpan.FromMilliseconds(50));
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendStreamingAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<Func<Stream, Task>>(),
|
||||
It.IsAny<PayloadLimits>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status504GatewayTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_StreamingWithUpstreamError_Returns502()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(supportsStreaming: true);
|
||||
var decision = CreateDecision(endpoint: endpoint);
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendStreamingAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<Func<Stream, Task>>(),
|
||||
It.IsAny<PayloadLimits>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Streaming failed"));
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status502BadGateway);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Query String Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithQueryString_IncludesInRequestPath()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision, path: "/api/test");
|
||||
context.Request.QueryString = new QueryString("?key=value&other=123");
|
||||
|
||||
string? capturedPath = null;
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ConnectionState, Frame, TimeSpan, CancellationToken>((conn, req, timeout, ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
capturedPath = requestFrame?.Path;
|
||||
})
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
capturedPath.Should().Be("/api/test?key=value&other=123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace StellaOps.Notify.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a localization bundle containing translated strings for a specific locale.
|
||||
/// </summary>
|
||||
public sealed class LocalizationBundleEntity
|
||||
{
|
||||
public required string BundleId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Locale { get; init; }
|
||||
public required string BundleKey { get; init; }
|
||||
public required string Strings { get; init; }
|
||||
public bool IsDefault { get; init; }
|
||||
public string? ParentLocale { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? Metadata { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? UpdatedBy { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Notify.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an operator override for bypassing quiet hours, throttling, or maintenance windows.
|
||||
/// </summary>
|
||||
public sealed class OperatorOverrideEntity
|
||||
{
|
||||
public required string OverrideId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string OverrideType { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
public string? ChannelId { get; init; }
|
||||
public string? RuleId { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Notify.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents throttle configuration for rate-limiting notifications.
|
||||
/// </summary>
|
||||
public sealed class ThrottleConfigEntity
|
||||
{
|
||||
public required string ConfigId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required TimeSpan DefaultWindow { get; init; }
|
||||
public int? MaxNotificationsPerWindow { get; init; }
|
||||
public string? ChannelId { get; init; }
|
||||
public bool IsDefault { get; init; }
|
||||
public bool Enabled { get; init; } = true;
|
||||
public string? Description { get; init; }
|
||||
public string? Metadata { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? UpdatedBy { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.Notify.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for throttle configuration.
|
||||
/// </summary>
|
||||
public interface IThrottleConfigRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a throttle configuration by ID.
|
||||
/// </summary>
|
||||
Task<ThrottleConfigEntity?> GetByIdAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all throttle configurations for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ThrottleConfigEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default throttle configuration for a tenant.
|
||||
/// </summary>
|
||||
Task<ThrottleConfigEntity?> GetDefaultAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets throttle configuration for a specific channel.
|
||||
/// </summary>
|
||||
Task<ThrottleConfigEntity?> GetByChannelAsync(string tenantId, string channelId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new throttle configuration.
|
||||
/// </summary>
|
||||
Task<ThrottleConfigEntity> CreateAsync(ThrottleConfigEntity config, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing throttle configuration.
|
||||
/// </summary>
|
||||
Task<bool> UpdateAsync(ThrottleConfigEntity config, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a throttle configuration.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -253,7 +253,8 @@ public static class NativeFormatDetector
|
||||
if (cmd == 0x1B && cmdsize >= 24 && offset + cmdsize <= span.Length) // LC_UUID
|
||||
{
|
||||
var uuidSpan = span.Slice(offset + 8, 16);
|
||||
uuid = new Guid(uuidSpan.ToArray()).ToString();
|
||||
var rawUuid = Convert.ToHexString(uuidSpan.ToArray()).ToLowerInvariant();
|
||||
uuid = $"macho-uuid:{rawUuid}";
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -267,7 +268,8 @@ public static class NativeFormatDetector
|
||||
}
|
||||
}
|
||||
|
||||
identity = new NativeBinaryIdentity(NativeFormat.MachO, arch, "darwin", Endianness: endianness, BuildId: null, Uuid: uuid, InterpreterPath: null);
|
||||
// Store Mach-O UUID in BuildId field (prefixed) and also in Uuid for backwards compatibility
|
||||
identity = new NativeBinaryIdentity(NativeFormat.MachO, arch, "darwin", Endianness: endianness, BuildId: uuid, Uuid: uuid, InterpreterPath: null);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -347,7 +349,8 @@ public static class NativeFormatDetector
|
||||
if (name[0] == (byte)'G' && name[1] == (byte)'N' && name[2] == (byte)'U')
|
||||
{
|
||||
var desc = note.Slice(descStart, (int)Math.Min(descsz, (uint)(note.Length - descStart)));
|
||||
return Convert.ToHexString(desc).ToLowerInvariant();
|
||||
var rawBuildId = Convert.ToHexString(desc).ToLowerInvariant();
|
||||
return $"gnu-build-id:{rawBuildId}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
|
||||
|
||||
@@ -8,30 +9,295 @@ public sealed class DotNetLanguageAnalyzer : ILanguageAnalyzer
|
||||
|
||||
public string DisplayName => ".NET Analyzer (preview)";
|
||||
|
||||
private DotNetAnalyzerOptions _options = new();
|
||||
|
||||
public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
var packages = await DotNetDependencyCollector.CollectAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
if (packages.Count == 0)
|
||||
_options = DotNetAnalyzerOptions.Load(context);
|
||||
|
||||
// Collect from deps.json files (installed packages)
|
||||
var installedPackages = await DotNetDependencyCollector.CollectAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Collect declared dependencies from build files
|
||||
var declaredCollector = new DotNetDeclaredDependencyCollector(context, _options);
|
||||
var declaredPackages = await declaredCollector.CollectAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Collect bundling signals (bounded candidate selection per Decision D3)
|
||||
var bundlingCollector = new DotNetBundlingSignalCollector(context);
|
||||
var bundlingSignals = bundlingCollector.Collect(cancellationToken);
|
||||
|
||||
if (installedPackages.Count > 0)
|
||||
{
|
||||
return;
|
||||
// Merge mode: we have installed packages from deps.json
|
||||
EmitMergedPackages(writer, installedPackages, declaredPackages, bundlingSignals, cancellationToken);
|
||||
}
|
||||
else if (declaredPackages.Count > 0)
|
||||
{
|
||||
// Fallback mode: no deps.json, emit declared-only packages
|
||||
EmitDeclaredOnlyPackages(writer, declaredPackages, cancellationToken);
|
||||
|
||||
// If bundling signals detected without deps.json, emit synthetic bundle markers
|
||||
EmitBundlingOnlySignals(writer, bundlingSignals, cancellationToken);
|
||||
}
|
||||
else if (bundlingSignals.Count > 0)
|
||||
{
|
||||
// Only bundling signals detected (rare case)
|
||||
EmitBundlingOnlySignals(writer, bundlingSignals, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitMergedPackages(
|
||||
LanguageComponentWriter writer,
|
||||
IReadOnlyList<DotNetPackage> installedPackages,
|
||||
IReadOnlyList<DotNetDeclaredPackage> declaredPackages,
|
||||
IReadOnlyList<BundlingSignal> bundlingSignals,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Build lookup for declared packages: key = normalizedId::version
|
||||
var declaredLookup = new Dictionary<string, DotNetDeclaredPackage>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var declared in declaredPackages)
|
||||
{
|
||||
if (declared.IsVersionResolved && !string.IsNullOrEmpty(declared.Version))
|
||||
{
|
||||
var key = $"{declared.NormalizedId}::{declared.Version}";
|
||||
if (!declaredLookup.ContainsKey(key))
|
||||
{
|
||||
declaredLookup[key] = declared;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var package in packages)
|
||||
// Build set of matched declared packages
|
||||
var matchedDeclared = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Flag to track if we've attached bundling signals to first entrypoint package
|
||||
var bundlingAttached = false;
|
||||
|
||||
// Emit installed packages, tagging those without declared records
|
||||
foreach (var package in installedPackages)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var lookupKey = $"{package.NormalizedId}::{package.Version}";
|
||||
var hasDeclaredRecord = declaredLookup.ContainsKey(lookupKey);
|
||||
|
||||
if (hasDeclaredRecord)
|
||||
{
|
||||
matchedDeclared.Add(lookupKey);
|
||||
}
|
||||
|
||||
var metadata = package.Metadata.ToList();
|
||||
if (!hasDeclaredRecord)
|
||||
{
|
||||
// Tag installed package that has no corresponding declared record
|
||||
metadata.Add(new KeyValuePair<string, string?>("declared.missing", "true"));
|
||||
}
|
||||
|
||||
// Attach bundling signals to entrypoint packages
|
||||
if (!bundlingAttached && bundlingSignals.Count > 0 && package.UsedByEntrypoint)
|
||||
{
|
||||
foreach (var signal in bundlingSignals)
|
||||
{
|
||||
foreach (var kvp in signal.ToMetadata())
|
||||
{
|
||||
metadata.Add(kvp);
|
||||
}
|
||||
}
|
||||
|
||||
bundlingAttached = true;
|
||||
}
|
||||
|
||||
metadata.Sort(static (a, b) => string.CompareOrdinal(a.Key, b.Key));
|
||||
|
||||
writer.AddFromPurl(
|
||||
analyzerId: Id,
|
||||
purl: package.Purl,
|
||||
name: package.Name,
|
||||
version: package.Version,
|
||||
type: "nuget",
|
||||
metadata: package.Metadata,
|
||||
metadata: metadata,
|
||||
evidence: package.Evidence,
|
||||
usedByEntrypoint: package.UsedByEntrypoint);
|
||||
}
|
||||
|
||||
// If no entrypoint package found but bundling signals exist, emit synthetic bundle marker
|
||||
if (!bundlingAttached && bundlingSignals.Count > 0)
|
||||
{
|
||||
EmitBundlingOnlySignals(writer, bundlingSignals, cancellationToken);
|
||||
}
|
||||
|
||||
// Emit declared packages that have no corresponding installed package
|
||||
foreach (var declared in declaredPackages)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!declared.IsVersionResolved || string.IsNullOrEmpty(declared.Version))
|
||||
{
|
||||
// Unresolved version - always emit as declared-only with explicit key
|
||||
var metadata = declared.Metadata.ToList();
|
||||
metadata.Add(new KeyValuePair<string, string?>("installed.missing", "true"));
|
||||
if (_options.EmitDependencyEdges && declared.Edges.Count > 0)
|
||||
{
|
||||
AddEdgeMetadata(metadata, declared.Edges, "edge");
|
||||
}
|
||||
|
||||
metadata.Sort(static (a, b) => string.CompareOrdinal(a.Key, b.Key));
|
||||
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: Id,
|
||||
componentKey: declared.ComponentKey,
|
||||
purl: null,
|
||||
name: declared.Name,
|
||||
version: declared.Version,
|
||||
type: "nuget",
|
||||
metadata: metadata,
|
||||
evidence: declared.Evidence,
|
||||
usedByEntrypoint: false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var lookupKey = $"{declared.NormalizedId}::{declared.Version}";
|
||||
if (matchedDeclared.Contains(lookupKey))
|
||||
{
|
||||
// Already matched with an installed package
|
||||
continue;
|
||||
}
|
||||
|
||||
// Declared package not in installed set - emit as declared-only
|
||||
{
|
||||
var metadata = declared.Metadata.ToList();
|
||||
metadata.Add(new KeyValuePair<string, string?>("installed.missing", "true"));
|
||||
if (_options.EmitDependencyEdges && declared.Edges.Count > 0)
|
||||
{
|
||||
AddEdgeMetadata(metadata, declared.Edges, "edge");
|
||||
}
|
||||
|
||||
metadata.Sort(static (a, b) => string.CompareOrdinal(a.Key, b.Key));
|
||||
|
||||
writer.AddFromPurl(
|
||||
analyzerId: Id,
|
||||
purl: declared.Purl!,
|
||||
name: declared.Name,
|
||||
version: declared.Version,
|
||||
type: "nuget",
|
||||
metadata: metadata,
|
||||
evidence: declared.Evidence,
|
||||
usedByEntrypoint: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitDeclaredOnlyPackages(
|
||||
LanguageComponentWriter writer,
|
||||
IReadOnlyList<DotNetDeclaredPackage> declaredPackages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var package in declaredPackages)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Build metadata with optional edges
|
||||
var metadata = package.Metadata.ToList();
|
||||
if (_options.EmitDependencyEdges && package.Edges.Count > 0)
|
||||
{
|
||||
AddEdgeMetadata(metadata, package.Edges, "edge");
|
||||
}
|
||||
|
||||
metadata.Sort(static (a, b) => string.CompareOrdinal(a.Key, b.Key));
|
||||
|
||||
if (package.Purl is not null)
|
||||
{
|
||||
// Resolved version - use PURL
|
||||
writer.AddFromPurl(
|
||||
analyzerId: Id,
|
||||
purl: package.Purl,
|
||||
name: package.Name,
|
||||
version: package.Version,
|
||||
type: "nuget",
|
||||
metadata: metadata,
|
||||
evidence: package.Evidence,
|
||||
usedByEntrypoint: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unresolved version - use explicit key
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: Id,
|
||||
componentKey: package.ComponentKey,
|
||||
purl: null,
|
||||
name: package.Name,
|
||||
version: package.Version,
|
||||
type: "nuget",
|
||||
metadata: metadata,
|
||||
evidence: package.Evidence,
|
||||
usedByEntrypoint: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddEdgeMetadata(
|
||||
List<KeyValuePair<string, string?>> metadata,
|
||||
IReadOnlyList<DotNetDependencyEdge> edges,
|
||||
string prefix)
|
||||
{
|
||||
if (edges.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var index = 0; index < edges.Count; index++)
|
||||
{
|
||||
var edge = edges[index];
|
||||
metadata.Add(new KeyValuePair<string, string?>($"{prefix}[{index}].target", edge.Target));
|
||||
metadata.Add(new KeyValuePair<string, string?>($"{prefix}[{index}].reason", edge.Reason));
|
||||
metadata.Add(new KeyValuePair<string, string?>($"{prefix}[{index}].confidence", edge.Confidence));
|
||||
metadata.Add(new KeyValuePair<string, string?>($"{prefix}[{index}].source", edge.Source));
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitBundlingOnlySignals(
|
||||
LanguageComponentWriter writer,
|
||||
IReadOnlyList<BundlingSignal> bundlingSignals,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (bundlingSignals.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var signal in bundlingSignals)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Emit a synthetic bundle marker component
|
||||
var metadata = new List<KeyValuePair<string, string?>>(signal.ToMetadata())
|
||||
{
|
||||
new("synthetic", "true"),
|
||||
new("provenance", "bundle-detection")
|
||||
};
|
||||
metadata.Sort(static (a, b) => string.CompareOrdinal(a.Key, b.Key));
|
||||
|
||||
var componentKey = $"bundle:dotnet/{signal.FilePath.Replace('/', '-').Replace('\\', '-')}";
|
||||
var appName = Path.GetFileNameWithoutExtension(signal.FilePath);
|
||||
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: Id,
|
||||
componentKey: componentKey,
|
||||
purl: null,
|
||||
name: $"[Bundle] {appName}",
|
||||
version: null,
|
||||
type: "bundle",
|
||||
metadata: metadata,
|
||||
evidence: [new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
"bundle-detection",
|
||||
signal.FilePath,
|
||||
signal.Kind.ToString(),
|
||||
null)],
|
||||
usedByEntrypoint: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Bundling;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Collects bundling signals from candidate files adjacent to deps.json/runtimeconfig.json.
|
||||
/// Applies Decision D3 bounded candidate selection rules.
|
||||
/// </summary>
|
||||
internal sealed class DotNetBundlingSignalCollector
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum file size to scan (500 MB).
|
||||
/// </summary>
|
||||
private const long MaxFileSizeBytes = 500 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of indicators to include in metadata.
|
||||
/// </summary>
|
||||
private const int MaxIndicators = 5;
|
||||
|
||||
private static readonly string[] ExecutableExtensions = [".exe", ".dll", ""];
|
||||
|
||||
private readonly LanguageAnalyzerContext _context;
|
||||
|
||||
public DotNetBundlingSignalCollector(LanguageAnalyzerContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects bundling signals from candidate files.
|
||||
/// </summary>
|
||||
public IReadOnlyList<BundlingSignal> Collect(CancellationToken cancellationToken)
|
||||
{
|
||||
var signals = new List<BundlingSignal>();
|
||||
var processedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Find all deps.json and runtimeconfig.json files
|
||||
var depsFiles = FindDepsFiles();
|
||||
var runtimeConfigFiles = FindRuntimeConfigFiles();
|
||||
|
||||
// Combine unique directories
|
||||
var directories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var depsFile in depsFiles)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(depsFile);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
directories.Add(dir);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var configFile in runtimeConfigFiles)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(configFile);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
directories.Add(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Process each directory
|
||||
foreach (var directory in directories.OrderBy(d => d, StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var candidates = GetCandidateFiles(directory, depsFiles.Concat(runtimeConfigFiles));
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (processedPaths.Contains(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
processedPaths.Add(candidate);
|
||||
|
||||
var signal = AnalyzeCandidate(candidate, cancellationToken);
|
||||
if (signal is not null)
|
||||
{
|
||||
signals.Add(signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return signals
|
||||
.OrderBy(s => s.FilePath, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private string[] FindDepsFiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(_context.RootPath, "*.deps.json", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint
|
||||
})
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private string[] FindRuntimeConfigFiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(_context.RootPath, "*.runtimeconfig.json", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint
|
||||
})
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetCandidateFiles(string directory, IEnumerable<string> manifestFiles)
|
||||
{
|
||||
// Extract app names from manifest files in this directory
|
||||
var appNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var manifestFile in manifestFiles)
|
||||
{
|
||||
var manifestDir = Path.GetDirectoryName(manifestFile);
|
||||
if (!string.Equals(manifestDir, directory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(manifestFile);
|
||||
|
||||
// Extract app name from "AppName.deps.json" or "AppName.runtimeconfig.json"
|
||||
string? appName = null;
|
||||
if (fileName.EndsWith(".deps.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
appName = fileName[..^".deps.json".Length];
|
||||
}
|
||||
else if (fileName.EndsWith(".runtimeconfig.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
appName = fileName[..^".runtimeconfig.json".Length];
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(appName))
|
||||
{
|
||||
appNames.Add(appName);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate candidate file paths
|
||||
foreach (var appName in appNames.OrderBy(n => n, StringComparer.Ordinal))
|
||||
{
|
||||
foreach (var ext in ExecutableExtensions)
|
||||
{
|
||||
var candidatePath = Path.Combine(directory, appName + ext);
|
||||
if (File.Exists(candidatePath))
|
||||
{
|
||||
yield return candidatePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private BundlingSignal? AnalyzeCandidate(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (!fileInfo.Exists)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var relativePath = _context.GetRelativePath(filePath).Replace('\\', '/');
|
||||
|
||||
// Check file size
|
||||
if (fileInfo.Length > MaxFileSizeBytes)
|
||||
{
|
||||
return new BundlingSignal(
|
||||
FilePath: relativePath,
|
||||
Kind: BundlingKind.Unknown,
|
||||
IsSkipped: true,
|
||||
SkipReason: "size-exceeded",
|
||||
Indicators: [],
|
||||
SizeBytes: fileInfo.Length,
|
||||
EstimatedBundledAssemblies: 0);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Try single-file detection first
|
||||
var singleFileResult = SingleFileAppDetector.Analyze(filePath);
|
||||
if (singleFileResult.IsSingleFile)
|
||||
{
|
||||
return new BundlingSignal(
|
||||
FilePath: relativePath,
|
||||
Kind: BundlingKind.SingleFile,
|
||||
IsSkipped: false,
|
||||
SkipReason: null,
|
||||
Indicators: singleFileResult.Indicators.Take(MaxIndicators).ToImmutableArray(),
|
||||
SizeBytes: singleFileResult.FileSize,
|
||||
EstimatedBundledAssemblies: singleFileResult.EstimatedBundledAssemblies);
|
||||
}
|
||||
|
||||
// Try ILMerge detection
|
||||
var ilMergeResult = ILMergedAssemblyDetector.Analyze(filePath);
|
||||
if (ilMergeResult.IsMerged)
|
||||
{
|
||||
var kind = ilMergeResult.Tool switch
|
||||
{
|
||||
BundlingTool.ILMerge => BundlingKind.ILMerge,
|
||||
BundlingTool.ILRepack => BundlingKind.ILRepack,
|
||||
BundlingTool.CosturaFody => BundlingKind.CosturaFody,
|
||||
_ => BundlingKind.Unknown
|
||||
};
|
||||
|
||||
return new BundlingSignal(
|
||||
FilePath: relativePath,
|
||||
Kind: kind,
|
||||
IsSkipped: false,
|
||||
SkipReason: null,
|
||||
Indicators: ilMergeResult.Indicators.Take(MaxIndicators).ToImmutableArray(),
|
||||
SizeBytes: fileInfo.Length,
|
||||
EstimatedBundledAssemblies: ilMergeResult.EmbeddedAssemblies.Length);
|
||||
}
|
||||
|
||||
// No bundling detected
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a detected bundling signal.
|
||||
/// </summary>
|
||||
internal sealed record BundlingSignal(
|
||||
string FilePath,
|
||||
BundlingKind Kind,
|
||||
bool IsSkipped,
|
||||
string? SkipReason,
|
||||
ImmutableArray<string> Indicators,
|
||||
long SizeBytes,
|
||||
int EstimatedBundledAssemblies)
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts to metadata key-value pairs.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> ToMetadata()
|
||||
{
|
||||
yield return new("bundle.detected", "true");
|
||||
yield return new("bundle.filePath", FilePath);
|
||||
yield return new("bundle.kind", Kind.ToString().ToLowerInvariant());
|
||||
yield return new("bundle.sizeBytes", SizeBytes.ToString());
|
||||
|
||||
if (IsSkipped)
|
||||
{
|
||||
yield return new("bundle.skipped", "true");
|
||||
if (!string.IsNullOrEmpty(SkipReason))
|
||||
{
|
||||
yield return new("bundle.skipReason", SkipReason);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (EstimatedBundledAssemblies > 0)
|
||||
{
|
||||
yield return new("bundle.estimatedAssemblies", EstimatedBundledAssemblies.ToString());
|
||||
}
|
||||
|
||||
for (var i = 0; i < Indicators.Length; i++)
|
||||
{
|
||||
yield return new($"bundle.indicator[{i}]", Indicators[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of bundling detected.
|
||||
/// </summary>
|
||||
internal enum BundlingKind
|
||||
{
|
||||
Unknown,
|
||||
SingleFile,
|
||||
ILMerge,
|
||||
ILRepack,
|
||||
CosturaFody
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Reflection;
|
||||
using System.Reflection.Metadata;
|
||||
using System.Reflection.Metadata.Ecma335;
|
||||
using System.Reflection.PortableExecutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Callgraph;
|
||||
|
||||
/// <summary>
|
||||
/// Builds .NET reachability graphs from assembly metadata.
|
||||
/// Extracts methods, call edges, synthetic roots, and emits unknowns.
|
||||
/// </summary>
|
||||
internal sealed class DotNetCallgraphBuilder
|
||||
{
|
||||
private readonly Dictionary<string, DotNetMethodNode> _methods = new();
|
||||
private readonly List<DotNetCallEdge> _edges = new();
|
||||
private readonly List<DotNetSyntheticRoot> _roots = new();
|
||||
private readonly List<DotNetUnknown> _unknowns = new();
|
||||
private readonly Dictionary<string, string> _typeToAssemblyPath = new();
|
||||
private readonly Dictionary<string, string?> _assemblyToPurl = new();
|
||||
private readonly string _contextDigest;
|
||||
private int _assemblyCount;
|
||||
private int _typeCount;
|
||||
|
||||
public DotNetCallgraphBuilder(string contextDigest)
|
||||
{
|
||||
_contextDigest = contextDigest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an assembly to the graph.
|
||||
/// </summary>
|
||||
public void AddAssembly(string assemblyPath, string? purl = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(assemblyPath);
|
||||
using var peReader = new PEReader(stream);
|
||||
|
||||
if (!peReader.HasMetadata)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var metadata = peReader.GetMetadataReader();
|
||||
var assemblyName = GetAssemblyName(metadata);
|
||||
|
||||
_assemblyCount++;
|
||||
_assemblyToPurl[assemblyName] = purl;
|
||||
|
||||
// Add types and methods
|
||||
foreach (var typeDefHandle in metadata.TypeDefinitions)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var typeDef = metadata.GetTypeDefinition(typeDefHandle);
|
||||
AddType(metadata, typeDef, assemblyName, assemblyPath, purl, cancellationToken);
|
||||
}
|
||||
|
||||
// Extract call edges
|
||||
foreach (var typeDefHandle in metadata.TypeDefinitions)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var typeDef = metadata.GetTypeDefinition(typeDefHandle);
|
||||
ExtractCallEdgesFromType(metadata, typeDef, assemblyName, assemblyPath, peReader);
|
||||
}
|
||||
}
|
||||
catch (BadImageFormatException)
|
||||
{
|
||||
var unknownId = DotNetGraphIdentifiers.ComputeUnknownId(
|
||||
assemblyPath,
|
||||
DotNetUnknownType.UnresolvedAssembly,
|
||||
null,
|
||||
null);
|
||||
_unknowns.Add(new DotNetUnknown(
|
||||
UnknownId: unknownId,
|
||||
UnknownType: DotNetUnknownType.UnresolvedAssembly,
|
||||
SourceId: assemblyPath,
|
||||
AssemblyName: Path.GetFileName(assemblyPath),
|
||||
TypeName: null,
|
||||
MethodName: null,
|
||||
Reason: "Assembly could not be parsed (invalid format)",
|
||||
AssemblyPath: assemblyPath));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the final reachability graph.
|
||||
/// </summary>
|
||||
public DotNetReachabilityGraph Build()
|
||||
{
|
||||
var methods = _methods.Values
|
||||
.OrderBy(m => m.AssemblyName)
|
||||
.ThenBy(m => m.TypeName)
|
||||
.ThenBy(m => m.MethodName)
|
||||
.ThenBy(m => m.Signature)
|
||||
.ToImmutableArray();
|
||||
|
||||
var edges = _edges
|
||||
.OrderBy(e => e.CallerId)
|
||||
.ThenBy(e => e.ILOffset)
|
||||
.ToImmutableArray();
|
||||
|
||||
var roots = _roots
|
||||
.OrderBy(r => (int)r.Phase)
|
||||
.ThenBy(r => r.Order)
|
||||
.ThenBy(r => r.TargetId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var unknowns = _unknowns
|
||||
.OrderBy(u => u.AssemblyPath)
|
||||
.ThenBy(u => u.SourceId)
|
||||
.ToImmutableArray();
|
||||
|
||||
var contentHash = DotNetGraphIdentifiers.ComputeGraphHash(methods, edges, roots);
|
||||
|
||||
var metadata = new DotNetGraphMetadata(
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
GeneratorVersion: DotNetGraphIdentifiers.GetGeneratorVersion(),
|
||||
ContextDigest: _contextDigest,
|
||||
AssemblyCount: _assemblyCount,
|
||||
TypeCount: _typeCount,
|
||||
MethodCount: methods.Length,
|
||||
EdgeCount: edges.Length,
|
||||
UnknownCount: unknowns.Length,
|
||||
SyntheticRootCount: roots.Length);
|
||||
|
||||
return new DotNetReachabilityGraph(
|
||||
_contextDigest,
|
||||
methods,
|
||||
edges,
|
||||
roots,
|
||||
unknowns,
|
||||
metadata,
|
||||
contentHash);
|
||||
}
|
||||
|
||||
private void AddType(
|
||||
MetadataReader metadata,
|
||||
TypeDefinition typeDef,
|
||||
string assemblyName,
|
||||
string assemblyPath,
|
||||
string? purl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var typeName = GetFullTypeName(metadata, typeDef);
|
||||
if (string.IsNullOrEmpty(typeName) || typeName.StartsWith("<"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_typeCount++;
|
||||
_typeToAssemblyPath[typeName] = assemblyPath;
|
||||
|
||||
var rootOrder = 0;
|
||||
|
||||
foreach (var methodDefHandle in typeDef.GetMethods())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var methodDef = metadata.GetMethodDefinition(methodDefHandle);
|
||||
var methodName = metadata.GetString(methodDef.Name);
|
||||
|
||||
if (string.IsNullOrEmpty(methodName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var signature = GetMethodSignature(metadata, methodDef);
|
||||
var methodId = DotNetGraphIdentifiers.ComputeMethodId(assemblyName, typeName, methodName, signature);
|
||||
var methodDigest = DotNetGraphIdentifiers.ComputeMethodDigest(assemblyName, typeName, methodName, signature);
|
||||
|
||||
var isStatic = (methodDef.Attributes & MethodAttributes.Static) != 0;
|
||||
var isPublic = (methodDef.Attributes & MethodAttributes.Public) != 0;
|
||||
var isVirtual = (methodDef.Attributes & MethodAttributes.Virtual) != 0;
|
||||
var isGeneric = methodDef.GetGenericParameters().Count > 0;
|
||||
|
||||
var node = new DotNetMethodNode(
|
||||
MethodId: methodId,
|
||||
AssemblyName: assemblyName,
|
||||
TypeName: typeName,
|
||||
MethodName: methodName,
|
||||
Signature: signature,
|
||||
Purl: purl,
|
||||
AssemblyPath: assemblyPath,
|
||||
MetadataToken: MetadataTokens.GetToken(methodDefHandle),
|
||||
MethodDigest: methodDigest,
|
||||
IsStatic: isStatic,
|
||||
IsPublic: isPublic,
|
||||
IsVirtual: isVirtual,
|
||||
IsGeneric: isGeneric);
|
||||
|
||||
_methods.TryAdd(methodId, node);
|
||||
|
||||
// Find synthetic roots
|
||||
AddSyntheticRootsForMethod(methodDef, methodName, typeName, methodId, assemblyPath, metadata, ref rootOrder);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSyntheticRootsForMethod(
|
||||
MethodDefinition methodDef,
|
||||
string methodName,
|
||||
string typeName,
|
||||
string methodId,
|
||||
string assemblyPath,
|
||||
MetadataReader metadata,
|
||||
ref int rootOrder)
|
||||
{
|
||||
var isStatic = (methodDef.Attributes & MethodAttributes.Static) != 0;
|
||||
var isPublic = (methodDef.Attributes & MethodAttributes.Public) != 0;
|
||||
|
||||
// Main entry point
|
||||
if (methodName == "Main" && isStatic)
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.AppStart, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: DotNetRootType.Main,
|
||||
Source: "Main",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.AppStart,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
|
||||
// Static constructor
|
||||
if (methodName == ".cctor")
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.ModuleInit, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: DotNetRootType.StaticConstructor,
|
||||
Source: "cctor",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.ModuleInit,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
|
||||
// Check for ModuleInitializer attribute
|
||||
if (HasAttribute(metadata, methodDef.GetCustomAttributes(), "System.Runtime.CompilerServices.ModuleInitializerAttribute"))
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.ModuleInit, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: DotNetRootType.ModuleInitializer,
|
||||
Source: "ModuleInitializer",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.ModuleInit,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
|
||||
// ASP.NET Controller actions
|
||||
if (typeName.EndsWith("Controller") && isPublic && !isStatic &&
|
||||
!methodName.StartsWith("get_") && !methodName.StartsWith("set_") &&
|
||||
methodName != ".ctor")
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.Runtime, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: DotNetRootType.ControllerAction,
|
||||
Source: "ControllerAction",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
|
||||
// Test methods (xUnit, NUnit, MSTest)
|
||||
if (HasAttribute(metadata, methodDef.GetCustomAttributes(), "Xunit.FactAttribute") ||
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "Xunit.TheoryAttribute") ||
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "NUnit.Framework.TestAttribute") ||
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute"))
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.Runtime, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: DotNetRootType.TestMethod,
|
||||
Source: "TestMethod",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
|
||||
// Azure Functions
|
||||
if (HasAttribute(metadata, methodDef.GetCustomAttributes(), "Microsoft.Azure.WebJobs.FunctionNameAttribute") ||
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "Microsoft.Azure.Functions.Worker.FunctionAttribute"))
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.Runtime, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: DotNetRootType.AzureFunction,
|
||||
Source: "AzureFunction",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
|
||||
// AWS Lambda
|
||||
if (HasAttribute(metadata, methodDef.GetCustomAttributes(), "Amazon.Lambda.Core.LambdaSerializerAttribute"))
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.Runtime, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: DotNetRootType.LambdaHandler,
|
||||
Source: "LambdaHandler",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
}
|
||||
|
||||
private void ExtractCallEdgesFromType(
|
||||
MetadataReader metadata,
|
||||
TypeDefinition typeDef,
|
||||
string assemblyName,
|
||||
string assemblyPath,
|
||||
PEReader peReader)
|
||||
{
|
||||
var typeName = GetFullTypeName(metadata, typeDef);
|
||||
if (string.IsNullOrEmpty(typeName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var methodDefHandle in typeDef.GetMethods())
|
||||
{
|
||||
var methodDef = metadata.GetMethodDefinition(methodDefHandle);
|
||||
var methodName = metadata.GetString(methodDef.Name);
|
||||
var signature = GetMethodSignature(metadata, methodDef);
|
||||
var callerId = DotNetGraphIdentifiers.ComputeMethodId(assemblyName, typeName, methodName, signature);
|
||||
|
||||
// Get method body
|
||||
var rva = methodDef.RelativeVirtualAddress;
|
||||
if (rva == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var methodBody = peReader.GetMethodBody(rva);
|
||||
ExtractCallEdgesFromMethodBody(metadata, methodBody, callerId, assemblyName, assemblyPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Method body could not be read
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ExtractCallEdgesFromMethodBody(
|
||||
MetadataReader metadata,
|
||||
MethodBodyBlock methodBody,
|
||||
string callerId,
|
||||
string assemblyName,
|
||||
string assemblyPath)
|
||||
{
|
||||
var ilBytes = methodBody.GetILBytes();
|
||||
if (ilBytes is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var offset = 0;
|
||||
while (offset < ilBytes.Length)
|
||||
{
|
||||
var ilOffset = offset;
|
||||
int opcode = ilBytes[offset++];
|
||||
|
||||
// Handle two-byte opcodes (0xFE prefix)
|
||||
if (opcode == 0xFE && offset < ilBytes.Length)
|
||||
{
|
||||
opcode = 0xFE00 | ilBytes[offset++];
|
||||
}
|
||||
|
||||
switch (opcode)
|
||||
{
|
||||
case 0x28: // call
|
||||
case 0x6F: // callvirt
|
||||
case 0x73: // newobj
|
||||
{
|
||||
if (offset + 4 > ilBytes.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var token = BitConverter.ToInt32(ilBytes, offset);
|
||||
offset += 4;
|
||||
|
||||
var edgeType = opcode switch
|
||||
{
|
||||
0x28 => DotNetEdgeType.Call,
|
||||
0x6F => DotNetEdgeType.CallVirt,
|
||||
0x73 => DotNetEdgeType.NewObj,
|
||||
_ => DotNetEdgeType.Call,
|
||||
};
|
||||
|
||||
AddCallEdge(metadata, callerId, token, ilOffset, edgeType, assemblyName, assemblyPath);
|
||||
break;
|
||||
}
|
||||
case 0xFE06: // ldftn (0xFE 0x06)
|
||||
case 0xFE07: // ldvirtftn (0xFE 0x07)
|
||||
{
|
||||
if (offset + 4 > ilBytes.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var token = BitConverter.ToInt32(ilBytes, offset);
|
||||
offset += 4;
|
||||
|
||||
var edgeType = opcode == 0xFE06 ? DotNetEdgeType.LdFtn : DotNetEdgeType.LdVirtFtn;
|
||||
AddCallEdge(metadata, callerId, token, ilOffset, edgeType, assemblyName, assemblyPath);
|
||||
break;
|
||||
}
|
||||
case 0x29: // calli
|
||||
{
|
||||
if (offset + 4 > ilBytes.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
offset += 4; // Skip signature token
|
||||
|
||||
// calli target is unknown at static analysis time
|
||||
var targetId = $"indirect:{ilOffset}";
|
||||
var edgeId = DotNetGraphIdentifiers.ComputeEdgeId(callerId, targetId, ilOffset);
|
||||
|
||||
_edges.Add(new DotNetCallEdge(
|
||||
EdgeId: edgeId,
|
||||
CallerId: callerId,
|
||||
CalleeId: targetId,
|
||||
CalleePurl: null,
|
||||
CalleeMethodDigest: null,
|
||||
EdgeType: DotNetEdgeType.CallI,
|
||||
ILOffset: ilOffset,
|
||||
IsResolved: false,
|
||||
Confidence: 0.2));
|
||||
|
||||
var unknownId = DotNetGraphIdentifiers.ComputeUnknownId(
|
||||
edgeId,
|
||||
DotNetUnknownType.DynamicTarget,
|
||||
null,
|
||||
null);
|
||||
_unknowns.Add(new DotNetUnknown(
|
||||
UnknownId: unknownId,
|
||||
UnknownType: DotNetUnknownType.DynamicTarget,
|
||||
SourceId: edgeId,
|
||||
AssemblyName: assemblyName,
|
||||
TypeName: null,
|
||||
MethodName: null,
|
||||
Reason: "Indirect call target requires runtime analysis",
|
||||
AssemblyPath: assemblyPath));
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
offset += GetILInstructionSize(opcode) - (opcode > 0xFF ? 2 : 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddCallEdge(
|
||||
MetadataReader metadata,
|
||||
string callerId,
|
||||
int token,
|
||||
int ilOffset,
|
||||
DotNetEdgeType edgeType,
|
||||
string assemblyName,
|
||||
string assemblyPath)
|
||||
{
|
||||
var handle = MetadataTokens.EntityHandle(token);
|
||||
|
||||
string? targetAssembly = null;
|
||||
string? targetType = null;
|
||||
string? targetMethod = null;
|
||||
string? targetSignature = null;
|
||||
|
||||
switch (handle.Kind)
|
||||
{
|
||||
case HandleKind.MethodDefinition:
|
||||
{
|
||||
var methodDef = metadata.GetMethodDefinition((MethodDefinitionHandle)handle);
|
||||
var typeDef = metadata.GetTypeDefinition(methodDef.GetDeclaringType());
|
||||
targetAssembly = assemblyName;
|
||||
targetType = GetFullTypeName(metadata, typeDef);
|
||||
targetMethod = metadata.GetString(methodDef.Name);
|
||||
targetSignature = GetMethodSignature(metadata, methodDef);
|
||||
break;
|
||||
}
|
||||
case HandleKind.MemberReference:
|
||||
{
|
||||
var memberRef = metadata.GetMemberReference((MemberReferenceHandle)handle);
|
||||
targetMethod = metadata.GetString(memberRef.Name);
|
||||
targetSignature = GetMemberRefSignature(metadata, memberRef);
|
||||
|
||||
switch (memberRef.Parent.Kind)
|
||||
{
|
||||
case HandleKind.TypeReference:
|
||||
var typeRef = metadata.GetTypeReference((TypeReferenceHandle)memberRef.Parent);
|
||||
targetType = GetTypeRefName(metadata, typeRef);
|
||||
targetAssembly = GetTypeRefAssembly(metadata, typeRef);
|
||||
break;
|
||||
case HandleKind.TypeDefinition:
|
||||
var typeDef = metadata.GetTypeDefinition((TypeDefinitionHandle)memberRef.Parent);
|
||||
targetType = GetFullTypeName(metadata, typeDef);
|
||||
targetAssembly = assemblyName;
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case HandleKind.MethodSpecification:
|
||||
{
|
||||
var methodSpec = metadata.GetMethodSpecification((MethodSpecificationHandle)handle);
|
||||
// Recursively resolve the generic method
|
||||
AddCallEdge(metadata, callerId, MetadataTokens.GetToken(methodSpec.Method), ilOffset, edgeType, assemblyName, assemblyPath);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetType is null || targetMethod is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var calleeId = DotNetGraphIdentifiers.ComputeMethodId(
|
||||
targetAssembly ?? "unknown",
|
||||
targetType,
|
||||
targetMethod,
|
||||
targetSignature ?? "()");
|
||||
|
||||
var isResolved = _methods.ContainsKey(calleeId) ||
|
||||
_typeToAssemblyPath.ContainsKey(targetType);
|
||||
var calleePurl = isResolved ? GetPurlForAssembly(targetAssembly) : null;
|
||||
|
||||
var edgeId = DotNetGraphIdentifiers.ComputeEdgeId(callerId, calleeId, ilOffset);
|
||||
|
||||
_edges.Add(new DotNetCallEdge(
|
||||
EdgeId: edgeId,
|
||||
CallerId: callerId,
|
||||
CalleeId: calleeId,
|
||||
CalleePurl: calleePurl,
|
||||
CalleeMethodDigest: null,
|
||||
EdgeType: edgeType,
|
||||
ILOffset: ilOffset,
|
||||
IsResolved: isResolved,
|
||||
Confidence: isResolved ? 1.0 : 0.7));
|
||||
|
||||
if (!isResolved && !string.IsNullOrEmpty(targetAssembly))
|
||||
{
|
||||
var unknownId = DotNetGraphIdentifiers.ComputeUnknownId(
|
||||
edgeId,
|
||||
DotNetUnknownType.UnresolvedMethod,
|
||||
targetType,
|
||||
targetMethod);
|
||||
_unknowns.Add(new DotNetUnknown(
|
||||
UnknownId: unknownId,
|
||||
UnknownType: DotNetUnknownType.UnresolvedMethod,
|
||||
SourceId: edgeId,
|
||||
AssemblyName: targetAssembly,
|
||||
TypeName: targetType,
|
||||
MethodName: targetMethod,
|
||||
Reason: "Method not found in analyzed assemblies",
|
||||
AssemblyPath: assemblyPath));
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetPurlForAssembly(string? assemblyName)
|
||||
{
|
||||
if (assemblyName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return _assemblyToPurl.TryGetValue(assemblyName, out var purl) ? purl : null;
|
||||
}
|
||||
|
||||
private static string GetAssemblyName(MetadataReader metadata)
|
||||
{
|
||||
if (metadata.IsAssembly)
|
||||
{
|
||||
var assemblyDef = metadata.GetAssemblyDefinition();
|
||||
return metadata.GetString(assemblyDef.Name);
|
||||
}
|
||||
|
||||
var moduleDef = metadata.GetModuleDefinition();
|
||||
return metadata.GetString(moduleDef.Name);
|
||||
}
|
||||
|
||||
private static string GetFullTypeName(MetadataReader metadata, TypeDefinition typeDef)
|
||||
{
|
||||
var name = metadata.GetString(typeDef.Name);
|
||||
var ns = metadata.GetString(typeDef.Namespace);
|
||||
|
||||
if (!typeDef.GetDeclaringType().IsNil)
|
||||
{
|
||||
var declaringType = metadata.GetTypeDefinition(typeDef.GetDeclaringType());
|
||||
var declaringName = GetFullTypeName(metadata, declaringType);
|
||||
return $"{declaringName}+{name}";
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}";
|
||||
}
|
||||
|
||||
private static string GetTypeRefName(MetadataReader metadata, TypeReference typeRef)
|
||||
{
|
||||
var name = metadata.GetString(typeRef.Name);
|
||||
var ns = metadata.GetString(typeRef.Namespace);
|
||||
return string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}";
|
||||
}
|
||||
|
||||
private static string? GetTypeRefAssembly(MetadataReader metadata, TypeReference typeRef)
|
||||
{
|
||||
switch (typeRef.ResolutionScope.Kind)
|
||||
{
|
||||
case HandleKind.AssemblyReference:
|
||||
var asmRef = metadata.GetAssemblyReference((AssemblyReferenceHandle)typeRef.ResolutionScope);
|
||||
return metadata.GetString(asmRef.Name);
|
||||
case HandleKind.ModuleReference:
|
||||
var modRef = metadata.GetModuleReference((ModuleReferenceHandle)typeRef.ResolutionScope);
|
||||
return metadata.GetString(modRef.Name);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetMethodSignature(MetadataReader metadata, MethodDefinition methodDef)
|
||||
{
|
||||
var sig = methodDef.Signature;
|
||||
var sigReader = metadata.GetBlobReader(sig);
|
||||
|
||||
// Simplified signature parsing
|
||||
var header = sigReader.ReadByte();
|
||||
var paramCount = sigReader.ReadCompressedInteger();
|
||||
|
||||
return $"({paramCount} params)";
|
||||
}
|
||||
|
||||
private static string GetMemberRefSignature(MetadataReader metadata, MemberReference memberRef)
|
||||
{
|
||||
var sig = memberRef.Signature;
|
||||
var sigReader = metadata.GetBlobReader(sig);
|
||||
|
||||
var header = sigReader.ReadByte();
|
||||
if ((header & 0x20) != 0) // HASTHIS
|
||||
{
|
||||
header = sigReader.ReadByte();
|
||||
}
|
||||
|
||||
var paramCount = sigReader.ReadCompressedInteger();
|
||||
return $"({paramCount} params)";
|
||||
}
|
||||
|
||||
private static bool HasAttribute(MetadataReader metadata, CustomAttributeHandleCollection attributes, string attributeTypeName)
|
||||
{
|
||||
foreach (var attrHandle in attributes)
|
||||
{
|
||||
var attr = metadata.GetCustomAttribute(attrHandle);
|
||||
var ctorHandle = attr.Constructor;
|
||||
|
||||
string? typeName = null;
|
||||
switch (ctorHandle.Kind)
|
||||
{
|
||||
case HandleKind.MemberReference:
|
||||
var memberRef = metadata.GetMemberReference((MemberReferenceHandle)ctorHandle);
|
||||
if (memberRef.Parent.Kind == HandleKind.TypeReference)
|
||||
{
|
||||
var typeRef = metadata.GetTypeReference((TypeReferenceHandle)memberRef.Parent);
|
||||
typeName = GetTypeRefName(metadata, typeRef);
|
||||
}
|
||||
|
||||
break;
|
||||
case HandleKind.MethodDefinition:
|
||||
var methodDef = metadata.GetMethodDefinition((MethodDefinitionHandle)ctorHandle);
|
||||
var declaringType = metadata.GetTypeDefinition(methodDef.GetDeclaringType());
|
||||
typeName = GetFullTypeName(metadata, declaringType);
|
||||
break;
|
||||
}
|
||||
|
||||
if (typeName is not null && attributeTypeName.Contains(typeName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int GetILInstructionSize(int opcode)
|
||||
{
|
||||
// Simplified IL instruction size lookup
|
||||
return opcode switch
|
||||
{
|
||||
// No operand (1 byte total)
|
||||
0x00 => 1, // nop
|
||||
0x01 => 1, // break
|
||||
>= 0x02 and <= 0x0E => 1, // ldarg.0-3, ldloc.0-3, stloc.0-3
|
||||
0x14 => 1, // ldnull
|
||||
>= 0x15 and <= 0x1E => 1, // ldc.i4.m1 through ldc.i4.8
|
||||
0x25 => 1, // dup
|
||||
0x26 => 1, // pop
|
||||
0x2A => 1, // ret
|
||||
>= 0x46 and <= 0x6E => 1, // ldind.*, stind.*, arithmetic, conversions
|
||||
>= 0x9A and <= 0x9C => 1, // throw, ldlen, etc.
|
||||
|
||||
// 1-byte operand (2 bytes total)
|
||||
0x0F => 2, // ldarg.s
|
||||
0x10 => 2, // ldarga.s
|
||||
0x11 => 2, // starg.s
|
||||
0x12 => 2, // ldloc.s
|
||||
0x13 => 2, // ldloca.s
|
||||
0x1F => 2, // ldc.i4.s
|
||||
>= 0x2B and <= 0x37 => 2, // br.s, brfalse.s, brtrue.s, etc.
|
||||
0xDE => 2, // leave.s
|
||||
|
||||
// 4-byte operand (5 bytes total)
|
||||
0x20 => 5, // ldc.i4
|
||||
0x21 => 9, // ldc.i8 (8-byte operand)
|
||||
0x22 => 5, // ldc.r4
|
||||
0x23 => 9, // ldc.r8 (8-byte operand)
|
||||
0x27 => 5, // jmp
|
||||
0x28 => 5, // call
|
||||
0x29 => 5, // calli
|
||||
>= 0x38 and <= 0x44 => 5, // br, brfalse, brtrue, beq, etc.
|
||||
0x45 => 5, // switch (base - actual size varies)
|
||||
0x6F => 5, // callvirt
|
||||
0x70 => 5, // cpobj
|
||||
0x71 => 5, // ldobj
|
||||
0x72 => 5, // ldstr
|
||||
0x73 => 5, // newobj
|
||||
0x74 => 5, // castclass
|
||||
0x75 => 5, // isinst
|
||||
0x79 => 5, // unbox
|
||||
0x7B => 5, // ldfld
|
||||
0x7C => 5, // ldflda
|
||||
0x7D => 5, // stfld
|
||||
0x7E => 5, // ldsfld
|
||||
0x7F => 5, // ldsflda
|
||||
0x80 => 5, // stsfld
|
||||
0x81 => 5, // stobj
|
||||
0x8C => 5, // box
|
||||
0x8D => 5, // newarr
|
||||
0x8F => 5, // ldelema
|
||||
0xA3 => 5, // ldelem
|
||||
0xA4 => 5, // stelem
|
||||
0xA5 => 5, // unbox.any
|
||||
0xC2 => 5, // refanyval
|
||||
0xC6 => 5, // mkrefany
|
||||
0xD0 => 5, // ldtoken
|
||||
0xDD => 5, // leave
|
||||
|
||||
// Two-byte opcodes (0xFE prefix) - sizes include the prefix byte
|
||||
0xFE00 => 2, // arglist
|
||||
0xFE01 => 2, // ceq
|
||||
0xFE02 => 2, // cgt
|
||||
0xFE03 => 2, // cgt.un
|
||||
0xFE04 => 2, // clt
|
||||
0xFE05 => 2, // clt.un
|
||||
0xFE06 => 6, // ldftn (2 + 4)
|
||||
0xFE07 => 6, // ldvirtftn (2 + 4)
|
||||
0xFE09 => 4, // ldarg (2 + 2)
|
||||
0xFE0A => 4, // ldarga (2 + 2)
|
||||
0xFE0B => 4, // starg (2 + 2)
|
||||
0xFE0C => 4, // ldloc (2 + 2)
|
||||
0xFE0D => 4, // ldloca (2 + 2)
|
||||
0xFE0E => 4, // stloc (2 + 2)
|
||||
0xFE0F => 2, // localloc
|
||||
0xFE11 => 2, // endfilter
|
||||
0xFE12 => 3, // unaligned. (2 + 1)
|
||||
0xFE13 => 2, // volatile.
|
||||
0xFE14 => 2, // tail.
|
||||
0xFE15 => 6, // initobj (2 + 4)
|
||||
0xFE16 => 6, // constrained. (2 + 4)
|
||||
0xFE17 => 2, // cpblk
|
||||
0xFE18 => 2, // initblk
|
||||
0xFE1A => 3, // no. (2 + 1)
|
||||
0xFE1C => 6, // sizeof (2 + 4)
|
||||
0xFE1D => 2, // refanytype
|
||||
0xFE1E => 2, // readonly.
|
||||
|
||||
_ => 1, // default for unrecognized
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Callgraph;
|
||||
|
||||
/// <summary>
|
||||
/// .NET reachability graph containing methods, call edges, and metadata.
|
||||
/// </summary>
|
||||
public sealed record DotNetReachabilityGraph(
|
||||
string ContextDigest,
|
||||
ImmutableArray<DotNetMethodNode> Methods,
|
||||
ImmutableArray<DotNetCallEdge> Edges,
|
||||
ImmutableArray<DotNetSyntheticRoot> SyntheticRoots,
|
||||
ImmutableArray<DotNetUnknown> Unknowns,
|
||||
DotNetGraphMetadata Metadata,
|
||||
string ContentHash);
|
||||
|
||||
/// <summary>
|
||||
/// A method node in the .NET call graph.
|
||||
/// </summary>
|
||||
/// <param name="MethodId">Deterministic method identifier (sha256 of assembly+type+name+signature).</param>
|
||||
/// <param name="AssemblyName">Name of the containing assembly.</param>
|
||||
/// <param name="TypeName">Fully qualified type name.</param>
|
||||
/// <param name="MethodName">Method name.</param>
|
||||
/// <param name="Signature">Method signature (parameters and return type).</param>
|
||||
/// <param name="Purl">Package URL if resolvable (e.g., pkg:nuget/Newtonsoft.Json@13.0.1).</param>
|
||||
/// <param name="AssemblyPath">Path to the containing assembly.</param>
|
||||
/// <param name="MetadataToken">IL metadata token.</param>
|
||||
/// <param name="MethodDigest">SHA-256 of (assembly + type + name + signature).</param>
|
||||
/// <param name="IsStatic">Whether the method is static.</param>
|
||||
/// <param name="IsPublic">Whether the method is public.</param>
|
||||
/// <param name="IsVirtual">Whether the method is virtual.</param>
|
||||
/// <param name="IsGeneric">Whether the method has generic parameters.</param>
|
||||
public sealed record DotNetMethodNode(
|
||||
string MethodId,
|
||||
string AssemblyName,
|
||||
string TypeName,
|
||||
string MethodName,
|
||||
string Signature,
|
||||
string? Purl,
|
||||
string AssemblyPath,
|
||||
int MetadataToken,
|
||||
string MethodDigest,
|
||||
bool IsStatic,
|
||||
bool IsPublic,
|
||||
bool IsVirtual,
|
||||
bool IsGeneric);
|
||||
|
||||
/// <summary>
|
||||
/// A call edge in the .NET call graph.
|
||||
/// </summary>
|
||||
/// <param name="EdgeId">Deterministic edge identifier.</param>
|
||||
/// <param name="CallerId">MethodId of the calling method.</param>
|
||||
/// <param name="CalleeId">MethodId of the called method (or Unknown placeholder).</param>
|
||||
/// <param name="CalleePurl">PURL of the callee if resolvable.</param>
|
||||
/// <param name="CalleeMethodDigest">Method digest of the callee.</param>
|
||||
/// <param name="EdgeType">Type of edge (call instruction type).</param>
|
||||
/// <param name="ILOffset">IL offset where call occurs.</param>
|
||||
/// <param name="IsResolved">Whether the callee was successfully resolved.</param>
|
||||
/// <param name="Confidence">Confidence level (1.0 for resolved, lower for heuristic).</param>
|
||||
public sealed record DotNetCallEdge(
|
||||
string EdgeId,
|
||||
string CallerId,
|
||||
string CalleeId,
|
||||
string? CalleePurl,
|
||||
string? CalleeMethodDigest,
|
||||
DotNetEdgeType EdgeType,
|
||||
int ILOffset,
|
||||
bool IsResolved,
|
||||
double Confidence);
|
||||
|
||||
/// <summary>
|
||||
/// Type of .NET call edge.
|
||||
/// </summary>
|
||||
public enum DotNetEdgeType
|
||||
{
|
||||
/// <summary>call - direct method call.</summary>
|
||||
Call,
|
||||
|
||||
/// <summary>callvirt - virtual method call.</summary>
|
||||
CallVirt,
|
||||
|
||||
/// <summary>newobj - constructor call.</summary>
|
||||
NewObj,
|
||||
|
||||
/// <summary>ldftn - load function pointer (delegate).</summary>
|
||||
LdFtn,
|
||||
|
||||
/// <summary>ldvirtftn - load virtual function pointer.</summary>
|
||||
LdVirtFtn,
|
||||
|
||||
/// <summary>calli - indirect call through function pointer.</summary>
|
||||
CallI,
|
||||
|
||||
/// <summary>P/Invoke call to native code.</summary>
|
||||
PInvoke,
|
||||
|
||||
/// <summary>Reflection-based invocation.</summary>
|
||||
Reflection,
|
||||
|
||||
/// <summary>Dynamic invocation (DLR).</summary>
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A synthetic root in the .NET call graph.
|
||||
/// </summary>
|
||||
/// <param name="RootId">Deterministic root identifier.</param>
|
||||
/// <param name="TargetId">MethodId of the target method.</param>
|
||||
/// <param name="RootType">Type of synthetic root.</param>
|
||||
/// <param name="Source">Source of the root (e.g., Main, ModuleInit, AspNetController).</param>
|
||||
/// <param name="AssemblyPath">Path to the containing assembly.</param>
|
||||
/// <param name="Phase">Execution phase.</param>
|
||||
/// <param name="Order">Order within the phase.</param>
|
||||
/// <param name="IsResolved">Whether the target was successfully resolved.</param>
|
||||
public sealed record DotNetSyntheticRoot(
|
||||
string RootId,
|
||||
string TargetId,
|
||||
DotNetRootType RootType,
|
||||
string Source,
|
||||
string AssemblyPath,
|
||||
DotNetRootPhase Phase,
|
||||
int Order,
|
||||
bool IsResolved = true);
|
||||
|
||||
/// <summary>
|
||||
/// Execution phase for .NET synthetic roots.
|
||||
/// </summary>
|
||||
public enum DotNetRootPhase
|
||||
{
|
||||
/// <summary>Module initialization - module initializers, static constructors.</summary>
|
||||
ModuleInit = 0,
|
||||
|
||||
/// <summary>Application startup - Main, Startup.Configure.</summary>
|
||||
AppStart = 1,
|
||||
|
||||
/// <summary>Runtime execution - controllers, handlers, tests.</summary>
|
||||
Runtime = 2,
|
||||
|
||||
/// <summary>Shutdown - finalizers, dispose.</summary>
|
||||
Shutdown = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of .NET synthetic root.
|
||||
/// </summary>
|
||||
public enum DotNetRootType
|
||||
{
|
||||
/// <summary>Main entry point.</summary>
|
||||
Main,
|
||||
|
||||
/// <summary>Module initializer ([ModuleInitializer]).</summary>
|
||||
ModuleInitializer,
|
||||
|
||||
/// <summary>Static constructor (.cctor).</summary>
|
||||
StaticConstructor,
|
||||
|
||||
/// <summary>ASP.NET Controller action.</summary>
|
||||
ControllerAction,
|
||||
|
||||
/// <summary>ASP.NET Minimal API endpoint.</summary>
|
||||
MinimalApiEndpoint,
|
||||
|
||||
/// <summary>gRPC service method.</summary>
|
||||
GrpcMethod,
|
||||
|
||||
/// <summary>Azure Function entry.</summary>
|
||||
AzureFunction,
|
||||
|
||||
/// <summary>AWS Lambda handler.</summary>
|
||||
LambdaHandler,
|
||||
|
||||
/// <summary>xUnit/NUnit/MSTest method.</summary>
|
||||
TestMethod,
|
||||
|
||||
/// <summary>Background service worker.</summary>
|
||||
BackgroundWorker,
|
||||
|
||||
/// <summary>Event handler (UI, etc.).</summary>
|
||||
EventHandler,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unknown/unresolved reference in the .NET call graph.
|
||||
/// </summary>
|
||||
public sealed record DotNetUnknown(
|
||||
string UnknownId,
|
||||
DotNetUnknownType UnknownType,
|
||||
string SourceId,
|
||||
string? AssemblyName,
|
||||
string? TypeName,
|
||||
string? MethodName,
|
||||
string Reason,
|
||||
string AssemblyPath);
|
||||
|
||||
/// <summary>
|
||||
/// Type of unknown reference in .NET.
|
||||
/// </summary>
|
||||
public enum DotNetUnknownType
|
||||
{
|
||||
/// <summary>Assembly could not be resolved.</summary>
|
||||
UnresolvedAssembly,
|
||||
|
||||
/// <summary>Type could not be resolved.</summary>
|
||||
UnresolvedType,
|
||||
|
||||
/// <summary>Method could not be resolved.</summary>
|
||||
UnresolvedMethod,
|
||||
|
||||
/// <summary>P/Invoke target is unknown.</summary>
|
||||
PInvokeTarget,
|
||||
|
||||
/// <summary>Reflection target is unknown.</summary>
|
||||
ReflectionTarget,
|
||||
|
||||
/// <summary>Dynamic invoke target is unknown.</summary>
|
||||
DynamicTarget,
|
||||
|
||||
/// <summary>Generic instantiation could not be resolved.</summary>
|
||||
UnresolvedGeneric,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for the .NET reachability graph.
|
||||
/// </summary>
|
||||
public sealed record DotNetGraphMetadata(
|
||||
DateTimeOffset GeneratedAt,
|
||||
string GeneratorVersion,
|
||||
string ContextDigest,
|
||||
int AssemblyCount,
|
||||
int TypeCount,
|
||||
int MethodCount,
|
||||
int EdgeCount,
|
||||
int UnknownCount,
|
||||
int SyntheticRootCount);
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods for creating deterministic .NET graph identifiers.
|
||||
/// </summary>
|
||||
internal static class DotNetGraphIdentifiers
|
||||
{
|
||||
private const string GeneratorVersion = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic method ID.
|
||||
/// </summary>
|
||||
public static string ComputeMethodId(string assemblyName, string typeName, string methodName, string signature)
|
||||
{
|
||||
var input = $"{assemblyName}:{typeName}:{methodName}:{signature}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"dnmethod:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic method digest.
|
||||
/// </summary>
|
||||
public static string ComputeMethodDigest(string assemblyName, string typeName, string methodName, string signature)
|
||||
{
|
||||
var input = $"{assemblyName}:{typeName}:{methodName}:{signature}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic edge ID.
|
||||
/// </summary>
|
||||
public static string ComputeEdgeId(string callerId, string calleeId, int ilOffset)
|
||||
{
|
||||
var input = $"{callerId}:{calleeId}:{ilOffset}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"dnedge:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic root ID.
|
||||
/// </summary>
|
||||
public static string ComputeRootId(DotNetRootPhase phase, int order, string targetId)
|
||||
{
|
||||
var phaseName = phase.ToString().ToLowerInvariant();
|
||||
return $"dnroot:{phaseName}:{order}:{targetId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic unknown ID.
|
||||
/// </summary>
|
||||
public static string ComputeUnknownId(string sourceId, DotNetUnknownType unknownType, string? typeName, string? methodName)
|
||||
{
|
||||
var input = $"{sourceId}:{unknownType}:{typeName ?? ""}:{methodName ?? ""}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"dnunk:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes content hash for the entire graph.
|
||||
/// </summary>
|
||||
public static string ComputeGraphHash(
|
||||
ImmutableArray<DotNetMethodNode> methods,
|
||||
ImmutableArray<DotNetCallEdge> edges,
|
||||
ImmutableArray<DotNetSyntheticRoot> roots)
|
||||
{
|
||||
using var sha = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
|
||||
foreach (var m in methods.OrderBy(m => m.MethodId))
|
||||
{
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(m.MethodId));
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(m.MethodDigest));
|
||||
}
|
||||
|
||||
foreach (var e in edges.OrderBy(e => e.EdgeId))
|
||||
{
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(e.EdgeId));
|
||||
}
|
||||
|
||||
foreach (var r in roots.OrderBy(r => r.RootId))
|
||||
{
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(r.RootId));
|
||||
}
|
||||
|
||||
return Convert.ToHexString(sha.GetCurrentHash()).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current generator version.
|
||||
/// </summary>
|
||||
public static string GetGeneratorVersion() => GeneratorVersion;
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Discovery;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Inheritance;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.LockFiles;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Parsing;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Collects declared dependencies from build files when no deps.json exists.
|
||||
/// Follows precedence order: packages.lock.json > csproj+CPM > packages.config.
|
||||
/// </summary>
|
||||
internal sealed class DotNetDeclaredDependencyCollector
|
||||
{
|
||||
private readonly LanguageAnalyzerContext _context;
|
||||
private readonly DotNetAnalyzerOptions _options;
|
||||
private readonly DotNetBuildFileDiscovery _discovery;
|
||||
|
||||
public DotNetDeclaredDependencyCollector(LanguageAnalyzerContext context, DotNetAnalyzerOptions options)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_discovery = new DotNetBuildFileDiscovery();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects declared dependencies from build files.
|
||||
/// </summary>
|
||||
public async ValueTask<IReadOnlyList<DotNetDeclaredPackage>> CollectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var discoveryResult = _discovery.Discover(_context.RootPath);
|
||||
if (!discoveryResult.HasFiles && discoveryResult.LockFiles.Length == 0 && discoveryResult.LegacyPackagesConfigs.Length == 0)
|
||||
{
|
||||
return Array.Empty<DotNetDeclaredPackage>();
|
||||
}
|
||||
|
||||
var aggregator = new DeclaredPackageAggregator();
|
||||
|
||||
// 1. Collect from packages.lock.json files (highest precedence for version resolution)
|
||||
foreach (var lockFile in discoveryResult.LockFiles.Where(f => f.FileType == DotNetFileType.PackagesLockJson))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await CollectFromLockFileAsync(lockFile, aggregator, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 2. Collect from project files with CPM resolution
|
||||
var cpmLookup = await BuildCpmLookupAsync(discoveryResult, cancellationToken).ConfigureAwait(false);
|
||||
var propsLookup = await BuildPropsLookupAsync(discoveryResult, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var projectFile in discoveryResult.ProjectFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await CollectFromProjectFileAsync(projectFile, cpmLookup, propsLookup, aggregator, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 3. Collect from legacy packages.config (lowest precedence)
|
||||
foreach (var packagesConfig in discoveryResult.LegacyPackagesConfigs)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await CollectFromPackagesConfigAsync(packagesConfig, aggregator, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return aggregator.Build();
|
||||
}
|
||||
|
||||
private async ValueTask CollectFromLockFileAsync(
|
||||
DiscoveredFile lockFile,
|
||||
DeclaredPackageAggregator aggregator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await PackagesLockJsonParser.ParseAsync(lockFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Dependencies.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var dependency in result.Dependencies)
|
||||
{
|
||||
var declaration = new DotNetDependencyDeclaration
|
||||
{
|
||||
PackageId = dependency.PackageId,
|
||||
Version = dependency.ResolvedVersion,
|
||||
TargetFrameworks = !string.IsNullOrEmpty(dependency.TargetFramework)
|
||||
? [dependency.TargetFramework]
|
||||
: [],
|
||||
IsDevelopmentDependency = false,
|
||||
Source = dependency.IsDirect ? "packages.lock.json (Direct)" : "packages.lock.json (Transitive)",
|
||||
Locator = lockFile.RelativePath,
|
||||
VersionSource = DotNetVersionSource.LockFile
|
||||
};
|
||||
|
||||
// Collect edges from lock file dependencies (format: "packageName:version")
|
||||
var edges = new List<DotNetDependencyEdge>();
|
||||
foreach (var dep in dependency.Dependencies)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dep))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse "packageName:version" format
|
||||
var colonIndex = dep.IndexOf(':');
|
||||
var targetId = colonIndex > 0 ? dep.Substring(0, colonIndex).Trim().ToLowerInvariant() : dep.Trim().ToLowerInvariant();
|
||||
|
||||
edges.Add(new DotNetDependencyEdge(
|
||||
Target: targetId,
|
||||
Reason: "declared-dependency",
|
||||
Confidence: "high",
|
||||
Source: "packages.lock.json"));
|
||||
}
|
||||
|
||||
aggregator.Add(declaration, lockFile.RelativePath, edges);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask CollectFromProjectFileAsync(
|
||||
DiscoveredFile projectFile,
|
||||
ImmutableDictionary<string, string> cpmLookup,
|
||||
ImmutableDictionary<string, string> propsLookup,
|
||||
DeclaredPackageAggregator aggregator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projectMetadata = await MsBuildProjectParser.ParseAsync(projectFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (projectMetadata.PackageReferences.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var packageRef in projectMetadata.PackageReferences)
|
||||
{
|
||||
var resolvedVersion = ResolveVersion(packageRef, cpmLookup, propsLookup, projectMetadata);
|
||||
var versionSource = DetermineVersionSource(packageRef, resolvedVersion, projectMetadata.ManagePackageVersionsCentrally);
|
||||
|
||||
var declaration = new DotNetDependencyDeclaration
|
||||
{
|
||||
PackageId = packageRef.PackageId,
|
||||
Version = resolvedVersion,
|
||||
TargetFrameworks = projectMetadata.TargetFrameworks,
|
||||
IsDevelopmentDependency = packageRef.IsDevelopmentDependency,
|
||||
IncludeAssets = packageRef.IncludeAssets,
|
||||
ExcludeAssets = packageRef.ExcludeAssets,
|
||||
PrivateAssets = packageRef.PrivateAssets,
|
||||
Condition = packageRef.Condition,
|
||||
Source = "csproj",
|
||||
Locator = projectFile.RelativePath,
|
||||
VersionSource = versionSource,
|
||||
VersionProperty = ExtractPropertyName(packageRef.Version)
|
||||
};
|
||||
|
||||
aggregator.Add(declaration, projectFile.RelativePath, edges: null);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask CollectFromPackagesConfigAsync(
|
||||
DiscoveredFile packagesConfig,
|
||||
DeclaredPackageAggregator aggregator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await PackagesConfigParser.ParseAsync(packagesConfig.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Packages.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var package in result.Packages)
|
||||
{
|
||||
var declaration = package with
|
||||
{
|
||||
Locator = packagesConfig.RelativePath
|
||||
};
|
||||
|
||||
aggregator.Add(declaration, packagesConfig.RelativePath, edges: null);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<ImmutableDictionary<string, string>> BuildCpmLookupAsync(
|
||||
DiscoveryResult discovery,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var cpmFile in discovery.DirectoryPackagesPropsFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await CentralPackageManagementParser.ParseAsync(cpmFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsEnabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var pv in result.PackageVersions)
|
||||
{
|
||||
if (!builder.ContainsKey(pv.PackageId) && !string.IsNullOrEmpty(pv.Version))
|
||||
{
|
||||
builder[pv.PackageId] = pv.Version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private async ValueTask<ImmutableDictionary<string, string>> BuildPropsLookupAsync(
|
||||
DiscoveryResult discovery,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var propsFile in discovery.DirectoryBuildPropsFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await DirectoryBuildPropsParser.ParseAsync(propsFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var kvp in result.Properties)
|
||||
{
|
||||
if (!builder.ContainsKey(kvp.Key))
|
||||
{
|
||||
builder[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static string? ResolveVersion(
|
||||
DotNetDependencyDeclaration packageRef,
|
||||
ImmutableDictionary<string, string> cpmLookup,
|
||||
ImmutableDictionary<string, string> propsLookup,
|
||||
DotNetProjectMetadata projectMetadata)
|
||||
{
|
||||
// If version is explicitly set and resolved, use it
|
||||
if (!string.IsNullOrEmpty(packageRef.Version) && packageRef.IsVersionResolved)
|
||||
{
|
||||
return packageRef.Version;
|
||||
}
|
||||
|
||||
// If version is a property reference, try to resolve it
|
||||
if (!string.IsNullOrEmpty(packageRef.Version) && packageRef.Version.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
var resolved = ResolvePropertyValue(packageRef.Version, propsLookup, projectMetadata.Properties);
|
||||
if (!string.IsNullOrEmpty(resolved) && !resolved.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Return the unresolved value for identity purposes
|
||||
return packageRef.Version;
|
||||
}
|
||||
|
||||
// If version is empty and CPM is enabled, look up in CPM
|
||||
if (string.IsNullOrEmpty(packageRef.Version) && projectMetadata.ManagePackageVersionsCentrally)
|
||||
{
|
||||
if (cpmLookup.TryGetValue(packageRef.PackageId, out var cpmVersion))
|
||||
{
|
||||
return cpmVersion;
|
||||
}
|
||||
|
||||
// CPM enabled but version not found - return null to trigger unresolved handling
|
||||
return null;
|
||||
}
|
||||
|
||||
return packageRef.Version;
|
||||
}
|
||||
|
||||
private static string? ResolvePropertyValue(
|
||||
string value,
|
||||
ImmutableDictionary<string, string> propsLookup,
|
||||
ImmutableDictionary<string, string> projectProperties)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var result = value;
|
||||
var maxIterations = 10; // Prevent infinite loops
|
||||
|
||||
for (var i = 0; i < maxIterations && result.Contains("$(", StringComparison.Ordinal); i++)
|
||||
{
|
||||
var startIdx = result.IndexOf("$(", StringComparison.Ordinal);
|
||||
var endIdx = result.IndexOf(')', startIdx);
|
||||
if (endIdx < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var propertyName = result.Substring(startIdx + 2, endIdx - startIdx - 2);
|
||||
string? propertyValue = null;
|
||||
|
||||
// Try project properties first, then props files
|
||||
if (projectProperties.TryGetValue(propertyName, out var projValue))
|
||||
{
|
||||
propertyValue = projValue;
|
||||
}
|
||||
else if (propsLookup.TryGetValue(propertyName, out var propsValue))
|
||||
{
|
||||
propertyValue = propsValue;
|
||||
}
|
||||
|
||||
if (propertyValue is not null)
|
||||
{
|
||||
result = result.Substring(0, startIdx) + propertyValue + result.Substring(endIdx + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Property not found, stop resolution
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static DotNetVersionSource DetermineVersionSource(
|
||||
DotNetDependencyDeclaration packageRef,
|
||||
string? resolvedVersion,
|
||||
bool cpmEnabled)
|
||||
{
|
||||
if (resolvedVersion is null)
|
||||
{
|
||||
return DotNetVersionSource.Unresolved;
|
||||
}
|
||||
|
||||
if (resolvedVersion.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
return DotNetVersionSource.Unresolved;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(packageRef.Version) && cpmEnabled)
|
||||
{
|
||||
return DotNetVersionSource.CentralPackageManagement;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(packageRef.Version) && packageRef.Version.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
return DotNetVersionSource.Property;
|
||||
}
|
||||
|
||||
return DotNetVersionSource.Direct;
|
||||
}
|
||||
|
||||
private static string? ExtractPropertyName(string? version)
|
||||
{
|
||||
if (string.IsNullOrEmpty(version))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var startIdx = version.IndexOf("$(", StringComparison.Ordinal);
|
||||
if (startIdx < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var endIdx = version.IndexOf(')', startIdx);
|
||||
if (endIdx < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return version.Substring(startIdx + 2, endIdx - startIdx - 2);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates declared packages with deduplication.
|
||||
/// </summary>
|
||||
internal sealed class DeclaredPackageAggregator
|
||||
{
|
||||
private readonly Dictionary<string, DotNetDeclaredPackageBuilder> _packages = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void Add(DotNetDependencyDeclaration declaration, string sourceLocator, IReadOnlyList<DotNetDependencyEdge>? edges = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(declaration.PackageId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedId = declaration.PackageId.Trim().ToLowerInvariant();
|
||||
var version = declaration.Version?.Trim() ?? string.Empty;
|
||||
var key = BuildKey(normalizedId, version, declaration.VersionSource);
|
||||
|
||||
if (!_packages.TryGetValue(key, out var builder))
|
||||
{
|
||||
builder = new DotNetDeclaredPackageBuilder(declaration.PackageId, normalizedId, version, declaration.VersionSource);
|
||||
_packages[key] = builder;
|
||||
}
|
||||
|
||||
builder.AddDeclaration(declaration, sourceLocator, edges);
|
||||
}
|
||||
|
||||
public IReadOnlyList<DotNetDeclaredPackage> Build()
|
||||
{
|
||||
if (_packages.Count == 0)
|
||||
{
|
||||
return Array.Empty<DotNetDeclaredPackage>();
|
||||
}
|
||||
|
||||
var result = new List<DotNetDeclaredPackage>(_packages.Count);
|
||||
foreach (var builder in _packages.Values)
|
||||
{
|
||||
result.Add(builder.Build());
|
||||
}
|
||||
|
||||
result.Sort(static (a, b) => string.CompareOrdinal(a.ComponentKey, b.ComponentKey));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string BuildKey(string normalizedId, string version, DotNetVersionSource versionSource)
|
||||
{
|
||||
// For resolved versions, key by id+version
|
||||
// For unresolved versions, include source info in key to avoid collisions
|
||||
if (versionSource == DotNetVersionSource.Unresolved || string.IsNullOrEmpty(version) || version.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
return $"unresolved::{normalizedId}::{version}";
|
||||
}
|
||||
|
||||
return $"{normalizedId}::{version}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for declared packages.
|
||||
/// </summary>
|
||||
internal sealed class DotNetDeclaredPackageBuilder
|
||||
{
|
||||
private readonly string _originalId;
|
||||
private readonly string _normalizedId;
|
||||
private readonly string _version;
|
||||
private readonly DotNetVersionSource _versionSource;
|
||||
|
||||
private readonly SortedSet<string> _sources = new(StringComparer.Ordinal);
|
||||
private readonly SortedSet<string> _locators = new(StringComparer.Ordinal);
|
||||
private readonly SortedSet<string> _targetFrameworks = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<LanguageComponentEvidence> _evidence = new(new LanguageComponentEvidenceComparer());
|
||||
private readonly Dictionary<string, DotNetDependencyEdge> _edges = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private bool _isDevelopmentDependency;
|
||||
private string? _unresolvedReason;
|
||||
|
||||
public DotNetDeclaredPackageBuilder(string originalId, string normalizedId, string version, DotNetVersionSource versionSource)
|
||||
{
|
||||
_originalId = originalId;
|
||||
_normalizedId = normalizedId;
|
||||
_version = version;
|
||||
_versionSource = versionSource;
|
||||
}
|
||||
|
||||
public void AddDeclaration(DotNetDependencyDeclaration declaration, string sourceLocator, IReadOnlyList<DotNetDependencyEdge>? edges = null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(declaration.Source))
|
||||
{
|
||||
_sources.Add(declaration.Source);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(sourceLocator))
|
||||
{
|
||||
_locators.Add(sourceLocator);
|
||||
}
|
||||
|
||||
foreach (var tfm in declaration.TargetFrameworks)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tfm))
|
||||
{
|
||||
_targetFrameworks.Add(tfm);
|
||||
}
|
||||
}
|
||||
|
||||
if (declaration.IsDevelopmentDependency)
|
||||
{
|
||||
_isDevelopmentDependency = true;
|
||||
}
|
||||
|
||||
// Determine unresolved reason
|
||||
if (_versionSource == DotNetVersionSource.Unresolved && _unresolvedReason is null)
|
||||
{
|
||||
_unresolvedReason = DetermineUnresolvedReason(declaration);
|
||||
}
|
||||
|
||||
// Add evidence
|
||||
if (!string.IsNullOrEmpty(sourceLocator))
|
||||
{
|
||||
_evidence.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
declaration.Source ?? "declared",
|
||||
sourceLocator,
|
||||
declaration.Coordinate,
|
||||
Sha256: null));
|
||||
}
|
||||
|
||||
// Add edges (deduped by target)
|
||||
if (edges is not null)
|
||||
{
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (!_edges.ContainsKey(edge.Target))
|
||||
{
|
||||
_edges[edge.Target] = edge;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DotNetDeclaredPackage Build()
|
||||
{
|
||||
var metadata = BuildMetadata();
|
||||
var evidence = _evidence
|
||||
.OrderBy(static e => e.Source, StringComparer.Ordinal)
|
||||
.ThenBy(static e => e.Locator, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
// Build ordered edges list
|
||||
var edges = _edges.Values
|
||||
.OrderBy(static e => e.Target, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new DotNetDeclaredPackage(
|
||||
name: _originalId,
|
||||
normalizedId: _normalizedId,
|
||||
version: _version,
|
||||
versionSource: _versionSource,
|
||||
isVersionResolved: _versionSource != DotNetVersionSource.Unresolved &&
|
||||
!string.IsNullOrEmpty(_version) &&
|
||||
!_version.Contains("$(", StringComparison.Ordinal),
|
||||
unresolvedReason: _unresolvedReason,
|
||||
isDevelopmentDependency: _isDevelopmentDependency,
|
||||
metadata: metadata,
|
||||
evidence: evidence,
|
||||
edges: edges);
|
||||
}
|
||||
|
||||
private IReadOnlyList<KeyValuePair<string, string?>> BuildMetadata()
|
||||
{
|
||||
var metadata = new List<KeyValuePair<string, string?>>(32)
|
||||
{
|
||||
new("package.id", _originalId),
|
||||
new("package.id.normalized", _normalizedId),
|
||||
new("package.version", _version),
|
||||
new("declaredOnly", "true"),
|
||||
new("declared.versionSource", _versionSource.ToString().ToLowerInvariant())
|
||||
};
|
||||
|
||||
if (!IsVersionResolved())
|
||||
{
|
||||
metadata.Add(new("declared.versionResolved", "false"));
|
||||
if (!string.IsNullOrEmpty(_unresolvedReason))
|
||||
{
|
||||
metadata.Add(new("declared.unresolvedReason", _unresolvedReason));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_version))
|
||||
{
|
||||
metadata.Add(new("declared.rawVersion", _version));
|
||||
}
|
||||
}
|
||||
|
||||
if (_isDevelopmentDependency)
|
||||
{
|
||||
metadata.Add(new("declared.isDevelopmentDependency", "true"));
|
||||
}
|
||||
|
||||
// Add sources
|
||||
var sourceIndex = 0;
|
||||
foreach (var source in _sources)
|
||||
{
|
||||
metadata.Add(new($"declared.source[{sourceIndex++}]", source));
|
||||
}
|
||||
|
||||
// Add locators
|
||||
var locatorIndex = 0;
|
||||
foreach (var locator in _locators)
|
||||
{
|
||||
metadata.Add(new($"declared.locator[{locatorIndex++}]", locator));
|
||||
}
|
||||
|
||||
// Add target frameworks
|
||||
var tfmIndex = 0;
|
||||
foreach (var tfm in _targetFrameworks)
|
||||
{
|
||||
metadata.Add(new($"declared.tfm[{tfmIndex++}]", tfm));
|
||||
}
|
||||
|
||||
metadata.Add(new("provenance", "declared"));
|
||||
metadata.Sort(static (a, b) => string.CompareOrdinal(a.Key, b.Key));
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private bool IsVersionResolved()
|
||||
=> _versionSource != DotNetVersionSource.Unresolved &&
|
||||
!string.IsNullOrEmpty(_version) &&
|
||||
!_version.Contains("$(", StringComparison.Ordinal);
|
||||
|
||||
private static string? DetermineUnresolvedReason(DotNetDependencyDeclaration declaration)
|
||||
{
|
||||
if (string.IsNullOrEmpty(declaration.Version))
|
||||
{
|
||||
if (declaration.VersionSource == DotNetVersionSource.CentralPackageManagement ||
|
||||
declaration.Source?.Contains("csproj", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
return "cpm-missing";
|
||||
}
|
||||
|
||||
return "version-omitted";
|
||||
}
|
||||
|
||||
if (declaration.Version.Contains("$(", StringComparison.Ordinal))
|
||||
{
|
||||
return "property-unresolved";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed class LanguageComponentEvidenceComparer : IEqualityComparer<LanguageComponentEvidence>
|
||||
{
|
||||
public bool Equals(LanguageComponentEvidence? x, LanguageComponentEvidence? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return x.Kind == y.Kind &&
|
||||
string.Equals(x.Source, y.Source, StringComparison.Ordinal) &&
|
||||
string.Equals(x.Locator, y.Locator, StringComparison.Ordinal) &&
|
||||
string.Equals(x.Value, y.Value, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public int GetHashCode(LanguageComponentEvidence obj)
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(obj.Kind);
|
||||
hash.Add(obj.Source, StringComparer.Ordinal);
|
||||
hash.Add(obj.Locator, StringComparer.Ordinal);
|
||||
hash.Add(obj.Value, StringComparer.Ordinal);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a declared-only .NET package (not from deps.json).
|
||||
/// </summary>
|
||||
internal sealed class DotNetDeclaredPackage
|
||||
{
|
||||
public DotNetDeclaredPackage(
|
||||
string name,
|
||||
string normalizedId,
|
||||
string version,
|
||||
DotNetVersionSource versionSource,
|
||||
bool isVersionResolved,
|
||||
string? unresolvedReason,
|
||||
bool isDevelopmentDependency,
|
||||
IReadOnlyList<KeyValuePair<string, string?>> metadata,
|
||||
IReadOnlyCollection<LanguageComponentEvidence> evidence,
|
||||
IReadOnlyList<DotNetDependencyEdge>? edges = null)
|
||||
{
|
||||
Name = string.IsNullOrWhiteSpace(name) ? normalizedId : name.Trim();
|
||||
NormalizedId = normalizedId;
|
||||
Version = version ?? string.Empty;
|
||||
VersionSource = versionSource;
|
||||
IsVersionResolved = isVersionResolved;
|
||||
UnresolvedReason = unresolvedReason;
|
||||
IsDevelopmentDependency = isDevelopmentDependency;
|
||||
Metadata = metadata ?? Array.Empty<KeyValuePair<string, string?>>();
|
||||
Evidence = evidence ?? Array.Empty<LanguageComponentEvidence>();
|
||||
Edges = edges ?? Array.Empty<DotNetDependencyEdge>();
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string NormalizedId { get; }
|
||||
public string Version { get; }
|
||||
public DotNetVersionSource VersionSource { get; }
|
||||
public bool IsVersionResolved { get; }
|
||||
public string? UnresolvedReason { get; }
|
||||
public bool IsDevelopmentDependency { get; }
|
||||
public IReadOnlyList<KeyValuePair<string, string?>> Metadata { get; }
|
||||
public IReadOnlyCollection<LanguageComponentEvidence> Evidence { get; }
|
||||
public IReadOnlyList<DotNetDependencyEdge> Edges { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the PURL if version is resolved, otherwise null.
|
||||
/// </summary>
|
||||
public string? Purl => IsVersionResolved && !string.IsNullOrEmpty(Version)
|
||||
? $"pkg:nuget/{NormalizedId}@{Version}"
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the component key (PURL-based if resolved, explicit key if unresolved).
|
||||
/// </summary>
|
||||
public string ComponentKey
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Purl is not null)
|
||||
{
|
||||
return $"purl::{Purl}";
|
||||
}
|
||||
|
||||
// Explicit key for unresolved versions: declared:nuget/<id>/<hash>
|
||||
var keyMaterial = $"{VersionSource}|{string.Join(",", Metadata.Where(m => m.Key.StartsWith("declared.locator", StringComparison.Ordinal)).Select(m => m.Value))}|{Version}";
|
||||
var hash = ComputeShortHash(keyMaterial);
|
||||
return $"declared:nuget/{NormalizedId}/{hash}";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeShortHash(string input)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hashBytes = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hashBytes).Substring(0, 8).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,8 @@ internal static class DotNetDependencyCollector
|
||||
.OrderBy(static path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (depsFiles.Length == 0)
|
||||
{
|
||||
return Array.Empty<DotNetPackage>();
|
||||
}
|
||||
// When no deps.json files exist, fallback to declared-only collection
|
||||
// is handled by DotNetDeclaredDependencyCollector called from the analyzer
|
||||
|
||||
var aggregator = new DotNetPackageAggregator(context, options, entrypoints, runtimeEdges);
|
||||
|
||||
|
||||
@@ -18,14 +18,19 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
// Track emitted modules to avoid duplicates (binary takes precedence over source)
|
||||
// Track emitted modules to avoid duplicates.
|
||||
// Key format: "path@version" for versioned deps, "path@main" for main modules.
|
||||
// Binary evidence takes precedence over source evidence - scan binaries first.
|
||||
var emittedModules = new HashSet<string>(StringComparer.Ordinal);
|
||||
// Track main module paths separately so source (devel) main modules are suppressed
|
||||
// when binary evidence exists for the same module path.
|
||||
var emittedMainModulePaths = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
// Phase 1: Source scanning (go.mod, go.sum, go.work, vendor)
|
||||
ScanSourceFiles(context, writer, emittedModules, cancellationToken);
|
||||
// Phase 1: Binary scanning (binary evidence is authoritative and takes precedence)
|
||||
ScanBinaries(context, writer, emittedModules, emittedMainModulePaths, cancellationToken);
|
||||
|
||||
// Phase 2: Binary scanning (existing behavior)
|
||||
ScanBinaries(context, writer, emittedModules, cancellationToken);
|
||||
// Phase 2: Source scanning (go.mod, go.sum, go.work, vendor) - skips modules with binary evidence
|
||||
ScanSourceFiles(context, writer, emittedModules, emittedMainModulePaths, cancellationToken);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -34,6 +39,7 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
LanguageAnalyzerContext context,
|
||||
LanguageComponentWriter writer,
|
||||
HashSet<string> emittedModules,
|
||||
HashSet<string> emittedMainModulePaths,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Discover Go projects
|
||||
@@ -70,17 +76,17 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
continue;
|
||||
}
|
||||
|
||||
// Emit the main module
|
||||
// Emit the main module (skip if binary evidence already exists for this module path)
|
||||
if (!string.IsNullOrEmpty(inventory.ModulePath))
|
||||
{
|
||||
EmitMainModuleFromSource(inventory, project, context, writer, emittedModules);
|
||||
EmitMainModuleFromSource(inventory, project, context, writer, emittedModules, emittedMainModulePaths);
|
||||
}
|
||||
|
||||
// Emit dependencies
|
||||
// Emit dependencies (skip if binary evidence already exists)
|
||||
foreach (var module in inventory.Modules.OrderBy(m => m.Path, StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
EmitSourceModule(module, inventory, project, context, writer, emittedModules);
|
||||
EmitSourceModule(module, inventory, project, context, writer, emittedModules, emittedMainModulePaths);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +96,7 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
LanguageAnalyzerContext context,
|
||||
LanguageComponentWriter writer,
|
||||
HashSet<string> emittedModules,
|
||||
HashSet<string> emittedMainModulePaths,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidatePaths = new List<string>();
|
||||
@@ -124,7 +131,7 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
continue;
|
||||
}
|
||||
|
||||
EmitComponents(buildInfo, context, writer, emittedModules);
|
||||
EmitComponents(buildInfo, context, writer, emittedModules, emittedMainModulePaths);
|
||||
}
|
||||
|
||||
foreach (var fallback in fallbackBinaries)
|
||||
@@ -139,15 +146,23 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
GoProjectDiscoverer.GoProject project,
|
||||
LanguageAnalyzerContext context,
|
||||
LanguageComponentWriter writer,
|
||||
HashSet<string> emittedModules)
|
||||
HashSet<string> emittedModules,
|
||||
HashSet<string> emittedMainModulePaths)
|
||||
{
|
||||
// Main module from go.mod (typically no version in source)
|
||||
var modulePath = inventory.ModulePath!;
|
||||
var moduleKey = $"{modulePath}@(devel)";
|
||||
|
||||
// If binary evidence already exists for this main module, skip source emission.
|
||||
// Binary main modules have concrete build info and take precedence over source (devel).
|
||||
if (emittedMainModulePaths.Contains(modulePath))
|
||||
{
|
||||
return; // Binary evidence takes precedence
|
||||
}
|
||||
|
||||
var moduleKey = $"{modulePath}@(devel)";
|
||||
if (!emittedModules.Add(moduleKey))
|
||||
{
|
||||
return; // Already emitted
|
||||
return; // Already emitted from another source location
|
||||
}
|
||||
|
||||
var relativePath = context.GetRelativePath(project.RootPath);
|
||||
@@ -239,6 +254,45 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
null));
|
||||
}
|
||||
|
||||
// Add capability metadata and evidence
|
||||
if (inventory.Capabilities.Length > 0)
|
||||
{
|
||||
// Summarize capability kinds
|
||||
var capabilityKinds = inventory.Capabilities
|
||||
.Select(c => c.Kind.ToString().ToLowerInvariant())
|
||||
.Distinct()
|
||||
.OrderBy(k => k)
|
||||
.ToList();
|
||||
metadata["capabilities"] = string.Join(",", capabilityKinds);
|
||||
|
||||
// Add risk summary
|
||||
if (inventory.HasCriticalCapabilities)
|
||||
{
|
||||
metadata["capabilities.maxRisk"] = "critical";
|
||||
}
|
||||
else if (inventory.HasHighRiskCapabilities)
|
||||
{
|
||||
metadata["capabilities.maxRisk"] = "high";
|
||||
}
|
||||
|
||||
// Add top capability evidence entries (limited to avoid noise)
|
||||
var topCapabilities = inventory.Capabilities
|
||||
.OrderByDescending(c => c.Risk)
|
||||
.ThenBy(c => c.SourceFile)
|
||||
.ThenBy(c => c.SourceLine)
|
||||
.Take(10);
|
||||
|
||||
foreach (var capability in topCapabilities)
|
||||
{
|
||||
evidence.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.Metadata,
|
||||
$"capability:{capability.Kind.ToString().ToLowerInvariant()}",
|
||||
$"{capability.SourceFile}:{capability.SourceLine}",
|
||||
capability.Pattern,
|
||||
null));
|
||||
}
|
||||
}
|
||||
|
||||
evidence.Sort(static (l, r) => string.CompareOrdinal(l.ComparisonKey, r.ComparisonKey));
|
||||
|
||||
// Main module typically has (devel) as version in source context
|
||||
@@ -259,10 +313,12 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
GoProjectDiscoverer.GoProject project,
|
||||
LanguageAnalyzerContext context,
|
||||
LanguageComponentWriter writer,
|
||||
HashSet<string> emittedModules)
|
||||
HashSet<string> emittedModules,
|
||||
HashSet<string> emittedMainModulePaths)
|
||||
{
|
||||
var moduleKey = $"{module.Path}@{module.Version}";
|
||||
|
||||
// Binary evidence takes precedence - if already emitted with same path@version, skip
|
||||
if (!emittedModules.Add(moduleKey))
|
||||
{
|
||||
return; // Already emitted (binary takes precedence)
|
||||
@@ -405,7 +461,7 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitComponents(GoBuildInfo buildInfo, LanguageAnalyzerContext context, LanguageComponentWriter writer, HashSet<string> emittedModules)
|
||||
private void EmitComponents(GoBuildInfo buildInfo, LanguageAnalyzerContext context, LanguageComponentWriter writer, HashSet<string> emittedModules, HashSet<string> emittedMainModulePaths)
|
||||
{
|
||||
var components = new List<GoModule> { buildInfo.MainModule };
|
||||
components.AddRange(buildInfo.Dependencies
|
||||
@@ -417,10 +473,16 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
|
||||
foreach (var module in components)
|
||||
{
|
||||
// Track emitted modules (binary evidence is more accurate than source)
|
||||
// Track emitted modules (binary evidence is authoritative and takes precedence over source)
|
||||
var moduleKey = $"{module.Path}@{module.Version ?? "(devel)"}";
|
||||
emittedModules.Add(moduleKey);
|
||||
|
||||
// Track main module paths so source (devel) versions are suppressed
|
||||
if (module.IsMain)
|
||||
{
|
||||
emittedMainModulePaths.Add(module.Path);
|
||||
}
|
||||
|
||||
var metadata = BuildMetadata(buildInfo, module, binaryRelativePath);
|
||||
var evidence = BuildEvidence(buildInfo, module, binaryRelativePath, context, ref binaryHash);
|
||||
var usedByEntrypoint = module.IsMain && context.UsageHints.IsPathUsed(buildInfo.AbsoluteBinaryPath);
|
||||
@@ -463,6 +525,7 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
{
|
||||
new("modulePath", module.Path),
|
||||
new("binaryPath", string.IsNullOrEmpty(binaryRelativePath) ? "." : binaryRelativePath),
|
||||
new("provenance", "binary"),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(module.Version))
|
||||
|
||||
@@ -72,6 +72,21 @@ internal static class GoBinaryScanner
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maximum file size to scan (128 MB). Files larger than this are skipped.
|
||||
/// </summary>
|
||||
private const long MaxFileSizeBytes = 128 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Window size for bounded reads (16 MB). We scan in chunks to avoid loading entire files.
|
||||
/// </summary>
|
||||
private const int WindowSizeBytes = 16 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Overlap between windows to catch magic bytes at window boundaries.
|
||||
/// </summary>
|
||||
private const int WindowOverlapBytes = 4096;
|
||||
|
||||
public static bool TryReadBuildInfo(string filePath, out string? goVersion, out string? moduleData)
|
||||
{
|
||||
goVersion = null;
|
||||
@@ -81,7 +96,7 @@ internal static class GoBinaryScanner
|
||||
try
|
||||
{
|
||||
info = new FileInfo(filePath);
|
||||
if (!info.Exists || info.Length < 64 || info.Length > 128 * 1024 * 1024)
|
||||
if (!info.Exists || info.Length < 64 || info.Length > MaxFileSizeBytes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -105,31 +120,45 @@ internal static class GoBinaryScanner
|
||||
return false;
|
||||
}
|
||||
|
||||
var inspectLength = (int)Math.Min(length, int.MaxValue);
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(inspectLength);
|
||||
// For small files, read the entire content
|
||||
if (length <= WindowSizeBytes)
|
||||
{
|
||||
return TryReadBuildInfoDirect(filePath, (int)length, out goVersion, out moduleData);
|
||||
}
|
||||
|
||||
// For larger files, use windowed scanning to bound memory usage
|
||||
return TryReadBuildInfoWindowed(filePath, length, out goVersion, out moduleData);
|
||||
}
|
||||
|
||||
private static bool TryReadBuildInfoDirect(string filePath, int length, out string? goVersion, out string? moduleData)
|
||||
{
|
||||
goVersion = null;
|
||||
moduleData = null;
|
||||
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(length);
|
||||
var bytesRead = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var totalRead = 0;
|
||||
|
||||
while (totalRead < inspectLength)
|
||||
while (bytesRead < length)
|
||||
{
|
||||
var read = stream.Read(buffer, totalRead, inspectLength - totalRead);
|
||||
var read = stream.Read(buffer, bytesRead, length - bytesRead);
|
||||
if (read <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
totalRead += read;
|
||||
bytesRead += read;
|
||||
}
|
||||
|
||||
if (totalRead < 64)
|
||||
if (bytesRead < 64)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var span = new ReadOnlySpan<byte>(buffer, 0, totalRead);
|
||||
var span = new ReadOnlySpan<byte>(buffer, 0, bytesRead);
|
||||
var offset = span.IndexOf(BuildInfoMagic.Span);
|
||||
if (offset < 0)
|
||||
{
|
||||
@@ -149,7 +178,81 @@ internal static class GoBinaryScanner
|
||||
}
|
||||
finally
|
||||
{
|
||||
Array.Clear(buffer, 0, inspectLength);
|
||||
Array.Clear(buffer, 0, bytesRead);
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadBuildInfoWindowed(string filePath, long length, out string? goVersion, out string? moduleData)
|
||||
{
|
||||
goVersion = null;
|
||||
moduleData = null;
|
||||
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(WindowSizeBytes);
|
||||
var bytesRead = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
long position = 0;
|
||||
|
||||
while (position < length)
|
||||
{
|
||||
// Calculate read size (with overlap for boundaries)
|
||||
var readSize = (int)Math.Min(WindowSizeBytes, length - position);
|
||||
|
||||
// Seek to position (accounting for overlap on subsequent windows)
|
||||
if (position > 0)
|
||||
{
|
||||
stream.Seek(position - WindowOverlapBytes, SeekOrigin.Begin);
|
||||
readSize = (int)Math.Min(WindowSizeBytes, length - position + WindowOverlapBytes);
|
||||
}
|
||||
|
||||
bytesRead = 0;
|
||||
while (bytesRead < readSize)
|
||||
{
|
||||
var read = stream.Read(buffer, bytesRead, readSize - bytesRead);
|
||||
if (read <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
bytesRead += read;
|
||||
}
|
||||
|
||||
if (bytesRead < 64)
|
||||
{
|
||||
position += WindowSizeBytes - WindowOverlapBytes;
|
||||
continue;
|
||||
}
|
||||
|
||||
var span = new ReadOnlySpan<byte>(buffer, 0, bytesRead);
|
||||
var offset = span.IndexOf(BuildInfoMagic.Span);
|
||||
if (offset >= 0)
|
||||
{
|
||||
var view = span[offset..];
|
||||
if (GoBuildInfoDecoder.TryDecode(view, out goVersion, out moduleData))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
position += WindowSizeBytes - WindowOverlapBytes;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Array.Clear(buffer, 0, bytesRead);
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Security;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
|
||||
|
||||
@@ -9,6 +11,12 @@ internal static class GoBuildInfoProvider
|
||||
{
|
||||
private static readonly ConcurrentDictionary<GoBinaryCacheKey, GoBuildInfo?> Cache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Size of header to hash for cache key (4 KB). This handles container layer edge cases
|
||||
/// where files may have the same path/size/mtime but different content.
|
||||
/// </summary>
|
||||
private const int HeaderHashSize = 4096;
|
||||
|
||||
public static bool TryGetBuildInfo(string absolutePath, out GoBuildInfo? info)
|
||||
{
|
||||
info = null;
|
||||
@@ -35,11 +43,64 @@ internal static class GoBuildInfoProvider
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = new GoBinaryCacheKey(absolutePath, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks);
|
||||
// Compute bounded header hash for cache key robustness in layered filesystems
|
||||
var headerHash = ComputeHeaderHash(absolutePath);
|
||||
var key = new GoBinaryCacheKey(absolutePath, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks, headerHash);
|
||||
info = Cache.GetOrAdd(key, static (cacheKey, path) => CreateBuildInfo(path), absolutePath);
|
||||
return info is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a truncated hash of the file header for cache key disambiguation.
|
||||
/// This handles edge cases in container layers where files may have identical metadata.
|
||||
/// </summary>
|
||||
private static long ComputeHeaderHash(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(HeaderHashSize);
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var bytesRead = stream.Read(buffer, 0, HeaderHashSize);
|
||||
if (bytesRead <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Use XxHash64 for speed (non-cryptographic, but fast and well-distributed)
|
||||
// Fall back to simple hash if not available
|
||||
return ComputeSimpleHash(buffer.AsSpan(0, bytesRead));
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple FNV-1a inspired hash for header bytes.
|
||||
/// </summary>
|
||||
private static long ComputeSimpleHash(ReadOnlySpan<byte> data)
|
||||
{
|
||||
const long fnvPrime = 0x00000100000001B3;
|
||||
const long fnvOffsetBasis = unchecked((long)0xcbf29ce484222325);
|
||||
|
||||
var hash = fnvOffsetBasis;
|
||||
foreach (var b in data)
|
||||
{
|
||||
hash ^= b;
|
||||
hash *= fnvPrime;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static GoBuildInfo? CreateBuildInfo(string absolutePath)
|
||||
{
|
||||
if (!GoBinaryScanner.TryReadBuildInfo(absolutePath, out var goVersion, out var moduleData))
|
||||
@@ -65,7 +126,11 @@ internal static class GoBuildInfoProvider
|
||||
return buildInfo;
|
||||
}
|
||||
|
||||
private readonly record struct GoBinaryCacheKey(string Path, long Length, long LastWriteTicks)
|
||||
/// <summary>
|
||||
/// Cache key for Go binaries. Includes path, length, mtime, and a bounded header hash
|
||||
/// for robustness in containerized/layered filesystem environments.
|
||||
/// </summary>
|
||||
private readonly record struct GoBinaryCacheKey(string Path, long Length, long LastWriteTicks, long HeaderHash)
|
||||
{
|
||||
private readonly string _normalizedPath = OperatingSystem.IsWindows()
|
||||
? Path.ToLowerInvariant()
|
||||
@@ -74,9 +139,10 @@ internal static class GoBuildInfoProvider
|
||||
public bool Equals(GoBinaryCacheKey other)
|
||||
=> Length == other.Length
|
||||
&& LastWriteTicks == other.LastWriteTicks
|
||||
&& HeaderHash == other.HeaderHash
|
||||
&& string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal);
|
||||
|
||||
public override int GetHashCode()
|
||||
=> HashCode.Combine(_normalizedPath, Length, LastWriteTicks);
|
||||
=> HashCode.Combine(_normalizedPath, Length, LastWriteTicks, HeaderHash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,22 @@ internal static class GoDwarfReader
|
||||
private static readonly byte[] VcsModifiedToken = Encoding.UTF8.GetBytes("vcs.modified=");
|
||||
private static readonly byte[] VcsTimeToken = Encoding.UTF8.GetBytes("vcs.time=");
|
||||
|
||||
/// <summary>
|
||||
/// Maximum file size to scan (256 MB). Files larger than this are skipped.
|
||||
/// </summary>
|
||||
private const long MaxFileSizeBytes = 256 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Window size for bounded reads (8 MB). VCS tokens are typically in build info sections,
|
||||
/// not spread throughout the binary.
|
||||
/// </summary>
|
||||
private const int WindowSizeBytes = 8 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Overlap between windows to catch tokens at window boundaries.
|
||||
/// </summary>
|
||||
private const int WindowOverlapBytes = 1024;
|
||||
|
||||
public static bool TryRead(string path, out GoDwarfMetadata? metadata)
|
||||
{
|
||||
metadata = null;
|
||||
@@ -30,32 +46,108 @@ internal static class GoDwarfReader
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!fileInfo.Exists || fileInfo.Length == 0 || fileInfo.Length > 256 * 1024 * 1024)
|
||||
if (!fileInfo.Exists || fileInfo.Length == 0 || fileInfo.Length > MaxFileSizeBytes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var length = fileInfo.Length;
|
||||
var readLength = (int)Math.Min(length, int.MaxValue);
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(readLength);
|
||||
|
||||
// For small files, read the entire content
|
||||
if (length <= WindowSizeBytes)
|
||||
{
|
||||
return TryReadDirect(path, (int)length, out metadata);
|
||||
}
|
||||
|
||||
// For larger files, use windowed scanning to bound memory usage
|
||||
return TryReadWindowed(path, length, out metadata);
|
||||
}
|
||||
|
||||
private static bool TryReadDirect(string path, int length, out GoDwarfMetadata? metadata)
|
||||
{
|
||||
metadata = null;
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(length);
|
||||
var bytesRead = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
bytesRead = stream.Read(buffer, 0, readLength);
|
||||
bytesRead = stream.Read(buffer, 0, length);
|
||||
if (bytesRead <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var data = new ReadOnlySpan<byte>(buffer, 0, bytesRead);
|
||||
return TryExtractMetadata(data, out metadata);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Array.Clear(buffer, 0, bytesRead);
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
var revision = ExtractValue(data, VcsRevisionToken);
|
||||
var modifiedText = ExtractValue(data, VcsModifiedToken);
|
||||
var timestamp = ExtractValue(data, VcsTimeToken);
|
||||
var system = ExtractValue(data, VcsSystemToken);
|
||||
private static bool TryReadWindowed(string path, long length, out GoDwarfMetadata? metadata)
|
||||
{
|
||||
metadata = null;
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(WindowSizeBytes);
|
||||
var bytesRead = 0;
|
||||
|
||||
// Track found values across windows (they may be spread or we find them in different windows)
|
||||
string? revision = null;
|
||||
string? modifiedText = null;
|
||||
string? timestamp = null;
|
||||
string? system = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
long position = 0;
|
||||
|
||||
while (position < length)
|
||||
{
|
||||
var readSize = (int)Math.Min(WindowSizeBytes, length - position);
|
||||
|
||||
// Seek to position (accounting for overlap on subsequent windows)
|
||||
if (position > 0)
|
||||
{
|
||||
stream.Seek(position - WindowOverlapBytes, SeekOrigin.Begin);
|
||||
readSize = (int)Math.Min(WindowSizeBytes, length - position + WindowOverlapBytes);
|
||||
}
|
||||
|
||||
bytesRead = stream.Read(buffer, 0, readSize);
|
||||
if (bytesRead <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var data = new ReadOnlySpan<byte>(buffer, 0, bytesRead);
|
||||
|
||||
// Try to extract values from this window
|
||||
revision ??= ExtractValue(data, VcsRevisionToken);
|
||||
modifiedText ??= ExtractValue(data, VcsModifiedToken);
|
||||
timestamp ??= ExtractValue(data, VcsTimeToken);
|
||||
system ??= ExtractValue(data, VcsSystemToken);
|
||||
|
||||
// Early exit if we found all values
|
||||
if (revision is not null && modifiedText is not null && timestamp is not null && system is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
position += WindowSizeBytes - WindowOverlapBytes;
|
||||
}
|
||||
|
||||
// Build metadata from collected values
|
||||
bool? modified = null;
|
||||
if (!string.IsNullOrWhiteSpace(modifiedText))
|
||||
{
|
||||
@@ -88,6 +180,33 @@ internal static class GoDwarfReader
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryExtractMetadata(ReadOnlySpan<byte> data, out GoDwarfMetadata? metadata)
|
||||
{
|
||||
metadata = null;
|
||||
|
||||
var revision = ExtractValue(data, VcsRevisionToken);
|
||||
var modifiedText = ExtractValue(data, VcsModifiedToken);
|
||||
var timestamp = ExtractValue(data, VcsTimeToken);
|
||||
var system = ExtractValue(data, VcsSystemToken);
|
||||
|
||||
bool? modified = null;
|
||||
if (!string.IsNullOrWhiteSpace(modifiedText))
|
||||
{
|
||||
if (bool.TryParse(modifiedText, out var parsed))
|
||||
{
|
||||
modified = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(revision) && string.IsNullOrWhiteSpace(system) && modified is null && string.IsNullOrWhiteSpace(timestamp))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
metadata = new GoDwarfMetadata(system, revision, modified, timestamp);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? ExtractValue(ReadOnlySpan<byte> data, ReadOnlySpan<byte> token)
|
||||
{
|
||||
var index = data.IndexOf(token);
|
||||
|
||||
@@ -18,7 +18,8 @@ internal static class GoProjectDiscoverer
|
||||
string? goSumPath,
|
||||
string? goWorkPath,
|
||||
string? vendorModulesPath,
|
||||
ImmutableArray<string> workspaceMembers)
|
||||
ImmutableArray<string> workspaceMembers,
|
||||
ImmutableArray<GoModParser.GoModReplace> workspaceReplaces = default)
|
||||
{
|
||||
RootPath = rootPath;
|
||||
GoModPath = goModPath;
|
||||
@@ -26,6 +27,7 @@ internal static class GoProjectDiscoverer
|
||||
GoWorkPath = goWorkPath;
|
||||
VendorModulesPath = vendorModulesPath;
|
||||
WorkspaceMembers = workspaceMembers;
|
||||
WorkspaceReplaces = workspaceReplaces.IsDefault ? ImmutableArray<GoModParser.GoModReplace>.Empty : workspaceReplaces;
|
||||
}
|
||||
|
||||
public string RootPath { get; }
|
||||
@@ -35,11 +37,18 @@ internal static class GoProjectDiscoverer
|
||||
public string? VendorModulesPath { get; }
|
||||
public ImmutableArray<string> WorkspaceMembers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Workspace-wide replace directives from go.work (applies to all member modules).
|
||||
/// Module-level replaces take precedence over these when both specify the same module.
|
||||
/// </summary>
|
||||
public ImmutableArray<GoModParser.GoModReplace> WorkspaceReplaces { get; }
|
||||
|
||||
public bool HasGoMod => GoModPath is not null;
|
||||
public bool HasGoSum => GoSumPath is not null;
|
||||
public bool HasGoWork => GoWorkPath is not null;
|
||||
public bool HasVendor => VendorModulesPath is not null;
|
||||
public bool IsWorkspace => HasGoWork && WorkspaceMembers.Length > 0;
|
||||
public bool HasWorkspaceReplaces => WorkspaceReplaces.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -160,7 +169,8 @@ internal static class GoProjectDiscoverer
|
||||
File.Exists(rootGoSum) ? rootGoSum : null,
|
||||
goWorkPath,
|
||||
File.Exists(vendorModules) ? vendorModules : null,
|
||||
workspaceMembers.ToImmutableArray());
|
||||
workspaceMembers.ToImmutableArray(),
|
||||
workData.Replaces);
|
||||
}
|
||||
|
||||
private static GoProject? DiscoverStandaloneProject(string projectDir)
|
||||
|
||||
@@ -56,6 +56,7 @@ internal static class GoSourceInventory
|
||||
ImmutableArray<string>.Empty,
|
||||
GoVersionConflictDetector.GoConflictAnalysis.Empty,
|
||||
GoCgoDetector.CgoAnalysisResult.Empty,
|
||||
ImmutableArray<GoCapabilityEvidence>.Empty,
|
||||
null);
|
||||
|
||||
public SourceInventoryResult(
|
||||
@@ -65,6 +66,7 @@ internal static class GoSourceInventory
|
||||
ImmutableArray<string> retractedVersions,
|
||||
GoVersionConflictDetector.GoConflictAnalysis conflictAnalysis,
|
||||
GoCgoDetector.CgoAnalysisResult cgoAnalysis,
|
||||
ImmutableArray<GoCapabilityEvidence> capabilities,
|
||||
string? license)
|
||||
{
|
||||
ModulePath = modulePath;
|
||||
@@ -73,12 +75,20 @@ internal static class GoSourceInventory
|
||||
RetractedVersions = retractedVersions;
|
||||
ConflictAnalysis = conflictAnalysis;
|
||||
CgoAnalysis = cgoAnalysis;
|
||||
Capabilities = capabilities;
|
||||
License = license;
|
||||
}
|
||||
|
||||
public string? ModulePath { get; }
|
||||
public string? GoVersion { get; }
|
||||
public ImmutableArray<GoSourceModule> Modules { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Versions of THIS module (the declaring module) that are retracted.
|
||||
/// Note: These are versions of the main module itself, NOT dependency versions.
|
||||
/// Go's `retract` directive only applies to the declaring module; we cannot know
|
||||
/// offline if a dependency's version is retracted.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> RetractedVersions { get; }
|
||||
|
||||
/// <summary>
|
||||
@@ -91,12 +101,27 @@ internal static class GoSourceInventory
|
||||
/// </summary>
|
||||
public GoCgoDetector.CgoAnalysisResult CgoAnalysis { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Security-relevant capabilities detected in source code.
|
||||
/// </summary>
|
||||
public ImmutableArray<GoCapabilityEvidence> Capabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Main module license (SPDX identifier).
|
||||
/// </summary>
|
||||
public string? License { get; }
|
||||
|
||||
public bool IsEmpty => Modules.IsEmpty && string.IsNullOrEmpty(ModulePath);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if any critical-risk capabilities were detected.
|
||||
/// </summary>
|
||||
public bool HasCriticalCapabilities => Capabilities.Any(c => c.Risk == CapabilityRisk.Critical);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if any high-risk capabilities were detected.
|
||||
/// </summary>
|
||||
public bool HasHighRiskCapabilities => Capabilities.Any(c => c.Risk >= CapabilityRisk.High);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -128,12 +153,24 @@ internal static class GoSourceInventory
|
||||
? GoVendorParser.Parse(project.VendorModulesPath!)
|
||||
: GoVendorParser.GoVendorData.Empty;
|
||||
|
||||
// Build replacement map
|
||||
var replacements = goMod.Replaces
|
||||
.ToImmutableDictionary(
|
||||
r => r.OldVersion is not null ? $"{r.OldPath}@{r.OldVersion}" : r.OldPath,
|
||||
r => r,
|
||||
StringComparer.Ordinal);
|
||||
// Build replacement map: workspace-level replaces first, then module-level (module takes precedence)
|
||||
var replacementBuilder = new Dictionary<string, GoModParser.GoModReplace>(StringComparer.Ordinal);
|
||||
|
||||
// Add workspace-level replaces first (from go.work)
|
||||
foreach (var r in project.WorkspaceReplaces)
|
||||
{
|
||||
var key = r.OldVersion is not null ? $"{r.OldPath}@{r.OldVersion}" : r.OldPath;
|
||||
replacementBuilder[key] = r;
|
||||
}
|
||||
|
||||
// Add module-level replaces (overrides workspace-level for same key)
|
||||
foreach (var r in goMod.Replaces)
|
||||
{
|
||||
var key = r.OldVersion is not null ? $"{r.OldPath}@{r.OldVersion}" : r.OldPath;
|
||||
replacementBuilder[key] = r;
|
||||
}
|
||||
|
||||
var replacements = replacementBuilder.ToImmutableDictionary(StringComparer.Ordinal);
|
||||
|
||||
// Build exclude set
|
||||
var excludes = goMod.Excludes
|
||||
@@ -267,6 +304,9 @@ internal static class GoSourceInventory
|
||||
// Analyze CGO usage in the module
|
||||
var cgoAnalysis = GoCgoDetector.AnalyzeModule(project.RootPath);
|
||||
|
||||
// Scan for security-relevant capabilities in source files
|
||||
var capabilities = ScanCapabilities(project.RootPath);
|
||||
|
||||
// Detect main module license
|
||||
var mainLicense = GoLicenseDetector.DetectLicense(project.RootPath);
|
||||
|
||||
@@ -277,9 +317,60 @@ internal static class GoSourceInventory
|
||||
retractedVersions,
|
||||
conflictAnalysis,
|
||||
cgoAnalysis,
|
||||
capabilities,
|
||||
mainLicense.SpdxIdentifier);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans Go source files for security-relevant capabilities.
|
||||
/// </summary>
|
||||
private static ImmutableArray<GoCapabilityEvidence> ScanCapabilities(string rootPath)
|
||||
{
|
||||
var capabilities = new List<GoCapabilityEvidence>();
|
||||
|
||||
try
|
||||
{
|
||||
var enumeration = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
MaxRecursionDepth = 10
|
||||
};
|
||||
|
||||
foreach (var goFile in Directory.EnumerateFiles(rootPath, "*.go", enumeration))
|
||||
{
|
||||
// Skip vendor and testdata directories
|
||||
if (goFile.Contains($"{Path.DirectorySeparatorChar}vendor{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) ||
|
||||
goFile.Contains($"{Path.DirectorySeparatorChar}testdata{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(goFile);
|
||||
var relativePath = Path.GetRelativePath(rootPath, goFile);
|
||||
var fileCapabilities = GoCapabilityScanner.ScanFile(content, relativePath);
|
||||
capabilities.AddRange(fileCapabilities);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Skip files that can't be read
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Skip files without read access
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Skip if directory access denied
|
||||
}
|
||||
|
||||
return capabilities.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds combined inventory for a workspace (all members).
|
||||
/// </summary>
|
||||
@@ -301,7 +392,7 @@ internal static class GoSourceInventory
|
||||
}
|
||||
}
|
||||
|
||||
// Build inventory for each workspace member
|
||||
// Build inventory for each workspace member, propagating workspace-level replaces
|
||||
foreach (var memberPath in workspaceProject.WorkspaceMembers)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -311,13 +402,15 @@ internal static class GoSourceInventory
|
||||
var memberGoSum = Path.Combine(memberFullPath, "go.sum");
|
||||
var memberVendor = Path.Combine(memberFullPath, "vendor", "modules.txt");
|
||||
|
||||
// Create member project with workspace-level replaces inherited from parent
|
||||
var memberProject = new GoProjectDiscoverer.GoProject(
|
||||
memberFullPath,
|
||||
File.Exists(memberGoMod) ? memberGoMod : null,
|
||||
File.Exists(memberGoSum) ? memberGoSum : null,
|
||||
null,
|
||||
File.Exists(memberVendor) ? memberVendor : null,
|
||||
ImmutableArray<string>.Empty);
|
||||
ImmutableArray<string>.Empty,
|
||||
workspaceProject.WorkspaceReplaces);
|
||||
|
||||
if (memberProject.HasGoMod)
|
||||
{
|
||||
|
||||
@@ -189,17 +189,13 @@ internal static partial class GoVersionConflictDetector
|
||||
"Required version is explicitly excluded"));
|
||||
}
|
||||
|
||||
// Check for retracted versions (in own module's go.mod)
|
||||
if (module.IsRetracted || retractedVersions.Contains(module.Version))
|
||||
{
|
||||
conflicts.Add(new GoVersionConflict(
|
||||
module.Path,
|
||||
module.Version,
|
||||
[module.Version],
|
||||
GoConflictSeverity.High,
|
||||
GoConflictType.RetractedVersion,
|
||||
"Using a retracted version - may have known issues"));
|
||||
}
|
||||
// Note: `retract` directives apply ONLY to the declaring module, not dependencies.
|
||||
// We cannot know if a dependency version is retracted without fetching that module's go.mod,
|
||||
// which is not offline-compatible. The `retractedVersions` parameter contains versions of the
|
||||
// main/declaring module that are retracted (for metadata purposes), NOT dependency retraction.
|
||||
// Therefore, we do NOT check `retractedVersions.Contains(module.Version)` here - that would
|
||||
// be a false positive. The `module.IsRetracted` flag should only be set if we have explicit
|
||||
// evidence of retraction for THIS specific module (currently not implemented).
|
||||
}
|
||||
|
||||
// Check for major version mismatches
|
||||
|
||||
@@ -0,0 +1,913 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.ClassPath;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Reflection;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Callgraph;
|
||||
|
||||
/// <summary>
|
||||
/// Builds Java reachability graphs from class path analysis.
|
||||
/// Extracts methods, call edges, synthetic roots, and emits unknowns.
|
||||
/// </summary>
|
||||
internal sealed class JavaCallgraphBuilder
|
||||
{
|
||||
private readonly Dictionary<string, JavaMethodNode> _methods = new();
|
||||
private readonly List<JavaCallEdge> _edges = new();
|
||||
private readonly List<JavaSyntheticRoot> _roots = new();
|
||||
private readonly List<JavaUnknown> _unknowns = new();
|
||||
private readonly Dictionary<string, string> _classToJarPath = new();
|
||||
private readonly string _contextDigest;
|
||||
private int _jarCount;
|
||||
private int _classCount;
|
||||
|
||||
public JavaCallgraphBuilder(string contextDigest)
|
||||
{
|
||||
_contextDigest = contextDigest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a class path analysis to the graph.
|
||||
/// </summary>
|
||||
public void AddClassPath(JavaClassPathAnalysis classPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var segment in classPath.Segments)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
_jarCount++;
|
||||
|
||||
// Derive PURL from segment identifier (simplified - would use proper mapping in production)
|
||||
var purl = DerivePurlFromSegment(segment);
|
||||
|
||||
foreach (var kvp in segment.ClassLocations)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var className = kvp.Key;
|
||||
var location = kvp.Value;
|
||||
|
||||
_classCount++;
|
||||
_classToJarPath[className] = segment.Identifier;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = location.OpenClassStream(cancellationToken);
|
||||
AddClassFile(stream, className, segment.Identifier, purl, cancellationToken);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Record as unknown if class file cannot be parsed
|
||||
var unknownId = JavaGraphIdentifiers.ComputeUnknownId(
|
||||
segment.Identifier,
|
||||
JavaUnknownType.UnresolvedClass,
|
||||
className,
|
||||
null);
|
||||
_unknowns.Add(new JavaUnknown(
|
||||
UnknownId: unknownId,
|
||||
UnknownType: JavaUnknownType.UnresolvedClass,
|
||||
SourceId: segment.Identifier,
|
||||
ClassName: className,
|
||||
MethodName: null,
|
||||
Reason: "Class file could not be parsed",
|
||||
JarPath: segment.Identifier));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? DerivePurlFromSegment(JavaClassPathSegment segment)
|
||||
{
|
||||
// Simplified PURL derivation from JAR path
|
||||
var fileName = Path.GetFileNameWithoutExtension(segment.Identifier);
|
||||
if (string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $"pkg:maven/{fileName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds reflection analysis edges.
|
||||
/// </summary>
|
||||
public void AddReflectionAnalysis(JavaReflectionAnalysis reflectionAnalysis)
|
||||
{
|
||||
foreach (var edge in reflectionAnalysis.Edges)
|
||||
{
|
||||
// Use actual property names from JavaReflectionEdge record
|
||||
var callerId = JavaGraphIdentifiers.ComputeMethodId(
|
||||
JavaGraphIdentifiers.NormalizeClassName(edge.SourceClass),
|
||||
edge.MethodName,
|
||||
edge.MethodDescriptor);
|
||||
|
||||
var targetClassName = edge.TargetType ?? "unknown";
|
||||
var isResolved = edge.TargetType is not null;
|
||||
|
||||
// For reflection, the callee is a class load, not a method call
|
||||
var calleeId = isResolved
|
||||
? JavaGraphIdentifiers.ComputeMethodId(JavaGraphIdentifiers.NormalizeClassName(targetClassName), "<clinit>", "()V")
|
||||
: $"reflection:{targetClassName}";
|
||||
|
||||
var edgeId = JavaGraphIdentifiers.ComputeEdgeId(callerId, calleeId, edge.InstructionOffset);
|
||||
var confidence = edge.Confidence == JavaReflectionConfidence.High ? 0.9 : 0.5;
|
||||
|
||||
var edgeType = edge.Reason switch
|
||||
{
|
||||
JavaReflectionReason.ClassForName => JavaEdgeType.Reflection,
|
||||
JavaReflectionReason.ClassLoaderLoadClass => JavaEdgeType.Reflection,
|
||||
JavaReflectionReason.ServiceLoaderLoad => JavaEdgeType.ServiceLoader,
|
||||
_ => JavaEdgeType.Reflection,
|
||||
};
|
||||
|
||||
_edges.Add(new JavaCallEdge(
|
||||
EdgeId: edgeId,
|
||||
CallerId: callerId,
|
||||
CalleeId: calleeId,
|
||||
CalleePurl: null, // Reflection targets often unknown
|
||||
CalleeMethodDigest: null,
|
||||
EdgeType: edgeType,
|
||||
BytecodeOffset: edge.InstructionOffset,
|
||||
IsResolved: isResolved,
|
||||
Confidence: confidence));
|
||||
|
||||
if (!isResolved)
|
||||
{
|
||||
var unknownId = JavaGraphIdentifiers.ComputeUnknownId(
|
||||
edgeId,
|
||||
JavaUnknownType.ReflectionTarget,
|
||||
null,
|
||||
null);
|
||||
_unknowns.Add(new JavaUnknown(
|
||||
UnknownId: unknownId,
|
||||
UnknownType: JavaUnknownType.ReflectionTarget,
|
||||
SourceId: edgeId,
|
||||
ClassName: null,
|
||||
MethodName: null,
|
||||
Reason: "Reflection target class could not be determined",
|
||||
JarPath: edge.SegmentIdentifier));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the final reachability graph.
|
||||
/// </summary>
|
||||
public JavaReachabilityGraph Build()
|
||||
{
|
||||
var methods = _methods.Values
|
||||
.OrderBy(m => m.ClassName)
|
||||
.ThenBy(m => m.MethodName)
|
||||
.ThenBy(m => m.Descriptor)
|
||||
.ToImmutableArray();
|
||||
|
||||
var edges = _edges
|
||||
.OrderBy(e => e.CallerId)
|
||||
.ThenBy(e => e.BytecodeOffset)
|
||||
.ToImmutableArray();
|
||||
|
||||
var roots = _roots
|
||||
.OrderBy(r => (int)r.Phase)
|
||||
.ThenBy(r => r.Order)
|
||||
.ThenBy(r => r.TargetId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var unknowns = _unknowns
|
||||
.OrderBy(u => u.JarPath)
|
||||
.ThenBy(u => u.SourceId)
|
||||
.ToImmutableArray();
|
||||
|
||||
var contentHash = JavaGraphIdentifiers.ComputeGraphHash(methods, edges, roots);
|
||||
|
||||
var metadata = new JavaGraphMetadata(
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
GeneratorVersion: JavaGraphIdentifiers.GetGeneratorVersion(),
|
||||
ContextDigest: _contextDigest,
|
||||
JarCount: _jarCount,
|
||||
ClassCount: _classCount,
|
||||
MethodCount: methods.Length,
|
||||
EdgeCount: edges.Length,
|
||||
UnknownCount: unknowns.Length,
|
||||
SyntheticRootCount: roots.Length);
|
||||
|
||||
return new JavaReachabilityGraph(
|
||||
_contextDigest,
|
||||
methods,
|
||||
edges,
|
||||
roots,
|
||||
unknowns,
|
||||
metadata,
|
||||
contentHash);
|
||||
}
|
||||
|
||||
private void AddClassFile(Stream stream, string className, string jarPath, string? purl, CancellationToken cancellationToken)
|
||||
{
|
||||
var classFile = JavaClassFileParser.Parse(stream, cancellationToken);
|
||||
var normalizedClassName = JavaGraphIdentifiers.NormalizeClassName(className);
|
||||
|
||||
// Add methods
|
||||
foreach (var method in classFile.Methods)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
AddMethod(normalizedClassName, method, jarPath, purl);
|
||||
}
|
||||
|
||||
// Find synthetic roots
|
||||
FindSyntheticRoots(normalizedClassName, classFile, jarPath);
|
||||
|
||||
// Extract call edges from bytecode
|
||||
foreach (var method in classFile.Methods)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ExtractCallEdges(normalizedClassName, method, jarPath, classFile.ConstantPool);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddMethod(string className, JavaClassFileParser.MethodInfo method, string jarPath, string? purl)
|
||||
{
|
||||
var methodId = JavaGraphIdentifiers.ComputeMethodId(className, method.Name, method.Descriptor);
|
||||
var methodDigest = JavaGraphIdentifiers.ComputeMethodDigest(className, method.Name, method.Descriptor, method.AccessFlags);
|
||||
|
||||
var isStatic = (method.AccessFlags & 0x0008) != 0;
|
||||
var isPublic = (method.AccessFlags & 0x0001) != 0;
|
||||
var isSynthetic = (method.AccessFlags & 0x1000) != 0;
|
||||
var isBridge = (method.AccessFlags & 0x0040) != 0;
|
||||
|
||||
var node = new JavaMethodNode(
|
||||
MethodId: methodId,
|
||||
ClassName: className,
|
||||
MethodName: method.Name,
|
||||
Descriptor: method.Descriptor,
|
||||
Purl: purl,
|
||||
JarPath: jarPath,
|
||||
AccessFlags: method.AccessFlags,
|
||||
MethodDigest: methodDigest,
|
||||
IsStatic: isStatic,
|
||||
IsPublic: isPublic,
|
||||
IsSynthetic: isSynthetic,
|
||||
IsBridge: isBridge);
|
||||
|
||||
_methods.TryAdd(methodId, node);
|
||||
}
|
||||
|
||||
private void FindSyntheticRoots(string className, JavaClassFileParser.ClassFile classFile, string jarPath)
|
||||
{
|
||||
var rootOrder = 0;
|
||||
|
||||
foreach (var method in classFile.Methods)
|
||||
{
|
||||
var methodId = JavaGraphIdentifiers.ComputeMethodId(className, method.Name, method.Descriptor);
|
||||
|
||||
// main method
|
||||
if (method.Name == "main" && method.Descriptor == "([Ljava/lang/String;)V" &&
|
||||
(method.AccessFlags & 0x0009) == 0x0009) // public static
|
||||
{
|
||||
var rootId = JavaGraphIdentifiers.ComputeRootId(JavaRootPhase.Main, rootOrder++, methodId);
|
||||
_roots.Add(new JavaSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: JavaRootType.Main,
|
||||
Source: "main",
|
||||
JarPath: jarPath,
|
||||
Phase: JavaRootPhase.Main,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
|
||||
// Static initializer
|
||||
if (method.Name == "<clinit>")
|
||||
{
|
||||
var rootId = JavaGraphIdentifiers.ComputeRootId(JavaRootPhase.ClassLoad, rootOrder++, methodId);
|
||||
_roots.Add(new JavaSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: JavaRootType.StaticInitializer,
|
||||
Source: "static_init",
|
||||
JarPath: jarPath,
|
||||
Phase: JavaRootPhase.ClassLoad,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
|
||||
// Servlet lifecycle methods
|
||||
if (classFile.SuperClassName?.Contains("Servlet") == true ||
|
||||
classFile.Interfaces.Any(i => i.Contains("Servlet")))
|
||||
{
|
||||
if (method.Name == "init" && method.Descriptor.StartsWith("(Ljavax/servlet/"))
|
||||
{
|
||||
var rootId = JavaGraphIdentifiers.ComputeRootId(JavaRootPhase.AppInit, rootOrder++, methodId);
|
||||
_roots.Add(new JavaSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: JavaRootType.ServletInit,
|
||||
Source: "servlet_init",
|
||||
JarPath: jarPath,
|
||||
Phase: JavaRootPhase.AppInit,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
else if (method.Name is "service" or "doGet" or "doPost" or "doPut" or "doDelete")
|
||||
{
|
||||
var rootId = JavaGraphIdentifiers.ComputeRootId(JavaRootPhase.Main, rootOrder++, methodId);
|
||||
_roots.Add(new JavaSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: JavaRootType.ServletHandler,
|
||||
Source: "servlet_handler",
|
||||
JarPath: jarPath,
|
||||
Phase: JavaRootPhase.Main,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
}
|
||||
|
||||
// JUnit test methods (check for @Test annotation in attributes)
|
||||
if ((method.AccessFlags & 0x0001) != 0 && // public
|
||||
method.Descriptor == "()V" &&
|
||||
!method.Name.StartsWith("<") &&
|
||||
method.HasTestAnnotation)
|
||||
{
|
||||
var rootId = JavaGraphIdentifiers.ComputeRootId(JavaRootPhase.Main, rootOrder++, methodId);
|
||||
_roots.Add(new JavaSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: methodId,
|
||||
RootType: JavaRootType.TestMethod,
|
||||
Source: "junit_test",
|
||||
JarPath: jarPath,
|
||||
Phase: JavaRootPhase.Main,
|
||||
Order: rootOrder - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ExtractCallEdges(
|
||||
string className,
|
||||
JavaClassFileParser.MethodInfo method,
|
||||
string jarPath,
|
||||
JavaClassFileParser.ConstantPool pool)
|
||||
{
|
||||
var callerId = JavaGraphIdentifiers.ComputeMethodId(className, method.Name, method.Descriptor);
|
||||
|
||||
if (method.Code is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var code = method.Code;
|
||||
var offset = 0;
|
||||
|
||||
while (offset < code.Length)
|
||||
{
|
||||
var instructionOffset = offset;
|
||||
var opcode = code[offset++];
|
||||
|
||||
switch (opcode)
|
||||
{
|
||||
case 0xB8: // invokestatic
|
||||
case 0xB6: // invokevirtual
|
||||
case 0xB7: // invokespecial
|
||||
case 0xB9: // invokeinterface
|
||||
{
|
||||
if (offset + 2 > code.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var methodIndex = (code[offset++] << 8) | code[offset++];
|
||||
if (opcode == 0xB9)
|
||||
{
|
||||
offset += 2; // count and zero
|
||||
}
|
||||
|
||||
var methodRef = pool.GetMethodReference(methodIndex);
|
||||
if (methodRef.HasValue)
|
||||
{
|
||||
var targetClass = JavaGraphIdentifiers.NormalizeClassName(methodRef.Value.OwnerInternalName);
|
||||
var targetMethodId = JavaGraphIdentifiers.ComputeMethodId(
|
||||
targetClass,
|
||||
methodRef.Value.Name,
|
||||
methodRef.Value.Descriptor);
|
||||
|
||||
var edgeType = opcode switch
|
||||
{
|
||||
0xB8 => JavaEdgeType.InvokeStatic,
|
||||
0xB6 => JavaEdgeType.InvokeVirtual,
|
||||
0xB7 => methodRef.Value.Name == "<init>" ? JavaEdgeType.Constructor : JavaEdgeType.InvokeSpecial,
|
||||
0xB9 => JavaEdgeType.InvokeInterface,
|
||||
_ => JavaEdgeType.InvokeVirtual,
|
||||
};
|
||||
|
||||
// Check if target is resolved (known in our method set)
|
||||
var isResolved = _methods.ContainsKey(targetMethodId) ||
|
||||
_classToJarPath.ContainsKey(targetClass.Replace('.', '/'));
|
||||
var calleePurl = isResolved ? GetPurlForClass(targetClass) : null;
|
||||
|
||||
var edgeId = JavaGraphIdentifiers.ComputeEdgeId(callerId, targetMethodId, instructionOffset);
|
||||
|
||||
_edges.Add(new JavaCallEdge(
|
||||
EdgeId: edgeId,
|
||||
CallerId: callerId,
|
||||
CalleeId: targetMethodId,
|
||||
CalleePurl: calleePurl,
|
||||
CalleeMethodDigest: null, // Would compute if method is in our set
|
||||
EdgeType: edgeType,
|
||||
BytecodeOffset: instructionOffset,
|
||||
IsResolved: isResolved,
|
||||
Confidence: isResolved ? 1.0 : 0.7));
|
||||
|
||||
if (!isResolved)
|
||||
{
|
||||
var unknownId = JavaGraphIdentifiers.ComputeUnknownId(
|
||||
edgeId,
|
||||
JavaUnknownType.UnresolvedMethod,
|
||||
targetClass,
|
||||
methodRef.Value.Name);
|
||||
_unknowns.Add(new JavaUnknown(
|
||||
UnknownId: unknownId,
|
||||
UnknownType: JavaUnknownType.UnresolvedMethod,
|
||||
SourceId: edgeId,
|
||||
ClassName: targetClass,
|
||||
MethodName: methodRef.Value.Name,
|
||||
Reason: "Method not found in analyzed classpath",
|
||||
JarPath: jarPath));
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 0xBA: // invokedynamic
|
||||
{
|
||||
if (offset + 4 > code.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var dynamicIndex = (code[offset++] << 8) | code[offset++];
|
||||
offset += 2; // skip zeros
|
||||
|
||||
// invokedynamic targets are typically lambdas/method refs - emit as unknown
|
||||
var targetId = $"dynamic:{dynamicIndex}";
|
||||
var edgeId = JavaGraphIdentifiers.ComputeEdgeId(callerId, targetId, instructionOffset);
|
||||
|
||||
_edges.Add(new JavaCallEdge(
|
||||
EdgeId: edgeId,
|
||||
CallerId: callerId,
|
||||
CalleeId: targetId,
|
||||
CalleePurl: null,
|
||||
CalleeMethodDigest: null,
|
||||
EdgeType: JavaEdgeType.InvokeDynamic,
|
||||
BytecodeOffset: instructionOffset,
|
||||
IsResolved: false,
|
||||
Confidence: 0.3));
|
||||
|
||||
var unknownId = JavaGraphIdentifiers.ComputeUnknownId(
|
||||
edgeId,
|
||||
JavaUnknownType.DynamicTarget,
|
||||
null,
|
||||
null);
|
||||
_unknowns.Add(new JavaUnknown(
|
||||
UnknownId: unknownId,
|
||||
UnknownType: JavaUnknownType.DynamicTarget,
|
||||
SourceId: edgeId,
|
||||
ClassName: null,
|
||||
MethodName: null,
|
||||
Reason: "invokedynamic target requires bootstrap method resolution",
|
||||
JarPath: jarPath));
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Skip other instructions - advance based on opcode
|
||||
offset += GetInstructionSize(opcode) - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetPurlForClass(string className)
|
||||
{
|
||||
var internalName = className.Replace('.', '/');
|
||||
if (_classToJarPath.TryGetValue(internalName, out var jarPath))
|
||||
{
|
||||
// In production, would map JAR to Maven coordinates
|
||||
return $"pkg:maven/{Path.GetFileNameWithoutExtension(jarPath)}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int GetInstructionSize(byte opcode)
|
||||
{
|
||||
// Simplified instruction size lookup - production would have full table
|
||||
return opcode switch
|
||||
{
|
||||
// Zero operand instructions
|
||||
>= 0x00 and <= 0x0F => 1, // nop, aconst_null, iconst_*, lconst_*, fconst_*, dconst_*
|
||||
>= 0x1A and <= 0x35 => 1, // iload_*, lload_*, fload_*, dload_*, aload_*, *aload
|
||||
>= 0x3B and <= 0x56 => 1, // istore_*, lstore_*, fstore_*, dstore_*, astore_*, *astore
|
||||
>= 0x57 and <= 0x83 => 1, // pop, dup, swap, arithmetic, conversions
|
||||
>= 0x94 and <= 0x98 => 1, // lcmp, fcmp*, dcmp*
|
||||
>= 0xAC and <= 0xB1 => 1, // *return, return
|
||||
0xBE => 1, // arraylength
|
||||
0xBF => 1, // athrow
|
||||
0xC2 => 1, // monitorenter
|
||||
0xC3 => 1, // monitorexit
|
||||
|
||||
// Single byte operand
|
||||
0x10 => 2, // bipush
|
||||
>= 0x15 and <= 0x19 => 2, // iload, lload, fload, dload, aload
|
||||
>= 0x36 and <= 0x3A => 2, // istore, lstore, fstore, dstore, astore
|
||||
0xA9 => 2, // ret
|
||||
0xBC => 2, // newarray
|
||||
|
||||
// Two byte operand
|
||||
0x11 => 3, // sipush
|
||||
0x12 => 2, // ldc
|
||||
0x13 => 3, // ldc_w
|
||||
0x14 => 3, // ldc2_w
|
||||
0x84 => 3, // iinc
|
||||
>= 0x99 and <= 0xA8 => 3, // if*, goto, jsr
|
||||
>= 0xB2 and <= 0xB5 => 3, // get/put static/field
|
||||
>= 0xB6 and <= 0xB8 => 3, // invoke virtual/special/static
|
||||
0xB9 => 5, // invokeinterface
|
||||
0xBA => 5, // invokedynamic
|
||||
0xBB => 3, // new
|
||||
0xBD => 3, // anewarray
|
||||
0xC0 => 3, // checkcast
|
||||
0xC1 => 3, // instanceof
|
||||
0xC5 => 4, // multianewarray
|
||||
0xC6 => 3, // ifnull
|
||||
0xC7 => 3, // ifnonnull
|
||||
0xC8 => 5, // goto_w
|
||||
0xC9 => 5, // jsr_w
|
||||
|
||||
// Variable length (tableswitch, lookupswitch) - simplified
|
||||
0xAA => 16, // tableswitch (minimum)
|
||||
0xAB => 8, // lookupswitch (minimum)
|
||||
|
||||
// wide prefix
|
||||
0xC4 => 4, // wide (varies, using minimum)
|
||||
|
||||
_ => 1, // default
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Java class file parser for callgraph extraction.
|
||||
/// </summary>
|
||||
internal static class JavaClassFileParser
|
||||
{
|
||||
public static ClassFile Parse(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
using var reader = new BinaryReader(stream, System.Text.Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
var magic = ReadUInt32BE(reader);
|
||||
if (magic != 0xCAFEBABE)
|
||||
{
|
||||
throw new InvalidDataException("Invalid Java class file magic.");
|
||||
}
|
||||
|
||||
_ = ReadUInt16BE(reader); // minor version
|
||||
_ = ReadUInt16BE(reader); // major version
|
||||
|
||||
var constantPoolCount = ReadUInt16BE(reader);
|
||||
var pool = new ConstantPool(constantPoolCount);
|
||||
|
||||
for (var i = 1; i < constantPoolCount; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tag = reader.ReadByte();
|
||||
var entry = ReadConstantPoolEntry(reader, tag);
|
||||
pool.Set(i, entry);
|
||||
|
||||
// Long and Double take two slots
|
||||
if (tag == 5 || tag == 6)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
_ = ReadUInt16BE(reader); // access flags
|
||||
var thisClassIndex = ReadUInt16BE(reader);
|
||||
var superClassIndex = ReadUInt16BE(reader);
|
||||
|
||||
var interfaceCount = ReadUInt16BE(reader);
|
||||
var interfaces = new string[interfaceCount];
|
||||
for (var i = 0; i < interfaceCount; i++)
|
||||
{
|
||||
var idx = ReadUInt16BE(reader);
|
||||
interfaces[i] = pool.GetClassName(idx) ?? "";
|
||||
}
|
||||
|
||||
var fieldCount = ReadUInt16BE(reader);
|
||||
for (var i = 0; i < fieldCount; i++)
|
||||
{
|
||||
SkipMember(reader);
|
||||
}
|
||||
|
||||
var methodCount = ReadUInt16BE(reader);
|
||||
var methods = new List<MethodInfo>(methodCount);
|
||||
for (var i = 0; i < methodCount; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var method = ReadMethod(reader, pool);
|
||||
methods.Add(method);
|
||||
}
|
||||
|
||||
// Skip class attributes
|
||||
var attrCount = ReadUInt16BE(reader);
|
||||
for (var i = 0; i < attrCount; i++)
|
||||
{
|
||||
SkipAttribute(reader);
|
||||
}
|
||||
|
||||
var thisClassName = pool.GetClassName(thisClassIndex);
|
||||
var superClassName = superClassIndex > 0 ? pool.GetClassName(superClassIndex) : null;
|
||||
|
||||
return new ClassFile(thisClassName ?? "", superClassName, interfaces.ToImmutableArray(), methods.ToImmutableArray(), pool);
|
||||
}
|
||||
|
||||
private static MethodInfo ReadMethod(BinaryReader reader, ConstantPool pool)
|
||||
{
|
||||
var accessFlags = ReadUInt16BE(reader);
|
||||
var nameIndex = ReadUInt16BE(reader);
|
||||
var descriptorIndex = ReadUInt16BE(reader);
|
||||
|
||||
var name = pool.GetUtf8(nameIndex) ?? "";
|
||||
var descriptor = pool.GetUtf8(descriptorIndex) ?? "";
|
||||
|
||||
byte[]? code = null;
|
||||
var hasTestAnnotation = false;
|
||||
|
||||
var attrCount = ReadUInt16BE(reader);
|
||||
for (var i = 0; i < attrCount; i++)
|
||||
{
|
||||
var attrNameIndex = ReadUInt16BE(reader);
|
||||
var attrLength = ReadUInt32BE(reader);
|
||||
var attrName = pool.GetUtf8(attrNameIndex) ?? "";
|
||||
|
||||
if (attrName == "Code")
|
||||
{
|
||||
_ = ReadUInt16BE(reader); // max_stack
|
||||
_ = ReadUInt16BE(reader); // max_locals
|
||||
var codeLength = ReadUInt32BE(reader);
|
||||
code = reader.ReadBytes((int)codeLength);
|
||||
|
||||
var exceptionTableLength = ReadUInt16BE(reader);
|
||||
for (var e = 0; e < exceptionTableLength; e++)
|
||||
{
|
||||
reader.ReadBytes(8);
|
||||
}
|
||||
|
||||
var codeAttrCount = ReadUInt16BE(reader);
|
||||
for (var c = 0; c < codeAttrCount; c++)
|
||||
{
|
||||
SkipAttribute(reader);
|
||||
}
|
||||
}
|
||||
else if (attrName == "RuntimeVisibleAnnotations" || attrName == "RuntimeInvisibleAnnotations")
|
||||
{
|
||||
var startPos = reader.BaseStream.Position;
|
||||
var numAnnotations = ReadUInt16BE(reader);
|
||||
for (var a = 0; a < numAnnotations; a++)
|
||||
{
|
||||
var typeIndex = ReadUInt16BE(reader);
|
||||
var annotationType = pool.GetUtf8(typeIndex) ?? "";
|
||||
if (annotationType.Contains("Test") || annotationType.Contains("org/junit"))
|
||||
{
|
||||
hasTestAnnotation = true;
|
||||
}
|
||||
|
||||
var numPairs = ReadUInt16BE(reader);
|
||||
for (var p = 0; p < numPairs; p++)
|
||||
{
|
||||
_ = ReadUInt16BE(reader); // element_name_index
|
||||
SkipAnnotationValue(reader);
|
||||
}
|
||||
}
|
||||
|
||||
// Seek to end of attribute if we didn't read it all
|
||||
reader.BaseStream.Position = startPos + attrLength - 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
reader.ReadBytes((int)attrLength);
|
||||
}
|
||||
}
|
||||
|
||||
return new MethodInfo(name, descriptor, accessFlags, code, hasTestAnnotation);
|
||||
}
|
||||
|
||||
private static void SkipMember(BinaryReader reader)
|
||||
{
|
||||
reader.ReadBytes(6); // access_flags, name_index, descriptor_index
|
||||
var attrCount = ReadUInt16BE(reader);
|
||||
for (var i = 0; i < attrCount; i++)
|
||||
{
|
||||
SkipAttribute(reader);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SkipAttribute(BinaryReader reader)
|
||||
{
|
||||
_ = ReadUInt16BE(reader); // name_index
|
||||
var length = ReadUInt32BE(reader);
|
||||
reader.ReadBytes((int)length);
|
||||
}
|
||||
|
||||
private static void SkipAnnotationValue(BinaryReader reader)
|
||||
{
|
||||
var tag = (char)reader.ReadByte();
|
||||
switch (tag)
|
||||
{
|
||||
case 'B':
|
||||
case 'C':
|
||||
case 'D':
|
||||
case 'F':
|
||||
case 'I':
|
||||
case 'J':
|
||||
case 'S':
|
||||
case 'Z':
|
||||
case 's':
|
||||
case 'c':
|
||||
ReadUInt16BE(reader);
|
||||
break;
|
||||
case 'e':
|
||||
ReadUInt16BE(reader);
|
||||
ReadUInt16BE(reader);
|
||||
break;
|
||||
case '@':
|
||||
ReadUInt16BE(reader);
|
||||
var numPairs = ReadUInt16BE(reader);
|
||||
for (var i = 0; i < numPairs; i++)
|
||||
{
|
||||
ReadUInt16BE(reader);
|
||||
SkipAnnotationValue(reader);
|
||||
}
|
||||
|
||||
break;
|
||||
case '[':
|
||||
var numValues = ReadUInt16BE(reader);
|
||||
for (var i = 0; i < numValues; i++)
|
||||
{
|
||||
SkipAnnotationValue(reader);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static ConstantPoolEntry ReadConstantPoolEntry(BinaryReader reader, byte tag)
|
||||
{
|
||||
return tag switch
|
||||
{
|
||||
1 => new ConstantPoolEntry.Utf8Entry(ReadUtf8(reader)),
|
||||
3 => new ConstantPoolEntry.IntegerEntry(ReadUInt32BE(reader)),
|
||||
4 => new ConstantPoolEntry.FloatEntry(reader.ReadBytes(4)),
|
||||
5 => new ConstantPoolEntry.LongEntry(reader.ReadBytes(8)),
|
||||
6 => new ConstantPoolEntry.DoubleEntry(reader.ReadBytes(8)),
|
||||
7 => new ConstantPoolEntry.ClassEntry(ReadUInt16BE(reader)),
|
||||
8 => new ConstantPoolEntry.StringEntry(ReadUInt16BE(reader)),
|
||||
9 => new ConstantPoolEntry.FieldrefEntry(ReadUInt16BE(reader), ReadUInt16BE(reader)),
|
||||
10 => new ConstantPoolEntry.MethodrefEntry(ReadUInt16BE(reader), ReadUInt16BE(reader)),
|
||||
11 => new ConstantPoolEntry.InterfaceMethodrefEntry(ReadUInt16BE(reader), ReadUInt16BE(reader)),
|
||||
12 => new ConstantPoolEntry.NameAndTypeEntry(ReadUInt16BE(reader), ReadUInt16BE(reader)),
|
||||
15 => new ConstantPoolEntry.MethodHandleEntry(reader.ReadByte(), ReadUInt16BE(reader)),
|
||||
16 => new ConstantPoolEntry.MethodTypeEntry(ReadUInt16BE(reader)),
|
||||
17 => new ConstantPoolEntry.DynamicEntry(ReadUInt16BE(reader), ReadUInt16BE(reader)),
|
||||
18 => new ConstantPoolEntry.InvokeDynamicEntry(ReadUInt16BE(reader), ReadUInt16BE(reader)),
|
||||
19 => new ConstantPoolEntry.ModuleEntry(ReadUInt16BE(reader)),
|
||||
20 => new ConstantPoolEntry.PackageEntry(ReadUInt16BE(reader)),
|
||||
_ => throw new InvalidDataException($"Unknown constant pool tag: {tag}"),
|
||||
};
|
||||
}
|
||||
|
||||
private static ushort ReadUInt16BE(BinaryReader reader)
|
||||
{
|
||||
var b1 = reader.ReadByte();
|
||||
var b2 = reader.ReadByte();
|
||||
return (ushort)((b1 << 8) | b2);
|
||||
}
|
||||
|
||||
private static uint ReadUInt32BE(BinaryReader reader)
|
||||
{
|
||||
var b1 = reader.ReadByte();
|
||||
var b2 = reader.ReadByte();
|
||||
var b3 = reader.ReadByte();
|
||||
var b4 = reader.ReadByte();
|
||||
return (uint)((b1 << 24) | (b2 << 16) | (b3 << 8) | b4);
|
||||
}
|
||||
|
||||
private static string ReadUtf8(BinaryReader reader)
|
||||
{
|
||||
var length = ReadUInt16BE(reader);
|
||||
var bytes = reader.ReadBytes(length);
|
||||
return System.Text.Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
|
||||
public sealed record ClassFile(
|
||||
string ThisClassName,
|
||||
string? SuperClassName,
|
||||
ImmutableArray<string> Interfaces,
|
||||
ImmutableArray<MethodInfo> Methods,
|
||||
ConstantPool ConstantPool);
|
||||
|
||||
public sealed record MethodInfo(
|
||||
string Name,
|
||||
string Descriptor,
|
||||
int AccessFlags,
|
||||
byte[]? Code,
|
||||
bool HasTestAnnotation);
|
||||
|
||||
public sealed class ConstantPool
|
||||
{
|
||||
private readonly ConstantPoolEntry?[] _entries;
|
||||
|
||||
public ConstantPool(int count)
|
||||
{
|
||||
_entries = new ConstantPoolEntry?[count];
|
||||
}
|
||||
|
||||
public void Set(int index, ConstantPoolEntry entry)
|
||||
{
|
||||
_entries[index] = entry;
|
||||
}
|
||||
|
||||
public string? GetUtf8(int index)
|
||||
{
|
||||
if (index <= 0 || index >= _entries.Length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return _entries[index] is ConstantPoolEntry.Utf8Entry utf8 ? utf8.Value : null;
|
||||
}
|
||||
|
||||
public string? GetClassName(int index)
|
||||
{
|
||||
if (_entries[index] is ConstantPoolEntry.ClassEntry classEntry)
|
||||
{
|
||||
return GetUtf8(classEntry.NameIndex);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public MethodReference? GetMethodReference(int index)
|
||||
{
|
||||
if (_entries[index] is not ConstantPoolEntry.MethodrefEntry and not ConstantPoolEntry.InterfaceMethodrefEntry)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var (classIndex, nameAndTypeIndex) = _entries[index] switch
|
||||
{
|
||||
ConstantPoolEntry.MethodrefEntry m => (m.ClassIndex, m.NameAndTypeIndex),
|
||||
ConstantPoolEntry.InterfaceMethodrefEntry m => (m.ClassIndex, m.NameAndTypeIndex),
|
||||
_ => (0, 0),
|
||||
};
|
||||
|
||||
var owner = GetClassName(classIndex);
|
||||
if (owner is null || _entries[nameAndTypeIndex] is not ConstantPoolEntry.NameAndTypeEntry nat)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var name = GetUtf8(nat.NameIndex) ?? "";
|
||||
var descriptor = GetUtf8(nat.DescriptorIndex) ?? "";
|
||||
return new MethodReference(owner, name, descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct MethodReference(string OwnerInternalName, string Name, string Descriptor);
|
||||
|
||||
public abstract record ConstantPoolEntry
|
||||
{
|
||||
public sealed record Utf8Entry(string Value) : ConstantPoolEntry;
|
||||
|
||||
public sealed record IntegerEntry(uint Value) : ConstantPoolEntry;
|
||||
|
||||
public sealed record FloatEntry(byte[] Bytes) : ConstantPoolEntry;
|
||||
|
||||
public sealed record LongEntry(byte[] Bytes) : ConstantPoolEntry;
|
||||
|
||||
public sealed record DoubleEntry(byte[] Bytes) : ConstantPoolEntry;
|
||||
|
||||
public sealed record ClassEntry(int NameIndex) : ConstantPoolEntry;
|
||||
|
||||
public sealed record StringEntry(int StringIndex) : ConstantPoolEntry;
|
||||
|
||||
public sealed record FieldrefEntry(int ClassIndex, int NameAndTypeIndex) : ConstantPoolEntry;
|
||||
|
||||
public sealed record MethodrefEntry(int ClassIndex, int NameAndTypeIndex) : ConstantPoolEntry;
|
||||
|
||||
public sealed record InterfaceMethodrefEntry(int ClassIndex, int NameAndTypeIndex) : ConstantPoolEntry;
|
||||
|
||||
public sealed record NameAndTypeEntry(int NameIndex, int DescriptorIndex) : ConstantPoolEntry;
|
||||
|
||||
public sealed record MethodHandleEntry(byte ReferenceKind, int ReferenceIndex) : ConstantPoolEntry;
|
||||
|
||||
public sealed record MethodTypeEntry(int DescriptorIndex) : ConstantPoolEntry;
|
||||
|
||||
public sealed record DynamicEntry(int BootstrapMethodAttrIndex, int NameAndTypeIndex) : ConstantPoolEntry;
|
||||
|
||||
public sealed record InvokeDynamicEntry(int BootstrapMethodAttrIndex, int NameAndTypeIndex) : ConstantPoolEntry;
|
||||
|
||||
public sealed record ModuleEntry(int NameIndex) : ConstantPoolEntry;
|
||||
|
||||
public sealed record PackageEntry(int NameIndex) : ConstantPoolEntry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Callgraph;
|
||||
|
||||
/// <summary>
|
||||
/// Java reachability graph containing methods, call edges, and metadata.
|
||||
/// </summary>
|
||||
public sealed record JavaReachabilityGraph(
|
||||
string ContextDigest,
|
||||
ImmutableArray<JavaMethodNode> Methods,
|
||||
ImmutableArray<JavaCallEdge> Edges,
|
||||
ImmutableArray<JavaSyntheticRoot> SyntheticRoots,
|
||||
ImmutableArray<JavaUnknown> Unknowns,
|
||||
JavaGraphMetadata Metadata,
|
||||
string ContentHash);
|
||||
|
||||
/// <summary>
|
||||
/// A method node in the Java call graph.
|
||||
/// </summary>
|
||||
/// <param name="MethodId">Deterministic method identifier (sha256 of class+name+descriptor).</param>
|
||||
/// <param name="ClassName">Fully qualified class name (e.g., java.lang.String).</param>
|
||||
/// <param name="MethodName">Method name.</param>
|
||||
/// <param name="Descriptor">JVM method descriptor (e.g., (Ljava/lang/String;)V).</param>
|
||||
/// <param name="Purl">Package URL if resolvable (e.g., pkg:maven/org.example/lib).</param>
|
||||
/// <param name="JarPath">Path to the containing JAR file.</param>
|
||||
/// <param name="AccessFlags">Method access flags (public, static, etc.).</param>
|
||||
/// <param name="MethodDigest">SHA-256 of (class + name + descriptor + accessFlags).</param>
|
||||
/// <param name="IsStatic">Whether the method is static.</param>
|
||||
/// <param name="IsPublic">Whether the method is public.</param>
|
||||
/// <param name="IsSynthetic">Whether the method is synthetic (compiler-generated).</param>
|
||||
/// <param name="IsBridge">Whether the method is a bridge method.</param>
|
||||
public sealed record JavaMethodNode(
|
||||
string MethodId,
|
||||
string ClassName,
|
||||
string MethodName,
|
||||
string Descriptor,
|
||||
string? Purl,
|
||||
string JarPath,
|
||||
int AccessFlags,
|
||||
string MethodDigest,
|
||||
bool IsStatic,
|
||||
bool IsPublic,
|
||||
bool IsSynthetic,
|
||||
bool IsBridge);
|
||||
|
||||
/// <summary>
|
||||
/// A call edge in the Java call graph.
|
||||
/// </summary>
|
||||
/// <param name="EdgeId">Deterministic edge identifier.</param>
|
||||
/// <param name="CallerId">MethodId of the calling method.</param>
|
||||
/// <param name="CalleeId">MethodId of the called method (or Unknown placeholder).</param>
|
||||
/// <param name="CalleePurl">PURL of the callee if resolvable.</param>
|
||||
/// <param name="CalleeMethodDigest">Method digest of the callee.</param>
|
||||
/// <param name="EdgeType">Type of edge (invoke type).</param>
|
||||
/// <param name="BytecodeOffset">Bytecode offset where call occurs.</param>
|
||||
/// <param name="IsResolved">Whether the callee was successfully resolved.</param>
|
||||
/// <param name="Confidence">Confidence level (1.0 for resolved, lower for heuristic).</param>
|
||||
public sealed record JavaCallEdge(
|
||||
string EdgeId,
|
||||
string CallerId,
|
||||
string CalleeId,
|
||||
string? CalleePurl,
|
||||
string? CalleeMethodDigest,
|
||||
JavaEdgeType EdgeType,
|
||||
int BytecodeOffset,
|
||||
bool IsResolved,
|
||||
double Confidence);
|
||||
|
||||
/// <summary>
|
||||
/// Type of Java call edge.
|
||||
/// </summary>
|
||||
public enum JavaEdgeType
|
||||
{
|
||||
/// <summary>invokestatic - static method call.</summary>
|
||||
InvokeStatic,
|
||||
|
||||
/// <summary>invokevirtual - virtual method call.</summary>
|
||||
InvokeVirtual,
|
||||
|
||||
/// <summary>invokeinterface - interface method call.</summary>
|
||||
InvokeInterface,
|
||||
|
||||
/// <summary>invokespecial - constructor, super, private method call.</summary>
|
||||
InvokeSpecial,
|
||||
|
||||
/// <summary>invokedynamic - lambda/method reference.</summary>
|
||||
InvokeDynamic,
|
||||
|
||||
/// <summary>Class.forName reflection call.</summary>
|
||||
Reflection,
|
||||
|
||||
/// <summary>ServiceLoader.load call.</summary>
|
||||
ServiceLoader,
|
||||
|
||||
/// <summary>Constructor invocation.</summary>
|
||||
Constructor,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A synthetic root in the Java call graph.
|
||||
/// </summary>
|
||||
/// <param name="RootId">Deterministic root identifier.</param>
|
||||
/// <param name="TargetId">MethodId of the target method.</param>
|
||||
/// <param name="RootType">Type of synthetic root.</param>
|
||||
/// <param name="Source">Source of the root (e.g., main, static_init, servlet).</param>
|
||||
/// <param name="JarPath">Path to the containing JAR.</param>
|
||||
/// <param name="Phase">Execution phase.</param>
|
||||
/// <param name="Order">Order within the phase.</param>
|
||||
/// <param name="IsResolved">Whether the target was successfully resolved.</param>
|
||||
public sealed record JavaSyntheticRoot(
|
||||
string RootId,
|
||||
string TargetId,
|
||||
JavaRootType RootType,
|
||||
string Source,
|
||||
string JarPath,
|
||||
JavaRootPhase Phase,
|
||||
int Order,
|
||||
bool IsResolved = true);
|
||||
|
||||
/// <summary>
|
||||
/// Execution phase for Java synthetic roots.
|
||||
/// </summary>
|
||||
public enum JavaRootPhase
|
||||
{
|
||||
/// <summary>Class loading phase - static initializers.</summary>
|
||||
ClassLoad = 0,
|
||||
|
||||
/// <summary>Application initialization - servlet init, Spring context.</summary>
|
||||
AppInit = 1,
|
||||
|
||||
/// <summary>Main execution - main method, request handlers.</summary>
|
||||
Main = 2,
|
||||
|
||||
/// <summary>Shutdown - destroy methods, shutdown hooks.</summary>
|
||||
Shutdown = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of Java synthetic root.
|
||||
/// </summary>
|
||||
public enum JavaRootType
|
||||
{
|
||||
/// <summary>main(String[] args) method.</summary>
|
||||
Main,
|
||||
|
||||
/// <summary>Static initializer block (<clinit>).</summary>
|
||||
StaticInitializer,
|
||||
|
||||
/// <summary>Instance initializer (<init>).</summary>
|
||||
Constructor,
|
||||
|
||||
/// <summary>Servlet init method.</summary>
|
||||
ServletInit,
|
||||
|
||||
/// <summary>Servlet service/doGet/doPost methods.</summary>
|
||||
ServletHandler,
|
||||
|
||||
/// <summary>Spring @PostConstruct.</summary>
|
||||
PostConstruct,
|
||||
|
||||
/// <summary>Spring @PreDestroy.</summary>
|
||||
PreDestroy,
|
||||
|
||||
/// <summary>JUnit @Test method.</summary>
|
||||
TestMethod,
|
||||
|
||||
/// <summary>Runtime shutdown hook.</summary>
|
||||
ShutdownHook,
|
||||
|
||||
/// <summary>Thread run method.</summary>
|
||||
ThreadRun,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unknown/unresolved reference in the Java call graph.
|
||||
/// </summary>
|
||||
public sealed record JavaUnknown(
|
||||
string UnknownId,
|
||||
JavaUnknownType UnknownType,
|
||||
string SourceId,
|
||||
string? ClassName,
|
||||
string? MethodName,
|
||||
string Reason,
|
||||
string JarPath);
|
||||
|
||||
/// <summary>
|
||||
/// Type of unknown reference in Java.
|
||||
/// </summary>
|
||||
public enum JavaUnknownType
|
||||
{
|
||||
/// <summary>Class could not be resolved.</summary>
|
||||
UnresolvedClass,
|
||||
|
||||
/// <summary>Method could not be resolved.</summary>
|
||||
UnresolvedMethod,
|
||||
|
||||
/// <summary>Dynamic invoke target is unknown.</summary>
|
||||
DynamicTarget,
|
||||
|
||||
/// <summary>Reflection target is unknown.</summary>
|
||||
ReflectionTarget,
|
||||
|
||||
/// <summary>Service provider class is unknown.</summary>
|
||||
ServiceProvider,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for the Java reachability graph.
|
||||
/// </summary>
|
||||
public sealed record JavaGraphMetadata(
|
||||
DateTimeOffset GeneratedAt,
|
||||
string GeneratorVersion,
|
||||
string ContextDigest,
|
||||
int JarCount,
|
||||
int ClassCount,
|
||||
int MethodCount,
|
||||
int EdgeCount,
|
||||
int UnknownCount,
|
||||
int SyntheticRootCount);
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods for creating deterministic Java graph identifiers.
|
||||
/// </summary>
|
||||
internal static class JavaGraphIdentifiers
|
||||
{
|
||||
private const string GeneratorVersion = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic method ID from class, name, and descriptor.
|
||||
/// </summary>
|
||||
public static string ComputeMethodId(string className, string methodName, string descriptor)
|
||||
{
|
||||
var input = $"{className}:{methodName}:{descriptor}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"jmethod:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic method digest.
|
||||
/// </summary>
|
||||
public static string ComputeMethodDigest(string className, string methodName, string descriptor, int accessFlags)
|
||||
{
|
||||
var input = $"{className}:{methodName}:{descriptor}:{accessFlags}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic edge ID.
|
||||
/// </summary>
|
||||
public static string ComputeEdgeId(string callerId, string calleeId, int bytecodeOffset)
|
||||
{
|
||||
var input = $"{callerId}:{calleeId}:{bytecodeOffset}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"jedge:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic root ID.
|
||||
/// </summary>
|
||||
public static string ComputeRootId(JavaRootPhase phase, int order, string targetId)
|
||||
{
|
||||
var phaseName = phase.ToString().ToLowerInvariant();
|
||||
return $"jroot:{phaseName}:{order}:{targetId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic unknown ID.
|
||||
/// </summary>
|
||||
public static string ComputeUnknownId(string sourceId, JavaUnknownType unknownType, string? className, string? methodName)
|
||||
{
|
||||
var input = $"{sourceId}:{unknownType}:{className ?? ""}:{methodName ?? ""}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"junk:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes content hash for the entire graph.
|
||||
/// </summary>
|
||||
public static string ComputeGraphHash(
|
||||
ImmutableArray<JavaMethodNode> methods,
|
||||
ImmutableArray<JavaCallEdge> edges,
|
||||
ImmutableArray<JavaSyntheticRoot> roots)
|
||||
{
|
||||
using var sha = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
|
||||
foreach (var m in methods.OrderBy(m => m.MethodId))
|
||||
{
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(m.MethodId));
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(m.MethodDigest));
|
||||
}
|
||||
|
||||
foreach (var e in edges.OrderBy(e => e.EdgeId))
|
||||
{
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(e.EdgeId));
|
||||
}
|
||||
|
||||
foreach (var r in roots.OrderBy(r => r.RootId))
|
||||
{
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(r.RootId));
|
||||
}
|
||||
|
||||
return Convert.ToHexString(sha.GetCurrentHash()).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current generator version.
|
||||
/// </summary>
|
||||
public static string GetGeneratorVersion() => GeneratorVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a JVM internal class name to fully qualified format.
|
||||
/// </summary>
|
||||
public static string NormalizeClassName(string internalName)
|
||||
{
|
||||
return internalName.Replace('/', '.');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a method descriptor to extract readable signature.
|
||||
/// </summary>
|
||||
public static string ParseDescriptor(string descriptor)
|
||||
{
|
||||
// Simplified parsing - full implementation would properly decode JVM descriptors
|
||||
return descriptor;
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,48 @@ using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Collects and parses Java lock files (Gradle lockfiles, Maven POMs) to produce dependency entries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><strong>Lock Precedence Rules (Sprint 0403 / Interlock 2):</strong></para>
|
||||
/// <list type="number">
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <strong>Gradle lockfiles</strong> are highest priority (most reliable, resolved coordinates).
|
||||
/// When multiple lockfiles exist across a multi-module project, they are processed in
|
||||
/// lexicographic order by relative path (ensures deterministic iteration).
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <strong>De-duplication rule:</strong> For the same GAV (group:artifact:version),
|
||||
/// the <em>first</em> lockfile encountered (by lexicographic path order) wins.
|
||||
/// This means root-level lockfiles (e.g., <c>gradle.lockfile</c>) are processed before
|
||||
/// submodule lockfiles (e.g., <c>app/gradle.lockfile</c>) and thus take precedence.
|
||||
/// <para>Rationale: Root lockfiles typically represent the resolved dependency graph for
|
||||
/// the entire project; module-level lockfiles may contain duplicates or overrides.</para>
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <strong>Gradle build files</strong> (when no lockfiles exist) are parsed with version
|
||||
/// catalog resolution. Same lexicographic + first-wins rule applies.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <strong>Maven POMs</strong> are lowest priority and are additive (TryAdd semantics).
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Each entry carries <c>lockLocator</c> (relative path to the source file) and
|
||||
/// <c>lockModulePath</c> (module directory context, e.g., <c>.</c> for root, <c>app</c> for submodule).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class JavaLockFileCollector
|
||||
{
|
||||
private static readonly string[] GradleLockPatterns = ["gradle.lockfile"];
|
||||
|
||||
public static async Task<JavaLockData> LoadAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
@@ -20,25 +58,17 @@ internal static class JavaLockFileCollector
|
||||
var entries = new Dictionary<string, JavaLockEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
var root = context.RootPath;
|
||||
|
||||
// Discover all build files
|
||||
// Discover all build files (returns paths sorted by RelativePath for determinism)
|
||||
var buildFiles = JavaBuildFileDiscovery.Discover(root);
|
||||
|
||||
// Priority 1: Gradle lockfiles (most reliable)
|
||||
foreach (var pattern in GradleLockPatterns)
|
||||
// Priority 1: Gradle lockfiles from discovery (most reliable)
|
||||
// Processed in lexicographic order by relative path; first-wins for duplicate GAVs.
|
||||
if (buildFiles.HasGradleLockFiles)
|
||||
{
|
||||
var lockPath = Path.Combine(root, pattern);
|
||||
if (File.Exists(lockPath))
|
||||
foreach (var lockFile in buildFiles.GradleLockFiles)
|
||||
{
|
||||
await ParseGradleLockFileAsync(context, lockPath, entries, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var dependencyLocksDir = Path.Combine(root, "gradle", "dependency-locks");
|
||||
if (Directory.Exists(dependencyLocksDir))
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(dependencyLocksDir, "*.lockfile", SearchOption.AllDirectories))
|
||||
{
|
||||
await ParseGradleLockFileAsync(context, file, entries, cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await ParseGradleLockFileAsync(context, lockFile.AbsolutePath, lockFile.ProjectDirectory, entries, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,12 +99,16 @@ internal static class JavaLockFileCollector
|
||||
private static async Task ParseGradleLockFileAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
string path,
|
||||
string modulePath,
|
||||
IDictionary<string, JavaLockEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var locator = NormalizeLocator(context, path);
|
||||
var normalizedModulePath = NormalizeModulePath(modulePath);
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
|
||||
{
|
||||
@@ -113,7 +147,8 @@ internal static class JavaLockFileCollector
|
||||
artifactId.Trim(),
|
||||
version.Trim(),
|
||||
Path.GetFileName(path),
|
||||
NormalizeLocator(context, path),
|
||||
locator,
|
||||
normalizedModulePath,
|
||||
configuration,
|
||||
null,
|
||||
null,
|
||||
@@ -124,10 +159,22 @@ internal static class JavaLockFileCollector
|
||||
null,
|
||||
null);
|
||||
|
||||
entries[entry.Key] = entry;
|
||||
// First-wins for duplicate GAVs (entries are processed in lexicographic order)
|
||||
entries.TryAdd(entry.Key, entry);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeModulePath(string? modulePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(modulePath))
|
||||
{
|
||||
return ".";
|
||||
}
|
||||
|
||||
var normalized = modulePath.Replace('\\', '/').Trim('/');
|
||||
return string.IsNullOrEmpty(normalized) ? "." : normalized;
|
||||
}
|
||||
|
||||
private static async Task ParseGradleBuildFilesAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
JavaBuildFiles buildFiles,
|
||||
@@ -190,6 +237,9 @@ internal static class JavaLockFileCollector
|
||||
GradleVersionCatalog? versionCatalog,
|
||||
IDictionary<string, JavaLockEntry> entries)
|
||||
{
|
||||
var locator = NormalizeLocator(context, buildFile.SourcePath);
|
||||
var modulePath = NormalizeModulePath(Path.GetDirectoryName(context.GetRelativePath(buildFile.SourcePath)));
|
||||
|
||||
foreach (var dep in buildFile.Dependencies)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dep.GroupId) || string.IsNullOrWhiteSpace(dep.ArtifactId))
|
||||
@@ -224,7 +274,8 @@ internal static class JavaLockFileCollector
|
||||
dep.ArtifactId,
|
||||
version,
|
||||
Path.GetFileName(buildFile.SourcePath),
|
||||
NormalizeLocator(context, buildFile.SourcePath),
|
||||
locator,
|
||||
modulePath,
|
||||
scope,
|
||||
null,
|
||||
null,
|
||||
@@ -257,6 +308,9 @@ internal static class JavaLockFileCollector
|
||||
var effectivePomBuilder = new MavenEffectivePomBuilder(context.RootPath);
|
||||
var effectivePom = await effectivePomBuilder.BuildAsync(pom, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var locator = NormalizeLocator(context, path);
|
||||
var modulePath = NormalizeModulePath(Path.GetDirectoryName(context.GetRelativePath(path)));
|
||||
|
||||
foreach (var dep in effectivePom.ResolvedDependencies)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -281,7 +335,8 @@ internal static class JavaLockFileCollector
|
||||
dep.ArtifactId,
|
||||
dep.Version,
|
||||
"pom.xml",
|
||||
NormalizeLocator(context, path),
|
||||
locator,
|
||||
modulePath,
|
||||
scope,
|
||||
null,
|
||||
null,
|
||||
@@ -311,6 +366,9 @@ internal static class JavaLockFileCollector
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var document = await XDocument.LoadAsync(stream, LoadOptions.None, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var locator = NormalizeLocator(context, path);
|
||||
var modulePath = NormalizeModulePath(Path.GetDirectoryName(context.GetRelativePath(path)));
|
||||
|
||||
var dependencies = document
|
||||
.Descendants()
|
||||
.Where(static element => element.Name.LocalName.Equals("dependency", StringComparison.OrdinalIgnoreCase));
|
||||
@@ -343,7 +401,8 @@ internal static class JavaLockFileCollector
|
||||
artifactId,
|
||||
version,
|
||||
"pom.xml",
|
||||
NormalizeLocator(context, path),
|
||||
locator,
|
||||
modulePath,
|
||||
scope,
|
||||
repository,
|
||||
null,
|
||||
@@ -400,6 +459,7 @@ internal sealed record JavaLockEntry(
|
||||
string Version,
|
||||
string Source,
|
||||
string Locator,
|
||||
string? LockModulePath,
|
||||
string? Configuration,
|
||||
string? Repository,
|
||||
string? ResolvedUrl,
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
@@ -61,6 +62,9 @@ public sealed class JavaLanguageAnalyzer : ILanguageAnalyzer
|
||||
}
|
||||
}
|
||||
|
||||
// Task 403-004: Emit runtime image components (explicit-key, no PURL to avoid false vuln matches)
|
||||
EmitRuntimeImageComponents(workspace, Id, writer, context, cancellationToken);
|
||||
|
||||
// E5: Detect version conflicts
|
||||
var conflictAnalysis = BuildConflictAnalysis(lockData);
|
||||
|
||||
@@ -849,6 +853,7 @@ public sealed class JavaLanguageAnalyzer : ILanguageAnalyzer
|
||||
AddMetadata(metadata, "lockConfiguration", entry.Configuration);
|
||||
AddMetadata(metadata, "lockRepository", entry.Repository);
|
||||
AddMetadata(metadata, "lockResolved", entry.ResolvedUrl);
|
||||
AddMetadata(metadata, "lockModulePath", entry.LockModulePath);
|
||||
|
||||
// E4: Add scope and risk level metadata
|
||||
AddMetadata(metadata, "declaredScope", entry.Scope);
|
||||
@@ -1678,6 +1683,121 @@ internal sealed record JniHintSummary(
|
||||
EvidenceSha256: sha256);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits runtime image components discovered by JavaWorkspaceNormalizer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><strong>Runtime Component Identity Decision (Sprint 0403 / Action 2):</strong></para>
|
||||
/// <para>
|
||||
/// Java runtime images are emitted using <em>explicit-key</em> (not PURL) to avoid false
|
||||
/// vulnerability matches. There is no standardized PURL scheme for JDK/JRE installations
|
||||
/// that reliably maps to CVE advisories. Using explicit-key ensures runtime context is
|
||||
/// captured without introducing misleading vuln alerts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The component key is formed from: analyzerId + "java-runtime" + version + vendor + relativePath.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deduplication: identical runtime images (same version+vendor+relativePath) are emitted only once.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void EmitRuntimeImageComponents(
|
||||
JavaWorkspace workspace,
|
||||
string analyzerId,
|
||||
LanguageComponentWriter writer,
|
||||
LanguageAnalyzerContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workspace);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(analyzerId);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (workspace.RuntimeImages.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplicate by (version, vendor, relativePath) - deterministic ordering
|
||||
var seenRuntimes = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var runtime in workspace.RuntimeImages.OrderBy(r => r.RelativePath, StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Create dedup key from identifying properties
|
||||
var dedupKey = $"{runtime.JavaVersion}|{runtime.Vendor}|{runtime.RelativePath}";
|
||||
if (!seenRuntimes.Add(dedupKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedPath = runtime.RelativePath.Replace('\\', '/');
|
||||
var releaseLocator = string.IsNullOrEmpty(normalizedPath) || normalizedPath == "."
|
||||
? "release"
|
||||
: $"{normalizedPath}/release";
|
||||
|
||||
// Compute evidence SHA256 from release file
|
||||
string? releaseSha256 = null;
|
||||
var releaseFilePath = Path.Combine(runtime.AbsolutePath, "release");
|
||||
if (File.Exists(releaseFilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var releaseBytes = File.ReadAllBytes(releaseFilePath);
|
||||
releaseSha256 = Convert.ToHexString(SHA256.HashData(releaseBytes)).ToLowerInvariant();
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Cannot read release file; proceed without SHA256
|
||||
}
|
||||
}
|
||||
|
||||
// Build component metadata
|
||||
var metadata = new List<KeyValuePair<string, string?>>(8);
|
||||
AddMetadata(metadata, "java.version", runtime.JavaVersion);
|
||||
AddMetadata(metadata, "java.vendor", runtime.Vendor);
|
||||
AddMetadata(metadata, "runtimeImagePath", normalizedPath, allowEmpty: true);
|
||||
AddMetadata(metadata, "componentType", "java-runtime");
|
||||
|
||||
// Build evidence referencing the release file
|
||||
var evidence = new[]
|
||||
{
|
||||
new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
"release",
|
||||
releaseLocator,
|
||||
null,
|
||||
releaseSha256),
|
||||
};
|
||||
|
||||
// Build explicit component key (no PURL to avoid false vuln matches)
|
||||
var componentKeyData = $"{runtime.JavaVersion}:{runtime.Vendor}:{normalizedPath}";
|
||||
var componentKey = LanguageExplicitKey.Create(
|
||||
analyzerId,
|
||||
"java-runtime",
|
||||
componentKeyData,
|
||||
releaseSha256 ?? string.Empty,
|
||||
releaseLocator);
|
||||
|
||||
// Emit component name: e.g., "java-runtime-21.0.1" or "java-runtime-21.0.1 (Eclipse Adoptium)"
|
||||
var componentName = string.IsNullOrWhiteSpace(runtime.Vendor)
|
||||
? $"java-runtime-{runtime.JavaVersion}"
|
||||
: $"java-runtime-{runtime.JavaVersion} ({runtime.Vendor})";
|
||||
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: analyzerId,
|
||||
componentKey: componentKey,
|
||||
purl: null,
|
||||
name: componentName,
|
||||
version: runtime.JavaVersion,
|
||||
type: "java-runtime",
|
||||
metadata: SortMetadata(metadata),
|
||||
evidence: evidence,
|
||||
usedByEntrypoint: false);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<KeyValuePair<string, string?>> CreateDeclaredMetadata(
|
||||
JavaLockEntry entry,
|
||||
VersionConflictAnalysis conflictAnalysis)
|
||||
|
||||
@@ -5,19 +5,33 @@ namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Packaging.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter for container layer overlays that may contain Python packages.
|
||||
/// Handles whiteout files and layer ordering.
|
||||
/// Implements OCI overlay semantics including whiteouts per Action 3 contract.
|
||||
/// </summary>
|
||||
internal sealed class ContainerLayerAdapter : IPythonPackagingAdapter
|
||||
{
|
||||
public string Name => "container-layer";
|
||||
public int Priority => 100; // Lowest priority - use other adapters first
|
||||
|
||||
/// <summary>
|
||||
/// Container-specific metadata keys.
|
||||
/// </summary>
|
||||
internal static class MetadataKeys
|
||||
{
|
||||
public const string OverlayIncomplete = "container.overlayIncomplete";
|
||||
public const string LayerSource = "container.layerSource";
|
||||
public const string LayerOrder = "container.layerOrder";
|
||||
public const string Warning = "container.warning";
|
||||
public const string WhiteoutApplied = "container.whiteoutApplied";
|
||||
public const string LayersProcessed = "container.layersProcessed";
|
||||
}
|
||||
|
||||
public bool CanHandle(PythonVirtualFileSystem vfs, string path)
|
||||
{
|
||||
// Container layers typically have specific patterns
|
||||
// Check for layer root markers or whiteout files
|
||||
return vfs.EnumerateFiles(path, ".wh.*").Any() ||
|
||||
HasContainerLayoutMarkers(vfs, path);
|
||||
HasContainerLayoutMarkers(vfs, path) ||
|
||||
HasLayerDirectories(path);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<PythonPackageInfo> DiscoverPackagesAsync(
|
||||
@@ -25,10 +39,96 @@ internal sealed class ContainerLayerAdapter : IPythonPackagingAdapter
|
||||
string path,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Discover packages from common Python installation paths in containers
|
||||
var pythonPaths = FindPythonPathsInContainer(vfs, path);
|
||||
// Discover container layers
|
||||
var layers = ContainerOverlayHandler.DiscoverLayers(path);
|
||||
|
||||
// Use DistInfoAdapter for each discovered path
|
||||
if (layers.Count > 0)
|
||||
{
|
||||
// Process with overlay semantics
|
||||
await foreach (var pkg in DiscoverWithOverlayAsync(vfs, path, layers, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return pkg;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No layer structure detected - scan as merged rootfs
|
||||
await foreach (var pkg in DiscoverFromMergedRootfsAsync(vfs, path, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return pkg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<PythonPackageInfo> DiscoverWithOverlayAsync(
|
||||
PythonVirtualFileSystem vfs,
|
||||
string rootPath,
|
||||
IReadOnlyList<ContainerOverlayHandler.LayerInfo> layers,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
// Build overlay result
|
||||
var overlayResult = ContainerOverlayHandler.ProcessLayers(layers, layerPath =>
|
||||
{
|
||||
return EnumerateFilesRecursive(layerPath);
|
||||
});
|
||||
|
||||
var discoveredPackages = new Dictionary<string, PythonPackageInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
var distInfoAdapter = new DistInfoAdapter();
|
||||
|
||||
// Process each layer in order
|
||||
foreach (var layer in layers.OrderBy(static l => l.Order))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Find Python paths in this layer
|
||||
var pythonPaths = FindPythonPathsInLayer(layer.Path);
|
||||
|
||||
foreach (var pythonPath in pythonPaths)
|
||||
{
|
||||
if (!distInfoAdapter.CanHandle(vfs, pythonPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await foreach (var pkg in distInfoAdapter.DiscoverPackagesAsync(vfs, pythonPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Check if package's metadata path is visible after overlay
|
||||
var isVisible = IsPackageVisible(pkg, layer.Path, overlayResult);
|
||||
|
||||
if (!isVisible)
|
||||
{
|
||||
// Package was whited out - remove from discovered
|
||||
discoveredPackages.Remove(pkg.NormalizedName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add container metadata
|
||||
var containerPkg = pkg with
|
||||
{
|
||||
Location = pythonPath,
|
||||
Confidence = AdjustConfidenceForOverlay(pkg.Confidence, overlayResult.IsComplete),
|
||||
ContainerMetadata = BuildContainerMetadata(layer, overlayResult)
|
||||
};
|
||||
|
||||
// Later layers override earlier ones (last-wins within overlay)
|
||||
discoveredPackages[pkg.NormalizedName] = containerPkg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pkg in discoveredPackages.Values.OrderBy(static p => p.NormalizedName, StringComparer.Ordinal))
|
||||
{
|
||||
yield return pkg;
|
||||
}
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<PythonPackageInfo> DiscoverFromMergedRootfsAsync(
|
||||
PythonVirtualFileSystem vfs,
|
||||
string path,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
// No layer structure - this is a merged rootfs, scan directly
|
||||
var pythonPaths = FindPythonPathsInContainer(vfs, path);
|
||||
var distInfoAdapter = new DistInfoAdapter();
|
||||
|
||||
foreach (var pythonPath in pythonPaths)
|
||||
@@ -42,7 +142,6 @@ internal sealed class ContainerLayerAdapter : IPythonPackagingAdapter
|
||||
|
||||
await foreach (var pkg in distInfoAdapter.DiscoverPackagesAsync(vfs, pythonPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Mark as coming from container layer
|
||||
yield return pkg with
|
||||
{
|
||||
Location = pythonPath,
|
||||
@@ -51,7 +150,7 @@ internal sealed class ContainerLayerAdapter : IPythonPackagingAdapter
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for vendored packages in /app, /opt, etc.
|
||||
// Also check for vendored packages
|
||||
var vendoredPaths = FindVendoredPathsInContainer(vfs, path);
|
||||
foreach (var vendoredPath in vendoredPaths)
|
||||
{
|
||||
@@ -64,9 +163,135 @@ internal sealed class ContainerLayerAdapter : IPythonPackagingAdapter
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPackageVisible(
|
||||
PythonPackageInfo pkg,
|
||||
string layerPath,
|
||||
ContainerOverlayHandler.OverlayResult overlay)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pkg.MetadataPath))
|
||||
{
|
||||
return true; // Can't check without metadata path
|
||||
}
|
||||
|
||||
// Build full path and check visibility
|
||||
var fullPath = Path.Combine(layerPath, pkg.MetadataPath.TrimStart('/'));
|
||||
return ContainerOverlayHandler.IsPathVisible(overlay, fullPath);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildContainerMetadata(
|
||||
ContainerOverlayHandler.LayerInfo layer,
|
||||
ContainerOverlayHandler.OverlayResult overlay)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
[MetadataKeys.LayerSource] = Path.GetFileName(layer.Path),
|
||||
[MetadataKeys.LayerOrder] = layer.Order.ToString(),
|
||||
[MetadataKeys.LayersProcessed] = overlay.ProcessedLayers.Count.ToString()
|
||||
};
|
||||
|
||||
if (!overlay.IsComplete)
|
||||
{
|
||||
metadata[MetadataKeys.OverlayIncomplete] = "true";
|
||||
}
|
||||
|
||||
if (overlay.Warning is not null)
|
||||
{
|
||||
metadata[MetadataKeys.Warning] = overlay.Warning;
|
||||
}
|
||||
|
||||
if (overlay.WhiteoutedPaths.Count > 0)
|
||||
{
|
||||
metadata[MetadataKeys.WhiteoutApplied] = "true";
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateFilesRecursive(string path)
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var options = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.System
|
||||
};
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(path, "*", options))
|
||||
{
|
||||
yield return file;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> FindPythonPathsInLayer(string layerPath)
|
||||
{
|
||||
var foundPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Common Python installation paths
|
||||
var patterns = new[]
|
||||
{
|
||||
"usr/lib/python*/site-packages",
|
||||
"usr/local/lib/python*/site-packages",
|
||||
"opt/*/lib/python*/site-packages",
|
||||
".venv/lib/python*/site-packages",
|
||||
"venv/lib/python*/site-packages"
|
||||
};
|
||||
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
var searchPath = Path.Combine(layerPath, pattern.Replace("*/", ""));
|
||||
|
||||
if (Directory.Exists(Path.GetDirectoryName(searchPath)))
|
||||
{
|
||||
try
|
||||
{
|
||||
var matches = Directory.GetDirectories(
|
||||
Path.GetDirectoryName(searchPath)!,
|
||||
Path.GetFileName(pattern.Replace("*/site-packages", "")),
|
||||
SearchOption.TopDirectoryOnly);
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
var sitePackages = Path.Combine(match, "site-packages");
|
||||
if (Directory.Exists(sitePackages))
|
||||
{
|
||||
foundPaths.Add(sitePackages);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore enumeration errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return foundPaths;
|
||||
}
|
||||
|
||||
private static bool HasLayerDirectories(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) || !Directory.Exists(path))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
return Directory.Exists(Path.Combine(path, "layers")) ||
|
||||
Directory.Exists(Path.Combine(path, ".layers")) ||
|
||||
Directory.GetDirectories(path, "layer*").Any();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasContainerLayoutMarkers(PythonVirtualFileSystem vfs, string path)
|
||||
{
|
||||
// Check for typical container root structure
|
||||
var markers = new[]
|
||||
{
|
||||
$"{path}/etc/os-release",
|
||||
@@ -83,18 +308,6 @@ internal sealed class ContainerLayerAdapter : IPythonPackagingAdapter
|
||||
{
|
||||
var foundPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Common Python installation paths in containers
|
||||
var pythonPathPatterns = new[]
|
||||
{
|
||||
$"{path}/usr/lib/python*/site-packages",
|
||||
$"{path}/usr/local/lib/python*/site-packages",
|
||||
$"{path}/opt/*/lib/python*/site-packages",
|
||||
$"{path}/home/*/.local/lib/python*/site-packages",
|
||||
$"{path}/.venv/lib/python*/site-packages",
|
||||
$"{path}/venv/lib/python*/site-packages"
|
||||
};
|
||||
|
||||
// Search for site-packages directories
|
||||
var sitePackagesDirs = vfs.EnumerateFiles(path, "site-packages/*")
|
||||
.Select(f => GetParentDirectory(f.VirtualPath))
|
||||
.Where(p => p is not null && p.EndsWith("site-packages", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -113,7 +326,6 @@ internal sealed class ContainerLayerAdapter : IPythonPackagingAdapter
|
||||
{
|
||||
var vendoredPaths = new List<string>();
|
||||
|
||||
// Common vendored package locations
|
||||
var vendorPatterns = new[]
|
||||
{
|
||||
$"{path}/app/vendor",
|
||||
@@ -138,9 +350,7 @@ internal sealed class ContainerLayerAdapter : IPythonPackagingAdapter
|
||||
string path,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
// Find packages by looking for __init__.py or standalone .py files
|
||||
var initFiles = vfs.EnumerateFiles(path, "__init__.py").ToList();
|
||||
|
||||
var discoveredPackages = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var initFile in initFiles)
|
||||
@@ -174,13 +384,13 @@ internal sealed class ContainerLayerAdapter : IPythonPackagingAdapter
|
||||
InstallerTool: null,
|
||||
EditableTarget: null,
|
||||
IsDirectDependency: true,
|
||||
Confidence: PythonPackageConfidence.Low);
|
||||
Confidence: PythonPackageConfidence.Low,
|
||||
ContainerMetadata: null);
|
||||
}
|
||||
}
|
||||
|
||||
private static PythonPackageConfidence AdjustConfidenceForContainer(PythonPackageConfidence confidence)
|
||||
{
|
||||
// Container layers may have incomplete or overlaid files
|
||||
return confidence switch
|
||||
{
|
||||
PythonPackageConfidence.Definitive => PythonPackageConfidence.High,
|
||||
@@ -188,6 +398,24 @@ internal sealed class ContainerLayerAdapter : IPythonPackagingAdapter
|
||||
};
|
||||
}
|
||||
|
||||
private static PythonPackageConfidence AdjustConfidenceForOverlay(
|
||||
PythonPackageConfidence confidence,
|
||||
bool isComplete)
|
||||
{
|
||||
if (!isComplete)
|
||||
{
|
||||
// Reduce confidence when overlay is incomplete
|
||||
return confidence switch
|
||||
{
|
||||
PythonPackageConfidence.Definitive => PythonPackageConfidence.Medium,
|
||||
PythonPackageConfidence.High => PythonPackageConfidence.Medium,
|
||||
_ => PythonPackageConfidence.Low
|
||||
};
|
||||
}
|
||||
|
||||
return AdjustConfidenceForContainer(confidence);
|
||||
}
|
||||
|
||||
private static string? GetParentDirectory(string path)
|
||||
{
|
||||
var lastSep = path.LastIndexOf('/');
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Packaging;
|
||||
|
||||
/// <summary>
|
||||
/// Handles OCI container overlay semantics including whiteouts and layer ordering.
|
||||
/// Per Action 3 contract in SPRINT_0405_0001_0001.
|
||||
/// </summary>
|
||||
internal sealed partial class ContainerOverlayHandler
|
||||
{
|
||||
private const string SingleFileWhiteoutPrefix = ".wh.";
|
||||
private const string OpaqueWhiteoutMarker = ".wh..wh..opq";
|
||||
|
||||
/// <summary>
|
||||
/// Represents a layer in the container overlay.
|
||||
/// </summary>
|
||||
internal sealed record LayerInfo(string Path, int Order, bool IsComplete);
|
||||
|
||||
/// <summary>
|
||||
/// Result of processing container layers with overlay semantics.
|
||||
/// </summary>
|
||||
internal sealed record OverlayResult(
|
||||
IReadOnlySet<string> VisiblePaths,
|
||||
IReadOnlySet<string> WhiteoutedPaths,
|
||||
IReadOnlyList<LayerInfo> ProcessedLayers,
|
||||
bool IsComplete,
|
||||
string? Warning);
|
||||
|
||||
/// <summary>
|
||||
/// Discovers and orders container layers deterministically.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LayerInfo> DiscoverLayers(string rootPath)
|
||||
{
|
||||
var layers = new List<LayerInfo>();
|
||||
|
||||
// Check for layer directories
|
||||
var layerDirs = new List<string>();
|
||||
|
||||
// Pattern 1: layers/* (direct children)
|
||||
var layersDir = Path.Combine(rootPath, "layers");
|
||||
if (Directory.Exists(layersDir))
|
||||
{
|
||||
layerDirs.AddRange(Directory.GetDirectories(layersDir)
|
||||
.OrderBy(static d => GetLayerSortKey(Path.GetFileName(d)), StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// Pattern 2: .layers/* (direct children)
|
||||
var dotLayersDir = Path.Combine(rootPath, ".layers");
|
||||
if (Directory.Exists(dotLayersDir))
|
||||
{
|
||||
layerDirs.AddRange(Directory.GetDirectories(dotLayersDir)
|
||||
.OrderBy(static d => GetLayerSortKey(Path.GetFileName(d)), StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// Pattern 3: layer* (direct children of root)
|
||||
var layerPrefixDirs = Directory.GetDirectories(rootPath, "layer*")
|
||||
.Where(static d => LayerPrefixPattern().IsMatch(Path.GetFileName(d)))
|
||||
.OrderBy(static d => GetLayerSortKey(Path.GetFileName(d)), StringComparer.OrdinalIgnoreCase);
|
||||
layerDirs.AddRange(layerPrefixDirs);
|
||||
|
||||
// Assign order based on discovery sequence
|
||||
var order = 0;
|
||||
foreach (var layerDir in layerDirs)
|
||||
{
|
||||
layers.Add(new LayerInfo(layerDir, order++, IsLayerComplete(layerDir)));
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes layers and returns visible paths after applying whiteout semantics.
|
||||
/// Lower order = earlier layer, higher order = later layer (takes precedence).
|
||||
/// </summary>
|
||||
public static OverlayResult ProcessLayers(IReadOnlyList<LayerInfo> layers, Func<string, IEnumerable<string>> enumerateFiles)
|
||||
{
|
||||
var visiblePaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var whiteoutedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var opaqueDirectories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var isComplete = true;
|
||||
string? warning = null;
|
||||
|
||||
// Process layers in order (lower index = earlier, higher index = later/overrides)
|
||||
foreach (var layer in layers.OrderBy(static l => l.Order))
|
||||
{
|
||||
if (!layer.IsComplete)
|
||||
{
|
||||
isComplete = false;
|
||||
}
|
||||
|
||||
var layerFiles = enumerateFiles(layer.Path).ToList();
|
||||
|
||||
// First pass: collect whiteouts and opaque markers
|
||||
var layerWhiteouts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var file in layerFiles)
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
var dirPath = Path.GetDirectoryName(file);
|
||||
|
||||
if (fileName == OpaqueWhiteoutMarker && dirPath is not null)
|
||||
{
|
||||
// Opaque whiteout: remove all prior contents of this directory
|
||||
var opaqueDir = NormalizePath(dirPath);
|
||||
opaqueDirectories.Add(opaqueDir);
|
||||
|
||||
// Remove any visible paths under this directory from prior layers
|
||||
var toRemove = visiblePaths.Where(p => IsUnderDirectory(p, opaqueDir)).ToList();
|
||||
foreach (var path in toRemove)
|
||||
{
|
||||
visiblePaths.Remove(path);
|
||||
whiteoutedPaths.Add(path);
|
||||
}
|
||||
}
|
||||
else if (fileName.StartsWith(SingleFileWhiteoutPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
// Single file whiteout: remove specific file
|
||||
var targetName = fileName[SingleFileWhiteoutPrefix.Length..];
|
||||
var targetPath = dirPath is not null
|
||||
? NormalizePath(Path.Combine(dirPath, targetName))
|
||||
: targetName;
|
||||
|
||||
layerWhiteouts.Add(targetPath);
|
||||
visiblePaths.Remove(targetPath);
|
||||
whiteoutedPaths.Add(targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: add non-whiteout files
|
||||
foreach (var file in layerFiles)
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
|
||||
// Skip whiteout marker files themselves
|
||||
if (fileName == OpaqueWhiteoutMarker ||
|
||||
fileName.StartsWith(SingleFileWhiteoutPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedPath = NormalizePath(file);
|
||||
|
||||
// Check if this file is under an opaque directory from a later layer
|
||||
// (shouldn't happen in forward processing, but be defensive)
|
||||
if (!layerWhiteouts.Contains(normalizedPath))
|
||||
{
|
||||
visiblePaths.Add(normalizedPath);
|
||||
whiteoutedPaths.Remove(normalizedPath); // File added back in later layer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isComplete)
|
||||
{
|
||||
warning = "Overlay context incomplete; inventory may include removed packages";
|
||||
}
|
||||
|
||||
return new OverlayResult(
|
||||
visiblePaths,
|
||||
whiteoutedPaths,
|
||||
layers,
|
||||
isComplete,
|
||||
warning);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a path would be visible after overlay processing.
|
||||
/// </summary>
|
||||
public static bool IsPathVisible(OverlayResult overlay, string path)
|
||||
{
|
||||
var normalized = NormalizePath(path);
|
||||
return overlay.VisiblePaths.Contains(normalized);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a deterministic sort key for layer directory names.
|
||||
/// Numeric prefixes are parsed for proper numeric sorting.
|
||||
/// </summary>
|
||||
private static string GetLayerSortKey(string dirName)
|
||||
{
|
||||
// Try to extract numeric prefix for proper numeric sorting
|
||||
var match = NumericPrefixPattern().Match(dirName);
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out var num))
|
||||
{
|
||||
// Pad numeric value for proper sorting
|
||||
return $"{num:D10}_{dirName}";
|
||||
}
|
||||
|
||||
return dirName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a layer directory appears complete.
|
||||
/// </summary>
|
||||
private static bool IsLayerComplete(string layerPath)
|
||||
{
|
||||
// Check for common markers that indicate a complete layer
|
||||
// - Has at least some content
|
||||
// - Doesn't have obvious truncation markers
|
||||
try
|
||||
{
|
||||
var hasContent = Directory.EnumerateFileSystemEntries(layerPath).Any();
|
||||
return hasContent;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a path for consistent comparison.
|
||||
/// </summary>
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
return path.Replace('\\', '/').TrimEnd('/');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a path is under a directory (considering normalized paths).
|
||||
/// </summary>
|
||||
private static bool IsUnderDirectory(string path, string directory)
|
||||
{
|
||||
var normalizedPath = NormalizePath(path);
|
||||
var normalizedDir = NormalizePath(directory);
|
||||
|
||||
return normalizedPath.StartsWith(normalizedDir + "/", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalizedPath.Equals(normalizedDir, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^layer(\d+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex LayerPrefixPattern();
|
||||
|
||||
[GeneratedRegex(@"^(\d+)", RegexOptions.Compiled)]
|
||||
private static partial Regex NumericPrefixPattern();
|
||||
}
|
||||
@@ -18,6 +18,7 @@ namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Packaging;
|
||||
/// <param name="EditableTarget">For editable installs, the target directory.</param>
|
||||
/// <param name="IsDirectDependency">Whether this is a direct (vs transitive) dependency.</param>
|
||||
/// <param name="Confidence">Confidence level in the package discovery.</param>
|
||||
/// <param name="ContainerMetadata">Container layer metadata when discovered from OCI layers.</param>
|
||||
internal sealed record PythonPackageInfo(
|
||||
string Name,
|
||||
string? Version,
|
||||
@@ -31,7 +32,8 @@ internal sealed record PythonPackageInfo(
|
||||
string? InstallerTool,
|
||||
string? EditableTarget,
|
||||
bool IsDirectDependency,
|
||||
PythonPackageConfidence Confidence)
|
||||
PythonPackageConfidence Confidence,
|
||||
IReadOnlyDictionary<string, string>? ContainerMetadata = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the normalized package name (lowercase, hyphens to underscores).
|
||||
@@ -94,6 +96,14 @@ internal sealed record PythonPackageInfo(
|
||||
|
||||
yield return new($"{prefix}.isDirect", IsDirectDependency.ToString());
|
||||
yield return new($"{prefix}.confidence", Confidence.ToString());
|
||||
|
||||
if (ContainerMetadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in ContainerMetadata)
|
||||
{
|
||||
yield return new(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,163 +3,460 @@ using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal;
|
||||
|
||||
internal static class PythonLockFileCollector
|
||||
/// <summary>
|
||||
/// Collects Python lock/requirements entries with deterministic precedence ordering.
|
||||
/// Precedence (highest to lowest): poetry.lock > Pipfile.lock > pdm.lock > uv.lock > requirements.txt > requirements-*.txt
|
||||
/// </summary>
|
||||
internal static partial class PythonLockFileCollector
|
||||
{
|
||||
private static readonly string[] RequirementPatterns =
|
||||
{
|
||||
"requirements.txt",
|
||||
"requirements-dev.txt",
|
||||
"requirements.prod.txt"
|
||||
};
|
||||
private const int MaxIncludeDepth = 10;
|
||||
private const int MaxUnsupportedSamples = 5;
|
||||
|
||||
private static readonly Regex RequirementLinePattern = new(@"^\s*(?<name>[A-Za-z0-9_.\-]+)(?<extras>\[[^\]]+\])?\s*(?<op>==|===)\s*(?<version>[^\s;#]+)", RegexOptions.Compiled);
|
||||
private static readonly Regex EditablePattern = new(@"^-{1,2}editable\s*=?\s*(?<path>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
/// <summary>
|
||||
/// Lock file source types in precedence order.
|
||||
/// </summary>
|
||||
private enum LockSourcePrecedence
|
||||
{
|
||||
PoetryLock = 1,
|
||||
PipfileLock = 2,
|
||||
PdmLock = 3,
|
||||
UvLock = 4,
|
||||
RequirementsTxt = 5,
|
||||
RequirementsVariant = 6,
|
||||
ConstraintsTxt = 7
|
||||
}
|
||||
|
||||
// PEP 508 requirement pattern: name[extras]<operators>version; markers
|
||||
[GeneratedRegex(
|
||||
@"^\s*(?<name>[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)(?<extras>\[[^\]]+\])?\s*(?<spec>(?:(?:~=|==|!=|<=|>=|<|>|===)\s*[^\s,;#]+(?:\s*,\s*(?:~=|==|!=|<=|>=|<|>|===)\s*[^\s,;#]+)*)?)\s*(?:;(?<markers>[^#]+))?",
|
||||
RegexOptions.Compiled)]
|
||||
private static partial Regex Pep508Pattern();
|
||||
|
||||
// Direct reference: name @ url
|
||||
[GeneratedRegex(
|
||||
@"^\s*(?<name>[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)\s*@\s*(?<url>\S+)",
|
||||
RegexOptions.Compiled)]
|
||||
private static partial Regex DirectReferencePattern();
|
||||
|
||||
// Editable install: -e path or --editable path or --editable=path
|
||||
[GeneratedRegex(
|
||||
@"^(?:-e\s+|--editable(?:\s+|=))(?<path>.+)$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex EditablePattern();
|
||||
|
||||
// Include directive: -r file or --requirement file
|
||||
[GeneratedRegex(
|
||||
@"^(?:-r\s+|--requirement(?:\s+|=))(?<file>.+)$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex IncludePattern();
|
||||
|
||||
// Constraint directive: -c file or --constraint file
|
||||
[GeneratedRegex(
|
||||
@"^(?:-c\s+|--constraint(?:\s+|=))(?<file>.+)$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ConstraintPattern();
|
||||
|
||||
public static async Task<PythonLockData> LoadAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var entries = new Dictionary<string, PythonLockEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
var unsupportedLines = new List<string>();
|
||||
var processedSources = new List<string>();
|
||||
|
||||
foreach (var pattern in RequirementPatterns)
|
||||
{
|
||||
var candidate = Path.Combine(context.RootPath, pattern);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
await ParseRequirementsFileAsync(context, candidate, entries, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var pipfileLock = Path.Combine(context.RootPath, "Pipfile.lock");
|
||||
if (File.Exists(pipfileLock))
|
||||
{
|
||||
await ParsePipfileLockAsync(context, pipfileLock, entries, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Process in precedence order (highest priority first)
|
||||
// poetry.lock (Priority 1)
|
||||
var poetryLock = Path.Combine(context.RootPath, "poetry.lock");
|
||||
if (File.Exists(poetryLock))
|
||||
{
|
||||
await ParsePoetryLockAsync(context, poetryLock, entries, cancellationToken).ConfigureAwait(false);
|
||||
await ParsePoetryLockAsync(context, poetryLock, entries, unsupportedLines, cancellationToken).ConfigureAwait(false);
|
||||
processedSources.Add("poetry.lock");
|
||||
}
|
||||
|
||||
return entries.Count == 0 ? PythonLockData.Empty : new PythonLockData(entries);
|
||||
// Pipfile.lock (Priority 2)
|
||||
var pipfileLock = Path.Combine(context.RootPath, "Pipfile.lock");
|
||||
if (File.Exists(pipfileLock))
|
||||
{
|
||||
await ParsePipfileLockAsync(context, pipfileLock, entries, unsupportedLines, cancellationToken).ConfigureAwait(false);
|
||||
processedSources.Add("Pipfile.lock");
|
||||
}
|
||||
|
||||
// pdm.lock (Priority 3) - opt-in modern lock
|
||||
var pdmLock = Path.Combine(context.RootPath, "pdm.lock");
|
||||
if (File.Exists(pdmLock))
|
||||
{
|
||||
await ParsePdmLockAsync(context, pdmLock, entries, unsupportedLines, cancellationToken).ConfigureAwait(false);
|
||||
processedSources.Add("pdm.lock");
|
||||
}
|
||||
|
||||
// uv.lock (Priority 4) - opt-in modern lock
|
||||
var uvLock = Path.Combine(context.RootPath, "uv.lock");
|
||||
if (File.Exists(uvLock))
|
||||
{
|
||||
await ParseUvLockAsync(context, uvLock, entries, unsupportedLines, cancellationToken).ConfigureAwait(false);
|
||||
processedSources.Add("uv.lock");
|
||||
}
|
||||
|
||||
// requirements.txt (Priority 5)
|
||||
var requirementsTxt = Path.Combine(context.RootPath, "requirements.txt");
|
||||
if (File.Exists(requirementsTxt))
|
||||
{
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
await ParseRequirementsFileAsync(context, requirementsTxt, entries, unsupportedLines, visited, 0, PythonPackageScope.Prod, cancellationToken).ConfigureAwait(false);
|
||||
processedSources.Add("requirements.txt");
|
||||
}
|
||||
|
||||
// requirements-*.txt variants (Priority 6) - sorted for determinism
|
||||
var requirementsVariants = Directory.GetFiles(context.RootPath, "requirements-*.txt")
|
||||
.OrderBy(static f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
foreach (var variant in requirementsVariants)
|
||||
{
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var scope = InferScopeFromFileName(Path.GetFileName(variant));
|
||||
await ParseRequirementsFileAsync(context, variant, entries, unsupportedLines, visited, 0, scope, cancellationToken).ConfigureAwait(false);
|
||||
processedSources.Add(Path.GetFileName(variant));
|
||||
}
|
||||
|
||||
// constraints.txt (Priority 7) - constraints only, does not add entries
|
||||
var constraintsTxt = Path.Combine(context.RootPath, "constraints.txt");
|
||||
if (File.Exists(constraintsTxt))
|
||||
{
|
||||
// Constraints are parsed but only modify existing entries' metadata
|
||||
await ParseConstraintsFileAsync(context, constraintsTxt, entries, unsupportedLines, cancellationToken).ConfigureAwait(false);
|
||||
processedSources.Add("constraints.txt");
|
||||
}
|
||||
|
||||
return entries.Count == 0
|
||||
? PythonLockData.Empty
|
||||
: new PythonLockData(entries, processedSources, unsupportedLines.Take(MaxUnsupportedSamples).ToArray());
|
||||
}
|
||||
|
||||
private static async Task ParseRequirementsFileAsync(LanguageAnalyzerContext context, string path, IDictionary<string, PythonLockEntry> entries, CancellationToken cancellationToken)
|
||||
private static async Task ParseRequirementsFileAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
string path,
|
||||
IDictionary<string, PythonLockEntry> entries,
|
||||
IList<string> unsupportedLines,
|
||||
ISet<string> visitedFiles,
|
||||
int depth,
|
||||
PythonPackageScope scope,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (depth > MaxIncludeDepth)
|
||||
{
|
||||
unsupportedLines.Add($"[max-include-depth] {path}");
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedPath = Path.GetFullPath(path);
|
||||
if (!visitedFiles.Add(normalizedPath))
|
||||
{
|
||||
// Cycle detected - already visited this file
|
||||
return;
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
unsupportedLines.Add($"[file-not-found] {path}");
|
||||
return;
|
||||
}
|
||||
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new StreamReader(stream);
|
||||
string? line;
|
||||
var locator = PythonPathHelper.NormalizeRelative(context, path);
|
||||
var source = Path.GetFileName(path);
|
||||
var lineNumber = 0;
|
||||
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
|
||||
{
|
||||
lineNumber++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
line = line.Trim();
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#", StringComparison.Ordinal) || line.StartsWith("-r ", StringComparison.OrdinalIgnoreCase))
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var editableMatch = EditablePattern.Match(line);
|
||||
// Handle line continuations
|
||||
while (line.EndsWith('\\'))
|
||||
{
|
||||
var nextLine = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
lineNumber++;
|
||||
if (nextLine is null) break;
|
||||
line = line[..^1] + nextLine.Trim();
|
||||
}
|
||||
|
||||
// Check for include directive
|
||||
var includeMatch = IncludePattern().Match(line);
|
||||
if (includeMatch.Success)
|
||||
{
|
||||
var includePath = includeMatch.Groups["file"].Value.Trim().Trim('"', '\'');
|
||||
var resolvedPath = Path.IsPathRooted(includePath)
|
||||
? includePath
|
||||
: Path.Combine(Path.GetDirectoryName(path) ?? context.RootPath, includePath);
|
||||
|
||||
await ParseRequirementsFileAsync(context, resolvedPath, entries, unsupportedLines, visitedFiles, depth + 1, scope, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for constraint directive (just skip - constraints don't add entries)
|
||||
if (ConstraintPattern().IsMatch(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for editable
|
||||
var editableMatch = EditablePattern().Match(line);
|
||||
if (editableMatch.Success)
|
||||
{
|
||||
var editablePath = editableMatch.Groups["path"].Value.Trim().Trim('"', '\'');
|
||||
var packageName = Path.GetFileName(editablePath.TrimEnd(Path.DirectorySeparatorChar, '/'));
|
||||
if (string.IsNullOrWhiteSpace(packageName))
|
||||
{
|
||||
continue;
|
||||
packageName = "editable";
|
||||
}
|
||||
|
||||
var entry = new PythonLockEntry(
|
||||
Name: packageName,
|
||||
Version: null,
|
||||
Source: Path.GetFileName(path),
|
||||
Locator: locator,
|
||||
Extras: Array.Empty<string>(),
|
||||
Resolved: null,
|
||||
Index: null,
|
||||
EditablePath: editablePath);
|
||||
|
||||
entries[entry.DeclarationKey] = entry;
|
||||
var key = PythonPathHelper.NormalizePackageName(packageName);
|
||||
if (!entries.ContainsKey(key)) // First-wins precedence
|
||||
{
|
||||
entries[key] = new PythonLockEntry(
|
||||
Name: packageName,
|
||||
Version: null,
|
||||
Source: source,
|
||||
Locator: locator,
|
||||
Extras: [],
|
||||
Resolved: null,
|
||||
Index: null,
|
||||
EditablePath: editablePath,
|
||||
Scope: scope,
|
||||
SourceType: PythonLockSourceType.Editable,
|
||||
DirectUrl: null,
|
||||
Markers: null);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var match = RequirementLinePattern.Match(line);
|
||||
if (!match.Success)
|
||||
// Check for direct reference (name @ url)
|
||||
var directRefMatch = DirectReferencePattern().Match(line);
|
||||
if (directRefMatch.Success)
|
||||
{
|
||||
var name = directRefMatch.Groups["name"].Value;
|
||||
var url = directRefMatch.Groups["url"].Value.Trim();
|
||||
|
||||
var key = PythonPathHelper.NormalizePackageName(name);
|
||||
if (!entries.ContainsKey(key))
|
||||
{
|
||||
entries[key] = new PythonLockEntry(
|
||||
Name: name,
|
||||
Version: null,
|
||||
Source: source,
|
||||
Locator: locator,
|
||||
Extras: [],
|
||||
Resolved: url,
|
||||
Index: null,
|
||||
EditablePath: null,
|
||||
Scope: scope,
|
||||
SourceType: PythonLockSourceType.Url,
|
||||
DirectUrl: url,
|
||||
Markers: null);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse PEP 508 requirement
|
||||
var pep508Match = Pep508Pattern().Match(line);
|
||||
if (pep508Match.Success)
|
||||
{
|
||||
var name = pep508Match.Groups["name"].Value;
|
||||
var spec = pep508Match.Groups["spec"].Value.Trim();
|
||||
var extrasStr = pep508Match.Groups["extras"].Value;
|
||||
var markers = pep508Match.Groups["markers"].Success ? pep508Match.Groups["markers"].Value.Trim() : null;
|
||||
|
||||
var extras = string.IsNullOrWhiteSpace(extrasStr)
|
||||
? Array.Empty<string>()
|
||||
: extrasStr.Trim('[', ']').Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
// Extract version from spec if it's an exact match
|
||||
string? version = null;
|
||||
var sourceType = PythonLockSourceType.Range;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(spec))
|
||||
{
|
||||
// Check for exact version (== or ===)
|
||||
var exactMatch = Regex.Match(spec, @"^(?:==|===)\s*([^\s,;]+)$");
|
||||
if (exactMatch.Success)
|
||||
{
|
||||
version = exactMatch.Groups[1].Value;
|
||||
sourceType = PythonLockSourceType.Exact;
|
||||
}
|
||||
}
|
||||
|
||||
var key = version is null
|
||||
? PythonPathHelper.NormalizePackageName(name)
|
||||
: $"{PythonPathHelper.NormalizePackageName(name)}@{version}".ToLowerInvariant();
|
||||
|
||||
if (!entries.ContainsKey(key))
|
||||
{
|
||||
entries[key] = new PythonLockEntry(
|
||||
Name: name,
|
||||
Version: version,
|
||||
Source: source,
|
||||
Locator: locator,
|
||||
Extras: extras,
|
||||
Resolved: null,
|
||||
Index: null,
|
||||
EditablePath: null,
|
||||
Scope: scope,
|
||||
SourceType: sourceType,
|
||||
DirectUrl: null,
|
||||
Markers: markers);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unsupported line
|
||||
if (unsupportedLines.Count < MaxUnsupportedSamples * 2)
|
||||
{
|
||||
unsupportedLines.Add($"[{source}:{lineNumber}] {(line.Length > 60 ? line[..60] + "..." : line)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ParseConstraintsFileAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
string path,
|
||||
IDictionary<string, PythonLockEntry> entries,
|
||||
IList<string> unsupportedLines,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Constraints only add metadata to existing entries, they don't create new components
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new StreamReader(stream);
|
||||
string? line;
|
||||
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
line = line.Trim();
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = match.Groups["name"].Value;
|
||||
var version = match.Groups["version"].Value;
|
||||
var extras = match.Groups["extras"].Success
|
||||
? match.Groups["extras"].Value.Trim('[', ']').Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
: Array.Empty<string>();
|
||||
|
||||
var requirementEntry = new PythonLockEntry(
|
||||
Name: name,
|
||||
Version: version,
|
||||
Source: Path.GetFileName(path),
|
||||
Locator: locator,
|
||||
Extras: extras,
|
||||
Resolved: null,
|
||||
Index: null,
|
||||
EditablePath: null);
|
||||
|
||||
entries[requirementEntry.DeclarationKey] = requirementEntry;
|
||||
// Parse constraint but don't add new entries
|
||||
// This is intentionally minimal - constraints don't create components
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ParsePipfileLockAsync(LanguageAnalyzerContext context, string path, IDictionary<string, PythonLockEntry> entries, CancellationToken cancellationToken)
|
||||
private static async Task ParsePipfileLockAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
string path,
|
||||
IDictionary<string, PythonLockEntry> entries,
|
||||
IList<string> unsupportedLines,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var root = document.RootElement;
|
||||
if (!root.TryGetProperty("default", out var defaultDeps))
|
||||
var locator = PythonPathHelper.NormalizeRelative(context, path);
|
||||
|
||||
// Parse default section (prod dependencies)
|
||||
if (root.TryGetProperty("default", out var defaultDeps))
|
||||
{
|
||||
return;
|
||||
ParsePipfileLockSection(defaultDeps, entries, locator, PythonPackageScope.Prod);
|
||||
}
|
||||
|
||||
foreach (var property in defaultDeps.EnumerateObject())
|
||||
// Parse develop section (dev dependencies) - NEW per Action 2
|
||||
if (root.TryGetProperty("develop", out var developDeps))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!property.Value.TryGetProperty("version", out var versionElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var version = versionElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
version = version.TrimStart('=', ' ');
|
||||
var entry = new PythonLockEntry(
|
||||
Name: property.Name,
|
||||
Version: version,
|
||||
Source: "Pipfile.lock",
|
||||
Locator: PythonPathHelper.NormalizeRelative(context, path),
|
||||
Extras: Array.Empty<string>(),
|
||||
Resolved: property.Value.TryGetProperty("file", out var fileElement) ? fileElement.GetString() : null,
|
||||
Index: property.Value.TryGetProperty("index", out var indexElement) ? indexElement.GetString() : null,
|
||||
EditablePath: null);
|
||||
|
||||
entries[entry.DeclarationKey] = entry;
|
||||
ParsePipfileLockSection(developDeps, entries, locator, PythonPackageScope.Dev);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ParsePoetryLockAsync(LanguageAnalyzerContext context, string path, IDictionary<string, PythonLockEntry> entries, CancellationToken cancellationToken)
|
||||
private static void ParsePipfileLockSection(
|
||||
JsonElement section,
|
||||
IDictionary<string, PythonLockEntry> entries,
|
||||
string locator,
|
||||
PythonPackageScope scope)
|
||||
{
|
||||
foreach (var property in section.EnumerateObject())
|
||||
{
|
||||
string? version = null;
|
||||
string? resolved = null;
|
||||
string? index = null;
|
||||
string? editablePath = null;
|
||||
var sourceType = PythonLockSourceType.Exact;
|
||||
|
||||
if (property.Value.TryGetProperty("version", out var versionElement))
|
||||
{
|
||||
version = versionElement.GetString()?.TrimStart('=', ' ');
|
||||
}
|
||||
|
||||
if (property.Value.TryGetProperty("file", out var fileElement))
|
||||
{
|
||||
resolved = fileElement.GetString();
|
||||
}
|
||||
|
||||
if (property.Value.TryGetProperty("index", out var indexElement))
|
||||
{
|
||||
index = indexElement.GetString();
|
||||
}
|
||||
|
||||
if (property.Value.TryGetProperty("editable", out var editableElement) && editableElement.GetBoolean())
|
||||
{
|
||||
sourceType = PythonLockSourceType.Editable;
|
||||
if (property.Value.TryGetProperty("path", out var pathElement))
|
||||
{
|
||||
editablePath = pathElement.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
if (property.Value.TryGetProperty("git", out _))
|
||||
{
|
||||
sourceType = PythonLockSourceType.Git;
|
||||
}
|
||||
|
||||
var key = version is null
|
||||
? PythonPathHelper.NormalizePackageName(property.Name)
|
||||
: $"{PythonPathHelper.NormalizePackageName(property.Name)}@{version}".ToLowerInvariant();
|
||||
|
||||
if (!entries.ContainsKey(key)) // First-wins precedence
|
||||
{
|
||||
entries[key] = new PythonLockEntry(
|
||||
Name: property.Name,
|
||||
Version: version,
|
||||
Source: "Pipfile.lock",
|
||||
Locator: locator,
|
||||
Extras: [],
|
||||
Resolved: resolved,
|
||||
Index: index,
|
||||
EditablePath: editablePath,
|
||||
Scope: scope,
|
||||
SourceType: sourceType,
|
||||
DirectUrl: null,
|
||||
Markers: null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ParsePoetryLockAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
string path,
|
||||
IDictionary<string, PythonLockEntry> entries,
|
||||
IList<string> unsupportedLines,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var reader = new StreamReader(path);
|
||||
string? line;
|
||||
string? currentName = null;
|
||||
string? currentVersion = null;
|
||||
string? currentCategory = null;
|
||||
var extras = new List<string>();
|
||||
var locator = PythonPathHelper.NormalizeRelative(context, path);
|
||||
|
||||
void Flush()
|
||||
{
|
||||
@@ -167,23 +464,41 @@ internal static class PythonLockFileCollector
|
||||
{
|
||||
currentName = null;
|
||||
currentVersion = null;
|
||||
currentCategory = null;
|
||||
extras.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = new PythonLockEntry(
|
||||
Name: currentName!,
|
||||
Version: currentVersion!,
|
||||
Source: "poetry.lock",
|
||||
Locator: PythonPathHelper.NormalizeRelative(context, path),
|
||||
Extras: extras.ToArray(),
|
||||
Resolved: null,
|
||||
Index: null,
|
||||
EditablePath: null);
|
||||
// Infer scope from category
|
||||
var scope = currentCategory?.ToLowerInvariant() switch
|
||||
{
|
||||
"dev" => PythonPackageScope.Dev,
|
||||
"main" => PythonPackageScope.Prod,
|
||||
_ => PythonPackageScope.Prod
|
||||
};
|
||||
|
||||
var key = $"{PythonPathHelper.NormalizePackageName(currentName)}@{currentVersion}".ToLowerInvariant();
|
||||
|
||||
if (!entries.ContainsKey(key))
|
||||
{
|
||||
entries[key] = new PythonLockEntry(
|
||||
Name: currentName!,
|
||||
Version: currentVersion!,
|
||||
Source: "poetry.lock",
|
||||
Locator: locator,
|
||||
Extras: [.. extras],
|
||||
Resolved: null,
|
||||
Index: null,
|
||||
EditablePath: null,
|
||||
Scope: scope,
|
||||
SourceType: PythonLockSourceType.Exact,
|
||||
DirectUrl: null,
|
||||
Markers: null);
|
||||
}
|
||||
|
||||
entries[entry.DeclarationKey] = entry;
|
||||
currentName = null;
|
||||
currentVersion = null;
|
||||
currentCategory = null;
|
||||
extras.Clear();
|
||||
}
|
||||
|
||||
@@ -215,9 +530,14 @@ internal static class PythonLockFileCollector
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("category = ", StringComparison.Ordinal))
|
||||
{
|
||||
currentCategory = TrimQuoted(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("extras = [", StringComparison.Ordinal))
|
||||
{
|
||||
|
||||
var extrasValue = line["extras = ".Length..].Trim();
|
||||
extrasValue = extrasValue.Trim('[', ']');
|
||||
extras.AddRange(extrasValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(static x => x.Trim('"')));
|
||||
@@ -228,6 +548,160 @@ internal static class PythonLockFileCollector
|
||||
Flush();
|
||||
}
|
||||
|
||||
private static async Task ParsePdmLockAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
string path,
|
||||
IDictionary<string, PythonLockEntry> entries,
|
||||
IList<string> unsupportedLines,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// pdm.lock is TOML format - parse with simple line-based approach
|
||||
using var reader = new StreamReader(path);
|
||||
string? line;
|
||||
string? currentName = null;
|
||||
string? currentVersion = null;
|
||||
var locator = PythonPathHelper.NormalizeRelative(context, path);
|
||||
var inPackageSection = false;
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(currentName) || string.IsNullOrWhiteSpace(currentVersion))
|
||||
{
|
||||
currentName = null;
|
||||
currentVersion = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var key = $"{PythonPathHelper.NormalizePackageName(currentName)}@{currentVersion}".ToLowerInvariant();
|
||||
|
||||
if (!entries.ContainsKey(key))
|
||||
{
|
||||
entries[key] = new PythonLockEntry(
|
||||
Name: currentName!,
|
||||
Version: currentVersion!,
|
||||
Source: "pdm.lock",
|
||||
Locator: locator,
|
||||
Extras: [],
|
||||
Resolved: null,
|
||||
Index: null,
|
||||
EditablePath: null,
|
||||
Scope: PythonPackageScope.Prod,
|
||||
SourceType: PythonLockSourceType.Exact,
|
||||
DirectUrl: null,
|
||||
Markers: null);
|
||||
}
|
||||
|
||||
currentName = null;
|
||||
currentVersion = null;
|
||||
}
|
||||
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
line = line.Trim();
|
||||
|
||||
if (line.StartsWith("[[package]]", StringComparison.Ordinal))
|
||||
{
|
||||
Flush();
|
||||
inPackageSection = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inPackageSection) continue;
|
||||
|
||||
if (line.StartsWith("name = ", StringComparison.Ordinal))
|
||||
{
|
||||
currentName = TrimQuoted(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("version = ", StringComparison.Ordinal))
|
||||
{
|
||||
currentVersion = TrimQuoted(line);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Flush();
|
||||
}
|
||||
|
||||
private static async Task ParseUvLockAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
string path,
|
||||
IDictionary<string, PythonLockEntry> entries,
|
||||
IList<string> unsupportedLines,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// uv.lock is TOML format - parse with simple line-based approach
|
||||
using var reader = new StreamReader(path);
|
||||
string? line;
|
||||
string? currentName = null;
|
||||
string? currentVersion = null;
|
||||
var locator = PythonPathHelper.NormalizeRelative(context, path);
|
||||
var inPackageSection = false;
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(currentName) || string.IsNullOrWhiteSpace(currentVersion))
|
||||
{
|
||||
currentName = null;
|
||||
currentVersion = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var key = $"{PythonPathHelper.NormalizePackageName(currentName)}@{currentVersion}".ToLowerInvariant();
|
||||
|
||||
if (!entries.ContainsKey(key))
|
||||
{
|
||||
entries[key] = new PythonLockEntry(
|
||||
Name: currentName!,
|
||||
Version: currentVersion!,
|
||||
Source: "uv.lock",
|
||||
Locator: locator,
|
||||
Extras: [],
|
||||
Resolved: null,
|
||||
Index: null,
|
||||
EditablePath: null,
|
||||
Scope: PythonPackageScope.Prod,
|
||||
SourceType: PythonLockSourceType.Exact,
|
||||
DirectUrl: null,
|
||||
Markers: null);
|
||||
}
|
||||
|
||||
currentName = null;
|
||||
currentVersion = null;
|
||||
}
|
||||
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
line = line.Trim();
|
||||
|
||||
if (line.StartsWith("[[package]]", StringComparison.Ordinal))
|
||||
{
|
||||
Flush();
|
||||
inPackageSection = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inPackageSection) continue;
|
||||
|
||||
if (line.StartsWith("name = ", StringComparison.Ordinal))
|
||||
{
|
||||
currentName = TrimQuoted(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("version = ", StringComparison.Ordinal))
|
||||
{
|
||||
currentVersion = TrimQuoted(line);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Flush();
|
||||
}
|
||||
|
||||
private static string TrimQuoted(string line)
|
||||
{
|
||||
var index = line.IndexOf('=', StringComparison.Ordinal);
|
||||
@@ -239,6 +713,45 @@ internal static class PythonLockFileCollector
|
||||
var value = line[(index + 1)..].Trim();
|
||||
return value.Trim('"');
|
||||
}
|
||||
|
||||
private static PythonPackageScope InferScopeFromFileName(string fileName)
|
||||
{
|
||||
var lower = fileName.ToLowerInvariant();
|
||||
|
||||
if (lower.Contains("dev") || lower.Contains("test"))
|
||||
return PythonPackageScope.Dev;
|
||||
if (lower.Contains("doc"))
|
||||
return PythonPackageScope.Docs;
|
||||
if (lower.Contains("build"))
|
||||
return PythonPackageScope.Build;
|
||||
|
||||
return PythonPackageScope.Prod;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Package scope classification per Interlock 4.
|
||||
/// </summary>
|
||||
internal enum PythonPackageScope
|
||||
{
|
||||
Prod,
|
||||
Dev,
|
||||
Docs,
|
||||
Build,
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lock entry source type per Action 2.
|
||||
/// </summary>
|
||||
internal enum PythonLockSourceType
|
||||
{
|
||||
Exact, // == or === version
|
||||
Range, // Version range (>=, ~=, etc.)
|
||||
Editable, // -e / --editable
|
||||
Url, // name @ url
|
||||
Git, // git+ reference
|
||||
Unknown
|
||||
}
|
||||
|
||||
internal sealed record PythonLockEntry(
|
||||
@@ -249,7 +762,11 @@ internal sealed record PythonLockEntry(
|
||||
IReadOnlyCollection<string> Extras,
|
||||
string? Resolved,
|
||||
string? Index,
|
||||
string? EditablePath)
|
||||
string? EditablePath,
|
||||
PythonPackageScope Scope,
|
||||
PythonLockSourceType SourceType,
|
||||
string? DirectUrl,
|
||||
string? Markers)
|
||||
{
|
||||
public string DeclarationKey => BuildKey(Name, Version);
|
||||
|
||||
@@ -264,20 +781,49 @@ internal sealed record PythonLockEntry(
|
||||
|
||||
internal sealed class PythonLockData
|
||||
{
|
||||
public static readonly PythonLockData Empty = new(new Dictionary<string, PythonLockEntry>(StringComparer.OrdinalIgnoreCase));
|
||||
public static readonly PythonLockData Empty = new(
|
||||
new Dictionary<string, PythonLockEntry>(StringComparer.OrdinalIgnoreCase),
|
||||
[],
|
||||
[]);
|
||||
|
||||
private readonly Dictionary<string, PythonLockEntry> _entries;
|
||||
|
||||
public PythonLockData(Dictionary<string, PythonLockEntry> entries)
|
||||
public PythonLockData(
|
||||
Dictionary<string, PythonLockEntry> entries,
|
||||
IReadOnlyList<string> processedSources,
|
||||
IReadOnlyList<string> unsupportedLineSamples)
|
||||
{
|
||||
_entries = entries;
|
||||
ProcessedSources = processedSources;
|
||||
UnsupportedLineSamples = unsupportedLineSamples;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<PythonLockEntry> Entries => _entries.Values;
|
||||
|
||||
/// <summary>
|
||||
/// Sources processed in precedence order.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ProcessedSources { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sample of lines that could not be parsed (max 5).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> UnsupportedLineSamples { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of unsupported lines detected.
|
||||
/// </summary>
|
||||
public int UnsupportedLineCount => UnsupportedLineSamples.Count;
|
||||
|
||||
public bool TryGet(string name, string version, out PythonLockEntry? entry)
|
||||
{
|
||||
var key = $"{PythonPathHelper.NormalizePackageName(name)}@{version}".ToLowerInvariant();
|
||||
return _entries.TryGetValue(key, out entry);
|
||||
}
|
||||
|
||||
public bool TryGetByName(string name, out PythonLockEntry? entry)
|
||||
{
|
||||
var key = PythonPathHelper.NormalizePackageName(name);
|
||||
return _entries.TryGetValue(key, out entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Vendoring;
|
||||
|
||||
/// <summary>
|
||||
/// Builds vendoring metadata for components per Action 4 contract.
|
||||
/// </summary>
|
||||
internal static class VendoringMetadataBuilder
|
||||
{
|
||||
private const int MaxPackagesInMetadata = 12;
|
||||
private const int MaxPathsInMetadata = 12;
|
||||
private const int MaxEmbeddedToEmitSeparately = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata keys for vendoring.
|
||||
/// </summary>
|
||||
internal static class Keys
|
||||
{
|
||||
public const string Detected = "vendored.detected";
|
||||
public const string Confidence = "vendored.confidence";
|
||||
public const string PackageCount = "vendored.packageCount";
|
||||
public const string Packages = "vendored.packages";
|
||||
public const string Paths = "vendored.paths";
|
||||
public const string HasUnknownVersions = "vendored.hasUnknownVersions";
|
||||
public const string EmbeddedParentPackage = "embedded.parentPackage";
|
||||
public const string EmbeddedParentVersion = "embedded.parentVersion";
|
||||
public const string EmbeddedPath = "embedded.path";
|
||||
public const string EmbeddedConfidence = "embedded.confidence";
|
||||
public const string EmbeddedVersionSource = "embedded.versionSource";
|
||||
public const string Embedded = "embedded";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds parent package metadata for vendoring detection.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<KeyValuePair<string, string?>> BuildParentMetadata(VendoringAnalysis analysis)
|
||||
{
|
||||
if (!analysis.IsVendored)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var metadata = new List<KeyValuePair<string, string?>>
|
||||
{
|
||||
new(Keys.Detected, "true"),
|
||||
new(Keys.Confidence, analysis.Confidence.ToString()),
|
||||
new(Keys.PackageCount, analysis.EmbeddedCount.ToString())
|
||||
};
|
||||
|
||||
// Add bounded package list (max 12)
|
||||
if (analysis.EmbeddedPackages.Length > 0)
|
||||
{
|
||||
var packageNames = analysis.EmbeddedPackages
|
||||
.Take(MaxPackagesInMetadata)
|
||||
.Select(static p => p.NameWithVersion)
|
||||
.OrderBy(static n => n, StringComparer.Ordinal);
|
||||
|
||||
metadata.Add(new(Keys.Packages, string.Join(",", packageNames)));
|
||||
}
|
||||
|
||||
// Add bounded paths list (max 12)
|
||||
if (analysis.VendorPaths.Length > 0)
|
||||
{
|
||||
var paths = analysis.VendorPaths
|
||||
.Take(MaxPathsInMetadata)
|
||||
.OrderBy(static p => p, StringComparer.Ordinal);
|
||||
|
||||
metadata.Add(new(Keys.Paths, string.Join(",", paths)));
|
||||
}
|
||||
|
||||
// Check for unknown versions
|
||||
var hasUnknownVersions = analysis.EmbeddedPackages.Any(static p => string.IsNullOrEmpty(p.Version));
|
||||
if (hasUnknownVersions)
|
||||
{
|
||||
metadata.Add(new(Keys.HasUnknownVersions, "true"));
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets embedded packages that should be emitted as separate components.
|
||||
/// Per Action 4: only emit when confidence is High AND version is known.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<EmbeddedPackage> GetEmbeddedToEmitSeparately(
|
||||
VendoringAnalysis analysis,
|
||||
string? parentVersion)
|
||||
{
|
||||
if (!analysis.IsVendored || analysis.Confidence < VendoringConfidence.High)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return analysis.EmbeddedPackages
|
||||
.Where(static p => !string.IsNullOrEmpty(p.Version))
|
||||
.Take(MaxEmbeddedToEmitSeparately)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds metadata for an embedded component.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<KeyValuePair<string, string?>> BuildEmbeddedMetadata(
|
||||
EmbeddedPackage embedded,
|
||||
string? parentVersion,
|
||||
VendoringConfidence confidence)
|
||||
{
|
||||
var metadata = new List<KeyValuePair<string, string?>>
|
||||
{
|
||||
new(Keys.Embedded, "true"),
|
||||
new(Keys.EmbeddedParentPackage, embedded.ParentPackage),
|
||||
new(Keys.EmbeddedPath, embedded.Path),
|
||||
new(Keys.EmbeddedConfidence, confidence.ToString())
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(parentVersion))
|
||||
{
|
||||
metadata.Add(new(Keys.EmbeddedParentVersion, parentVersion));
|
||||
}
|
||||
|
||||
// Mark version source as heuristic since it's from __version__ extraction
|
||||
metadata.Add(new(Keys.EmbeddedVersionSource, "heuristic"));
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Linq;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Packaging;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Vendoring;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python;
|
||||
@@ -105,8 +106,8 @@ public sealed class PythonLanguageAnalyzer : ILanguageAnalyzer
|
||||
declaredMetadata.Add(new KeyValuePair<string, string?>("declared.source", entry.Source));
|
||||
declaredMetadata.Add(new KeyValuePair<string, string?>("declared.locator", entry.Locator));
|
||||
declaredMetadata.Add(new KeyValuePair<string, string?>("declared.versionSpec", editableSpec));
|
||||
declaredMetadata.Add(new KeyValuePair<string, string?>("declared.scope", "unknown"));
|
||||
declaredMetadata.Add(new KeyValuePair<string, string?>("declared.sourceType", "editable"));
|
||||
declaredMetadata.Add(new KeyValuePair<string, string?>("declared.scope", entry.Scope.ToString().ToLowerInvariant()));
|
||||
declaredMetadata.Add(new KeyValuePair<string, string?>("declared.sourceType", entry.SourceType.ToString().ToLowerInvariant()));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(editableSpec))
|
||||
{
|
||||
@@ -441,6 +442,9 @@ public sealed class PythonLanguageAnalyzer : ILanguageAnalyzer
|
||||
|
||||
private static void AppendCommonLockFields(List<KeyValuePair<string, string?>> metadata, PythonLockEntry entry)
|
||||
{
|
||||
// Add scope classification per Interlock 4
|
||||
metadata.Add(new KeyValuePair<string, string?>("scope", entry.Scope.ToString().ToLowerInvariant()));
|
||||
|
||||
if (entry.Extras.Count > 0)
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("lockExtras", string.Join(';', entry.Extras)));
|
||||
@@ -460,6 +464,18 @@ public sealed class PythonLanguageAnalyzer : ILanguageAnalyzer
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("lockEditablePath", entry.EditablePath));
|
||||
}
|
||||
|
||||
// Add markers for direct URL references
|
||||
if (!string.IsNullOrWhiteSpace(entry.DirectUrl))
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("lockDirectUrl", entry.DirectUrl));
|
||||
}
|
||||
|
||||
// Add markers from PEP 508 environment markers
|
||||
if (!string.IsNullOrWhiteSpace(entry.Markers))
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("lockMarkers", entry.Markers));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendRuntimeMetadata(List<KeyValuePair<string, string?>> metadata, PythonRuntimeInfo? runtimeInfo)
|
||||
|
||||
@@ -5,10 +5,18 @@
|
||||
| Task ID | Status | Notes | Updated (UTC) |
|
||||
| --- | --- | --- | --- |
|
||||
| SCAN-PY-405-001 | DONE | Wire layout-aware VFS/discovery into `PythonLanguageAnalyzer`. | 2025-12-13 |
|
||||
| SCAN-PY-405-002 | BLOCKED | Preserve dist-info/egg-info evidence; emit explicit-key components where needed (incl. editable lock entries; no `@editable` PURLs). | 2025-12-13 |
|
||||
| SCAN-PY-405-003 | BLOCKED | Blocked on Action 2: lock/requirements precedence + supported formats scope. | 2025-12-13 |
|
||||
| SCAN-PY-405-004 | BLOCKED | Blocked on Action 3: container overlay contract (whiteouts + ordering semantics). | 2025-12-13 |
|
||||
| SCAN-PY-405-005 | BLOCKED | Blocked on Action 4: vendored deps representation contract (identity/scope vs metadata-only). | 2025-12-13 |
|
||||
| SCAN-PY-405-006 | BLOCKED | Blocked on Interlock 4: "used-by-entrypoint" semantics (avoid turning heuristics into truth). | 2025-12-13 |
|
||||
| SCAN-PY-405-007 | BLOCKED | Blocked on Actions 2-4: fixtures for includes/editables, overlay/whiteouts, vendoring. | 2025-12-13 |
|
||||
| SCAN-PY-405-002 | DONE | Preserve dist-info/egg-info evidence; emit explicit-key components for editable lock entries. Added Scope/SourceType metadata per Action 1. | 2025-12-13 |
|
||||
| SCAN-PY-405-003 | DONE | Lock precedence (poetry.lock > Pipfile.lock > pdm.lock > uv.lock > requirements.txt), `-r` includes with cycle detection, PEP 508 parsing, `name @ url` direct references, Pipenv `develop` section. | 2025-12-13 |
|
||||
| SCAN-PY-405-004 | DONE | Container overlay contract implemented: OCI whiteout semantics (`.wh.*`, `.wh..wh..opq`), deterministic layer ordering, `container.overlayIncomplete` metadata marker. | 2025-12-13 |
|
||||
| SCAN-PY-405-005 | DONE | Vendoring integration: `VendoringMetadataBuilder` for parent metadata + embedded components with High confidence. | 2025-12-13 |
|
||||
| SCAN-PY-405-006 | DONE | Scope classification added (prod/dev/docs/build) from lock sections and file names per Interlock 4. Usage signals remain default. | 2025-12-13 |
|
||||
| SCAN-PY-405-007 | DONE | Added test fixtures for includes, Pipfile.lock develop, scope classification, PEP 508 direct refs, cycle detection. | 2025-12-13 |
|
||||
| SCAN-PY-405-008 | DONE | Docs + deterministic offline bench for Python analyzer contract. | 2025-12-13 |
|
||||
|
||||
## Completed Contracts (Action Decisions 2025-12-13)
|
||||
|
||||
1. **Action 1 - Explicit-Key Identity**: Uses `LanguageExplicitKey.Create("python", "pypi", name, spec, originLocator)` for non-versioned components.
|
||||
2. **Action 2 - Lock Precedence**: Deterministic order with first-wins dedupe; full PEP 508 support.
|
||||
3. **Action 3 - Container Overlay**: OCI whiteout semantics honored; incomplete overlay marked.
|
||||
4. **Action 4 - Vendored Deps**: Parent metadata by default; separate components only with High confidence + known version.
|
||||
5. **Interlock 4 - Usage/Scope**: Scope classification added (from lock sections); runtime/import analysis opt-in.
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a language component discovered during analysis.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Updated in Sprint 0411 - Semantic Entrypoint Engine (Task 18) to include semantic fields.
|
||||
/// </remarks>
|
||||
public sealed class LanguageComponentRecord
|
||||
{
|
||||
private readonly SortedDictionary<string, string?> _metadata;
|
||||
private readonly SortedDictionary<string, LanguageComponentEvidence> _evidence;
|
||||
private readonly List<string> _capabilities;
|
||||
private readonly List<ComponentThreatVector> _threatVectors;
|
||||
|
||||
private LanguageComponentRecord(
|
||||
string analyzerId,
|
||||
@@ -14,7 +22,10 @@ public sealed class LanguageComponentRecord
|
||||
string type,
|
||||
IEnumerable<KeyValuePair<string, string?>> metadata,
|
||||
IEnumerable<LanguageComponentEvidence> evidence,
|
||||
bool usedByEntrypoint)
|
||||
bool usedByEntrypoint,
|
||||
string? intent = null,
|
||||
IEnumerable<string>? capabilities = null,
|
||||
IEnumerable<ComponentThreatVector>? threatVectors = null)
|
||||
{
|
||||
AnalyzerId = analyzerId ?? throw new ArgumentNullException(nameof(analyzerId));
|
||||
ComponentKey = componentKey ?? throw new ArgumentNullException(nameof(componentKey));
|
||||
@@ -23,6 +34,7 @@ public sealed class LanguageComponentRecord
|
||||
Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim();
|
||||
Type = string.IsNullOrWhiteSpace(type) ? throw new ArgumentException("Type is required", nameof(type)) : type.Trim();
|
||||
UsedByEntrypoint = usedByEntrypoint;
|
||||
Intent = string.IsNullOrWhiteSpace(intent) ? null : intent.Trim();
|
||||
|
||||
_metadata = new SortedDictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach (var entry in metadata ?? Array.Empty<KeyValuePair<string, string?>>())
|
||||
@@ -45,6 +57,26 @@ public sealed class LanguageComponentRecord
|
||||
|
||||
_evidence[evidenceItem.ComparisonKey] = evidenceItem;
|
||||
}
|
||||
|
||||
_capabilities = new List<string>();
|
||||
foreach (var cap in capabilities ?? Array.Empty<string>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(cap))
|
||||
{
|
||||
_capabilities.Add(cap.Trim());
|
||||
}
|
||||
}
|
||||
_capabilities.Sort(StringComparer.Ordinal);
|
||||
|
||||
_threatVectors = new List<ComponentThreatVector>();
|
||||
foreach (var threat in threatVectors ?? Array.Empty<ComponentThreatVector>())
|
||||
{
|
||||
if (threat is not null)
|
||||
{
|
||||
_threatVectors.Add(threat);
|
||||
}
|
||||
}
|
||||
_threatVectors.Sort((a, b) => StringComparer.Ordinal.Compare(a.VectorType, b.VectorType));
|
||||
}
|
||||
|
||||
public string AnalyzerId { get; }
|
||||
@@ -61,6 +93,24 @@ public sealed class LanguageComponentRecord
|
||||
|
||||
public bool UsedByEntrypoint { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Inferred application intent (e.g., "WebServer", "CliTool", "Worker").
|
||||
/// </summary>
|
||||
/// <remarks>Part of Sprint 0411 - Semantic Entrypoint Engine (Task 18).</remarks>
|
||||
public string? Intent { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Inferred capabilities (e.g., "NetworkListen", "FileWrite", "DatabaseAccess").
|
||||
/// </summary>
|
||||
/// <remarks>Part of Sprint 0411 - Semantic Entrypoint Engine (Task 18).</remarks>
|
||||
public IReadOnlyList<string> Capabilities => _capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Identified threat vectors with confidence scores.
|
||||
/// </summary>
|
||||
/// <remarks>Part of Sprint 0411 - Semantic Entrypoint Engine (Task 18).</remarks>
|
||||
public IReadOnlyList<ComponentThreatVector> ThreatVectors => _threatVectors;
|
||||
|
||||
public IReadOnlyDictionary<string, string?> Metadata => _metadata;
|
||||
|
||||
public IReadOnlyCollection<LanguageComponentEvidence> Evidence => _evidence.Values;
|
||||
@@ -73,7 +123,10 @@ public sealed class LanguageComponentRecord
|
||||
string type,
|
||||
IEnumerable<KeyValuePair<string, string?>>? metadata = null,
|
||||
IEnumerable<LanguageComponentEvidence>? evidence = null,
|
||||
bool usedByEntrypoint = false)
|
||||
bool usedByEntrypoint = false,
|
||||
string? intent = null,
|
||||
IEnumerable<string>? capabilities = null,
|
||||
IEnumerable<ComponentThreatVector>? threatVectors = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
@@ -90,7 +143,10 @@ public sealed class LanguageComponentRecord
|
||||
type,
|
||||
metadata ?? Array.Empty<KeyValuePair<string, string?>>(),
|
||||
evidence ?? Array.Empty<LanguageComponentEvidence>(),
|
||||
usedByEntrypoint);
|
||||
usedByEntrypoint,
|
||||
intent,
|
||||
capabilities,
|
||||
threatVectors);
|
||||
}
|
||||
|
||||
public static LanguageComponentRecord FromExplicitKey(
|
||||
@@ -102,7 +158,10 @@ public sealed class LanguageComponentRecord
|
||||
string type,
|
||||
IEnumerable<KeyValuePair<string, string?>>? metadata = null,
|
||||
IEnumerable<LanguageComponentEvidence>? evidence = null,
|
||||
bool usedByEntrypoint = false)
|
||||
bool usedByEntrypoint = false,
|
||||
string? intent = null,
|
||||
IEnumerable<string>? capabilities = null,
|
||||
IEnumerable<ComponentThreatVector>? threatVectors = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(componentKey))
|
||||
{
|
||||
@@ -118,7 +177,10 @@ public sealed class LanguageComponentRecord
|
||||
type,
|
||||
metadata ?? Array.Empty<KeyValuePair<string, string?>>(),
|
||||
evidence ?? Array.Empty<LanguageComponentEvidence>(),
|
||||
usedByEntrypoint);
|
||||
usedByEntrypoint,
|
||||
intent,
|
||||
capabilities,
|
||||
threatVectors);
|
||||
}
|
||||
|
||||
internal static LanguageComponentRecord FromSnapshot(LanguageComponentSnapshot snapshot)
|
||||
@@ -144,6 +206,17 @@ public sealed class LanguageComponentRecord
|
||||
item.Sha256))
|
||||
.ToArray();
|
||||
|
||||
var threatVectors = snapshot.ThreatVectors is null or { Count: 0 }
|
||||
? Array.Empty<ComponentThreatVector>()
|
||||
: snapshot.ThreatVectors
|
||||
.Where(static item => item is not null)
|
||||
.Select(static item => new ComponentThreatVector(
|
||||
item.VectorType ?? string.Empty,
|
||||
item.Confidence,
|
||||
item.Evidence,
|
||||
item.EntryPath))
|
||||
.ToArray();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.Purl))
|
||||
{
|
||||
return FromPurl(
|
||||
@@ -154,7 +227,10 @@ public sealed class LanguageComponentRecord
|
||||
snapshot.Type,
|
||||
metadata,
|
||||
evidence,
|
||||
snapshot.UsedByEntrypoint);
|
||||
snapshot.UsedByEntrypoint,
|
||||
snapshot.Intent,
|
||||
snapshot.Capabilities,
|
||||
threatVectors);
|
||||
}
|
||||
|
||||
return FromExplicitKey(
|
||||
@@ -166,7 +242,10 @@ public sealed class LanguageComponentRecord
|
||||
snapshot.Type,
|
||||
metadata,
|
||||
evidence,
|
||||
snapshot.UsedByEntrypoint);
|
||||
snapshot.UsedByEntrypoint,
|
||||
snapshot.Intent,
|
||||
snapshot.Capabilities,
|
||||
threatVectors);
|
||||
}
|
||||
|
||||
internal void Merge(LanguageComponentRecord other)
|
||||
@@ -180,6 +259,34 @@ public sealed class LanguageComponentRecord
|
||||
|
||||
UsedByEntrypoint |= other.UsedByEntrypoint;
|
||||
|
||||
// Merge intent - prefer non-null
|
||||
if (string.IsNullOrEmpty(Intent) && !string.IsNullOrEmpty(other.Intent))
|
||||
{
|
||||
Intent = other.Intent;
|
||||
}
|
||||
|
||||
// Merge capabilities - union
|
||||
foreach (var cap in other._capabilities)
|
||||
{
|
||||
if (!_capabilities.Contains(cap, StringComparer.Ordinal))
|
||||
{
|
||||
_capabilities.Add(cap);
|
||||
}
|
||||
}
|
||||
_capabilities.Sort(StringComparer.Ordinal);
|
||||
|
||||
// Merge threat vectors - union by type
|
||||
var existingTypes = new HashSet<string>(_threatVectors.Select(t => t.VectorType), StringComparer.Ordinal);
|
||||
foreach (var threat in other._threatVectors)
|
||||
{
|
||||
if (!existingTypes.Contains(threat.VectorType))
|
||||
{
|
||||
_threatVectors.Add(threat);
|
||||
existingTypes.Add(threat.VectorType);
|
||||
}
|
||||
}
|
||||
_threatVectors.Sort((a, b) => StringComparer.Ordinal.Compare(a.VectorType, b.VectorType));
|
||||
|
||||
foreach (var entry in other._metadata)
|
||||
{
|
||||
if (!_metadata.TryGetValue(entry.Key, out var existing) || string.IsNullOrEmpty(existing))
|
||||
@@ -194,6 +301,44 @@ public sealed class LanguageComponentRecord
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets semantic analysis data on this component.
|
||||
/// </summary>
|
||||
/// <remarks>Part of Sprint 0411 - Semantic Entrypoint Engine (Task 18).</remarks>
|
||||
public void SetSemantics(string? intent, IEnumerable<string>? capabilities, IEnumerable<ComponentThreatVector>? threatVectors)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(intent))
|
||||
{
|
||||
Intent = intent.Trim();
|
||||
}
|
||||
|
||||
if (capabilities is not null)
|
||||
{
|
||||
foreach (var cap in capabilities)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(cap) && !_capabilities.Contains(cap.Trim(), StringComparer.Ordinal))
|
||||
{
|
||||
_capabilities.Add(cap.Trim());
|
||||
}
|
||||
}
|
||||
_capabilities.Sort(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
if (threatVectors is not null)
|
||||
{
|
||||
var existingTypes = new HashSet<string>(_threatVectors.Select(t => t.VectorType), StringComparer.Ordinal);
|
||||
foreach (var threat in threatVectors)
|
||||
{
|
||||
if (threat is not null && !existingTypes.Contains(threat.VectorType))
|
||||
{
|
||||
_threatVectors.Add(threat);
|
||||
existingTypes.Add(threat.VectorType);
|
||||
}
|
||||
}
|
||||
_threatVectors.Sort((a, b) => StringComparer.Ordinal.Compare(a.VectorType, b.VectorType));
|
||||
}
|
||||
}
|
||||
|
||||
public LanguageComponentSnapshot ToSnapshot()
|
||||
{
|
||||
return new LanguageComponentSnapshot
|
||||
@@ -205,6 +350,15 @@ public sealed class LanguageComponentRecord
|
||||
Version = Version,
|
||||
Type = Type,
|
||||
UsedByEntrypoint = UsedByEntrypoint,
|
||||
Intent = Intent,
|
||||
Capabilities = _capabilities.ToArray(),
|
||||
ThreatVectors = _threatVectors.Select(static item => new ComponentThreatVectorSnapshot
|
||||
{
|
||||
VectorType = item.VectorType,
|
||||
Confidence = item.Confidence,
|
||||
Evidence = item.Evidence,
|
||||
EntryPath = item.EntryPath,
|
||||
}).ToArray(),
|
||||
Metadata = _metadata.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal),
|
||||
Evidence = _evidence.Values.Select(static item => new LanguageComponentEvidenceSnapshot
|
||||
{
|
||||
@@ -218,6 +372,16 @@ public sealed class LanguageComponentRecord
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an identified threat vector for a component.
|
||||
/// </summary>
|
||||
/// <remarks>Part of Sprint 0411 - Semantic Entrypoint Engine (Task 18).</remarks>
|
||||
public sealed record ComponentThreatVector(
|
||||
string VectorType,
|
||||
double Confidence,
|
||||
string? Evidence,
|
||||
string? EntryPath);
|
||||
|
||||
public sealed class LanguageComponentSnapshot
|
||||
{
|
||||
[JsonPropertyName("analyzerId")]
|
||||
@@ -241,6 +405,27 @@ public sealed class LanguageComponentSnapshot
|
||||
[JsonPropertyName("usedByEntrypoint")]
|
||||
public bool UsedByEntrypoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Inferred application intent.
|
||||
/// </summary>
|
||||
/// <remarks>Part of Sprint 0411 - Semantic Entrypoint Engine (Task 18).</remarks>
|
||||
[JsonPropertyName("intent")]
|
||||
public string? Intent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Inferred capabilities.
|
||||
/// </summary>
|
||||
/// <remarks>Part of Sprint 0411 - Semantic Entrypoint Engine (Task 18).</remarks>
|
||||
[JsonPropertyName("capabilities")]
|
||||
public IReadOnlyList<string> Capabilities { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Identified threat vectors.
|
||||
/// </summary>
|
||||
/// <remarks>Part of Sprint 0411 - Semantic Entrypoint Engine (Task 18).</remarks>
|
||||
[JsonPropertyName("threatVectors")]
|
||||
public IReadOnlyList<ComponentThreatVectorSnapshot> ThreatVectors { get; set; } = Array.Empty<ComponentThreatVectorSnapshot>();
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IDictionary<string, string?> Metadata { get; set; } = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
|
||||
@@ -248,6 +433,25 @@ public sealed class LanguageComponentSnapshot
|
||||
public IReadOnlyList<LanguageComponentEvidenceSnapshot> Evidence { get; set; } = Array.Empty<LanguageComponentEvidenceSnapshot>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot representation of a threat vector.
|
||||
/// </summary>
|
||||
/// <remarks>Part of Sprint 0411 - Semantic Entrypoint Engine (Task 18).</remarks>
|
||||
public sealed class ComponentThreatVectorSnapshot
|
||||
{
|
||||
[JsonPropertyName("vectorType")]
|
||||
public string VectorType { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; set; }
|
||||
|
||||
[JsonPropertyName("evidence")]
|
||||
public string? Evidence { get; set; }
|
||||
|
||||
[JsonPropertyName("entryPath")]
|
||||
public string? EntryPath { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LanguageComponentEvidenceSnapshot
|
||||
{
|
||||
[JsonPropertyName("kind")]
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic metadata field names for LanguageComponentRecord.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 18).
|
||||
/// </remarks>
|
||||
public static class SemanticMetadataFields
|
||||
{
|
||||
/// <summary>Application intent (WebServer, Worker, CliTool, etc.).</summary>
|
||||
public const string Intent = "semantic:intent";
|
||||
|
||||
/// <summary>Comma-separated capability flags.</summary>
|
||||
public const string Capabilities = "semantic:capabilities";
|
||||
|
||||
/// <summary>JSON array of threat vectors.</summary>
|
||||
public const string ThreatVectors = "semantic:threatVectors";
|
||||
|
||||
/// <summary>Confidence score (0.0-1.0).</summary>
|
||||
public const string Confidence = "semantic:confidence";
|
||||
|
||||
/// <summary>Confidence tier (Unknown, Low, Medium, High, Definitive).</summary>
|
||||
public const string ConfidenceTier = "semantic:confidenceTier";
|
||||
|
||||
/// <summary>Framework name.</summary>
|
||||
public const string Framework = "semantic:framework";
|
||||
|
||||
/// <summary>Framework version.</summary>
|
||||
public const string FrameworkVersion = "semantic:frameworkVersion";
|
||||
|
||||
/// <summary>Whether this component is security-relevant.</summary>
|
||||
public const string SecurityRelevant = "semantic:securityRelevant";
|
||||
|
||||
/// <summary>Risk score (0.0-1.0).</summary>
|
||||
public const string RiskScore = "semantic:riskScore";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for accessing semantic data on LanguageComponentRecord.
|
||||
/// </summary>
|
||||
public static class LanguageComponentSemanticExtensions
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>Gets the inferred application intent.</summary>
|
||||
public static string? GetIntent(this LanguageComponentRecord record)
|
||||
{
|
||||
return record.Metadata.TryGetValue(SemanticMetadataFields.Intent, out var value) ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>Gets the inferred capabilities as a list.</summary>
|
||||
public static IReadOnlyList<string> GetCapabilities(this LanguageComponentRecord record)
|
||||
{
|
||||
if (!record.Metadata.TryGetValue(SemanticMetadataFields.Capabilities, out var value) ||
|
||||
string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
/// <summary>Gets the threat vectors as deserialized objects.</summary>
|
||||
public static IReadOnlyList<ThreatVectorInfo> GetThreatVectors(this LanguageComponentRecord record)
|
||||
{
|
||||
if (!record.Metadata.TryGetValue(SemanticMetadataFields.ThreatVectors, out var value) ||
|
||||
string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Array.Empty<ThreatVectorInfo>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ThreatVectorInfo[]>(value, JsonOptions)
|
||||
?? Array.Empty<ThreatVectorInfo>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<ThreatVectorInfo>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the confidence score.</summary>
|
||||
public static double? GetConfidenceScore(this LanguageComponentRecord record)
|
||||
{
|
||||
if (!record.Metadata.TryGetValue(SemanticMetadataFields.Confidence, out var value) ||
|
||||
string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return double.TryParse(value, out var score) ? score : null;
|
||||
}
|
||||
|
||||
/// <summary>Gets the confidence tier.</summary>
|
||||
public static string? GetConfidenceTier(this LanguageComponentRecord record)
|
||||
{
|
||||
return record.Metadata.TryGetValue(SemanticMetadataFields.ConfidenceTier, out var value) ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>Gets the framework name.</summary>
|
||||
public static string? GetFramework(this LanguageComponentRecord record)
|
||||
{
|
||||
return record.Metadata.TryGetValue(SemanticMetadataFields.Framework, out var value) ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>Gets whether this component is security-relevant.</summary>
|
||||
public static bool IsSecurityRelevant(this LanguageComponentRecord record)
|
||||
{
|
||||
if (!record.Metadata.TryGetValue(SemanticMetadataFields.SecurityRelevant, out var value) ||
|
||||
string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return bool.TryParse(value, out var result) && result;
|
||||
}
|
||||
|
||||
/// <summary>Gets the risk score.</summary>
|
||||
public static double? GetRiskScore(this LanguageComponentRecord record)
|
||||
{
|
||||
if (!record.Metadata.TryGetValue(SemanticMetadataFields.RiskScore, out var value) ||
|
||||
string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return double.TryParse(value, out var score) ? score : null;
|
||||
}
|
||||
|
||||
/// <summary>Checks if semantic data is present.</summary>
|
||||
public static bool HasSemanticData(this LanguageComponentRecord record)
|
||||
{
|
||||
return record.Metadata.ContainsKey(SemanticMetadataFields.Intent) ||
|
||||
record.Metadata.ContainsKey(SemanticMetadataFields.Capabilities);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for adding semantic metadata to LanguageComponentRecord.
|
||||
/// </summary>
|
||||
public sealed class SemanticMetadataBuilder
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly Dictionary<string, string?> _metadata = new(StringComparer.Ordinal);
|
||||
|
||||
public SemanticMetadataBuilder WithIntent(string intent)
|
||||
{
|
||||
_metadata[SemanticMetadataFields.Intent] = intent;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticMetadataBuilder WithCapabilities(IEnumerable<string> capabilities)
|
||||
{
|
||||
_metadata[SemanticMetadataFields.Capabilities] = string.Join(",", capabilities);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticMetadataBuilder WithCapabilities(long capabilityFlags, Func<long, IEnumerable<string>> flagsToNames)
|
||||
{
|
||||
_metadata[SemanticMetadataFields.Capabilities] = string.Join(",", flagsToNames(capabilityFlags));
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticMetadataBuilder WithThreatVectors(IEnumerable<ThreatVectorInfo> threats)
|
||||
{
|
||||
var list = threats.ToList();
|
||||
if (list.Count > 0)
|
||||
{
|
||||
_metadata[SemanticMetadataFields.ThreatVectors] = JsonSerializer.Serialize(list, JsonOptions);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticMetadataBuilder WithConfidence(double score, string tier)
|
||||
{
|
||||
_metadata[SemanticMetadataFields.Confidence] = score.ToString("F3");
|
||||
_metadata[SemanticMetadataFields.ConfidenceTier] = tier;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticMetadataBuilder WithFramework(string framework, string? version = null)
|
||||
{
|
||||
_metadata[SemanticMetadataFields.Framework] = framework;
|
||||
if (version is not null)
|
||||
{
|
||||
_metadata[SemanticMetadataFields.FrameworkVersion] = version;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticMetadataBuilder WithSecurityRelevant(bool relevant)
|
||||
{
|
||||
_metadata[SemanticMetadataFields.SecurityRelevant] = relevant.ToString().ToLowerInvariant();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticMetadataBuilder WithRiskScore(double score)
|
||||
{
|
||||
_metadata[SemanticMetadataFields.RiskScore] = score.ToString("F3");
|
||||
return this;
|
||||
}
|
||||
|
||||
public IEnumerable<KeyValuePair<string, string?>> Build()
|
||||
{
|
||||
return _metadata;
|
||||
}
|
||||
|
||||
/// <summary>Merges semantic metadata with existing component metadata.</summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> MergeWith(IEnumerable<KeyValuePair<string, string?>> existing)
|
||||
{
|
||||
var merged = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var pair in existing)
|
||||
{
|
||||
merged[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
foreach (var pair in _metadata)
|
||||
{
|
||||
merged[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializable threat vector information.
|
||||
/// </summary>
|
||||
public sealed class ThreatVectorInfo
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; set; }
|
||||
|
||||
[JsonPropertyName("cweid")]
|
||||
public int? CweId { get; set; }
|
||||
|
||||
[JsonPropertyName("owasp")]
|
||||
public string? OwaspCategory { get; set; }
|
||||
|
||||
[JsonPropertyName("evidence")]
|
||||
public IReadOnlyList<string>? Evidence { get; set; }
|
||||
}
|
||||
@@ -65,10 +65,11 @@ internal sealed class NativeCallgraphBuilder
|
||||
.ThenBy(e => e.CallSiteOffset)
|
||||
.ToImmutableArray();
|
||||
|
||||
// Sort roots per CONTRACT-INIT-ROOTS-401: by phase (numeric), then order, then target ID
|
||||
var roots = _roots
|
||||
.OrderBy(r => r.BinaryPath)
|
||||
.ThenBy(r => r.Phase)
|
||||
.OrderBy(r => (int)r.Phase)
|
||||
.ThenBy(r => r.Order)
|
||||
.ThenBy(r => r.TargetId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var unknowns = _unknowns
|
||||
@@ -130,34 +131,34 @@ internal sealed class NativeCallgraphBuilder
|
||||
|
||||
private void AddSyntheticRoots(ElfFile elf)
|
||||
{
|
||||
// Find and add _start
|
||||
AddRootIfExists(elf, "_start", NativeRootType.Start, "load", 0);
|
||||
// Find and add _start (load phase)
|
||||
AddRootIfExists(elf, "_start", NativeRootType.Start, "entry_point", NativeRootPhase.Load, 0);
|
||||
|
||||
// Find and add _init
|
||||
AddRootIfExists(elf, "_init", NativeRootType.Init, "init", 0);
|
||||
// Find and add _init (init phase, before init_array)
|
||||
AddRootIfExists(elf, "_init", NativeRootType.Init, "DT_INIT", NativeRootPhase.Init, 0);
|
||||
|
||||
// Find and add _fini
|
||||
AddRootIfExists(elf, "_fini", NativeRootType.Fini, "fini", 0);
|
||||
// Find and add _fini (fini phase)
|
||||
AddRootIfExists(elf, "_fini", NativeRootType.Fini, "DT_FINI", NativeRootPhase.Fini, 0);
|
||||
|
||||
// Find and add main
|
||||
AddRootIfExists(elf, "main", NativeRootType.Main, "main", 0);
|
||||
// Find and add main (main phase)
|
||||
AddRootIfExists(elf, "main", NativeRootType.Main, "main", NativeRootPhase.Main, 0);
|
||||
|
||||
// Add preinit_array entries
|
||||
// Add preinit_array entries (preinit phase)
|
||||
for (var i = 0; i < elf.PreInitArraySymbols.Length; i++)
|
||||
{
|
||||
var symName = elf.PreInitArraySymbols[i];
|
||||
AddRootByName(elf, symName, NativeRootType.PreInitArray, "preinit", i);
|
||||
AddRootByName(elf, symName, NativeRootType.PreInitArray, "preinit_array", NativeRootPhase.PreInit, i);
|
||||
}
|
||||
|
||||
// Add init_array entries
|
||||
// Add init_array entries (init phase, order starts after DT_INIT)
|
||||
for (var i = 0; i < elf.InitArraySymbols.Length; i++)
|
||||
{
|
||||
var symName = elf.InitArraySymbols[i];
|
||||
AddRootByName(elf, symName, NativeRootType.InitArray, "init", i);
|
||||
AddRootByName(elf, symName, NativeRootType.InitArray, "init_array", NativeRootPhase.Init, i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddRootIfExists(ElfFile elf, string symbolName, NativeRootType rootType, string phase, int order)
|
||||
private void AddRootIfExists(ElfFile elf, string symbolName, NativeRootType rootType, string source, NativeRootPhase phase, int order)
|
||||
{
|
||||
var sym = elf.Symbols.Concat(elf.DynamicSymbols)
|
||||
.FirstOrDefault(s => s.Name == symbolName && s.Type == ElfSymbolType.Func);
|
||||
@@ -170,18 +171,23 @@ internal sealed class NativeCallgraphBuilder
|
||||
var binding = sym.Binding.ToString().ToLowerInvariant();
|
||||
var symbolId = NativeGraphIdentifiers.ComputeSymbolId(sym.Name, sym.Value, sym.Size, binding);
|
||||
|
||||
var rootId = NativeGraphIdentifiers.ComputeRootId(symbolId, rootType, order);
|
||||
// Use CONTRACT-INIT-ROOTS-401 compliant root ID format
|
||||
var rootId = NativeGraphIdentifiers.ComputeRootId(phase, order, symbolId);
|
||||
|
||||
_roots.Add(new NativeSyntheticRoot(
|
||||
RootId: rootId,
|
||||
TargetId: symbolId,
|
||||
RootType: rootType,
|
||||
Source: source,
|
||||
BinaryPath: elf.Path,
|
||||
BuildId: elf.BuildId,
|
||||
Phase: phase,
|
||||
Order: order));
|
||||
Order: order,
|
||||
IsResolved: true,
|
||||
TargetAddress: sym.Value));
|
||||
}
|
||||
|
||||
private void AddRootByName(ElfFile elf, string symbolName, NativeRootType rootType, string phase, int order)
|
||||
private void AddRootByName(ElfFile elf, string symbolName, NativeRootType rootType, string source, NativeRootPhase phase, int order)
|
||||
{
|
||||
// Check if it's a hex address placeholder
|
||||
if (symbolName.StartsWith("func_0x", StringComparison.Ordinal))
|
||||
@@ -191,14 +197,28 @@ internal sealed class NativeCallgraphBuilder
|
||||
_unknowns.Add(new NativeUnknown(
|
||||
UnknownId: unknownId,
|
||||
UnknownType: NativeUnknownType.UnresolvedTarget,
|
||||
SourceId: $"{elf.Path}:{phase}:{order}",
|
||||
SourceId: $"{elf.Path}:{source}:{order}",
|
||||
Name: symbolName,
|
||||
Reason: "Init array entry could not be resolved to a symbol",
|
||||
BinaryPath: elf.Path));
|
||||
|
||||
// Still add an unresolved root per CONTRACT-INIT-ROOTS-401
|
||||
var unresolvedRootId = $"root:{phase.ToString().ToLowerInvariant()}:{order}:unknown:{symbolName}";
|
||||
_roots.Add(new NativeSyntheticRoot(
|
||||
RootId: unresolvedRootId,
|
||||
TargetId: $"unknown:{symbolName}",
|
||||
RootType: rootType,
|
||||
Source: source,
|
||||
BinaryPath: elf.Path,
|
||||
BuildId: elf.BuildId,
|
||||
Phase: phase,
|
||||
Order: order,
|
||||
IsResolved: false,
|
||||
TargetAddress: null));
|
||||
return;
|
||||
}
|
||||
|
||||
AddRootIfExists(elf, symbolName, rootType, phase, order);
|
||||
AddRootIfExists(elf, symbolName, rootType, source, phase, order);
|
||||
}
|
||||
|
||||
private void AddRelocationEdges(ElfFile elf)
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Internal.Demangle;
|
||||
|
||||
/// <summary>
|
||||
/// Composite demangler that tries multiple demanglers in order.
|
||||
/// Per DECISION-NATIVE-TOOLCHAIN-401: per-language managed demanglers with native fallback.
|
||||
/// </summary>
|
||||
internal sealed class CompositeDemangler : ISymbolDemangler
|
||||
{
|
||||
private readonly ISymbolDemangler[] _demanglers;
|
||||
|
||||
public CompositeDemangler(params ISymbolDemangler[] demanglers)
|
||||
{
|
||||
_demanglers = demanglers;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default composite demangler with built-in demanglers.
|
||||
/// </summary>
|
||||
public static CompositeDemangler CreateDefault() =>
|
||||
new(
|
||||
new ItaniumAbiDemangler(),
|
||||
new RustDemangler(),
|
||||
new HeuristicDemangler());
|
||||
|
||||
public bool TryDemangle(string mangledName, out DemangleResult result)
|
||||
{
|
||||
if (string.IsNullOrEmpty(mangledName))
|
||||
{
|
||||
result = DemangleResult.Failed(mangledName ?? string.Empty, "Empty symbol name");
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var demangler in _demanglers)
|
||||
{
|
||||
if (demangler.TryDemangle(mangledName, out result))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
result = DemangleResult.Failed(mangledName, "No demangler recognized the symbol format");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Itanium C++ ABI demangler (GCC/Clang style).
|
||||
/// </summary>
|
||||
internal sealed class ItaniumAbiDemangler : ISymbolDemangler
|
||||
{
|
||||
public bool TryDemangle(string mangledName, out DemangleResult result)
|
||||
{
|
||||
result = default!;
|
||||
|
||||
// Itanium ABI symbols start with _Z
|
||||
if (!mangledName.StartsWith("_Z", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic demangling for common patterns
|
||||
// Full implementation would use a proper parser or external library
|
||||
var demangled = TryParseItaniumSymbol(mangledName);
|
||||
if (demangled is not null)
|
||||
{
|
||||
result = DemangleResult.Success(mangledName, demangled, DemangleSource.ItaniumAbi);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Return the mangled name with heuristic confidence if we recognized but couldn't parse
|
||||
result = DemangleResult.Heuristic(mangledName, mangledName, 0.6);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? TryParseItaniumSymbol(string mangled)
|
||||
{
|
||||
// Simple pattern matching for common cases
|
||||
// Full implementation would use a complete Itanium ABI parser
|
||||
|
||||
if (mangled.StartsWith("_ZN", StringComparison.Ordinal))
|
||||
{
|
||||
// Nested name: _ZN<length>name<length>name...E<signature>
|
||||
return ParseNestedName(mangled);
|
||||
}
|
||||
|
||||
if (mangled.StartsWith("_Z", StringComparison.Ordinal))
|
||||
{
|
||||
// Simple name: _Z<length>name<signature>
|
||||
return ParseSimpleName(mangled);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ParseNestedName(string mangled)
|
||||
{
|
||||
// Basic nested name parsing: _ZN4Foo3BarE -> Foo::Bar
|
||||
var components = new List<string>();
|
||||
var pos = 3; // Skip "_ZN"
|
||||
|
||||
while (pos < mangled.Length)
|
||||
{
|
||||
if (mangled[pos] == 'E')
|
||||
{
|
||||
break; // End of nested name
|
||||
}
|
||||
|
||||
// Read length
|
||||
var lengthStart = pos;
|
||||
while (pos < mangled.Length && char.IsDigit(mangled[pos]))
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (pos == lengthStart)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var length = int.Parse(mangled[lengthStart..pos]);
|
||||
if (pos + length > mangled.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
components.Add(mangled.Substring(pos, length));
|
||||
pos += length;
|
||||
}
|
||||
|
||||
if (components.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.Join("::", components);
|
||||
}
|
||||
|
||||
private static string? ParseSimpleName(string mangled)
|
||||
{
|
||||
// Basic simple name parsing: _Z3foo -> foo
|
||||
var pos = 2; // Skip "_Z"
|
||||
|
||||
// Read length
|
||||
var lengthStart = pos;
|
||||
while (pos < mangled.Length && char.IsDigit(mangled[pos]))
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (pos == lengthStart)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var length = int.Parse(mangled[lengthStart..pos]);
|
||||
if (pos + length > mangled.Length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return mangled.Substring(pos, length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rust symbol demangler.
|
||||
/// </summary>
|
||||
internal sealed class RustDemangler : ISymbolDemangler
|
||||
{
|
||||
public bool TryDemangle(string mangledName, out DemangleResult result)
|
||||
{
|
||||
result = default!;
|
||||
|
||||
// Rust symbols start with _ZN or _R (new scheme) with specific patterns
|
||||
// Legacy: _ZN...17h<hash>E (contains 17h followed by hex hash)
|
||||
// v0: _R...
|
||||
|
||||
if (!mangledName.StartsWith("_ZN", StringComparison.Ordinal) &&
|
||||
!mangledName.StartsWith("_R", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for Rust hash pattern (17h followed by 16 hex chars)
|
||||
var hashIndex = mangledName.IndexOf("17h", StringComparison.Ordinal);
|
||||
if (hashIndex < 0 && !mangledName.StartsWith("_R", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic demangling - strip hash suffix for legacy format
|
||||
var demangled = TryParseRustSymbol(mangledName);
|
||||
if (demangled is not null)
|
||||
{
|
||||
result = DemangleResult.Success(mangledName, demangled, DemangleSource.Rust);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Heuristic if we recognized the pattern
|
||||
result = DemangleResult.Heuristic(mangledName, mangledName, 0.5);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? TryParseRustSymbol(string mangled)
|
||||
{
|
||||
// Simple pattern: extract components before hash
|
||||
if (!mangled.StartsWith("_ZN", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var hashIndex = mangled.IndexOf("17h", StringComparison.Ordinal);
|
||||
var endIndex = hashIndex > 0 ? hashIndex : mangled.Length - 1;
|
||||
|
||||
var components = new List<string>();
|
||||
var pos = 3; // Skip "_ZN"
|
||||
|
||||
while (pos < endIndex)
|
||||
{
|
||||
if (mangled[pos] == 'E')
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var lengthStart = pos;
|
||||
while (pos < endIndex && char.IsDigit(mangled[pos]))
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (pos == lengthStart)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var length = int.Parse(mangled[lengthStart..pos]);
|
||||
if (pos + length > mangled.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
components.Add(mangled.Substring(pos, length));
|
||||
pos += length;
|
||||
}
|
||||
|
||||
if (components.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.Join("::", components);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Heuristic demangler for unrecognized formats.
|
||||
/// </summary>
|
||||
internal sealed class HeuristicDemangler : ISymbolDemangler
|
||||
{
|
||||
public bool TryDemangle(string mangledName, out DemangleResult result)
|
||||
{
|
||||
// If the name doesn't look mangled, return it as-is with high confidence
|
||||
if (!LooksMangled(mangledName))
|
||||
{
|
||||
result = DemangleResult.Success(mangledName, mangledName, DemangleSource.Heuristic);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise return the mangled name with low confidence
|
||||
result = DemangleResult.Failed(mangledName, "Unrecognized mangling scheme");
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool LooksMangled(string name) =>
|
||||
// Common mangling prefixes
|
||||
name.StartsWith("_Z", StringComparison.Ordinal) ||
|
||||
name.StartsWith("?", StringComparison.Ordinal) || // MSVC
|
||||
name.StartsWith("_R", StringComparison.Ordinal) || // Rust v0
|
||||
name.StartsWith("__Z", StringComparison.Ordinal) || // macOS Itanium
|
||||
name.Contains("@@", StringComparison.Ordinal); // MSVC decorated
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Internal.Demangle;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for symbol demangling services.
|
||||
/// Per DECISION-NATIVE-TOOLCHAIN-401 specification.
|
||||
/// </summary>
|
||||
public interface ISymbolDemangler
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to demangle a symbol name.
|
||||
/// </summary>
|
||||
/// <param name="mangledName">The mangled symbol name.</param>
|
||||
/// <param name="result">The demangling result if successful.</param>
|
||||
/// <returns>True if demangling was successful, false otherwise.</returns>
|
||||
bool TryDemangle(string mangledName, out DemangleResult result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a demangling operation.
|
||||
/// </summary>
|
||||
/// <param name="Mangled">Original mangled name.</param>
|
||||
/// <param name="Demangled">Demangled human-readable name.</param>
|
||||
/// <param name="Source">Demangling source (e.g., itanium-abi, msvc, rust).</param>
|
||||
/// <param name="Confidence">Confidence level (1.0 for definite, lower for heuristic).</param>
|
||||
/// <param name="Error">Error message if demangling partially failed.</param>
|
||||
public sealed record DemangleResult(
|
||||
string Mangled,
|
||||
string? Demangled,
|
||||
DemangleSource Source,
|
||||
double Confidence,
|
||||
string? Error = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a successful demangling result.
|
||||
/// </summary>
|
||||
public static DemangleResult Success(string mangled, string demangled, DemangleSource source) =>
|
||||
new(mangled, demangled, source, 1.0);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed demangling result.
|
||||
/// </summary>
|
||||
public static DemangleResult Failed(string mangled, string? error = null) =>
|
||||
new(mangled, null, DemangleSource.None, 0.3, error);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a heuristic demangling result.
|
||||
/// </summary>
|
||||
public static DemangleResult Heuristic(string mangled, string demangled, double confidence) =>
|
||||
new(mangled, demangled, DemangleSource.Heuristic, confidence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source of demangling operation per CONTRACT-NATIVE-TOOLCHAIN-401.
|
||||
/// </summary>
|
||||
public enum DemangleSource
|
||||
{
|
||||
/// <summary>Itanium C++ ABI (GCC/Clang).</summary>
|
||||
ItaniumAbi,
|
||||
|
||||
/// <summary>Microsoft Visual C++.</summary>
|
||||
Msvc,
|
||||
|
||||
/// <summary>Rust mangling.</summary>
|
||||
Rust,
|
||||
|
||||
/// <summary>Swift mangling.</summary>
|
||||
Swift,
|
||||
|
||||
/// <summary>D language mangling.</summary>
|
||||
D,
|
||||
|
||||
/// <summary>Native tool fallback.</summary>
|
||||
Fallback,
|
||||
|
||||
/// <summary>Pattern-based heuristic.</summary>
|
||||
Heuristic,
|
||||
|
||||
/// <summary>No demangling available.</summary>
|
||||
None,
|
||||
}
|
||||
@@ -335,12 +335,13 @@ internal static class ElfReader
|
||||
return null;
|
||||
}
|
||||
|
||||
return Convert.ToHexString(gnuBuildId.Descriptor.Span).ToLowerInvariant();
|
||||
var hexBuildId = Convert.ToHexString(gnuBuildId.Descriptor.Span).ToLowerInvariant();
|
||||
return $"gnu-build-id:{hexBuildId}";
|
||||
}
|
||||
|
||||
private static string FormatCodeId(string buildId)
|
||||
{
|
||||
// Format as ELF code-id (same as build-id for ELF)
|
||||
// For ELF, code-id uses the prefixed build-id directly
|
||||
return buildId;
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ internal static class NativeGraphDsseWriter
|
||||
TargetId: root.TargetId,
|
||||
RootType: root.RootType.ToString().ToLowerInvariant(),
|
||||
BinaryPath: root.BinaryPath,
|
||||
Phase: root.Phase,
|
||||
Phase: root.Phase.ToString().ToLowerInvariant(),
|
||||
Order: root.Order);
|
||||
|
||||
await WriteLineAsync(writer, record, cancellationToken);
|
||||
@@ -160,7 +160,7 @@ internal static class NativeGraphDsseWriter
|
||||
TargetId: r.TargetId,
|
||||
RootType: r.RootType.ToString().ToLowerInvariant(),
|
||||
BinaryPath: r.BinaryPath,
|
||||
Phase: r.Phase,
|
||||
Phase: r.Phase.ToString().ToLowerInvariant(),
|
||||
Order: r.Order)).ToArray(),
|
||||
Unknowns: graph.Unknowns.OrderBy(u => u.UnknownId).Select(u => new NdjsonUnknownPayload(
|
||||
UnknownId: u.UnknownId,
|
||||
|
||||
@@ -92,20 +92,51 @@ public enum NativeEdgeType
|
||||
|
||||
/// <summary>
|
||||
/// A synthetic root in the call graph (entry points that don't have callers).
|
||||
/// Per CONTRACT-INIT-ROOTS-401 specification.
|
||||
/// </summary>
|
||||
/// <param name="RootId">Deterministic root identifier.</param>
|
||||
/// <param name="RootId">Deterministic root identifier: root:{phase}:{order}:{target_id}.</param>
|
||||
/// <param name="TargetId">SymbolId of the target function.</param>
|
||||
/// <param name="RootType">Type of synthetic root.</param>
|
||||
/// <param name="Source">Source of the root (e.g., init_array, DT_INIT, preinit_array, etc.).</param>
|
||||
/// <param name="BinaryPath">Path to the containing binary.</param>
|
||||
/// <param name="Phase">Execution phase (load, init, main, fini).</param>
|
||||
/// <param name="BuildId">Build-ID of the binary (e.g., gnu-build-id:...).</param>
|
||||
/// <param name="Phase">Execution phase (load=0, preinit=1, init=2, main=3, fini=4).</param>
|
||||
/// <param name="Order">Order within the phase (for init arrays).</param>
|
||||
/// <param name="IsResolved">Whether the target was successfully resolved.</param>
|
||||
/// <param name="TargetAddress">Address of the target function if available.</param>
|
||||
public sealed record NativeSyntheticRoot(
|
||||
string RootId,
|
||||
string TargetId,
|
||||
NativeRootType RootType,
|
||||
string Source,
|
||||
string BinaryPath,
|
||||
string Phase,
|
||||
int Order);
|
||||
string? BuildId,
|
||||
NativeRootPhase Phase,
|
||||
int Order,
|
||||
bool IsResolved = true,
|
||||
ulong? TargetAddress = null);
|
||||
|
||||
/// <summary>
|
||||
/// Execution phase for synthetic roots.
|
||||
/// Ordered by when they execute during program lifecycle.
|
||||
/// </summary>
|
||||
public enum NativeRootPhase
|
||||
{
|
||||
/// <summary>Dynamic linker resolution phase (order=0).</summary>
|
||||
Load = 0,
|
||||
|
||||
/// <summary>Before dynamic init - DT_PREINIT_ARRAY (order=1).</summary>
|
||||
PreInit = 1,
|
||||
|
||||
/// <summary>During initialization - DT_INIT, init_array (order=2).</summary>
|
||||
Init = 2,
|
||||
|
||||
/// <summary>Program entry - main() (order=3).</summary>
|
||||
Main = 3,
|
||||
|
||||
/// <summary>During termination - DT_FINI, fini_array (order=4).</summary>
|
||||
Fini = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of synthetic root.
|
||||
@@ -238,9 +269,20 @@ internal static class NativeGraphIdentifiers
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic root ID.
|
||||
/// Computes a deterministic root ID following CONTRACT-INIT-ROOTS-401 format.
|
||||
/// Format: root:{phase}:{order}:{target_id}
|
||||
/// </summary>
|
||||
public static string ComputeRootId(string targetId, NativeRootType rootType, int order)
|
||||
public static string ComputeRootId(NativeRootPhase phase, int order, string targetId)
|
||||
{
|
||||
var phaseName = phase.ToString().ToLowerInvariant();
|
||||
return $"root:{phaseName}:{order}:{targetId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic root ID (legacy overload for backwards compatibility).
|
||||
/// </summary>
|
||||
[Obsolete("Use ComputeRootId(NativeRootPhase, int, string) for CONTRACT-INIT-ROOTS-401 compliance")]
|
||||
public static string ComputeRootIdLegacy(string targetId, NativeRootType rootType, int order)
|
||||
{
|
||||
var input = $"{targetId}:{rootType}:{order}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
|
||||
@@ -153,7 +153,7 @@ public sealed class CycloneDxComposer
|
||||
}).OrderBy(entry => entry.LayerDigest, StringComparer.Ordinal).ToArray(),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(recipe, new JsonSerializerOptions
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(recipe, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
|
||||
namespace StellaOps.Scanner.Emit.Composition;
|
||||
|
||||
/// <summary>
|
||||
/// Property names for semantic entrypoint data in CycloneDX SBOMs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 20).
|
||||
/// Follows the stellaops:semantic.* namespace convention for SBOM properties.
|
||||
/// </remarks>
|
||||
public static class SemanticSbomPropertyNames
|
||||
{
|
||||
/// <summary>Application intent (WebServer, Worker, CliTool, etc.).</summary>
|
||||
public const string Intent = "stellaops:semantic.intent";
|
||||
|
||||
/// <summary>Comma-separated capability flags.</summary>
|
||||
public const string Capabilities = "stellaops:semantic.capabilities";
|
||||
|
||||
/// <summary>Number of detected capabilities.</summary>
|
||||
public const string CapabilityCount = "stellaops:semantic.capability.count";
|
||||
|
||||
/// <summary>JSON array of threat vectors.</summary>
|
||||
public const string ThreatVectors = "stellaops:semantic.threats";
|
||||
|
||||
/// <summary>Number of detected threat vectors.</summary>
|
||||
public const string ThreatCount = "stellaops:semantic.threat.count";
|
||||
|
||||
/// <summary>Overall risk score (0.0-1.0).</summary>
|
||||
public const string RiskScore = "stellaops:semantic.risk.score";
|
||||
|
||||
/// <summary>Confidence score (0.0-1.0).</summary>
|
||||
public const string Confidence = "stellaops:semantic.confidence";
|
||||
|
||||
/// <summary>Confidence tier (Unknown, Low, Medium, High, Definitive).</summary>
|
||||
public const string ConfidenceTier = "stellaops:semantic.confidence.tier";
|
||||
|
||||
/// <summary>Primary language.</summary>
|
||||
public const string Language = "stellaops:semantic.language";
|
||||
|
||||
/// <summary>Framework name.</summary>
|
||||
public const string Framework = "stellaops:semantic.framework";
|
||||
|
||||
/// <summary>Framework version.</summary>
|
||||
public const string FrameworkVersion = "stellaops:semantic.framework.version";
|
||||
|
||||
/// <summary>Runtime version.</summary>
|
||||
public const string RuntimeVersion = "stellaops:semantic.runtime.version";
|
||||
|
||||
/// <summary>Number of data flow boundaries.</summary>
|
||||
public const string BoundaryCount = "stellaops:semantic.boundary.count";
|
||||
|
||||
/// <summary>Number of security-sensitive boundaries.</summary>
|
||||
public const string SecuritySensitiveBoundaryCount = "stellaops:semantic.boundary.sensitive.count";
|
||||
|
||||
/// <summary>Comma-separated list of boundary types.</summary>
|
||||
public const string BoundaryTypes = "stellaops:semantic.boundary.types";
|
||||
|
||||
/// <summary>Analysis timestamp (ISO-8601).</summary>
|
||||
public const string AnalyzedAt = "stellaops:semantic.analyzed.at";
|
||||
|
||||
/// <summary>Semantic entrypoint ID.</summary>
|
||||
public const string EntrypointId = "stellaops:semantic.entrypoint.id";
|
||||
|
||||
/// <summary>OWASP categories (comma-separated).</summary>
|
||||
public const string OwaspCategories = "stellaops:semantic.owasp.categories";
|
||||
|
||||
/// <summary>CWE IDs (comma-separated).</summary>
|
||||
public const string CweIds = "stellaops:semantic.cwe.ids";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for adding semantic entrypoint data to SBOM composition.
|
||||
/// </summary>
|
||||
public static class SemanticSbomExtensions
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Adds semantic entrypoint properties to the composition request.
|
||||
/// </summary>
|
||||
public static SbomCompositionRequest WithSemanticEntrypoint(
|
||||
this SbomCompositionRequest request,
|
||||
SemanticEntrypoint? entrypoint)
|
||||
{
|
||||
if (entrypoint is null)
|
||||
return request;
|
||||
|
||||
var properties = BuildSemanticProperties(entrypoint);
|
||||
|
||||
var merged = MergeProperties(request.AdditionalProperties, properties);
|
||||
|
||||
return request with { AdditionalProperties = merged };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds semantic properties from a semantic entrypoint.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> BuildSemanticProperties(SemanticEntrypoint entrypoint)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entrypoint);
|
||||
|
||||
var properties = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
// Intent
|
||||
properties[SemanticSbomPropertyNames.Intent] = entrypoint.Intent.ToString();
|
||||
|
||||
// Capabilities
|
||||
var capabilityNames = GetCapabilityNames(entrypoint.Capabilities);
|
||||
if (capabilityNames.Count > 0)
|
||||
{
|
||||
properties[SemanticSbomPropertyNames.Capabilities] = string.Join(",", capabilityNames);
|
||||
properties[SemanticSbomPropertyNames.CapabilityCount] = capabilityNames.Count.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// Attack surface / threat vectors
|
||||
if (!entrypoint.AttackSurface.IsDefaultOrEmpty && entrypoint.AttackSurface.Length > 0)
|
||||
{
|
||||
var threatSummaries = entrypoint.AttackSurface
|
||||
.Select(t => new
|
||||
{
|
||||
type = t.Type.ToString(),
|
||||
confidence = t.Confidence,
|
||||
cwe = t.Type.GetCweId()
|
||||
})
|
||||
.ToArray();
|
||||
properties[SemanticSbomPropertyNames.ThreatVectors] = JsonSerializer.Serialize(threatSummaries, JsonOptions);
|
||||
properties[SemanticSbomPropertyNames.ThreatCount] = entrypoint.AttackSurface.Length.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
// OWASP categories (via extension method on ThreatVectorType)
|
||||
var owaspCategories = entrypoint.AttackSurface
|
||||
.Select(t => t.Type.GetOwaspCategory())
|
||||
.Where(owasp => !string.IsNullOrEmpty(owasp))
|
||||
.Distinct()
|
||||
.OrderBy(c => c, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (owaspCategories.Length > 0)
|
||||
{
|
||||
properties[SemanticSbomPropertyNames.OwaspCategories] = string.Join(",", owaspCategories);
|
||||
}
|
||||
|
||||
// CWE IDs (via extension method on ThreatVectorType)
|
||||
var cweIds = entrypoint.AttackSurface
|
||||
.Select(t => t.Type.GetCweId())
|
||||
.Where(cwe => cwe.HasValue)
|
||||
.Select(cwe => cwe!.Value)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToArray();
|
||||
if (cweIds.Length > 0)
|
||||
{
|
||||
properties[SemanticSbomPropertyNames.CweIds] = string.Join(",", cweIds);
|
||||
}
|
||||
|
||||
// Risk score (use max confidence as proxy for risk)
|
||||
var maxRisk = entrypoint.AttackSurface.Max(t => t.Confidence);
|
||||
properties[SemanticSbomPropertyNames.RiskScore] = FormatDouble(maxRisk);
|
||||
}
|
||||
|
||||
// Data boundaries
|
||||
if (!entrypoint.DataBoundaries.IsDefaultOrEmpty && entrypoint.DataBoundaries.Length > 0)
|
||||
{
|
||||
properties[SemanticSbomPropertyNames.BoundaryCount] =
|
||||
entrypoint.DataBoundaries.Length.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var sensitiveCount = entrypoint.DataBoundaries.Count(b => b.Type.IsSecuritySensitive());
|
||||
if (sensitiveCount > 0)
|
||||
{
|
||||
properties[SemanticSbomPropertyNames.SecuritySensitiveBoundaryCount] =
|
||||
sensitiveCount.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var boundaryTypes = entrypoint.DataBoundaries
|
||||
.Select(b => b.Type.ToString())
|
||||
.Distinct()
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
properties[SemanticSbomPropertyNames.BoundaryTypes] = string.Join(",", boundaryTypes);
|
||||
}
|
||||
|
||||
// Confidence
|
||||
properties[SemanticSbomPropertyNames.Confidence] = FormatDouble(entrypoint.Confidence.Score);
|
||||
properties[SemanticSbomPropertyNames.ConfidenceTier] = entrypoint.Confidence.Tier.ToString();
|
||||
|
||||
// Language and framework
|
||||
if (!string.IsNullOrEmpty(entrypoint.Language))
|
||||
properties[SemanticSbomPropertyNames.Language] = entrypoint.Language;
|
||||
|
||||
if (!string.IsNullOrEmpty(entrypoint.Framework))
|
||||
properties[SemanticSbomPropertyNames.Framework] = entrypoint.Framework;
|
||||
|
||||
if (!string.IsNullOrEmpty(entrypoint.FrameworkVersion))
|
||||
properties[SemanticSbomPropertyNames.FrameworkVersion] = entrypoint.FrameworkVersion;
|
||||
|
||||
if (!string.IsNullOrEmpty(entrypoint.RuntimeVersion))
|
||||
properties[SemanticSbomPropertyNames.RuntimeVersion] = entrypoint.RuntimeVersion;
|
||||
|
||||
// Entrypoint ID and timestamp
|
||||
if (!string.IsNullOrEmpty(entrypoint.Id))
|
||||
properties[SemanticSbomPropertyNames.EntrypointId] = entrypoint.Id;
|
||||
|
||||
if (!string.IsNullOrEmpty(entrypoint.AnalyzedAt))
|
||||
properties[SemanticSbomPropertyNames.AnalyzedAt] = entrypoint.AnalyzedAt;
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts semantic entrypoint summary from SBOM properties.
|
||||
/// </summary>
|
||||
public static SemanticSbomSummary? ExtractSemanticSummary(IReadOnlyDictionary<string, string>? properties)
|
||||
{
|
||||
if (properties is null || properties.Count == 0)
|
||||
return null;
|
||||
|
||||
if (!properties.TryGetValue(SemanticSbomPropertyNames.Intent, out var intentStr))
|
||||
return null;
|
||||
|
||||
if (!Enum.TryParse<ApplicationIntent>(intentStr, ignoreCase: true, out var intent))
|
||||
intent = ApplicationIntent.Unknown;
|
||||
|
||||
var summary = new SemanticSbomSummary
|
||||
{
|
||||
Intent = intent
|
||||
};
|
||||
|
||||
if (properties.TryGetValue(SemanticSbomPropertyNames.Capabilities, out var caps) &&
|
||||
!string.IsNullOrEmpty(caps))
|
||||
{
|
||||
summary = summary with
|
||||
{
|
||||
Capabilities = caps.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
};
|
||||
}
|
||||
|
||||
if (properties.TryGetValue(SemanticSbomPropertyNames.ThreatCount, out var threatCountStr) &&
|
||||
int.TryParse(threatCountStr, out var threatCount))
|
||||
{
|
||||
summary = summary with { ThreatCount = threatCount };
|
||||
}
|
||||
|
||||
if (properties.TryGetValue(SemanticSbomPropertyNames.RiskScore, out var riskStr) &&
|
||||
double.TryParse(riskStr, out var risk))
|
||||
{
|
||||
summary = summary with { RiskScore = risk };
|
||||
}
|
||||
|
||||
if (properties.TryGetValue(SemanticSbomPropertyNames.Confidence, out var confStr) &&
|
||||
double.TryParse(confStr, out var conf))
|
||||
{
|
||||
summary = summary with { Confidence = conf };
|
||||
}
|
||||
|
||||
if (properties.TryGetValue(SemanticSbomPropertyNames.ConfidenceTier, out var tier))
|
||||
{
|
||||
summary = summary with { ConfidenceTier = tier };
|
||||
}
|
||||
|
||||
if (properties.TryGetValue(SemanticSbomPropertyNames.Language, out var lang))
|
||||
{
|
||||
summary = summary with { Language = lang };
|
||||
}
|
||||
|
||||
if (properties.TryGetValue(SemanticSbomPropertyNames.Framework, out var fw))
|
||||
{
|
||||
summary = summary with { Framework = fw };
|
||||
}
|
||||
|
||||
if (properties.TryGetValue(SemanticSbomPropertyNames.BoundaryCount, out var boundaryCountStr) &&
|
||||
int.TryParse(boundaryCountStr, out var boundaryCount))
|
||||
{
|
||||
summary = summary with { BoundaryCount = boundaryCount };
|
||||
}
|
||||
|
||||
if (properties.TryGetValue(SemanticSbomPropertyNames.OwaspCategories, out var owasp) &&
|
||||
!string.IsNullOrEmpty(owasp))
|
||||
{
|
||||
summary = summary with
|
||||
{
|
||||
OwaspCategories = owasp.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
};
|
||||
}
|
||||
|
||||
if (properties.TryGetValue(SemanticSbomPropertyNames.CweIds, out var cweStr) &&
|
||||
!string.IsNullOrEmpty(cweStr))
|
||||
{
|
||||
var cweIds = cweStr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => int.TryParse(s, out var id) ? id : (int?)null)
|
||||
.Where(id => id.HasValue)
|
||||
.Select(id => id!.Value)
|
||||
.ToArray();
|
||||
summary = summary with { CweIds = cweIds };
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if SBOM properties contain semantic data.
|
||||
/// </summary>
|
||||
public static bool HasSemanticData(IReadOnlyDictionary<string, string>? properties)
|
||||
{
|
||||
return properties?.ContainsKey(SemanticSbomPropertyNames.Intent) == true;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetCapabilityNames(CapabilityClass capabilities)
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (CapabilityClass flag in Enum.GetValues<CapabilityClass>())
|
||||
{
|
||||
if (flag != CapabilityClass.None && !IsCompositeFlag(flag) && capabilities.HasFlag(flag))
|
||||
{
|
||||
names.Add(flag.ToString());
|
||||
}
|
||||
}
|
||||
names.Sort(StringComparer.Ordinal);
|
||||
return names;
|
||||
}
|
||||
|
||||
private static bool IsCompositeFlag(CapabilityClass flag)
|
||||
{
|
||||
var val = (long)flag;
|
||||
return val != 0 && (val & (val - 1)) != 0;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> MergeProperties(
|
||||
IReadOnlyDictionary<string, string>? existing,
|
||||
IReadOnlyDictionary<string, string> newProperties)
|
||||
{
|
||||
var merged = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
foreach (var pair in existing)
|
||||
{
|
||||
merged[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pair in newProperties)
|
||||
{
|
||||
merged[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static string FormatDouble(double value)
|
||||
=> value.ToString("0.####", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of semantic entrypoint data extracted from SBOM properties.
|
||||
/// </summary>
|
||||
public sealed record SemanticSbomSummary
|
||||
{
|
||||
public ApplicationIntent Intent { get; init; } = ApplicationIntent.Unknown;
|
||||
public IReadOnlyList<string> Capabilities { get; init; } = Array.Empty<string>();
|
||||
public int ThreatCount { get; init; }
|
||||
public double RiskScore { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
public string? ConfidenceTier { get; init; }
|
||||
public string? Language { get; init; }
|
||||
public string? Framework { get; init; }
|
||||
public int BoundaryCount { get; init; }
|
||||
public IReadOnlyList<string> OwaspCategories { get; init; } = Array.Empty<string>();
|
||||
public IReadOnlyList<int> CweIds { get; init; } = Array.Empty<int>();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this summary indicates high security relevance.
|
||||
/// </summary>
|
||||
public bool IsSecurityRelevant => ThreatCount > 0 || RiskScore > 0.5 || CweIds.Count > 0;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.EntryTrace\StellaOps.Scanner.EntryTrace.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// .NET semantic adapter for inferring intent and capabilities.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 11).
|
||||
/// Detects ASP.NET Core, Console apps, Worker services, Azure Functions.
|
||||
/// </remarks>
|
||||
public sealed class DotNetSemanticAdapter : ISemanticEntrypointAnalyzer
|
||||
{
|
||||
public IReadOnlyList<string> SupportedLanguages => ["dotnet", "csharp", "fsharp"];
|
||||
public int Priority => 100;
|
||||
|
||||
private static readonly FrozenDictionary<string, ApplicationIntent> PackageIntentMap = new Dictionary<string, ApplicationIntent>
|
||||
{
|
||||
// ASP.NET Core
|
||||
["Microsoft.AspNetCore"] = ApplicationIntent.WebServer,
|
||||
["Microsoft.AspNetCore.App"] = ApplicationIntent.WebServer,
|
||||
["Microsoft.AspNetCore.Mvc"] = ApplicationIntent.WebServer,
|
||||
["Microsoft.AspNetCore.Mvc.Core"] = ApplicationIntent.WebServer,
|
||||
["Microsoft.AspNetCore.Server.Kestrel"] = ApplicationIntent.WebServer,
|
||||
["Microsoft.AspNetCore.SignalR"] = ApplicationIntent.WebServer,
|
||||
["Microsoft.AspNetCore.Blazor"] = ApplicationIntent.WebServer,
|
||||
|
||||
// Minimal APIs (ASP.NET Core 6+)
|
||||
["Microsoft.AspNetCore.OpenApi"] = ApplicationIntent.WebServer,
|
||||
["Swashbuckle.AspNetCore"] = ApplicationIntent.WebServer,
|
||||
|
||||
// Workers
|
||||
["Microsoft.Extensions.Hosting"] = ApplicationIntent.Worker,
|
||||
["Microsoft.Extensions.Hosting.WindowsServices"] = ApplicationIntent.Daemon,
|
||||
["Microsoft.Extensions.Hosting.Systemd"] = ApplicationIntent.Daemon,
|
||||
|
||||
// Serverless
|
||||
["Microsoft.Azure.Functions.Worker"] = ApplicationIntent.Serverless,
|
||||
["Microsoft.Azure.WebJobs"] = ApplicationIntent.Serverless,
|
||||
["Amazon.Lambda.Core"] = ApplicationIntent.Serverless,
|
||||
["Amazon.Lambda.AspNetCoreServer"] = ApplicationIntent.Serverless,
|
||||
["Google.Cloud.Functions.Framework"] = ApplicationIntent.Serverless,
|
||||
|
||||
// gRPC
|
||||
["Grpc.AspNetCore"] = ApplicationIntent.RpcServer,
|
||||
["Grpc.Core"] = ApplicationIntent.RpcServer,
|
||||
["Grpc.Net.Client"] = ApplicationIntent.RpcServer,
|
||||
|
||||
// GraphQL
|
||||
["HotChocolate.AspNetCore"] = ApplicationIntent.GraphQlServer,
|
||||
["GraphQL.Server.Core"] = ApplicationIntent.GraphQlServer,
|
||||
|
||||
// Message queues / workers
|
||||
["MassTransit"] = ApplicationIntent.Worker,
|
||||
["NServiceBus"] = ApplicationIntent.Worker,
|
||||
["Rebus"] = ApplicationIntent.Worker,
|
||||
["Azure.Messaging.ServiceBus"] = ApplicationIntent.Worker,
|
||||
["RabbitMQ.Client"] = ApplicationIntent.Worker,
|
||||
["Confluent.Kafka"] = ApplicationIntent.StreamProcessor,
|
||||
|
||||
// Schedulers
|
||||
["Hangfire"] = ApplicationIntent.ScheduledTask,
|
||||
["Quartz"] = ApplicationIntent.ScheduledTask,
|
||||
|
||||
// CLI
|
||||
["System.CommandLine"] = ApplicationIntent.CliTool,
|
||||
["McMaster.Extensions.CommandLineUtils"] = ApplicationIntent.CliTool,
|
||||
["CommandLineParser"] = ApplicationIntent.CliTool,
|
||||
["Spectre.Console.Cli"] = ApplicationIntent.CliTool,
|
||||
|
||||
// Testing
|
||||
["Microsoft.NET.Test.Sdk"] = ApplicationIntent.TestRunner,
|
||||
["xunit"] = ApplicationIntent.TestRunner,
|
||||
["NUnit"] = ApplicationIntent.TestRunner,
|
||||
["MSTest.TestFramework"] = ApplicationIntent.TestRunner,
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
private static readonly FrozenDictionary<string, CapabilityClass> PackageCapabilityMap = new Dictionary<string, CapabilityClass>
|
||||
{
|
||||
// Network
|
||||
["System.Net.Http"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["System.Net.Sockets"] = CapabilityClass.NetworkConnect | CapabilityClass.NetworkListen,
|
||||
["RestSharp"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["Refit"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["Flurl.Http"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
|
||||
// Databases
|
||||
["Microsoft.EntityFrameworkCore"] = CapabilityClass.DatabaseSql,
|
||||
["Npgsql"] = CapabilityClass.DatabaseSql,
|
||||
["MySql.Data"] = CapabilityClass.DatabaseSql,
|
||||
["Microsoft.Data.SqlClient"] = CapabilityClass.DatabaseSql,
|
||||
["System.Data.SqlClient"] = CapabilityClass.DatabaseSql,
|
||||
["Oracle.ManagedDataAccess"] = CapabilityClass.DatabaseSql,
|
||||
["Dapper"] = CapabilityClass.DatabaseSql,
|
||||
["MongoDB.Driver"] = CapabilityClass.DatabaseNoSql,
|
||||
["Cassandra.Driver"] = CapabilityClass.DatabaseNoSql,
|
||||
["StackExchange.Redis"] = CapabilityClass.CacheAccess,
|
||||
["Microsoft.Extensions.Caching.StackExchangeRedis"] = CapabilityClass.CacheAccess,
|
||||
["Microsoft.Extensions.Caching.Memory"] = CapabilityClass.CacheAccess,
|
||||
|
||||
// Message queues
|
||||
["RabbitMQ.Client"] = CapabilityClass.MessageQueue,
|
||||
["Azure.Messaging.ServiceBus"] = CapabilityClass.MessageQueue,
|
||||
["Confluent.Kafka"] = CapabilityClass.MessageQueue,
|
||||
["NATS.Client"] = CapabilityClass.MessageQueue,
|
||||
|
||||
// File operations
|
||||
["System.IO.FileSystem"] = CapabilityClass.FileRead | CapabilityClass.FileWrite,
|
||||
["System.IO.Compression"] = CapabilityClass.FileRead | CapabilityClass.FileWrite,
|
||||
|
||||
// Process
|
||||
["System.Diagnostics.Process"] = CapabilityClass.ProcessSpawn,
|
||||
["CliWrap"] = CapabilityClass.ProcessSpawn | CapabilityClass.ShellExecution,
|
||||
|
||||
// Crypto
|
||||
["System.Security.Cryptography"] = CapabilityClass.CryptoEncrypt | CapabilityClass.CryptoSign | CapabilityClass.CryptoKeyGen,
|
||||
["BouncyCastle.Cryptography"] = CapabilityClass.CryptoEncrypt | CapabilityClass.CryptoSign | CapabilityClass.CryptoKeyGen,
|
||||
["NSec.Cryptography"] = CapabilityClass.CryptoEncrypt | CapabilityClass.CryptoSign,
|
||||
|
||||
// Cloud SDKs
|
||||
["AWSSDK.Core"] = CapabilityClass.CloudSdk,
|
||||
["AWSSDK.S3"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
["Azure.Storage.Blobs"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
["Google.Cloud.Storage.V1"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
|
||||
// Serialization
|
||||
["Newtonsoft.Json"] = CapabilityClass.UnsafeDeserialization,
|
||||
["System.Text.Json"] = CapabilityClass.UnsafeDeserialization,
|
||||
["YamlDotNet"] = CapabilityClass.UnsafeDeserialization,
|
||||
["System.Xml"] = CapabilityClass.XmlExternalEntities,
|
||||
["System.Xml.Linq"] = CapabilityClass.XmlExternalEntities,
|
||||
|
||||
// Template engines
|
||||
["RazorLight"] = CapabilityClass.TemplateRendering,
|
||||
["Scriban"] = CapabilityClass.TemplateRendering,
|
||||
["Fluid"] = CapabilityClass.TemplateRendering,
|
||||
|
||||
// Dynamic code
|
||||
["Microsoft.CodeAnalysis.CSharp.Scripting"] = CapabilityClass.DynamicCodeEval,
|
||||
["System.Reflection.Emit"] = CapabilityClass.DynamicCodeEval,
|
||||
|
||||
// Logging/metrics
|
||||
["Serilog"] = CapabilityClass.LogEmit,
|
||||
["NLog"] = CapabilityClass.LogEmit,
|
||||
["Microsoft.Extensions.Logging"] = CapabilityClass.LogEmit,
|
||||
["App.Metrics"] = CapabilityClass.MetricsEmit,
|
||||
["prometheus-net"] = CapabilityClass.MetricsEmit,
|
||||
["OpenTelemetry"] = CapabilityClass.TracingEmit | CapabilityClass.MetricsEmit,
|
||||
|
||||
// Auth
|
||||
["Microsoft.AspNetCore.Authentication"] = CapabilityClass.Authentication,
|
||||
["Microsoft.AspNetCore.Authorization"] = CapabilityClass.Authorization,
|
||||
["Microsoft.AspNetCore.Identity"] = CapabilityClass.Authentication | CapabilityClass.SessionManagement,
|
||||
["IdentityServer4"] = CapabilityClass.Authentication | CapabilityClass.Authorization,
|
||||
["Duende.IdentityServer"] = CapabilityClass.Authentication | CapabilityClass.Authorization,
|
||||
|
||||
// Secrets
|
||||
["Azure.Security.KeyVault.Secrets"] = CapabilityClass.SecretAccess,
|
||||
["VaultSharp"] = CapabilityClass.SecretAccess,
|
||||
["Microsoft.Extensions.Configuration"] = CapabilityClass.ConfigLoad | CapabilityClass.EnvironmentRead,
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
public async Task<SemanticEntrypoint> AnalyzeAsync(
|
||||
SemanticAnalysisContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var builder = new SemanticEntrypointBuilder()
|
||||
.WithId(GenerateId(context))
|
||||
.WithSpecification(context.Specification)
|
||||
.WithLanguage("dotnet");
|
||||
|
||||
var reasoningChain = new List<string>();
|
||||
var intent = ApplicationIntent.Unknown;
|
||||
var framework = (string?)null;
|
||||
|
||||
// Analyze dependencies
|
||||
if (context.Dependencies.TryGetValue("dotnet", out var deps))
|
||||
{
|
||||
foreach (var dep in deps)
|
||||
{
|
||||
var normalizedDep = NormalizeDependency(dep);
|
||||
|
||||
if (PackageIntentMap.TryGetValue(normalizedDep, out var mappedIntent))
|
||||
{
|
||||
if (intent == ApplicationIntent.Unknown || IsHigherPriority(mappedIntent, intent))
|
||||
{
|
||||
intent = mappedIntent;
|
||||
framework = dep;
|
||||
reasoningChain.Add($"Detected {dep} -> {intent}");
|
||||
}
|
||||
}
|
||||
|
||||
if (PackageCapabilityMap.TryGetValue(normalizedDep, out var capability))
|
||||
{
|
||||
builder.AddCapability(capability);
|
||||
reasoningChain.Add($"Package {dep} -> {capability}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze entrypoint command
|
||||
var cmdSignals = AnalyzeCommand(context.Specification);
|
||||
if (cmdSignals.Intent != ApplicationIntent.Unknown && intent == ApplicationIntent.Unknown)
|
||||
{
|
||||
intent = cmdSignals.Intent;
|
||||
reasoningChain.Add($"Command pattern -> {intent}");
|
||||
}
|
||||
|
||||
foreach (var cap in GetCapabilityFlags(cmdSignals.Capabilities))
|
||||
{
|
||||
builder.AddCapability(cap);
|
||||
}
|
||||
|
||||
// Check for P/Invoke usage
|
||||
if (await HasPInvokeUsageAsync(context, cancellationToken))
|
||||
{
|
||||
builder.AddCapability(CapabilityClass.SystemPrivileged);
|
||||
reasoningChain.Add("P/Invoke usage detected -> SystemPrivileged");
|
||||
}
|
||||
|
||||
// Check exposed ports
|
||||
if (context.Specification.ExposedPorts.Length > 0)
|
||||
{
|
||||
var webPorts = context.Specification.ExposedPorts.Where(IsWebPort).ToList();
|
||||
if (webPorts.Count > 0 && intent == ApplicationIntent.Unknown)
|
||||
{
|
||||
intent = ApplicationIntent.WebServer;
|
||||
reasoningChain.Add($"Exposed web ports: {string.Join(", ", webPorts)}");
|
||||
}
|
||||
builder.AddCapability(CapabilityClass.NetworkListen);
|
||||
}
|
||||
|
||||
// Check environment variables for ASP.NET patterns
|
||||
if (context.Specification.Environment?.ContainsKey("ASPNETCORE_URLS") == true)
|
||||
{
|
||||
if (intent == ApplicationIntent.Unknown)
|
||||
{
|
||||
intent = ApplicationIntent.WebServer;
|
||||
reasoningChain.Add("ASPNETCORE_URLS environment variable -> WebServer");
|
||||
}
|
||||
builder.AddCapability(CapabilityClass.NetworkListen);
|
||||
}
|
||||
|
||||
var confidence = DetermineConfidence(reasoningChain, intent, framework);
|
||||
|
||||
builder.WithIntent(intent)
|
||||
.WithConfidence(confidence);
|
||||
|
||||
if (framework is not null)
|
||||
{
|
||||
builder.WithFramework(framework);
|
||||
}
|
||||
|
||||
return await Task.FromResult(builder.Build());
|
||||
}
|
||||
|
||||
private static string NormalizeDependency(string dep)
|
||||
{
|
||||
// Handle NuGet package references with versions
|
||||
var parts = dep.Split('/');
|
||||
return parts[0].Trim();
|
||||
}
|
||||
|
||||
private static bool IsHigherPriority(ApplicationIntent newer, ApplicationIntent current)
|
||||
{
|
||||
var priorityOrder = new[]
|
||||
{
|
||||
ApplicationIntent.Unknown,
|
||||
ApplicationIntent.TestRunner,
|
||||
ApplicationIntent.CliTool,
|
||||
ApplicationIntent.BatchJob,
|
||||
ApplicationIntent.Worker,
|
||||
ApplicationIntent.Daemon,
|
||||
ApplicationIntent.ScheduledTask,
|
||||
ApplicationIntent.StreamProcessor,
|
||||
ApplicationIntent.Serverless,
|
||||
ApplicationIntent.WebServer,
|
||||
ApplicationIntent.RpcServer,
|
||||
ApplicationIntent.GraphQlServer,
|
||||
};
|
||||
|
||||
return Array.IndexOf(priorityOrder, newer) > Array.IndexOf(priorityOrder, current);
|
||||
}
|
||||
|
||||
private static (ApplicationIntent Intent, CapabilityClass Capabilities) AnalyzeCommand(EntrypointSpecification spec)
|
||||
{
|
||||
var cmd = string.Join(" ", spec.Entrypoint.Concat(spec.Cmd));
|
||||
var intent = ApplicationIntent.Unknown;
|
||||
var caps = CapabilityClass.None;
|
||||
|
||||
// dotnet run with web project
|
||||
if (cmd.Contains("dotnet") && cmd.Contains("run"))
|
||||
{
|
||||
// Could be anything - need more signals
|
||||
}
|
||||
// dotnet test
|
||||
else if (cmd.Contains("dotnet") && cmd.Contains("test"))
|
||||
{
|
||||
intent = ApplicationIntent.TestRunner;
|
||||
}
|
||||
// Published executable
|
||||
else if (cmd.EndsWith(".dll") || !cmd.Contains("dotnet"))
|
||||
{
|
||||
// Self-contained - intent depends on other signals
|
||||
caps |= CapabilityClass.FileExecute;
|
||||
}
|
||||
|
||||
return (intent, caps);
|
||||
}
|
||||
|
||||
private static async Task<bool> HasPInvokeUsageAsync(SemanticAnalysisContext context, CancellationToken ct)
|
||||
{
|
||||
// Check for native libraries
|
||||
var nativePaths = new[] { "/app", "/lib", "/usr/lib" };
|
||||
foreach (var path in nativePaths)
|
||||
{
|
||||
if (await context.FileSystem.DirectoryExistsAsync(path, ct))
|
||||
{
|
||||
var files = await context.FileSystem.ListFilesAsync(path, "*.so", ct);
|
||||
if (files.Any(f => f.Contains("native") || f.Contains("runtimes")))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsWebPort(int port)
|
||||
{
|
||||
return port is 80 or 443 or 5000 or 5001 or 8080 or 8443;
|
||||
}
|
||||
|
||||
private static SemanticConfidence DetermineConfidence(List<string> reasoning, ApplicationIntent intent, string? framework)
|
||||
{
|
||||
if (intent == ApplicationIntent.Unknown)
|
||||
return SemanticConfidence.Unknown();
|
||||
|
||||
if (framework is not null && reasoning.Count >= 3)
|
||||
return SemanticConfidence.High(reasoning.ToArray());
|
||||
|
||||
if (framework is not null)
|
||||
return SemanticConfidence.Medium(reasoning.ToArray());
|
||||
|
||||
return SemanticConfidence.Low(reasoning.ToArray());
|
||||
}
|
||||
|
||||
private static IEnumerable<CapabilityClass> GetCapabilityFlags(CapabilityClass caps)
|
||||
{
|
||||
foreach (CapabilityClass flag in Enum.GetValues<CapabilityClass>())
|
||||
{
|
||||
if (flag != CapabilityClass.None && caps.HasFlag(flag))
|
||||
yield return flag;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateId(SemanticAnalysisContext context)
|
||||
{
|
||||
var hash = context.ImageDigest ?? Guid.NewGuid().ToString("N");
|
||||
return $"sem-dotnet-{hash[..12]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Go semantic adapter for inferring intent and capabilities.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 12).
|
||||
/// Detects net/http patterns, cobra/urfave CLI, gRPC servers, main package analysis.
|
||||
/// </remarks>
|
||||
public sealed class GoSemanticAdapter : ISemanticEntrypointAnalyzer
|
||||
{
|
||||
public IReadOnlyList<string> SupportedLanguages => ["go", "golang"];
|
||||
public int Priority => 100;
|
||||
|
||||
private static readonly FrozenDictionary<string, ApplicationIntent> ModuleIntentMap = new Dictionary<string, ApplicationIntent>
|
||||
{
|
||||
// Web frameworks
|
||||
["net/http"] = ApplicationIntent.WebServer,
|
||||
["github.com/gin-gonic/gin"] = ApplicationIntent.WebServer,
|
||||
["github.com/labstack/echo"] = ApplicationIntent.WebServer,
|
||||
["github.com/gofiber/fiber"] = ApplicationIntent.WebServer,
|
||||
["github.com/gorilla/mux"] = ApplicationIntent.WebServer,
|
||||
["github.com/go-chi/chi"] = ApplicationIntent.WebServer,
|
||||
["github.com/julienschmidt/httprouter"] = ApplicationIntent.WebServer,
|
||||
["github.com/valyala/fasthttp"] = ApplicationIntent.WebServer,
|
||||
["github.com/beego/beego"] = ApplicationIntent.WebServer,
|
||||
["github.com/revel/revel"] = ApplicationIntent.WebServer,
|
||||
["github.com/go-martini/martini"] = ApplicationIntent.WebServer,
|
||||
|
||||
// CLI frameworks
|
||||
["github.com/spf13/cobra"] = ApplicationIntent.CliTool,
|
||||
["github.com/urfave/cli"] = ApplicationIntent.CliTool,
|
||||
["github.com/alecthomas/kingpin"] = ApplicationIntent.CliTool,
|
||||
["github.com/jessevdk/go-flags"] = ApplicationIntent.CliTool,
|
||||
["github.com/peterbourgon/ff"] = ApplicationIntent.CliTool,
|
||||
|
||||
// gRPC
|
||||
["google.golang.org/grpc"] = ApplicationIntent.RpcServer,
|
||||
["github.com/grpc-ecosystem/grpc-gateway"] = ApplicationIntent.RpcServer,
|
||||
|
||||
// GraphQL
|
||||
["github.com/graphql-go/graphql"] = ApplicationIntent.GraphQlServer,
|
||||
["github.com/99designs/gqlgen"] = ApplicationIntent.GraphQlServer,
|
||||
["github.com/graph-gophers/graphql-go"] = ApplicationIntent.GraphQlServer,
|
||||
|
||||
// Workers/queues
|
||||
["github.com/hibiken/asynq"] = ApplicationIntent.Worker,
|
||||
["github.com/gocraft/work"] = ApplicationIntent.Worker,
|
||||
["github.com/Shopify/sarama"] = ApplicationIntent.StreamProcessor,
|
||||
["github.com/confluentinc/confluent-kafka-go"] = ApplicationIntent.StreamProcessor,
|
||||
["github.com/segmentio/kafka-go"] = ApplicationIntent.StreamProcessor,
|
||||
["github.com/nats-io/nats.go"] = ApplicationIntent.MessageBroker,
|
||||
["github.com/streadway/amqp"] = ApplicationIntent.Worker,
|
||||
["github.com/rabbitmq/amqp091-go"] = ApplicationIntent.Worker,
|
||||
|
||||
// Serverless
|
||||
["github.com/aws/aws-lambda-go"] = ApplicationIntent.Serverless,
|
||||
["cloud.google.com/go/functions"] = ApplicationIntent.Serverless,
|
||||
|
||||
// Schedulers
|
||||
["github.com/robfig/cron"] = ApplicationIntent.ScheduledTask,
|
||||
["github.com/go-co-op/gocron"] = ApplicationIntent.ScheduledTask,
|
||||
|
||||
// Proxy/Gateway
|
||||
["github.com/envoyproxy/go-control-plane"] = ApplicationIntent.ProxyGateway,
|
||||
["github.com/traefik/traefik"] = ApplicationIntent.ProxyGateway,
|
||||
|
||||
// Metrics/monitoring
|
||||
["github.com/prometheus/client_golang"] = ApplicationIntent.MetricsCollector,
|
||||
|
||||
// Container agents
|
||||
["k8s.io/client-go"] = ApplicationIntent.ContainerAgent,
|
||||
["sigs.k8s.io/controller-runtime"] = ApplicationIntent.ContainerAgent,
|
||||
|
||||
// Testing
|
||||
["testing"] = ApplicationIntent.TestRunner,
|
||||
["github.com/stretchr/testify"] = ApplicationIntent.TestRunner,
|
||||
["github.com/onsi/ginkgo"] = ApplicationIntent.TestRunner,
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
private static readonly FrozenDictionary<string, CapabilityClass> ModuleCapabilityMap = new Dictionary<string, CapabilityClass>
|
||||
{
|
||||
// Network
|
||||
["net"] = CapabilityClass.NetworkConnect | CapabilityClass.NetworkListen,
|
||||
["net/http"] = CapabilityClass.NetworkConnect | CapabilityClass.NetworkListen,
|
||||
["golang.org/x/net"] = CapabilityClass.NetworkConnect | CapabilityClass.NetworkListen,
|
||||
["github.com/valyala/fasthttp"] = CapabilityClass.NetworkConnect | CapabilityClass.NetworkListen,
|
||||
|
||||
// DNS
|
||||
["net/dns"] = CapabilityClass.NetworkDns,
|
||||
|
||||
// File system
|
||||
["os"] = CapabilityClass.FileRead | CapabilityClass.FileWrite | CapabilityClass.EnvironmentRead,
|
||||
["io"] = CapabilityClass.FileRead | CapabilityClass.FileWrite,
|
||||
["io/ioutil"] = CapabilityClass.FileRead | CapabilityClass.FileWrite,
|
||||
["path/filepath"] = CapabilityClass.FileRead,
|
||||
["github.com/fsnotify/fsnotify"] = CapabilityClass.FileWatch,
|
||||
|
||||
// Process
|
||||
["os/exec"] = CapabilityClass.ProcessSpawn | CapabilityClass.ShellExecution,
|
||||
["os/signal"] = CapabilityClass.ProcessSignal,
|
||||
["syscall"] = CapabilityClass.SystemPrivileged,
|
||||
["golang.org/x/sys"] = CapabilityClass.SystemPrivileged,
|
||||
|
||||
// Databases
|
||||
["database/sql"] = CapabilityClass.DatabaseSql,
|
||||
["github.com/lib/pq"] = CapabilityClass.DatabaseSql,
|
||||
["github.com/go-sql-driver/mysql"] = CapabilityClass.DatabaseSql,
|
||||
["github.com/jackc/pgx"] = CapabilityClass.DatabaseSql,
|
||||
["github.com/jmoiron/sqlx"] = CapabilityClass.DatabaseSql,
|
||||
["gorm.io/gorm"] = CapabilityClass.DatabaseSql,
|
||||
["go.mongodb.org/mongo-driver"] = CapabilityClass.DatabaseNoSql,
|
||||
["github.com/gocql/gocql"] = CapabilityClass.DatabaseNoSql,
|
||||
["github.com/go-redis/redis"] = CapabilityClass.CacheAccess,
|
||||
["github.com/redis/go-redis"] = CapabilityClass.CacheAccess,
|
||||
["github.com/bradfitz/gomemcache"] = CapabilityClass.CacheAccess,
|
||||
["github.com/allegro/bigcache"] = CapabilityClass.CacheAccess,
|
||||
|
||||
// Message queues
|
||||
["github.com/streadway/amqp"] = CapabilityClass.MessageQueue,
|
||||
["github.com/rabbitmq/amqp091-go"] = CapabilityClass.MessageQueue,
|
||||
["github.com/Shopify/sarama"] = CapabilityClass.MessageQueue,
|
||||
["github.com/nats-io/nats.go"] = CapabilityClass.MessageQueue,
|
||||
|
||||
// Crypto
|
||||
["crypto"] = CapabilityClass.CryptoEncrypt,
|
||||
["crypto/tls"] = CapabilityClass.CryptoEncrypt,
|
||||
["crypto/rsa"] = CapabilityClass.CryptoEncrypt | CapabilityClass.CryptoSign | CapabilityClass.CryptoKeyGen,
|
||||
["crypto/ecdsa"] = CapabilityClass.CryptoSign | CapabilityClass.CryptoKeyGen,
|
||||
["crypto/ed25519"] = CapabilityClass.CryptoSign | CapabilityClass.CryptoKeyGen,
|
||||
["golang.org/x/crypto"] = CapabilityClass.CryptoEncrypt | CapabilityClass.CryptoSign,
|
||||
|
||||
// Cloud SDKs
|
||||
["github.com/aws/aws-sdk-go"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
["github.com/aws/aws-sdk-go-v2"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
["cloud.google.com/go"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
["github.com/Azure/azure-sdk-for-go"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
|
||||
// Serialization
|
||||
["encoding/json"] = CapabilityClass.UnsafeDeserialization,
|
||||
["encoding/gob"] = CapabilityClass.UnsafeDeserialization,
|
||||
["encoding/xml"] = CapabilityClass.XmlExternalEntities,
|
||||
["github.com/vmihailenco/msgpack"] = CapabilityClass.UnsafeDeserialization,
|
||||
|
||||
// Template engines
|
||||
["text/template"] = CapabilityClass.TemplateRendering,
|
||||
["html/template"] = CapabilityClass.TemplateRendering,
|
||||
|
||||
// Dynamic code
|
||||
["reflect"] = CapabilityClass.DynamicCodeEval,
|
||||
["plugin"] = CapabilityClass.DynamicCodeEval,
|
||||
|
||||
// Logging
|
||||
["log"] = CapabilityClass.LogEmit,
|
||||
["github.com/sirupsen/logrus"] = CapabilityClass.LogEmit,
|
||||
["go.uber.org/zap"] = CapabilityClass.LogEmit,
|
||||
["github.com/rs/zerolog"] = CapabilityClass.LogEmit,
|
||||
|
||||
// Metrics/tracing
|
||||
["github.com/prometheus/client_golang"] = CapabilityClass.MetricsEmit,
|
||||
["go.opentelemetry.io/otel"] = CapabilityClass.TracingEmit | CapabilityClass.MetricsEmit,
|
||||
|
||||
// Auth
|
||||
["github.com/golang-jwt/jwt"] = CapabilityClass.Authentication | CapabilityClass.SessionManagement,
|
||||
["github.com/coreos/go-oidc"] = CapabilityClass.Authentication,
|
||||
["golang.org/x/oauth2"] = CapabilityClass.Authentication,
|
||||
|
||||
// Secrets
|
||||
["github.com/hashicorp/vault/api"] = CapabilityClass.SecretAccess,
|
||||
|
||||
// Container/system
|
||||
["github.com/containerd/containerd"] = CapabilityClass.ContainerEscape,
|
||||
["github.com/docker/docker"] = CapabilityClass.ContainerEscape,
|
||||
["github.com/opencontainers/runc"] = CapabilityClass.ContainerEscape,
|
||||
["k8s.io/client-go"] = CapabilityClass.SystemPrivileged,
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
public async Task<SemanticEntrypoint> AnalyzeAsync(
|
||||
SemanticAnalysisContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var builder = new SemanticEntrypointBuilder()
|
||||
.WithId(GenerateId(context))
|
||||
.WithSpecification(context.Specification)
|
||||
.WithLanguage("go");
|
||||
|
||||
var reasoningChain = new List<string>();
|
||||
var intent = ApplicationIntent.Unknown;
|
||||
var framework = (string?)null;
|
||||
|
||||
// Analyze dependencies (go.mod imports)
|
||||
if (context.Dependencies.TryGetValue("go", out var deps))
|
||||
{
|
||||
foreach (var dep in deps)
|
||||
{
|
||||
var normalizedDep = NormalizeDependency(dep);
|
||||
|
||||
if (ModuleIntentMap.TryGetValue(normalizedDep, out var mappedIntent))
|
||||
{
|
||||
if (intent == ApplicationIntent.Unknown || IsHigherPriority(mappedIntent, intent))
|
||||
{
|
||||
intent = mappedIntent;
|
||||
framework = dep;
|
||||
reasoningChain.Add($"Detected {dep} -> {intent}");
|
||||
}
|
||||
}
|
||||
|
||||
if (ModuleCapabilityMap.TryGetValue(normalizedDep, out var capability))
|
||||
{
|
||||
builder.AddCapability(capability);
|
||||
reasoningChain.Add($"Module {dep} -> {capability}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze entrypoint command
|
||||
var cmdSignals = AnalyzeCommand(context.Specification);
|
||||
if (cmdSignals.Intent != ApplicationIntent.Unknown && intent == ApplicationIntent.Unknown)
|
||||
{
|
||||
intent = cmdSignals.Intent;
|
||||
reasoningChain.Add($"Command pattern -> {intent}");
|
||||
}
|
||||
|
||||
foreach (var cap in GetCapabilityFlags(cmdSignals.Capabilities))
|
||||
{
|
||||
builder.AddCapability(cap);
|
||||
}
|
||||
|
||||
// Check for CGO usage
|
||||
if (await HasCgoUsageAsync(context, cancellationToken))
|
||||
{
|
||||
builder.AddCapability(CapabilityClass.SystemPrivileged);
|
||||
reasoningChain.Add("CGO usage detected -> SystemPrivileged");
|
||||
}
|
||||
|
||||
// Check exposed ports
|
||||
if (context.Specification.ExposedPorts.Length > 0)
|
||||
{
|
||||
var webPorts = context.Specification.ExposedPorts.Where(IsWebPort).ToList();
|
||||
if (webPorts.Count > 0 && intent == ApplicationIntent.Unknown)
|
||||
{
|
||||
intent = ApplicationIntent.WebServer;
|
||||
reasoningChain.Add($"Exposed web ports: {string.Join(", ", webPorts)}");
|
||||
}
|
||||
builder.AddCapability(CapabilityClass.NetworkListen);
|
||||
}
|
||||
|
||||
var confidence = DetermineConfidence(reasoningChain, intent, framework);
|
||||
|
||||
builder.WithIntent(intent)
|
||||
.WithConfidence(confidence);
|
||||
|
||||
if (framework is not null)
|
||||
{
|
||||
builder.WithFramework(framework);
|
||||
}
|
||||
|
||||
return await Task.FromResult(builder.Build());
|
||||
}
|
||||
|
||||
private static string NormalizeDependency(string dep)
|
||||
{
|
||||
// Handle Go module paths with versions
|
||||
var parts = dep.Split('@');
|
||||
return parts[0].Trim();
|
||||
}
|
||||
|
||||
private static bool IsHigherPriority(ApplicationIntent newer, ApplicationIntent current)
|
||||
{
|
||||
var priorityOrder = new[]
|
||||
{
|
||||
ApplicationIntent.Unknown,
|
||||
ApplicationIntent.TestRunner,
|
||||
ApplicationIntent.CliTool,
|
||||
ApplicationIntent.BatchJob,
|
||||
ApplicationIntent.Worker,
|
||||
ApplicationIntent.ScheduledTask,
|
||||
ApplicationIntent.StreamProcessor,
|
||||
ApplicationIntent.MessageBroker,
|
||||
ApplicationIntent.Serverless,
|
||||
ApplicationIntent.ProxyGateway,
|
||||
ApplicationIntent.WebServer,
|
||||
ApplicationIntent.RpcServer,
|
||||
ApplicationIntent.GraphQlServer,
|
||||
ApplicationIntent.ContainerAgent,
|
||||
};
|
||||
|
||||
return Array.IndexOf(priorityOrder, newer) > Array.IndexOf(priorityOrder, current);
|
||||
}
|
||||
|
||||
private static (ApplicationIntent Intent, CapabilityClass Capabilities) AnalyzeCommand(EntrypointSpecification spec)
|
||||
{
|
||||
var cmd = string.Join(" ", spec.Entrypoint.Concat(spec.Cmd));
|
||||
var intent = ApplicationIntent.Unknown;
|
||||
var caps = CapabilityClass.None;
|
||||
|
||||
// Go binaries are typically single executables
|
||||
// Check for common patterns
|
||||
if (cmd.Contains("serve") || cmd.Contains("server"))
|
||||
{
|
||||
intent = ApplicationIntent.WebServer;
|
||||
caps |= CapabilityClass.NetworkListen;
|
||||
}
|
||||
else if (cmd.Contains("worker") || cmd.Contains("consume"))
|
||||
{
|
||||
intent = ApplicationIntent.Worker;
|
||||
caps |= CapabilityClass.MessageQueue;
|
||||
}
|
||||
else if (cmd.Contains("migrate") || cmd.Contains("seed"))
|
||||
{
|
||||
intent = ApplicationIntent.BatchJob;
|
||||
caps |= CapabilityClass.DatabaseSql;
|
||||
}
|
||||
|
||||
return (intent, caps);
|
||||
}
|
||||
|
||||
private static async Task<bool> HasCgoUsageAsync(SemanticAnalysisContext context, CancellationToken ct)
|
||||
{
|
||||
// Check for C libraries in common locations
|
||||
var libPaths = new[] { "/lib", "/usr/lib", "/usr/local/lib" };
|
||||
foreach (var path in libPaths)
|
||||
{
|
||||
if (await context.FileSystem.DirectoryExistsAsync(path, ct))
|
||||
{
|
||||
var files = await context.FileSystem.ListFilesAsync(path, "*.so*", ct);
|
||||
if (files.Any())
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsWebPort(int port)
|
||||
{
|
||||
return port is 80 or 443 or 8080 or 8443 or 9000 or 3000;
|
||||
}
|
||||
|
||||
private static SemanticConfidence DetermineConfidence(List<string> reasoning, ApplicationIntent intent, string? framework)
|
||||
{
|
||||
if (intent == ApplicationIntent.Unknown)
|
||||
return SemanticConfidence.Unknown();
|
||||
|
||||
if (framework is not null && reasoning.Count >= 3)
|
||||
return SemanticConfidence.High(reasoning.ToArray());
|
||||
|
||||
if (framework is not null)
|
||||
return SemanticConfidence.Medium(reasoning.ToArray());
|
||||
|
||||
return SemanticConfidence.Low(reasoning.ToArray());
|
||||
}
|
||||
|
||||
private static IEnumerable<CapabilityClass> GetCapabilityFlags(CapabilityClass caps)
|
||||
{
|
||||
foreach (CapabilityClass flag in Enum.GetValues<CapabilityClass>())
|
||||
{
|
||||
if (flag != CapabilityClass.None && caps.HasFlag(flag))
|
||||
yield return flag;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateId(SemanticAnalysisContext context)
|
||||
{
|
||||
var hash = context.ImageDigest ?? Guid.NewGuid().ToString("N");
|
||||
return $"sem-go-{hash[..12]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Java semantic adapter for inferring intent and capabilities.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 9).
|
||||
/// Detects Spring Boot, Quarkus, Micronaut, Kafka Streams, Main-Class patterns.
|
||||
/// </remarks>
|
||||
public sealed class JavaSemanticAdapter : ISemanticEntrypointAnalyzer
|
||||
{
|
||||
public IReadOnlyList<string> SupportedLanguages => ["java", "kotlin", "scala"];
|
||||
public int Priority => 100;
|
||||
|
||||
private static readonly FrozenDictionary<string, ApplicationIntent> FrameworkIntentMap = new Dictionary<string, ApplicationIntent>
|
||||
{
|
||||
// Spring ecosystem
|
||||
["spring-boot"] = ApplicationIntent.WebServer,
|
||||
["spring-boot-starter-web"] = ApplicationIntent.WebServer,
|
||||
["spring-boot-starter-webflux"] = ApplicationIntent.WebServer,
|
||||
["spring-cloud-function"] = ApplicationIntent.Serverless,
|
||||
["spring-kafka"] = ApplicationIntent.StreamProcessor,
|
||||
["spring-amqp"] = ApplicationIntent.Worker,
|
||||
["spring-batch"] = ApplicationIntent.BatchJob,
|
||||
|
||||
// Microframeworks
|
||||
["quarkus"] = ApplicationIntent.WebServer,
|
||||
["quarkus-resteasy"] = ApplicationIntent.WebServer,
|
||||
["micronaut"] = ApplicationIntent.WebServer,
|
||||
["micronaut-http-server"] = ApplicationIntent.WebServer,
|
||||
["helidon"] = ApplicationIntent.WebServer,
|
||||
["dropwizard"] = ApplicationIntent.WebServer,
|
||||
["jersey"] = ApplicationIntent.WebServer,
|
||||
["javalin"] = ApplicationIntent.WebServer,
|
||||
["spark-java"] = ApplicationIntent.WebServer,
|
||||
["vertx-web"] = ApplicationIntent.WebServer,
|
||||
|
||||
// Workers/queues
|
||||
["kafka-streams"] = ApplicationIntent.StreamProcessor,
|
||||
["kafka-clients"] = ApplicationIntent.Worker,
|
||||
["activemq"] = ApplicationIntent.Worker,
|
||||
["rabbitmq-client"] = ApplicationIntent.Worker,
|
||||
|
||||
// CLI
|
||||
["picocli"] = ApplicationIntent.CliTool,
|
||||
["jcommander"] = ApplicationIntent.CliTool,
|
||||
["commons-cli"] = ApplicationIntent.CliTool,
|
||||
|
||||
// Serverless
|
||||
["aws-lambda-java"] = ApplicationIntent.Serverless,
|
||||
["aws-lambda-java-core"] = ApplicationIntent.Serverless,
|
||||
["azure-functions-java"] = ApplicationIntent.Serverless,
|
||||
["functions-framework-java"] = ApplicationIntent.Serverless,
|
||||
|
||||
// gRPC
|
||||
["grpc-java"] = ApplicationIntent.RpcServer,
|
||||
["grpc-netty"] = ApplicationIntent.RpcServer,
|
||||
["grpc-stub"] = ApplicationIntent.RpcServer,
|
||||
|
||||
// GraphQL
|
||||
["graphql-java"] = ApplicationIntent.GraphQlServer,
|
||||
["netflix-dgs"] = ApplicationIntent.GraphQlServer,
|
||||
["graphql-spring-boot"] = ApplicationIntent.GraphQlServer,
|
||||
|
||||
// Database servers (when running as embedded)
|
||||
["h2"] = ApplicationIntent.DatabaseServer,
|
||||
["derby"] = ApplicationIntent.DatabaseServer,
|
||||
|
||||
// Testing
|
||||
["junit"] = ApplicationIntent.TestRunner,
|
||||
["testng"] = ApplicationIntent.TestRunner,
|
||||
["mockito"] = ApplicationIntent.TestRunner,
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
private static readonly FrozenDictionary<string, CapabilityClass> DependencyCapabilityMap = new Dictionary<string, CapabilityClass>
|
||||
{
|
||||
// Network
|
||||
["netty"] = CapabilityClass.NetworkConnect | CapabilityClass.NetworkListen,
|
||||
["okhttp"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["apache-httpclient"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["jersey-client"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["retrofit"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["feign"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
|
||||
// Databases
|
||||
["jdbc"] = CapabilityClass.DatabaseSql,
|
||||
["postgresql"] = CapabilityClass.DatabaseSql,
|
||||
["mysql-connector"] = CapabilityClass.DatabaseSql,
|
||||
["ojdbc"] = CapabilityClass.DatabaseSql,
|
||||
["mssql-jdbc"] = CapabilityClass.DatabaseSql,
|
||||
["hibernate"] = CapabilityClass.DatabaseSql,
|
||||
["jpa"] = CapabilityClass.DatabaseSql,
|
||||
["mybatis"] = CapabilityClass.DatabaseSql,
|
||||
["jooq"] = CapabilityClass.DatabaseSql,
|
||||
["mongo-java-driver"] = CapabilityClass.DatabaseNoSql,
|
||||
["cassandra-driver"] = CapabilityClass.DatabaseNoSql,
|
||||
["jedis"] = CapabilityClass.CacheAccess,
|
||||
["lettuce"] = CapabilityClass.CacheAccess,
|
||||
["redisson"] = CapabilityClass.CacheAccess,
|
||||
["ehcache"] = CapabilityClass.CacheAccess,
|
||||
["caffeine"] = CapabilityClass.CacheAccess,
|
||||
|
||||
// Message queues
|
||||
["jms"] = CapabilityClass.MessageQueue,
|
||||
["activemq"] = CapabilityClass.MessageQueue,
|
||||
["kafka"] = CapabilityClass.MessageQueue,
|
||||
["rabbitmq"] = CapabilityClass.MessageQueue,
|
||||
|
||||
// File operations
|
||||
["commons-io"] = CapabilityClass.FileRead | CapabilityClass.FileWrite,
|
||||
["java.nio.file"] = CapabilityClass.FileRead | CapabilityClass.FileWrite,
|
||||
|
||||
// Process
|
||||
["processbuilder"] = CapabilityClass.ProcessSpawn,
|
||||
["runtime.exec"] = CapabilityClass.ProcessSpawn | CapabilityClass.ShellExecution,
|
||||
|
||||
// Crypto
|
||||
["bouncycastle"] = CapabilityClass.CryptoEncrypt | CapabilityClass.CryptoSign | CapabilityClass.CryptoKeyGen,
|
||||
["jasypt"] = CapabilityClass.CryptoEncrypt,
|
||||
["tink"] = CapabilityClass.CryptoEncrypt | CapabilityClass.CryptoSign,
|
||||
|
||||
// Cloud SDKs
|
||||
["aws-sdk-java"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
["google-cloud-java"] = CapabilityClass.CloudSdk,
|
||||
["azure-sdk"] = CapabilityClass.CloudSdk,
|
||||
|
||||
// Serialization (potentially unsafe)
|
||||
["jackson"] = CapabilityClass.UnsafeDeserialization,
|
||||
["gson"] = CapabilityClass.UnsafeDeserialization,
|
||||
["xstream"] = CapabilityClass.UnsafeDeserialization | CapabilityClass.XmlExternalEntities,
|
||||
["fastjson"] = CapabilityClass.UnsafeDeserialization,
|
||||
["kryo"] = CapabilityClass.UnsafeDeserialization,
|
||||
["java.io.objectinputstream"] = CapabilityClass.UnsafeDeserialization,
|
||||
|
||||
// XML
|
||||
["dom4j"] = CapabilityClass.XmlExternalEntities,
|
||||
["jdom"] = CapabilityClass.XmlExternalEntities,
|
||||
["woodstox"] = CapabilityClass.XmlExternalEntities,
|
||||
|
||||
// Template engines
|
||||
["thymeleaf"] = CapabilityClass.TemplateRendering,
|
||||
["freemarker"] = CapabilityClass.TemplateRendering,
|
||||
["velocity"] = CapabilityClass.TemplateRendering,
|
||||
["pebble"] = CapabilityClass.TemplateRendering,
|
||||
|
||||
// Logging
|
||||
["slf4j"] = CapabilityClass.LogEmit,
|
||||
["log4j"] = CapabilityClass.LogEmit,
|
||||
["logback"] = CapabilityClass.LogEmit,
|
||||
|
||||
// Metrics
|
||||
["micrometer"] = CapabilityClass.MetricsEmit,
|
||||
["prometheus"] = CapabilityClass.MetricsEmit,
|
||||
["opentelemetry"] = CapabilityClass.TracingEmit | CapabilityClass.MetricsEmit,
|
||||
["jaeger"] = CapabilityClass.TracingEmit,
|
||||
["zipkin"] = CapabilityClass.TracingEmit,
|
||||
|
||||
// Auth
|
||||
["spring-security"] = CapabilityClass.Authentication | CapabilityClass.Authorization,
|
||||
["shiro"] = CapabilityClass.Authentication | CapabilityClass.Authorization,
|
||||
["jwt"] = CapabilityClass.Authentication | CapabilityClass.SessionManagement,
|
||||
["oauth2"] = CapabilityClass.Authentication,
|
||||
["keycloak"] = CapabilityClass.Authentication | CapabilityClass.Authorization,
|
||||
|
||||
// Secrets
|
||||
["vault-java-driver"] = CapabilityClass.SecretAccess,
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
public async Task<SemanticEntrypoint> AnalyzeAsync(
|
||||
SemanticAnalysisContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var builder = new SemanticEntrypointBuilder()
|
||||
.WithId(GenerateId(context))
|
||||
.WithSpecification(context.Specification)
|
||||
.WithLanguage("java");
|
||||
|
||||
var reasoningChain = new List<string>();
|
||||
var intent = ApplicationIntent.Unknown;
|
||||
var framework = (string?)null;
|
||||
|
||||
// Analyze dependencies
|
||||
if (context.Dependencies.TryGetValue("java", out var deps))
|
||||
{
|
||||
foreach (var dep in deps)
|
||||
{
|
||||
var normalizedDep = NormalizeDependency(dep);
|
||||
|
||||
if (FrameworkIntentMap.TryGetValue(normalizedDep, out var mappedIntent))
|
||||
{
|
||||
if (intent == ApplicationIntent.Unknown || IsHigherPriority(mappedIntent, intent))
|
||||
{
|
||||
intent = mappedIntent;
|
||||
framework = dep;
|
||||
reasoningChain.Add($"Detected {dep} -> {intent}");
|
||||
}
|
||||
}
|
||||
|
||||
if (DependencyCapabilityMap.TryGetValue(normalizedDep, out var capability))
|
||||
{
|
||||
builder.AddCapability(capability);
|
||||
reasoningChain.Add($"Dependency {dep} -> {capability}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze entrypoint command
|
||||
var cmdSignals = AnalyzeCommand(context.Specification);
|
||||
if (cmdSignals.Intent != ApplicationIntent.Unknown && intent == ApplicationIntent.Unknown)
|
||||
{
|
||||
intent = cmdSignals.Intent;
|
||||
reasoningChain.Add($"Command pattern -> {intent}");
|
||||
}
|
||||
|
||||
foreach (var cap in GetCapabilityFlags(cmdSignals.Capabilities))
|
||||
{
|
||||
builder.AddCapability(cap);
|
||||
}
|
||||
|
||||
// Check for JNI usage
|
||||
if (await HasJniUsageAsync(context, cancellationToken))
|
||||
{
|
||||
builder.AddCapability(CapabilityClass.SystemPrivileged);
|
||||
reasoningChain.Add("JNI usage detected -> SystemPrivileged");
|
||||
}
|
||||
|
||||
// Check exposed ports
|
||||
if (context.Specification.ExposedPorts.Length > 0)
|
||||
{
|
||||
var webPorts = context.Specification.ExposedPorts.Where(IsWebPort).ToList();
|
||||
if (webPorts.Count > 0 && intent == ApplicationIntent.Unknown)
|
||||
{
|
||||
intent = ApplicationIntent.WebServer;
|
||||
reasoningChain.Add($"Exposed web ports: {string.Join(", ", webPorts)}");
|
||||
}
|
||||
builder.AddCapability(CapabilityClass.NetworkListen);
|
||||
}
|
||||
|
||||
var confidence = DetermineConfidence(reasoningChain, intent, framework);
|
||||
|
||||
builder.WithIntent(intent)
|
||||
.WithConfidence(confidence);
|
||||
|
||||
if (framework is not null)
|
||||
{
|
||||
builder.WithFramework(framework);
|
||||
}
|
||||
|
||||
return await Task.FromResult(builder.Build());
|
||||
}
|
||||
|
||||
private static string NormalizeDependency(string dep)
|
||||
{
|
||||
// Handle Maven coordinates (groupId:artifactId:version)
|
||||
var parts = dep.Split(':');
|
||||
var artifactId = parts.Length >= 2 ? parts[1] : parts[0];
|
||||
return artifactId.ToLowerInvariant().Replace("_", "-");
|
||||
}
|
||||
|
||||
private static bool IsHigherPriority(ApplicationIntent newer, ApplicationIntent current)
|
||||
{
|
||||
var priorityOrder = new[]
|
||||
{
|
||||
ApplicationIntent.Unknown,
|
||||
ApplicationIntent.TestRunner,
|
||||
ApplicationIntent.CliTool,
|
||||
ApplicationIntent.BatchJob,
|
||||
ApplicationIntent.Worker,
|
||||
ApplicationIntent.StreamProcessor,
|
||||
ApplicationIntent.Serverless,
|
||||
ApplicationIntent.WebServer,
|
||||
ApplicationIntent.RpcServer,
|
||||
ApplicationIntent.GraphQlServer,
|
||||
};
|
||||
|
||||
return Array.IndexOf(priorityOrder, newer) > Array.IndexOf(priorityOrder, current);
|
||||
}
|
||||
|
||||
private static (ApplicationIntent Intent, CapabilityClass Capabilities) AnalyzeCommand(EntrypointSpecification spec)
|
||||
{
|
||||
var cmd = string.Join(" ", spec.Entrypoint.Concat(spec.Cmd));
|
||||
var intent = ApplicationIntent.Unknown;
|
||||
var caps = CapabilityClass.None;
|
||||
|
||||
// Check for Spring Boot executable JAR
|
||||
if (cmd.Contains("-jar") && (cmd.Contains("spring") || cmd.Contains("boot")))
|
||||
{
|
||||
intent = ApplicationIntent.WebServer;
|
||||
caps |= CapabilityClass.NetworkListen;
|
||||
}
|
||||
// Quarkus runner
|
||||
else if (cmd.Contains("quarkus-run") || cmd.Contains("quarkus.jar"))
|
||||
{
|
||||
intent = ApplicationIntent.WebServer;
|
||||
caps |= CapabilityClass.NetworkListen;
|
||||
}
|
||||
// Kafka Streams
|
||||
else if (cmd.Contains("kafka") && cmd.Contains("streams"))
|
||||
{
|
||||
intent = ApplicationIntent.StreamProcessor;
|
||||
caps |= CapabilityClass.MessageQueue;
|
||||
}
|
||||
// Test runners
|
||||
else if (cmd.Contains("junit") || cmd.Contains("testng") || cmd.Contains("surefire"))
|
||||
{
|
||||
intent = ApplicationIntent.TestRunner;
|
||||
}
|
||||
// GraalVM native image
|
||||
else if (cmd.Contains("native-image") || !cmd.Contains("java"))
|
||||
{
|
||||
// Native executable - intent depends on other signals
|
||||
caps |= CapabilityClass.FileExecute;
|
||||
}
|
||||
|
||||
return (intent, caps);
|
||||
}
|
||||
|
||||
private static async Task<bool> HasJniUsageAsync(SemanticAnalysisContext context, CancellationToken ct)
|
||||
{
|
||||
// Check for .so files in common JNI locations
|
||||
var jniPaths = new[] { "/usr/lib", "/lib", "/app/lib", "/opt/app/lib" };
|
||||
foreach (var path in jniPaths)
|
||||
{
|
||||
if (await context.FileSystem.DirectoryExistsAsync(path, ct))
|
||||
{
|
||||
var files = await context.FileSystem.ListFilesAsync(path, "*.so", ct);
|
||||
if (files.Any())
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsWebPort(int port)
|
||||
{
|
||||
return port is 80 or 443 or 8080 or 8443 or 9000 or 8081 or 8082;
|
||||
}
|
||||
|
||||
private static SemanticConfidence DetermineConfidence(List<string> reasoning, ApplicationIntent intent, string? framework)
|
||||
{
|
||||
if (intent == ApplicationIntent.Unknown)
|
||||
return SemanticConfidence.Unknown();
|
||||
|
||||
if (framework is not null && reasoning.Count >= 3)
|
||||
return SemanticConfidence.High(reasoning.ToArray());
|
||||
|
||||
if (framework is not null)
|
||||
return SemanticConfidence.Medium(reasoning.ToArray());
|
||||
|
||||
return SemanticConfidence.Low(reasoning.ToArray());
|
||||
}
|
||||
|
||||
private static IEnumerable<CapabilityClass> GetCapabilityFlags(CapabilityClass caps)
|
||||
{
|
||||
foreach (CapabilityClass flag in Enum.GetValues<CapabilityClass>())
|
||||
{
|
||||
if (flag != CapabilityClass.None && caps.HasFlag(flag))
|
||||
yield return flag;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateId(SemanticAnalysisContext context)
|
||||
{
|
||||
var hash = context.ImageDigest ?? Guid.NewGuid().ToString("N");
|
||||
return $"sem-java-{hash[..12]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Node.js semantic adapter for inferring intent and capabilities.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 10).
|
||||
/// Detects Express, Koa, Fastify, CLI bin entries, worker threads, Lambda handlers.
|
||||
/// </remarks>
|
||||
public sealed class NodeSemanticAdapter : ISemanticEntrypointAnalyzer
|
||||
{
|
||||
public IReadOnlyList<string> SupportedLanguages => ["node", "javascript", "typescript"];
|
||||
public int Priority => 100;
|
||||
|
||||
private static readonly FrozenDictionary<string, ApplicationIntent> PackageIntentMap = new Dictionary<string, ApplicationIntent>
|
||||
{
|
||||
// Web frameworks
|
||||
["express"] = ApplicationIntent.WebServer,
|
||||
["koa"] = ApplicationIntent.WebServer,
|
||||
["fastify"] = ApplicationIntent.WebServer,
|
||||
["hapi"] = ApplicationIntent.WebServer,
|
||||
["restify"] = ApplicationIntent.WebServer,
|
||||
["polka"] = ApplicationIntent.WebServer,
|
||||
["micro"] = ApplicationIntent.WebServer,
|
||||
["nest"] = ApplicationIntent.WebServer,
|
||||
["@nestjs/core"] = ApplicationIntent.WebServer,
|
||||
["@nestjs/platform-express"] = ApplicationIntent.WebServer,
|
||||
["next"] = ApplicationIntent.WebServer,
|
||||
["nuxt"] = ApplicationIntent.WebServer,
|
||||
["sveltekit"] = ApplicationIntent.WebServer,
|
||||
["remix"] = ApplicationIntent.WebServer,
|
||||
["adonis"] = ApplicationIntent.WebServer,
|
||||
|
||||
// Workers/queues
|
||||
["bull"] = ApplicationIntent.Worker,
|
||||
["bullmq"] = ApplicationIntent.Worker,
|
||||
["agenda"] = ApplicationIntent.Worker,
|
||||
["bee-queue"] = ApplicationIntent.Worker,
|
||||
["kue"] = ApplicationIntent.Worker,
|
||||
|
||||
// CLI
|
||||
["commander"] = ApplicationIntent.CliTool,
|
||||
["yargs"] = ApplicationIntent.CliTool,
|
||||
["meow"] = ApplicationIntent.CliTool,
|
||||
["oclif"] = ApplicationIntent.CliTool,
|
||||
["inquirer"] = ApplicationIntent.CliTool,
|
||||
["vorpal"] = ApplicationIntent.CliTool,
|
||||
["caporal"] = ApplicationIntent.CliTool,
|
||||
|
||||
// Serverless
|
||||
["aws-lambda"] = ApplicationIntent.Serverless,
|
||||
["@aws-sdk/lambda"] = ApplicationIntent.Serverless,
|
||||
["serverless"] = ApplicationIntent.Serverless,
|
||||
["@azure/functions"] = ApplicationIntent.Serverless,
|
||||
["@google-cloud/functions-framework"] = ApplicationIntent.Serverless,
|
||||
|
||||
// gRPC
|
||||
["@grpc/grpc-js"] = ApplicationIntent.RpcServer,
|
||||
["grpc"] = ApplicationIntent.RpcServer,
|
||||
|
||||
// GraphQL
|
||||
["apollo-server"] = ApplicationIntent.GraphQlServer,
|
||||
["@apollo/server"] = ApplicationIntent.GraphQlServer,
|
||||
["graphql-yoga"] = ApplicationIntent.GraphQlServer,
|
||||
["mercurius"] = ApplicationIntent.GraphQlServer,
|
||||
["type-graphql"] = ApplicationIntent.GraphQlServer,
|
||||
|
||||
// Stream processing
|
||||
["kafka-node"] = ApplicationIntent.StreamProcessor,
|
||||
["kafkajs"] = ApplicationIntent.StreamProcessor,
|
||||
|
||||
// Schedulers
|
||||
["node-cron"] = ApplicationIntent.ScheduledTask,
|
||||
["cron"] = ApplicationIntent.ScheduledTask,
|
||||
["node-schedule"] = ApplicationIntent.ScheduledTask,
|
||||
|
||||
// Metrics/monitoring
|
||||
["prom-client"] = ApplicationIntent.MetricsCollector,
|
||||
|
||||
// Proxy
|
||||
["http-proxy"] = ApplicationIntent.ProxyGateway,
|
||||
["http-proxy-middleware"] = ApplicationIntent.ProxyGateway,
|
||||
|
||||
// Testing
|
||||
["jest"] = ApplicationIntent.TestRunner,
|
||||
["mocha"] = ApplicationIntent.TestRunner,
|
||||
["vitest"] = ApplicationIntent.TestRunner,
|
||||
["ava"] = ApplicationIntent.TestRunner,
|
||||
["tap"] = ApplicationIntent.TestRunner,
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
private static readonly FrozenDictionary<string, CapabilityClass> PackageCapabilityMap = new Dictionary<string, CapabilityClass>
|
||||
{
|
||||
// Network
|
||||
["axios"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["got"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["node-fetch"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["undici"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["request"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["superagent"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["socket.io"] = CapabilityClass.NetworkConnect | CapabilityClass.NetworkListen,
|
||||
["ws"] = CapabilityClass.NetworkConnect | CapabilityClass.NetworkListen,
|
||||
["net"] = CapabilityClass.NetworkConnect | CapabilityClass.NetworkListen,
|
||||
["dgram"] = CapabilityClass.NetworkConnect | CapabilityClass.NetworkRaw,
|
||||
|
||||
// File system
|
||||
["fs-extra"] = CapabilityClass.FileRead | CapabilityClass.FileWrite,
|
||||
["graceful-fs"] = CapabilityClass.FileRead | CapabilityClass.FileWrite,
|
||||
["glob"] = CapabilityClass.FileRead,
|
||||
["chokidar"] = CapabilityClass.FileWatch,
|
||||
["multer"] = CapabilityClass.FileUpload,
|
||||
["formidable"] = CapabilityClass.FileUpload,
|
||||
["busboy"] = CapabilityClass.FileUpload,
|
||||
|
||||
// Process
|
||||
["child_process"] = CapabilityClass.ProcessSpawn | CapabilityClass.ShellExecution,
|
||||
["execa"] = CapabilityClass.ProcessSpawn | CapabilityClass.ShellExecution,
|
||||
["shelljs"] = CapabilityClass.ProcessSpawn | CapabilityClass.ShellExecution,
|
||||
["cross-spawn"] = CapabilityClass.ProcessSpawn,
|
||||
|
||||
// Databases
|
||||
["pg"] = CapabilityClass.DatabaseSql,
|
||||
["mysql"] = CapabilityClass.DatabaseSql,
|
||||
["mysql2"] = CapabilityClass.DatabaseSql,
|
||||
["mssql"] = CapabilityClass.DatabaseSql,
|
||||
["sqlite3"] = CapabilityClass.DatabaseSql,
|
||||
["better-sqlite3"] = CapabilityClass.DatabaseSql,
|
||||
["sequelize"] = CapabilityClass.DatabaseSql,
|
||||
["typeorm"] = CapabilityClass.DatabaseSql,
|
||||
["prisma"] = CapabilityClass.DatabaseSql,
|
||||
["knex"] = CapabilityClass.DatabaseSql,
|
||||
["drizzle-orm"] = CapabilityClass.DatabaseSql,
|
||||
["mongoose"] = CapabilityClass.DatabaseNoSql,
|
||||
["mongodb"] = CapabilityClass.DatabaseNoSql,
|
||||
["cassandra-driver"] = CapabilityClass.DatabaseNoSql,
|
||||
["redis"] = CapabilityClass.CacheAccess,
|
||||
["ioredis"] = CapabilityClass.CacheAccess,
|
||||
["memcached"] = CapabilityClass.CacheAccess,
|
||||
|
||||
// Message queues
|
||||
["amqplib"] = CapabilityClass.MessageQueue,
|
||||
["kafkajs"] = CapabilityClass.MessageQueue,
|
||||
["sqs-consumer"] = CapabilityClass.MessageQueue,
|
||||
|
||||
// Crypto
|
||||
["crypto"] = CapabilityClass.CryptoEncrypt,
|
||||
["bcrypt"] = CapabilityClass.CryptoEncrypt,
|
||||
["argon2"] = CapabilityClass.CryptoEncrypt,
|
||||
["jose"] = CapabilityClass.CryptoSign | CapabilityClass.CryptoEncrypt,
|
||||
["jsonwebtoken"] = CapabilityClass.CryptoSign,
|
||||
["node-forge"] = CapabilityClass.CryptoEncrypt | CapabilityClass.CryptoSign | CapabilityClass.CryptoKeyGen,
|
||||
|
||||
// Cloud SDKs
|
||||
["@aws-sdk/client-s3"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
["aws-sdk"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
["@google-cloud/storage"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
["@azure/storage-blob"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
|
||||
// Unsafe patterns
|
||||
["vm"] = CapabilityClass.DynamicCodeEval,
|
||||
["vm2"] = CapabilityClass.DynamicCodeEval,
|
||||
["isolated-vm"] = CapabilityClass.DynamicCodeEval,
|
||||
["serialize-javascript"] = CapabilityClass.UnsafeDeserialization,
|
||||
["node-serialize"] = CapabilityClass.UnsafeDeserialization,
|
||||
["xml2js"] = CapabilityClass.XmlExternalEntities,
|
||||
["fast-xml-parser"] = CapabilityClass.XmlExternalEntities,
|
||||
|
||||
// Template engines
|
||||
["ejs"] = CapabilityClass.TemplateRendering,
|
||||
["pug"] = CapabilityClass.TemplateRendering,
|
||||
["handlebars"] = CapabilityClass.TemplateRendering,
|
||||
["nunjucks"] = CapabilityClass.TemplateRendering,
|
||||
["mustache"] = CapabilityClass.TemplateRendering,
|
||||
|
||||
// Logging/metrics
|
||||
["winston"] = CapabilityClass.LogEmit,
|
||||
["pino"] = CapabilityClass.LogEmit,
|
||||
["bunyan"] = CapabilityClass.LogEmit,
|
||||
["morgan"] = CapabilityClass.LogEmit,
|
||||
["prom-client"] = CapabilityClass.MetricsEmit,
|
||||
["@opentelemetry/sdk-node"] = CapabilityClass.TracingEmit | CapabilityClass.MetricsEmit,
|
||||
|
||||
// Auth
|
||||
["passport"] = CapabilityClass.Authentication,
|
||||
["express-session"] = CapabilityClass.SessionManagement,
|
||||
["cookie-session"] = CapabilityClass.SessionManagement,
|
||||
["helmet"] = CapabilityClass.Authorization,
|
||||
|
||||
// Config/secrets
|
||||
["dotenv"] = CapabilityClass.SecretAccess | CapabilityClass.ConfigLoad | CapabilityClass.EnvironmentRead,
|
||||
["config"] = CapabilityClass.ConfigLoad,
|
||||
["@hashicorp/vault"] = CapabilityClass.SecretAccess,
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
public async Task<SemanticEntrypoint> AnalyzeAsync(
|
||||
SemanticAnalysisContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var builder = new SemanticEntrypointBuilder()
|
||||
.WithId(GenerateId(context))
|
||||
.WithSpecification(context.Specification)
|
||||
.WithLanguage("node");
|
||||
|
||||
var reasoningChain = new List<string>();
|
||||
var intent = ApplicationIntent.Unknown;
|
||||
var framework = (string?)null;
|
||||
|
||||
// Analyze dependencies
|
||||
if (context.Dependencies.TryGetValue("node", out var deps))
|
||||
{
|
||||
foreach (var dep in deps)
|
||||
{
|
||||
var normalizedDep = NormalizeDependency(dep);
|
||||
|
||||
if (PackageIntentMap.TryGetValue(normalizedDep, out var mappedIntent))
|
||||
{
|
||||
if (intent == ApplicationIntent.Unknown || IsHigherPriority(mappedIntent, intent))
|
||||
{
|
||||
intent = mappedIntent;
|
||||
framework = dep;
|
||||
reasoningChain.Add($"Detected {dep} -> {intent}");
|
||||
}
|
||||
}
|
||||
|
||||
if (PackageCapabilityMap.TryGetValue(normalizedDep, out var capability))
|
||||
{
|
||||
builder.AddCapability(capability);
|
||||
reasoningChain.Add($"Package {dep} -> {capability}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze entrypoint command
|
||||
var cmdSignals = AnalyzeCommand(context.Specification);
|
||||
if (cmdSignals.Intent != ApplicationIntent.Unknown && intent == ApplicationIntent.Unknown)
|
||||
{
|
||||
intent = cmdSignals.Intent;
|
||||
reasoningChain.Add($"Command pattern -> {intent}");
|
||||
}
|
||||
|
||||
foreach (var cap in GetCapabilityFlags(cmdSignals.Capabilities))
|
||||
{
|
||||
builder.AddCapability(cap);
|
||||
}
|
||||
|
||||
// Check package.json for bin entries -> CLI tool
|
||||
if (context.ManifestPaths.TryGetValue("package.json", out var pkgPath))
|
||||
{
|
||||
if (await HasBinEntriesAsync(context, pkgPath, cancellationToken))
|
||||
{
|
||||
if (intent == ApplicationIntent.Unknown)
|
||||
{
|
||||
intent = ApplicationIntent.CliTool;
|
||||
reasoningChain.Add("package.json has bin entries -> CliTool");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check exposed ports
|
||||
if (context.Specification.ExposedPorts.Length > 0)
|
||||
{
|
||||
var webPorts = context.Specification.ExposedPorts.Where(IsWebPort).ToList();
|
||||
if (webPorts.Count > 0 && intent == ApplicationIntent.Unknown)
|
||||
{
|
||||
intent = ApplicationIntent.WebServer;
|
||||
reasoningChain.Add($"Exposed web ports: {string.Join(", ", webPorts)}");
|
||||
}
|
||||
builder.AddCapability(CapabilityClass.NetworkListen);
|
||||
}
|
||||
|
||||
var confidence = DetermineConfidence(reasoningChain, intent, framework);
|
||||
|
||||
builder.WithIntent(intent)
|
||||
.WithConfidence(confidence);
|
||||
|
||||
if (framework is not null)
|
||||
{
|
||||
builder.WithFramework(framework);
|
||||
}
|
||||
|
||||
return await Task.FromResult(builder.Build());
|
||||
}
|
||||
|
||||
private static string NormalizeDependency(string dep)
|
||||
{
|
||||
// Handle scoped packages and versions
|
||||
return dep.ToLowerInvariant()
|
||||
.Split('@')[0] // Remove version
|
||||
.Trim();
|
||||
}
|
||||
|
||||
private static bool IsHigherPriority(ApplicationIntent newer, ApplicationIntent current)
|
||||
{
|
||||
var priorityOrder = new[]
|
||||
{
|
||||
ApplicationIntent.Unknown,
|
||||
ApplicationIntent.TestRunner,
|
||||
ApplicationIntent.CliTool,
|
||||
ApplicationIntent.BatchJob,
|
||||
ApplicationIntent.Worker,
|
||||
ApplicationIntent.ScheduledTask,
|
||||
ApplicationIntent.StreamProcessor,
|
||||
ApplicationIntent.Serverless,
|
||||
ApplicationIntent.WebServer,
|
||||
ApplicationIntent.RpcServer,
|
||||
ApplicationIntent.GraphQlServer,
|
||||
};
|
||||
|
||||
return Array.IndexOf(priorityOrder, newer) > Array.IndexOf(priorityOrder, current);
|
||||
}
|
||||
|
||||
private static (ApplicationIntent Intent, CapabilityClass Capabilities) AnalyzeCommand(EntrypointSpecification spec)
|
||||
{
|
||||
var cmd = string.Join(" ", spec.Entrypoint.Concat(spec.Cmd));
|
||||
var intent = ApplicationIntent.Unknown;
|
||||
var caps = CapabilityClass.None;
|
||||
|
||||
// Next.js
|
||||
if (cmd.Contains("next") && cmd.Contains("start"))
|
||||
{
|
||||
intent = ApplicationIntent.WebServer;
|
||||
caps |= CapabilityClass.NetworkListen;
|
||||
}
|
||||
// Nuxt
|
||||
else if (cmd.Contains("nuxt") && cmd.Contains("start"))
|
||||
{
|
||||
intent = ApplicationIntent.WebServer;
|
||||
caps |= CapabilityClass.NetworkListen;
|
||||
}
|
||||
// NestJS
|
||||
else if (cmd.Contains("nest") && cmd.Contains("start"))
|
||||
{
|
||||
intent = ApplicationIntent.WebServer;
|
||||
caps |= CapabilityClass.NetworkListen;
|
||||
}
|
||||
// PM2
|
||||
else if (cmd.Contains("pm2"))
|
||||
{
|
||||
intent = ApplicationIntent.Daemon;
|
||||
caps |= CapabilityClass.ProcessSpawn;
|
||||
}
|
||||
// Node with --inspect
|
||||
else if (cmd.Contains("--inspect"))
|
||||
{
|
||||
intent = ApplicationIntent.DevServer;
|
||||
}
|
||||
// Test runners
|
||||
else if (cmd.Contains("jest") || cmd.Contains("mocha") || cmd.Contains("vitest"))
|
||||
{
|
||||
intent = ApplicationIntent.TestRunner;
|
||||
}
|
||||
// Worker threads
|
||||
else if (cmd.Contains("worker_threads"))
|
||||
{
|
||||
caps |= CapabilityClass.ProcessSpawn;
|
||||
}
|
||||
|
||||
return (intent, caps);
|
||||
}
|
||||
|
||||
private static async Task<bool> HasBinEntriesAsync(SemanticAnalysisContext context, string pkgPath, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await context.FileSystem.ReadFileAsync(pkgPath, ct);
|
||||
return content.Contains("\"bin\"");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsWebPort(int port)
|
||||
{
|
||||
return port is 80 or 443 or 3000 or 3001 or 8000 or 8080 or 8443 or 9000 or 4000;
|
||||
}
|
||||
|
||||
private static SemanticConfidence DetermineConfidence(List<string> reasoning, ApplicationIntent intent, string? framework)
|
||||
{
|
||||
if (intent == ApplicationIntent.Unknown)
|
||||
return SemanticConfidence.Unknown();
|
||||
|
||||
if (framework is not null && reasoning.Count >= 3)
|
||||
return SemanticConfidence.High(reasoning.ToArray());
|
||||
|
||||
if (framework is not null)
|
||||
return SemanticConfidence.Medium(reasoning.ToArray());
|
||||
|
||||
return SemanticConfidence.Low(reasoning.ToArray());
|
||||
}
|
||||
|
||||
private static IEnumerable<CapabilityClass> GetCapabilityFlags(CapabilityClass caps)
|
||||
{
|
||||
foreach (CapabilityClass flag in Enum.GetValues<CapabilityClass>())
|
||||
{
|
||||
if (flag != CapabilityClass.None && caps.HasFlag(flag))
|
||||
yield return flag;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateId(SemanticAnalysisContext context)
|
||||
{
|
||||
var hash = context.ImageDigest ?? Guid.NewGuid().ToString("N");
|
||||
return $"sem-node-{hash[..12]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Python semantic adapter for inferring intent and capabilities.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 8).
|
||||
/// Detects Django, Flask, FastAPI, Celery, Click, Typer, Lambda handlers.
|
||||
/// </remarks>
|
||||
public sealed class PythonSemanticAdapter : ISemanticEntrypointAnalyzer
|
||||
{
|
||||
public IReadOnlyList<string> SupportedLanguages => ["python"];
|
||||
public int Priority => 100;
|
||||
|
||||
private static readonly FrozenDictionary<string, ApplicationIntent> FrameworkIntentMap = new Dictionary<string, ApplicationIntent>
|
||||
{
|
||||
// Web frameworks
|
||||
["django"] = ApplicationIntent.WebServer,
|
||||
["flask"] = ApplicationIntent.WebServer,
|
||||
["fastapi"] = ApplicationIntent.WebServer,
|
||||
["starlette"] = ApplicationIntent.WebServer,
|
||||
["tornado"] = ApplicationIntent.WebServer,
|
||||
["aiohttp"] = ApplicationIntent.WebServer,
|
||||
["sanic"] = ApplicationIntent.WebServer,
|
||||
["bottle"] = ApplicationIntent.WebServer,
|
||||
["pyramid"] = ApplicationIntent.WebServer,
|
||||
["falcon"] = ApplicationIntent.WebServer,
|
||||
["quart"] = ApplicationIntent.WebServer,
|
||||
["litestar"] = ApplicationIntent.WebServer,
|
||||
|
||||
// Workers/queues
|
||||
["celery"] = ApplicationIntent.Worker,
|
||||
["rq"] = ApplicationIntent.Worker,
|
||||
["dramatiq"] = ApplicationIntent.Worker,
|
||||
["huey"] = ApplicationIntent.Worker,
|
||||
["arq"] = ApplicationIntent.Worker,
|
||||
|
||||
// CLI
|
||||
["click"] = ApplicationIntent.CliTool,
|
||||
["typer"] = ApplicationIntent.CliTool,
|
||||
["argparse"] = ApplicationIntent.CliTool,
|
||||
["fire"] = ApplicationIntent.CliTool,
|
||||
|
||||
// Serverless
|
||||
["awslambdaric"] = ApplicationIntent.Serverless,
|
||||
["aws_lambda_powertools"] = ApplicationIntent.Serverless,
|
||||
["mangum"] = ApplicationIntent.Serverless,
|
||||
["chalice"] = ApplicationIntent.Serverless,
|
||||
|
||||
// gRPC
|
||||
["grpcio"] = ApplicationIntent.RpcServer,
|
||||
["grpc"] = ApplicationIntent.RpcServer,
|
||||
|
||||
// GraphQL
|
||||
["graphene"] = ApplicationIntent.GraphQlServer,
|
||||
["strawberry"] = ApplicationIntent.GraphQlServer,
|
||||
["ariadne"] = ApplicationIntent.GraphQlServer,
|
||||
|
||||
// ML inference
|
||||
["tensorflow_serving"] = ApplicationIntent.MlInferenceServer,
|
||||
["mlflow"] = ApplicationIntent.MlInferenceServer,
|
||||
["bentoml"] = ApplicationIntent.MlInferenceServer,
|
||||
["ray"] = ApplicationIntent.MlInferenceServer,
|
||||
|
||||
// Stream processing
|
||||
["faust"] = ApplicationIntent.StreamProcessor,
|
||||
["kafka"] = ApplicationIntent.StreamProcessor,
|
||||
|
||||
// Schedulers
|
||||
["apscheduler"] = ApplicationIntent.ScheduledTask,
|
||||
["schedule"] = ApplicationIntent.ScheduledTask,
|
||||
|
||||
// Metrics/monitoring
|
||||
["prometheus_client"] = ApplicationIntent.MetricsCollector,
|
||||
|
||||
// Testing (should be deprioritized)
|
||||
["pytest"] = ApplicationIntent.TestRunner,
|
||||
["unittest"] = ApplicationIntent.TestRunner,
|
||||
["nose"] = ApplicationIntent.TestRunner,
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
private static readonly FrozenDictionary<string, CapabilityClass> ImportCapabilityMap = new Dictionary<string, CapabilityClass>
|
||||
{
|
||||
// Network
|
||||
["socket"] = CapabilityClass.NetworkConnect | CapabilityClass.NetworkListen,
|
||||
["http.server"] = CapabilityClass.NetworkListen,
|
||||
["http.client"] = CapabilityClass.NetworkConnect,
|
||||
["urllib"] = CapabilityClass.NetworkConnect,
|
||||
["urllib3"] = CapabilityClass.NetworkConnect,
|
||||
["requests"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["httpx"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["aiohttp"] = CapabilityClass.NetworkConnect | CapabilityClass.NetworkListen,
|
||||
|
||||
// File system
|
||||
["os"] = CapabilityClass.FileRead | CapabilityClass.FileWrite | CapabilityClass.EnvironmentRead,
|
||||
["os.path"] = CapabilityClass.FileRead,
|
||||
["pathlib"] = CapabilityClass.FileRead | CapabilityClass.FileWrite,
|
||||
["shutil"] = CapabilityClass.FileRead | CapabilityClass.FileWrite,
|
||||
["tempfile"] = CapabilityClass.FileWrite,
|
||||
["io"] = CapabilityClass.FileRead | CapabilityClass.FileWrite,
|
||||
["glob"] = CapabilityClass.FileRead,
|
||||
|
||||
// Process
|
||||
["subprocess"] = CapabilityClass.ProcessSpawn | CapabilityClass.ShellExecution,
|
||||
["multiprocessing"] = CapabilityClass.ProcessSpawn,
|
||||
["os.system"] = CapabilityClass.ProcessSpawn | CapabilityClass.ShellExecution,
|
||||
["signal"] = CapabilityClass.ProcessSignal,
|
||||
|
||||
// Crypto
|
||||
["cryptography"] = CapabilityClass.CryptoEncrypt | CapabilityClass.CryptoSign,
|
||||
["hashlib"] = CapabilityClass.CryptoEncrypt,
|
||||
["secrets"] = CapabilityClass.CryptoKeyGen,
|
||||
["ssl"] = CapabilityClass.CryptoEncrypt,
|
||||
["nacl"] = CapabilityClass.CryptoEncrypt | CapabilityClass.CryptoSign,
|
||||
["pynacl"] = CapabilityClass.CryptoEncrypt | CapabilityClass.CryptoSign,
|
||||
|
||||
// Databases
|
||||
["sqlite3"] = CapabilityClass.DatabaseSql,
|
||||
["psycopg2"] = CapabilityClass.DatabaseSql,
|
||||
["psycopg"] = CapabilityClass.DatabaseSql,
|
||||
["asyncpg"] = CapabilityClass.DatabaseSql,
|
||||
["pymysql"] = CapabilityClass.DatabaseSql,
|
||||
["mysql.connector"] = CapabilityClass.DatabaseSql,
|
||||
["sqlalchemy"] = CapabilityClass.DatabaseSql,
|
||||
["pymongo"] = CapabilityClass.DatabaseNoSql,
|
||||
["motor"] = CapabilityClass.DatabaseNoSql,
|
||||
["redis"] = CapabilityClass.CacheAccess,
|
||||
["aioredis"] = CapabilityClass.CacheAccess,
|
||||
["elasticsearch"] = CapabilityClass.DatabaseNoSql,
|
||||
|
||||
// Message queues
|
||||
["pika"] = CapabilityClass.MessageQueue,
|
||||
["kombu"] = CapabilityClass.MessageQueue,
|
||||
["aiokafka"] = CapabilityClass.MessageQueue,
|
||||
["confluent_kafka"] = CapabilityClass.MessageQueue,
|
||||
|
||||
// Cloud SDKs
|
||||
["boto3"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
["botocore"] = CapabilityClass.CloudSdk,
|
||||
["google.cloud"] = CapabilityClass.CloudSdk,
|
||||
["azure"] = CapabilityClass.CloudSdk,
|
||||
|
||||
// Unsafe patterns
|
||||
["pickle"] = CapabilityClass.UnsafeDeserialization,
|
||||
["marshal"] = CapabilityClass.UnsafeDeserialization,
|
||||
["yaml"] = CapabilityClass.UnsafeDeserialization, // yaml.load without Loader
|
||||
["xml.etree"] = CapabilityClass.XmlExternalEntities,
|
||||
["lxml"] = CapabilityClass.XmlExternalEntities,
|
||||
["exec"] = CapabilityClass.DynamicCodeEval,
|
||||
["eval"] = CapabilityClass.DynamicCodeEval,
|
||||
["compile"] = CapabilityClass.DynamicCodeEval,
|
||||
|
||||
// Template engines
|
||||
["jinja2"] = CapabilityClass.TemplateRendering,
|
||||
["mako"] = CapabilityClass.TemplateRendering,
|
||||
["django.template"] = CapabilityClass.TemplateRendering,
|
||||
|
||||
// Logging/metrics
|
||||
["logging"] = CapabilityClass.LogEmit,
|
||||
["structlog"] = CapabilityClass.LogEmit,
|
||||
["prometheus_client"] = CapabilityClass.MetricsEmit,
|
||||
["opentelemetry"] = CapabilityClass.TracingEmit | CapabilityClass.MetricsEmit,
|
||||
|
||||
// Auth
|
||||
["passlib"] = CapabilityClass.Authentication,
|
||||
["python_jwt"] = CapabilityClass.Authentication | CapabilityClass.SessionManagement,
|
||||
["authlib"] = CapabilityClass.Authentication,
|
||||
|
||||
// Secrets
|
||||
["dotenv"] = CapabilityClass.SecretAccess | CapabilityClass.ConfigLoad,
|
||||
["hvac"] = CapabilityClass.SecretAccess,
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
public async Task<SemanticEntrypoint> AnalyzeAsync(
|
||||
SemanticAnalysisContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var builder = new SemanticEntrypointBuilder()
|
||||
.WithId(GenerateId(context))
|
||||
.WithSpecification(context.Specification)
|
||||
.WithLanguage("python");
|
||||
|
||||
var reasoningChain = new List<string>();
|
||||
var intent = ApplicationIntent.Unknown;
|
||||
var framework = (string?)null;
|
||||
|
||||
// Analyze dependencies to determine intent and capabilities
|
||||
if (context.Dependencies.TryGetValue("python", out var deps))
|
||||
{
|
||||
foreach (var dep in deps)
|
||||
{
|
||||
var normalizedDep = NormalizeDependency(dep);
|
||||
|
||||
// Check framework intent
|
||||
if (FrameworkIntentMap.TryGetValue(normalizedDep, out var mappedIntent))
|
||||
{
|
||||
if (intent == ApplicationIntent.Unknown || IsHigherPriority(mappedIntent, intent))
|
||||
{
|
||||
intent = mappedIntent;
|
||||
framework = dep;
|
||||
reasoningChain.Add($"Detected {dep} -> {intent}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check capability imports
|
||||
if (ImportCapabilityMap.TryGetValue(normalizedDep, out var capability))
|
||||
{
|
||||
builder.AddCapability(capability);
|
||||
reasoningChain.Add($"Import {dep} -> {capability}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze entrypoint command for additional signals
|
||||
var cmdSignals = AnalyzeCommand(context.Specification);
|
||||
if (cmdSignals.Intent != ApplicationIntent.Unknown && intent == ApplicationIntent.Unknown)
|
||||
{
|
||||
intent = cmdSignals.Intent;
|
||||
reasoningChain.Add($"Command pattern -> {intent}");
|
||||
}
|
||||
|
||||
foreach (var cap in GetCapabilityFlags(cmdSignals.Capabilities))
|
||||
{
|
||||
builder.AddCapability(cap);
|
||||
}
|
||||
|
||||
// Check exposed ports for web server inference
|
||||
if (context.Specification.ExposedPorts.Length > 0)
|
||||
{
|
||||
var webPorts = context.Specification.ExposedPorts.Where(IsWebPort).ToList();
|
||||
if (webPorts.Count > 0 && intent == ApplicationIntent.Unknown)
|
||||
{
|
||||
intent = ApplicationIntent.WebServer;
|
||||
reasoningChain.Add($"Exposed web ports: {string.Join(", ", webPorts)}");
|
||||
}
|
||||
builder.AddCapability(CapabilityClass.NetworkListen);
|
||||
}
|
||||
|
||||
// Build confidence based on evidence
|
||||
var confidence = DetermineConfidence(reasoningChain, intent, framework);
|
||||
|
||||
builder.WithIntent(intent)
|
||||
.WithConfidence(confidence);
|
||||
|
||||
if (framework is not null)
|
||||
{
|
||||
builder.WithFramework(framework);
|
||||
}
|
||||
|
||||
return await Task.FromResult(builder.Build());
|
||||
}
|
||||
|
||||
private static string NormalizeDependency(string dep)
|
||||
{
|
||||
return dep.ToLowerInvariant()
|
||||
.Replace("-", "_")
|
||||
.Split('[')[0]
|
||||
.Split('=')[0]
|
||||
.Split('>')[0]
|
||||
.Split('<')[0]
|
||||
.Trim();
|
||||
}
|
||||
|
||||
private static bool IsHigherPriority(ApplicationIntent newer, ApplicationIntent current)
|
||||
{
|
||||
// WebServer and Worker are higher priority than CLI/Batch
|
||||
var priorityOrder = new[]
|
||||
{
|
||||
ApplicationIntent.Unknown,
|
||||
ApplicationIntent.TestRunner,
|
||||
ApplicationIntent.CliTool,
|
||||
ApplicationIntent.BatchJob,
|
||||
ApplicationIntent.Worker,
|
||||
ApplicationIntent.Serverless,
|
||||
ApplicationIntent.WebServer,
|
||||
ApplicationIntent.RpcServer,
|
||||
ApplicationIntent.GraphQlServer,
|
||||
};
|
||||
|
||||
return Array.IndexOf(priorityOrder, newer) > Array.IndexOf(priorityOrder, current);
|
||||
}
|
||||
|
||||
private static (ApplicationIntent Intent, CapabilityClass Capabilities) AnalyzeCommand(EntrypointSpecification spec)
|
||||
{
|
||||
var cmd = string.Join(" ", spec.Entrypoint.Concat(spec.Cmd));
|
||||
var intent = ApplicationIntent.Unknown;
|
||||
var caps = CapabilityClass.None;
|
||||
|
||||
if (cmd.Contains("gunicorn") || cmd.Contains("uvicorn") || cmd.Contains("hypercorn") ||
|
||||
cmd.Contains("daphne") || cmd.Contains("waitress"))
|
||||
{
|
||||
intent = ApplicationIntent.WebServer;
|
||||
caps |= CapabilityClass.NetworkListen;
|
||||
}
|
||||
else if (cmd.Contains("celery") && cmd.Contains("worker"))
|
||||
{
|
||||
intent = ApplicationIntent.Worker;
|
||||
caps |= CapabilityClass.MessageQueue;
|
||||
}
|
||||
else if (cmd.Contains("celery") && cmd.Contains("beat"))
|
||||
{
|
||||
intent = ApplicationIntent.ScheduledTask;
|
||||
}
|
||||
else if (cmd.Contains("python") && cmd.Contains("-m"))
|
||||
{
|
||||
// Module execution - could be anything
|
||||
if (cmd.Contains("flask"))
|
||||
intent = ApplicationIntent.WebServer;
|
||||
else if (cmd.Contains("django"))
|
||||
intent = ApplicationIntent.WebServer;
|
||||
}
|
||||
else if (cmd.Contains("pytest") || cmd.Contains("-m pytest"))
|
||||
{
|
||||
intent = ApplicationIntent.TestRunner;
|
||||
}
|
||||
|
||||
return (intent, caps);
|
||||
}
|
||||
|
||||
private static bool IsWebPort(int port)
|
||||
{
|
||||
return port is 80 or 443 or 8000 or 8080 or 8443 or 3000 or 5000 or 5001 or 9000;
|
||||
}
|
||||
|
||||
private static SemanticConfidence DetermineConfidence(List<string> reasoning, ApplicationIntent intent, string? framework)
|
||||
{
|
||||
if (intent == ApplicationIntent.Unknown)
|
||||
return SemanticConfidence.Unknown();
|
||||
|
||||
if (framework is not null && reasoning.Count >= 3)
|
||||
return SemanticConfidence.High(reasoning.ToArray());
|
||||
|
||||
if (framework is not null)
|
||||
return SemanticConfidence.Medium(reasoning.ToArray());
|
||||
|
||||
return SemanticConfidence.Low(reasoning.ToArray());
|
||||
}
|
||||
|
||||
private static IEnumerable<CapabilityClass> GetCapabilityFlags(CapabilityClass caps)
|
||||
{
|
||||
foreach (CapabilityClass flag in Enum.GetValues<CapabilityClass>())
|
||||
{
|
||||
if (flag != CapabilityClass.None && caps.HasFlag(flag))
|
||||
yield return flag;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateId(SemanticAnalysisContext context)
|
||||
{
|
||||
var hash = context.ImageDigest ?? Guid.NewGuid().ToString("N");
|
||||
return $"sem-py-{hash[..12]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic.Analysis;
|
||||
|
||||
/// <summary>
|
||||
/// Detects capabilities from imports, dependencies, and code patterns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 13).
|
||||
/// Analyzes dependencies to infer what capabilities an application has.
|
||||
/// </remarks>
|
||||
public sealed class CapabilityDetector
|
||||
{
|
||||
private readonly FrozenDictionary<string, CapabilityClass> _pythonCapabilities;
|
||||
private readonly FrozenDictionary<string, CapabilityClass> _nodeCapabilities;
|
||||
private readonly FrozenDictionary<string, CapabilityClass> _javaCapabilities;
|
||||
private readonly FrozenDictionary<string, CapabilityClass> _goCapabilities;
|
||||
private readonly FrozenDictionary<string, CapabilityClass> _dotnetCapabilities;
|
||||
|
||||
public CapabilityDetector()
|
||||
{
|
||||
_pythonCapabilities = BuildPythonCapabilities();
|
||||
_nodeCapabilities = BuildNodeCapabilities();
|
||||
_javaCapabilities = BuildJavaCapabilities();
|
||||
_goCapabilities = BuildGoCapabilities();
|
||||
_dotnetCapabilities = BuildDotNetCapabilities();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects capabilities from the analysis context.
|
||||
/// </summary>
|
||||
public CapabilityDetectionResult Detect(SemanticAnalysisContext context)
|
||||
{
|
||||
var capabilities = CapabilityClass.None;
|
||||
var evidence = new List<CapabilityEvidence>();
|
||||
|
||||
// Analyze by language
|
||||
foreach (var (lang, deps) in context.Dependencies)
|
||||
{
|
||||
var langCaps = DetectForLanguage(lang, deps);
|
||||
foreach (var (cap, ev) in langCaps)
|
||||
{
|
||||
capabilities |= cap;
|
||||
evidence.Add(ev);
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze exposed ports
|
||||
var portCaps = DetectFromPorts(context.Specification.ExposedPorts);
|
||||
capabilities |= portCaps.Capabilities;
|
||||
evidence.AddRange(portCaps.Evidence);
|
||||
|
||||
// Analyze environment variables
|
||||
var envCaps = DetectFromEnvironment(context.Specification.Environment);
|
||||
capabilities |= envCaps.Capabilities;
|
||||
evidence.AddRange(envCaps.Evidence);
|
||||
|
||||
// Analyze volumes
|
||||
var volCaps = DetectFromVolumes(context.Specification.Volumes);
|
||||
capabilities |= volCaps.Capabilities;
|
||||
evidence.AddRange(volCaps.Evidence);
|
||||
|
||||
// Analyze command
|
||||
var cmdCaps = DetectFromCommand(context.Specification);
|
||||
capabilities |= cmdCaps.Capabilities;
|
||||
evidence.AddRange(cmdCaps.Evidence);
|
||||
|
||||
return new CapabilityDetectionResult
|
||||
{
|
||||
Capabilities = capabilities,
|
||||
Evidence = evidence.ToImmutableArray(),
|
||||
Confidence = CalculateConfidence(evidence)
|
||||
};
|
||||
}
|
||||
|
||||
private IEnumerable<(CapabilityClass Capability, CapabilityEvidence Evidence)> DetectForLanguage(
|
||||
string language, IReadOnlyList<string> dependencies)
|
||||
{
|
||||
var capMap = language.ToLowerInvariant() switch
|
||||
{
|
||||
"python" => _pythonCapabilities,
|
||||
"node" or "javascript" or "typescript" => _nodeCapabilities,
|
||||
"java" or "kotlin" or "scala" => _javaCapabilities,
|
||||
"go" or "golang" => _goCapabilities,
|
||||
"dotnet" or "csharp" or "fsharp" => _dotnetCapabilities,
|
||||
_ => FrozenDictionary<string, CapabilityClass>.Empty
|
||||
};
|
||||
|
||||
foreach (var dep in dependencies)
|
||||
{
|
||||
var normalized = NormalizeDependency(dep, language);
|
||||
if (capMap.TryGetValue(normalized, out var capability))
|
||||
{
|
||||
yield return (capability, new CapabilityEvidence
|
||||
{
|
||||
Source = EvidenceSource.Dependency,
|
||||
Language = language,
|
||||
Artifact = dep,
|
||||
Capability = capability,
|
||||
Confidence = 0.9
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static (CapabilityClass Capabilities, ImmutableArray<CapabilityEvidence> Evidence) DetectFromPorts(
|
||||
ImmutableArray<int> ports)
|
||||
{
|
||||
var caps = CapabilityClass.None;
|
||||
var evidence = new List<CapabilityEvidence>();
|
||||
|
||||
if (ports.Length > 0)
|
||||
{
|
||||
caps |= CapabilityClass.NetworkListen;
|
||||
evidence.Add(new CapabilityEvidence
|
||||
{
|
||||
Source = EvidenceSource.ExposedPort,
|
||||
Artifact = string.Join(", ", ports),
|
||||
Capability = CapabilityClass.NetworkListen,
|
||||
Confidence = 1.0
|
||||
});
|
||||
|
||||
// Check for specific service ports
|
||||
foreach (var port in ports)
|
||||
{
|
||||
var portCap = port switch
|
||||
{
|
||||
5432 => CapabilityClass.DatabaseSql, // PostgreSQL
|
||||
3306 => CapabilityClass.DatabaseSql, // MySQL
|
||||
27017 => CapabilityClass.DatabaseNoSql, // MongoDB
|
||||
6379 => CapabilityClass.CacheAccess, // Redis
|
||||
5672 or 15672 => CapabilityClass.MessageQueue, // RabbitMQ
|
||||
9092 => CapabilityClass.MessageQueue, // Kafka
|
||||
_ => CapabilityClass.None
|
||||
};
|
||||
|
||||
if (portCap != CapabilityClass.None)
|
||||
{
|
||||
caps |= portCap;
|
||||
evidence.Add(new CapabilityEvidence
|
||||
{
|
||||
Source = EvidenceSource.ExposedPort,
|
||||
Artifact = $"Port {port}",
|
||||
Capability = portCap,
|
||||
Confidence = 0.8
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (caps, evidence.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static (CapabilityClass Capabilities, ImmutableArray<CapabilityEvidence> Evidence) DetectFromEnvironment(
|
||||
ImmutableDictionary<string, string>? env)
|
||||
{
|
||||
if (env is null)
|
||||
return (CapabilityClass.None, ImmutableArray<CapabilityEvidence>.Empty);
|
||||
|
||||
var caps = CapabilityClass.None;
|
||||
var evidence = new List<CapabilityEvidence>();
|
||||
|
||||
var sensitivePatterns = new Dictionary<string, CapabilityClass>
|
||||
{
|
||||
["DATABASE_URL"] = CapabilityClass.DatabaseSql,
|
||||
["POSTGRES_"] = CapabilityClass.DatabaseSql,
|
||||
["MYSQL_"] = CapabilityClass.DatabaseSql,
|
||||
["MONGODB_"] = CapabilityClass.DatabaseNoSql,
|
||||
["REDIS_"] = CapabilityClass.CacheAccess,
|
||||
["RABBITMQ_"] = CapabilityClass.MessageQueue,
|
||||
["KAFKA_"] = CapabilityClass.MessageQueue,
|
||||
["AWS_"] = CapabilityClass.CloudSdk,
|
||||
["AZURE_"] = CapabilityClass.CloudSdk,
|
||||
["GCP_"] = CapabilityClass.CloudSdk,
|
||||
["GOOGLE_"] = CapabilityClass.CloudSdk,
|
||||
["API_KEY"] = CapabilityClass.SecretAccess,
|
||||
["SECRET"] = CapabilityClass.SecretAccess,
|
||||
["PASSWORD"] = CapabilityClass.SecretAccess,
|
||||
["TOKEN"] = CapabilityClass.SecretAccess,
|
||||
["SMTP_"] = CapabilityClass.EmailSend,
|
||||
["MAIL_"] = CapabilityClass.EmailSend,
|
||||
};
|
||||
|
||||
foreach (var key in env.Keys)
|
||||
{
|
||||
foreach (var (pattern, cap) in sensitivePatterns)
|
||||
{
|
||||
if (key.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
caps |= cap;
|
||||
evidence.Add(new CapabilityEvidence
|
||||
{
|
||||
Source = EvidenceSource.EnvironmentVariable,
|
||||
Artifact = key,
|
||||
Capability = cap,
|
||||
Confidence = 0.7
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (caps, evidence.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static (CapabilityClass Capabilities, ImmutableArray<CapabilityEvidence> Evidence) DetectFromVolumes(
|
||||
ImmutableArray<string> volumes)
|
||||
{
|
||||
var caps = CapabilityClass.None;
|
||||
var evidence = new List<CapabilityEvidence>();
|
||||
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
caps |= CapabilityClass.FileRead | CapabilityClass.FileWrite;
|
||||
evidence.Add(new CapabilityEvidence
|
||||
{
|
||||
Source = EvidenceSource.Volume,
|
||||
Artifact = volume,
|
||||
Capability = CapabilityClass.FileRead | CapabilityClass.FileWrite,
|
||||
Confidence = 1.0
|
||||
});
|
||||
|
||||
// Check for sensitive paths
|
||||
if (volume.Contains("/var/run/docker.sock"))
|
||||
{
|
||||
caps |= CapabilityClass.ContainerEscape;
|
||||
evidence.Add(new CapabilityEvidence
|
||||
{
|
||||
Source = EvidenceSource.Volume,
|
||||
Artifact = volume,
|
||||
Capability = CapabilityClass.ContainerEscape,
|
||||
Confidence = 1.0
|
||||
});
|
||||
}
|
||||
else if (volume.Contains("/etc") || volume.Contains("/proc") || volume.Contains("/sys"))
|
||||
{
|
||||
caps |= CapabilityClass.SystemPrivileged;
|
||||
evidence.Add(new CapabilityEvidence
|
||||
{
|
||||
Source = EvidenceSource.Volume,
|
||||
Artifact = volume,
|
||||
Capability = CapabilityClass.SystemPrivileged,
|
||||
Confidence = 0.9
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (caps, evidence.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static (CapabilityClass Capabilities, ImmutableArray<CapabilityEvidence> Evidence) DetectFromCommand(
|
||||
EntrypointSpecification spec)
|
||||
{
|
||||
var caps = CapabilityClass.None;
|
||||
var evidence = new List<CapabilityEvidence>();
|
||||
|
||||
var cmd = string.Join(" ", spec.Entrypoint.Concat(spec.Cmd));
|
||||
|
||||
if (cmd.Contains("sh ") || cmd.Contains("bash ") || cmd.Contains("/bin/sh") || cmd.Contains("/bin/bash"))
|
||||
{
|
||||
caps |= CapabilityClass.ShellExecution;
|
||||
evidence.Add(new CapabilityEvidence
|
||||
{
|
||||
Source = EvidenceSource.Command,
|
||||
Artifact = cmd,
|
||||
Capability = CapabilityClass.ShellExecution,
|
||||
Confidence = 0.9
|
||||
});
|
||||
}
|
||||
|
||||
if (cmd.Contains("sudo") || cmd.Contains("su -"))
|
||||
{
|
||||
caps |= CapabilityClass.SystemPrivileged;
|
||||
evidence.Add(new CapabilityEvidence
|
||||
{
|
||||
Source = EvidenceSource.Command,
|
||||
Artifact = cmd,
|
||||
Capability = CapabilityClass.SystemPrivileged,
|
||||
Confidence = 0.95
|
||||
});
|
||||
}
|
||||
|
||||
return (caps, evidence.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static string NormalizeDependency(string dep, string language)
|
||||
{
|
||||
return language.ToLowerInvariant() switch
|
||||
{
|
||||
"python" => dep.ToLowerInvariant().Replace("-", "_").Split('[')[0].Split('=')[0].Trim(),
|
||||
"node" or "javascript" or "typescript" => dep.ToLowerInvariant().Split('@')[0].Trim(),
|
||||
"java" => dep.Split(':').Length >= 2 ? dep.Split(':')[1].ToLowerInvariant() : dep.ToLowerInvariant(),
|
||||
"go" => dep.Split('@')[0].Trim(),
|
||||
"dotnet" => dep.Split('/')[0].Trim(),
|
||||
_ => dep.ToLowerInvariant().Trim()
|
||||
};
|
||||
}
|
||||
|
||||
private static SemanticConfidence CalculateConfidence(List<CapabilityEvidence> evidence)
|
||||
{
|
||||
if (evidence.Count == 0)
|
||||
return SemanticConfidence.Unknown();
|
||||
|
||||
var avgConfidence = evidence.Average(e => e.Confidence);
|
||||
var reasons = evidence.Select(e => $"{e.Source}: {e.Artifact} -> {e.Capability}").ToArray();
|
||||
|
||||
return SemanticConfidence.FromScore(avgConfidence, reasons.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static FrozenDictionary<string, CapabilityClass> BuildPythonCapabilities() =>
|
||||
new Dictionary<string, CapabilityClass>
|
||||
{
|
||||
["socket"] = CapabilityClass.NetworkConnect | CapabilityClass.NetworkListen,
|
||||
["requests"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["httpx"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["subprocess"] = CapabilityClass.ProcessSpawn | CapabilityClass.ShellExecution,
|
||||
["psycopg2"] = CapabilityClass.DatabaseSql,
|
||||
["sqlalchemy"] = CapabilityClass.DatabaseSql,
|
||||
["pymongo"] = CapabilityClass.DatabaseNoSql,
|
||||
["redis"] = CapabilityClass.CacheAccess,
|
||||
["celery"] = CapabilityClass.MessageQueue,
|
||||
["boto3"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
["cryptography"] = CapabilityClass.CryptoEncrypt | CapabilityClass.CryptoSign,
|
||||
["pickle"] = CapabilityClass.UnsafeDeserialization,
|
||||
["jinja2"] = CapabilityClass.TemplateRendering,
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
private static FrozenDictionary<string, CapabilityClass> BuildNodeCapabilities() =>
|
||||
new Dictionary<string, CapabilityClass>
|
||||
{
|
||||
["axios"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["express"] = CapabilityClass.NetworkListen | CapabilityClass.UserInput,
|
||||
["child_process"] = CapabilityClass.ProcessSpawn | CapabilityClass.ShellExecution,
|
||||
["pg"] = CapabilityClass.DatabaseSql,
|
||||
["mongoose"] = CapabilityClass.DatabaseNoSql,
|
||||
["redis"] = CapabilityClass.CacheAccess,
|
||||
["amqplib"] = CapabilityClass.MessageQueue,
|
||||
["aws_sdk"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
["crypto"] = CapabilityClass.CryptoEncrypt,
|
||||
["vm"] = CapabilityClass.DynamicCodeEval,
|
||||
["ejs"] = CapabilityClass.TemplateRendering,
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
private static FrozenDictionary<string, CapabilityClass> BuildJavaCapabilities() =>
|
||||
new Dictionary<string, CapabilityClass>
|
||||
{
|
||||
["okhttp"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["spring_boot_starter_web"] = CapabilityClass.NetworkListen | CapabilityClass.UserInput,
|
||||
["jdbc"] = CapabilityClass.DatabaseSql,
|
||||
["hibernate"] = CapabilityClass.DatabaseSql,
|
||||
["mongo_java_driver"] = CapabilityClass.DatabaseNoSql,
|
||||
["jedis"] = CapabilityClass.CacheAccess,
|
||||
["kafka_clients"] = CapabilityClass.MessageQueue,
|
||||
["aws_sdk_java"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
["bouncycastle"] = CapabilityClass.CryptoEncrypt | CapabilityClass.CryptoSign,
|
||||
["jackson"] = CapabilityClass.UnsafeDeserialization,
|
||||
["thymeleaf"] = CapabilityClass.TemplateRendering,
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
private static FrozenDictionary<string, CapabilityClass> BuildGoCapabilities() =>
|
||||
new Dictionary<string, CapabilityClass>
|
||||
{
|
||||
["net/http"] = CapabilityClass.NetworkConnect | CapabilityClass.NetworkListen,
|
||||
["os/exec"] = CapabilityClass.ProcessSpawn | CapabilityClass.ShellExecution,
|
||||
["database/sql"] = CapabilityClass.DatabaseSql,
|
||||
["go.mongodb.org/mongo_driver"] = CapabilityClass.DatabaseNoSql,
|
||||
["github.com/go_redis/redis"] = CapabilityClass.CacheAccess,
|
||||
["github.com/shopify/sarama"] = CapabilityClass.MessageQueue,
|
||||
["github.com/aws/aws_sdk_go"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
["crypto"] = CapabilityClass.CryptoEncrypt,
|
||||
["encoding/gob"] = CapabilityClass.UnsafeDeserialization,
|
||||
["html/template"] = CapabilityClass.TemplateRendering,
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
private static FrozenDictionary<string, CapabilityClass> BuildDotNetCapabilities() =>
|
||||
new Dictionary<string, CapabilityClass>
|
||||
{
|
||||
["system.net.http"] = CapabilityClass.NetworkConnect | CapabilityClass.ExternalHttpApi,
|
||||
["microsoft.aspnetcore"] = CapabilityClass.NetworkListen | CapabilityClass.UserInput,
|
||||
["system.diagnostics.process"] = CapabilityClass.ProcessSpawn,
|
||||
["microsoft.entityframeworkcore"] = CapabilityClass.DatabaseSql,
|
||||
["mongodb.driver"] = CapabilityClass.DatabaseNoSql,
|
||||
["stackexchange.redis"] = CapabilityClass.CacheAccess,
|
||||
["rabbitmq.client"] = CapabilityClass.MessageQueue,
|
||||
["awssdk.core"] = CapabilityClass.CloudSdk | CapabilityClass.ObjectStorage,
|
||||
["system.security.cryptography"] = CapabilityClass.CryptoEncrypt | CapabilityClass.CryptoSign,
|
||||
["newtonsoft.json"] = CapabilityClass.UnsafeDeserialization,
|
||||
["razorlight"] = CapabilityClass.TemplateRendering,
|
||||
}.ToFrozenDictionary();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of capability detection.
|
||||
/// </summary>
|
||||
public sealed record CapabilityDetectionResult
|
||||
{
|
||||
public required CapabilityClass Capabilities { get; init; }
|
||||
public required ImmutableArray<CapabilityEvidence> Evidence { get; init; }
|
||||
public required SemanticConfidence Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence for a detected capability.
|
||||
/// </summary>
|
||||
public sealed record CapabilityEvidence
|
||||
{
|
||||
public required EvidenceSource Source { get; init; }
|
||||
public string? Language { get; init; }
|
||||
public required string Artifact { get; init; }
|
||||
public required CapabilityClass Capability { get; init; }
|
||||
public required double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source of capability evidence.
|
||||
/// </summary>
|
||||
public enum EvidenceSource
|
||||
{
|
||||
Dependency,
|
||||
Import,
|
||||
ExposedPort,
|
||||
EnvironmentVariable,
|
||||
Volume,
|
||||
Command,
|
||||
Label,
|
||||
CodePattern,
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic.Analysis;
|
||||
|
||||
/// <summary>
|
||||
/// Maps data flow boundaries from entrypoint through framework handlers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 15).
|
||||
/// Traces data flow edges from entrypoint to I/O boundaries.
|
||||
/// </remarks>
|
||||
public sealed class DataBoundaryMapper
|
||||
{
|
||||
private readonly FrozenDictionary<ApplicationIntent, List<DataFlowBoundaryType>> _intentBoundaries;
|
||||
private readonly FrozenDictionary<CapabilityClass, List<DataFlowBoundaryType>> _capabilityBoundaries;
|
||||
|
||||
public DataBoundaryMapper()
|
||||
{
|
||||
_intentBoundaries = BuildIntentBoundaries();
|
||||
_capabilityBoundaries = BuildCapabilityBoundaries();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps data flow boundaries for the given context.
|
||||
/// </summary>
|
||||
public DataBoundaryMappingResult Map(
|
||||
SemanticAnalysisContext context,
|
||||
ApplicationIntent intent,
|
||||
CapabilityClass capabilities,
|
||||
IReadOnlyList<CapabilityEvidence> evidence)
|
||||
{
|
||||
var boundaries = new List<DataFlowBoundary>();
|
||||
|
||||
// Add boundaries based on intent
|
||||
if (_intentBoundaries.TryGetValue(intent, out var intentBoundaryTypes))
|
||||
{
|
||||
foreach (var boundaryType in intentBoundaryTypes)
|
||||
{
|
||||
boundaries.Add(CreateBoundary(boundaryType, $"Intent: {intent}", 0.8));
|
||||
}
|
||||
}
|
||||
|
||||
// Add boundaries based on capabilities
|
||||
foreach (var cap in GetCapabilityFlags(capabilities))
|
||||
{
|
||||
if (_capabilityBoundaries.TryGetValue(cap, out var capBoundaryTypes))
|
||||
{
|
||||
foreach (var boundaryType in capBoundaryTypes)
|
||||
{
|
||||
if (!boundaries.Any(b => b.Type == boundaryType))
|
||||
{
|
||||
boundaries.Add(CreateBoundary(boundaryType, $"Capability: {cap}", 0.7));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add boundaries based on exposed ports
|
||||
foreach (var port in context.Specification.ExposedPorts)
|
||||
{
|
||||
var portBoundaries = InferFromPort(port);
|
||||
foreach (var boundary in portBoundaries)
|
||||
{
|
||||
if (!boundaries.Any(b => b.Type == boundary.Type))
|
||||
{
|
||||
boundaries.Add(boundary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add boundaries based on environment variables
|
||||
if (context.Specification.Environment is not null)
|
||||
{
|
||||
var envBoundaries = InferFromEnvironment(context.Specification.Environment);
|
||||
foreach (var boundary in envBoundaries)
|
||||
{
|
||||
if (!boundaries.Any(b => b.Type == boundary.Type))
|
||||
{
|
||||
boundaries.Add(boundary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add boundaries based on evidence
|
||||
foreach (var ev in evidence)
|
||||
{
|
||||
var evBoundaries = InferFromEvidence(ev);
|
||||
foreach (var boundary in evBoundaries)
|
||||
{
|
||||
if (!boundaries.Any(b => b.Type == boundary.Type))
|
||||
{
|
||||
boundaries.Add(boundary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Infer sensitivity for each boundary
|
||||
boundaries = boundaries.Select(b => InferSensitivity(b, capabilities)).ToList();
|
||||
|
||||
// Sort by security relevance
|
||||
boundaries = boundaries.OrderByDescending(b => b.Type.IsSecuritySensitive())
|
||||
.ThenByDescending(b => b.Confidence)
|
||||
.ToList();
|
||||
|
||||
return new DataBoundaryMappingResult
|
||||
{
|
||||
Boundaries = boundaries.ToImmutableArray(),
|
||||
InboundCount = boundaries.Count(b => b.Direction == DataFlowDirection.Inbound),
|
||||
OutboundCount = boundaries.Count(b => b.Direction == DataFlowDirection.Outbound),
|
||||
SecuritySensitiveCount = boundaries.Count(b => b.Type.IsSecuritySensitive()),
|
||||
Confidence = CalculateConfidence(boundaries)
|
||||
};
|
||||
}
|
||||
|
||||
private static DataFlowBoundary CreateBoundary(
|
||||
DataFlowBoundaryType type,
|
||||
string evidenceReason,
|
||||
double confidence)
|
||||
{
|
||||
return new DataFlowBoundary
|
||||
{
|
||||
Type = type,
|
||||
Direction = type.GetDefaultDirection(),
|
||||
Sensitivity = DataSensitivity.Unknown,
|
||||
Confidence = confidence,
|
||||
Evidence = ImmutableArray.Create(evidenceReason)
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<DataFlowBoundary> InferFromPort(int port)
|
||||
{
|
||||
var boundaries = new List<DataFlowBoundary>();
|
||||
|
||||
DataFlowBoundaryType? boundaryType = port switch
|
||||
{
|
||||
80 or 443 or 8080 or 8443 or 3000 or 5000 or 9000 => DataFlowBoundaryType.HttpRequest,
|
||||
5432 or 3306 or 1433 or 1521 => DataFlowBoundaryType.DatabaseQuery,
|
||||
6379 => DataFlowBoundaryType.CacheRead,
|
||||
5672 or 9092 => DataFlowBoundaryType.MessageReceive,
|
||||
25 or 587 or 465 => null, // SMTP - no direct boundary type
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (boundaryType.HasValue)
|
||||
{
|
||||
boundaries.Add(new DataFlowBoundary
|
||||
{
|
||||
Type = boundaryType.Value,
|
||||
Direction = boundaryType.Value.GetDefaultDirection(),
|
||||
Sensitivity = DataSensitivity.Unknown,
|
||||
Confidence = 0.85,
|
||||
Evidence = ImmutableArray.Create($"Exposed port: {port}")
|
||||
});
|
||||
|
||||
// Add corresponding response boundary for request types
|
||||
if (boundaryType.Value == DataFlowBoundaryType.HttpRequest)
|
||||
{
|
||||
boundaries.Add(new DataFlowBoundary
|
||||
{
|
||||
Type = DataFlowBoundaryType.HttpResponse,
|
||||
Direction = DataFlowDirection.Outbound,
|
||||
Sensitivity = DataSensitivity.Unknown,
|
||||
Confidence = 0.85,
|
||||
Evidence = ImmutableArray.Create($"HTTP port: {port}")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return boundaries;
|
||||
}
|
||||
|
||||
private static IEnumerable<DataFlowBoundary> InferFromEnvironment(
|
||||
ImmutableDictionary<string, string> env)
|
||||
{
|
||||
var boundaries = new List<DataFlowBoundary>();
|
||||
|
||||
// Always add environment variable boundary if env vars are present
|
||||
boundaries.Add(new DataFlowBoundary
|
||||
{
|
||||
Type = DataFlowBoundaryType.EnvironmentVar,
|
||||
Direction = DataFlowDirection.Inbound,
|
||||
Sensitivity = env.Keys.Any(k =>
|
||||
k.Contains("SECRET", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("PASSWORD", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("KEY", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("TOKEN", StringComparison.OrdinalIgnoreCase))
|
||||
? DataSensitivity.Restricted
|
||||
: DataSensitivity.Internal,
|
||||
Confidence = 1.0,
|
||||
Evidence = ImmutableArray.Create($"Environment variables: {env.Count}")
|
||||
});
|
||||
|
||||
// Check for specific service connections
|
||||
if (env.Keys.Any(k => k.Contains("DATABASE") || k.Contains("DB_")))
|
||||
{
|
||||
boundaries.Add(new DataFlowBoundary
|
||||
{
|
||||
Type = DataFlowBoundaryType.DatabaseQuery,
|
||||
Direction = DataFlowDirection.Outbound,
|
||||
Sensitivity = DataSensitivity.Confidential,
|
||||
Confidence = 0.8,
|
||||
Evidence = ImmutableArray.Create("Database connection in environment")
|
||||
});
|
||||
}
|
||||
|
||||
if (env.Keys.Any(k => k.Contains("REDIS") || k.Contains("CACHE")))
|
||||
{
|
||||
boundaries.Add(new DataFlowBoundary
|
||||
{
|
||||
Type = DataFlowBoundaryType.CacheRead,
|
||||
Direction = DataFlowDirection.Inbound,
|
||||
Sensitivity = DataSensitivity.Internal,
|
||||
Confidence = 0.8,
|
||||
Evidence = ImmutableArray.Create("Cache connection in environment")
|
||||
});
|
||||
}
|
||||
|
||||
return boundaries;
|
||||
}
|
||||
|
||||
private static IEnumerable<DataFlowBoundary> InferFromEvidence(CapabilityEvidence evidence)
|
||||
{
|
||||
var boundaries = new List<DataFlowBoundary>();
|
||||
|
||||
// Map capability evidence to boundaries
|
||||
var boundaryType = evidence.Capability switch
|
||||
{
|
||||
CapabilityClass.DatabaseSql => DataFlowBoundaryType.DatabaseQuery,
|
||||
CapabilityClass.DatabaseNoSql => DataFlowBoundaryType.DatabaseQuery,
|
||||
CapabilityClass.CacheAccess => DataFlowBoundaryType.CacheRead,
|
||||
CapabilityClass.MessageQueue => DataFlowBoundaryType.MessageReceive,
|
||||
CapabilityClass.FileRead => DataFlowBoundaryType.FileInput,
|
||||
CapabilityClass.FileWrite => DataFlowBoundaryType.FileOutput,
|
||||
CapabilityClass.FileUpload => DataFlowBoundaryType.FileInput,
|
||||
CapabilityClass.ExternalHttpApi => DataFlowBoundaryType.ExternalApiCall,
|
||||
CapabilityClass.NetworkListen => DataFlowBoundaryType.SocketRead,
|
||||
CapabilityClass.NetworkConnect => DataFlowBoundaryType.SocketWrite,
|
||||
CapabilityClass.ProcessSpawn => DataFlowBoundaryType.ProcessSpawn,
|
||||
CapabilityClass.ConfigLoad => DataFlowBoundaryType.ConfigRead,
|
||||
CapabilityClass.EnvironmentRead => DataFlowBoundaryType.EnvironmentVar,
|
||||
_ => (DataFlowBoundaryType?)null
|
||||
};
|
||||
|
||||
if (boundaryType.HasValue)
|
||||
{
|
||||
boundaries.Add(new DataFlowBoundary
|
||||
{
|
||||
Type = boundaryType.Value,
|
||||
Direction = boundaryType.Value.GetDefaultDirection(),
|
||||
Sensitivity = DataSensitivity.Unknown,
|
||||
Confidence = evidence.Confidence * 0.9,
|
||||
Evidence = ImmutableArray.Create($"{evidence.Source}: {evidence.Artifact}")
|
||||
});
|
||||
}
|
||||
|
||||
return boundaries;
|
||||
}
|
||||
|
||||
private static DataFlowBoundary InferSensitivity(DataFlowBoundary boundary, CapabilityClass capabilities)
|
||||
{
|
||||
var sensitivity = boundary.Type switch
|
||||
{
|
||||
// Database operations are typically confidential
|
||||
DataFlowBoundaryType.DatabaseQuery or DataFlowBoundaryType.DatabaseResult =>
|
||||
capabilities.HasFlag(CapabilityClass.SecretAccess) ? DataSensitivity.Restricted : DataSensitivity.Confidential,
|
||||
|
||||
// Configuration and environment are internal/restricted
|
||||
DataFlowBoundaryType.ConfigRead or DataFlowBoundaryType.EnvironmentVar =>
|
||||
capabilities.HasFlag(CapabilityClass.SecretAccess) ? DataSensitivity.Restricted : DataSensitivity.Internal,
|
||||
|
||||
// HTTP requests can carry sensitive data
|
||||
DataFlowBoundaryType.HttpRequest =>
|
||||
capabilities.HasFlag(CapabilityClass.Authentication) ? DataSensitivity.Confidential : DataSensitivity.Internal,
|
||||
|
||||
// Process spawning is sensitive
|
||||
DataFlowBoundaryType.ProcessSpawn => DataSensitivity.Confidential,
|
||||
|
||||
// External API calls may expose internal data
|
||||
DataFlowBoundaryType.ExternalApiCall or DataFlowBoundaryType.ExternalApiResponse =>
|
||||
DataSensitivity.Internal,
|
||||
|
||||
// Cache and message queue are typically internal
|
||||
DataFlowBoundaryType.CacheRead or DataFlowBoundaryType.CacheWrite or
|
||||
DataFlowBoundaryType.MessageReceive or DataFlowBoundaryType.MessageSend =>
|
||||
DataSensitivity.Internal,
|
||||
|
||||
// Standard I/O is typically public
|
||||
DataFlowBoundaryType.StandardInput or DataFlowBoundaryType.StandardOutput or DataFlowBoundaryType.StandardError =>
|
||||
DataSensitivity.Public,
|
||||
|
||||
// Default to unknown
|
||||
_ => boundary.Sensitivity
|
||||
};
|
||||
|
||||
return boundary with { Sensitivity = sensitivity };
|
||||
}
|
||||
|
||||
private static IEnumerable<CapabilityClass> GetCapabilityFlags(CapabilityClass caps)
|
||||
{
|
||||
foreach (CapabilityClass flag in Enum.GetValues<CapabilityClass>())
|
||||
{
|
||||
if (flag != CapabilityClass.None && caps.HasFlag(flag))
|
||||
yield return flag;
|
||||
}
|
||||
}
|
||||
|
||||
private static SemanticConfidence CalculateConfidence(List<DataFlowBoundary> boundaries)
|
||||
{
|
||||
if (boundaries.Count == 0)
|
||||
return SemanticConfidence.Unknown();
|
||||
|
||||
var avgConfidence = boundaries.Average(b => b.Confidence);
|
||||
var reasons = boundaries.Select(b => $"{b.Type} ({b.Direction})").ToArray();
|
||||
|
||||
return SemanticConfidence.FromScore(avgConfidence, reasons.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static FrozenDictionary<ApplicationIntent, List<DataFlowBoundaryType>> BuildIntentBoundaries() =>
|
||||
new Dictionary<ApplicationIntent, List<DataFlowBoundaryType>>
|
||||
{
|
||||
[ApplicationIntent.WebServer] =
|
||||
[
|
||||
DataFlowBoundaryType.HttpRequest,
|
||||
DataFlowBoundaryType.HttpResponse
|
||||
],
|
||||
[ApplicationIntent.CliTool] =
|
||||
[
|
||||
DataFlowBoundaryType.CommandLineArg,
|
||||
DataFlowBoundaryType.StandardInput,
|
||||
DataFlowBoundaryType.StandardOutput,
|
||||
DataFlowBoundaryType.StandardError
|
||||
],
|
||||
[ApplicationIntent.Worker] =
|
||||
[
|
||||
DataFlowBoundaryType.MessageReceive,
|
||||
DataFlowBoundaryType.MessageSend
|
||||
],
|
||||
[ApplicationIntent.BatchJob] =
|
||||
[
|
||||
DataFlowBoundaryType.FileInput,
|
||||
DataFlowBoundaryType.FileOutput,
|
||||
DataFlowBoundaryType.DatabaseQuery,
|
||||
DataFlowBoundaryType.DatabaseResult
|
||||
],
|
||||
[ApplicationIntent.Serverless] =
|
||||
[
|
||||
DataFlowBoundaryType.HttpRequest,
|
||||
DataFlowBoundaryType.HttpResponse,
|
||||
DataFlowBoundaryType.EnvironmentVar
|
||||
],
|
||||
[ApplicationIntent.DatabaseServer] =
|
||||
[
|
||||
DataFlowBoundaryType.SocketRead,
|
||||
DataFlowBoundaryType.SocketWrite,
|
||||
DataFlowBoundaryType.FileInput,
|
||||
DataFlowBoundaryType.FileOutput
|
||||
],
|
||||
[ApplicationIntent.MessageBroker] =
|
||||
[
|
||||
DataFlowBoundaryType.SocketRead,
|
||||
DataFlowBoundaryType.SocketWrite,
|
||||
DataFlowBoundaryType.MessageReceive,
|
||||
DataFlowBoundaryType.MessageSend
|
||||
],
|
||||
[ApplicationIntent.CacheServer] =
|
||||
[
|
||||
DataFlowBoundaryType.SocketRead,
|
||||
DataFlowBoundaryType.SocketWrite,
|
||||
DataFlowBoundaryType.CacheRead,
|
||||
DataFlowBoundaryType.CacheWrite
|
||||
],
|
||||
[ApplicationIntent.RpcServer] =
|
||||
[
|
||||
DataFlowBoundaryType.SocketRead,
|
||||
DataFlowBoundaryType.SocketWrite
|
||||
],
|
||||
[ApplicationIntent.GraphQlServer] =
|
||||
[
|
||||
DataFlowBoundaryType.HttpRequest,
|
||||
DataFlowBoundaryType.HttpResponse,
|
||||
DataFlowBoundaryType.DatabaseQuery
|
||||
],
|
||||
[ApplicationIntent.StreamProcessor] =
|
||||
[
|
||||
DataFlowBoundaryType.MessageReceive,
|
||||
DataFlowBoundaryType.MessageSend,
|
||||
DataFlowBoundaryType.DatabaseQuery
|
||||
],
|
||||
[ApplicationIntent.ProxyGateway] =
|
||||
[
|
||||
DataFlowBoundaryType.HttpRequest,
|
||||
DataFlowBoundaryType.HttpResponse,
|
||||
DataFlowBoundaryType.ExternalApiCall,
|
||||
DataFlowBoundaryType.ExternalApiResponse
|
||||
],
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
private static FrozenDictionary<CapabilityClass, List<DataFlowBoundaryType>> BuildCapabilityBoundaries() =>
|
||||
new Dictionary<CapabilityClass, List<DataFlowBoundaryType>>
|
||||
{
|
||||
[CapabilityClass.DatabaseSql] = [DataFlowBoundaryType.DatabaseQuery, DataFlowBoundaryType.DatabaseResult],
|
||||
[CapabilityClass.DatabaseNoSql] = [DataFlowBoundaryType.DatabaseQuery, DataFlowBoundaryType.DatabaseResult],
|
||||
[CapabilityClass.CacheAccess] = [DataFlowBoundaryType.CacheRead, DataFlowBoundaryType.CacheWrite],
|
||||
[CapabilityClass.MessageQueue] = [DataFlowBoundaryType.MessageReceive, DataFlowBoundaryType.MessageSend],
|
||||
[CapabilityClass.FileRead] = [DataFlowBoundaryType.FileInput],
|
||||
[CapabilityClass.FileWrite] = [DataFlowBoundaryType.FileOutput],
|
||||
[CapabilityClass.FileUpload] = [DataFlowBoundaryType.FileInput],
|
||||
[CapabilityClass.ExternalHttpApi] = [DataFlowBoundaryType.ExternalApiCall, DataFlowBoundaryType.ExternalApiResponse],
|
||||
[CapabilityClass.ProcessSpawn] = [DataFlowBoundaryType.ProcessSpawn],
|
||||
[CapabilityClass.ConfigLoad] = [DataFlowBoundaryType.ConfigRead],
|
||||
[CapabilityClass.EnvironmentRead] = [DataFlowBoundaryType.EnvironmentVar],
|
||||
[CapabilityClass.NetworkListen] = [DataFlowBoundaryType.SocketRead, DataFlowBoundaryType.SocketWrite],
|
||||
[CapabilityClass.NetworkConnect] = [DataFlowBoundaryType.SocketWrite],
|
||||
[CapabilityClass.UserInput] = [DataFlowBoundaryType.HttpRequest],
|
||||
}.ToFrozenDictionary();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of data boundary mapping.
|
||||
/// </summary>
|
||||
public sealed record DataBoundaryMappingResult
|
||||
{
|
||||
public required ImmutableArray<DataFlowBoundary> Boundaries { get; init; }
|
||||
public required int InboundCount { get; init; }
|
||||
public required int OutboundCount { get; init; }
|
||||
public required int SecuritySensitiveCount { get; init; }
|
||||
public required SemanticConfidence Confidence { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic.Analysis;
|
||||
|
||||
/// <summary>
|
||||
/// Infers threat vectors from capabilities and framework patterns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 14).
|
||||
/// Maps capabilities to potential attack vectors with confidence scoring.
|
||||
/// </remarks>
|
||||
public sealed class ThreatVectorInferrer
|
||||
{
|
||||
private readonly FrozenDictionary<ThreatVectorType, ThreatVectorRule> _rules;
|
||||
|
||||
public ThreatVectorInferrer()
|
||||
{
|
||||
_rules = BuildRules();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Infers threat vectors from detected capabilities and intent.
|
||||
/// </summary>
|
||||
public ThreatInferenceResult Infer(
|
||||
CapabilityClass capabilities,
|
||||
ApplicationIntent intent,
|
||||
IReadOnlyList<CapabilityEvidence> evidence)
|
||||
{
|
||||
var threats = new List<ThreatVector>();
|
||||
|
||||
foreach (var (threatType, rule) in _rules)
|
||||
{
|
||||
var matchResult = EvaluateRule(rule, capabilities, intent, evidence);
|
||||
if (matchResult.Matches)
|
||||
{
|
||||
threats.Add(new ThreatVector
|
||||
{
|
||||
Type = threatType,
|
||||
Confidence = matchResult.Confidence,
|
||||
ContributingCapabilities = matchResult.MatchedCapabilities,
|
||||
Evidence = matchResult.Evidence,
|
||||
EntryPaths = ImmutableArray<string>.Empty,
|
||||
Metadata = null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by confidence descending
|
||||
threats = threats.OrderByDescending(t => t.Confidence).ToList();
|
||||
|
||||
return new ThreatInferenceResult
|
||||
{
|
||||
ThreatVectors = threats.ToImmutableArray(),
|
||||
OverallRiskScore = CalculateRiskScore(threats),
|
||||
Confidence = CalculateConfidence(threats)
|
||||
};
|
||||
}
|
||||
|
||||
private static RuleMatchResult EvaluateRule(
|
||||
ThreatVectorRule rule,
|
||||
CapabilityClass capabilities,
|
||||
ApplicationIntent intent,
|
||||
IReadOnlyList<CapabilityEvidence> evidence)
|
||||
{
|
||||
var matchedCaps = CapabilityClass.None;
|
||||
var evidenceStrings = new List<string>();
|
||||
var score = 0.0;
|
||||
|
||||
// Check required capabilities
|
||||
foreach (var reqCap in rule.RequiredCapabilities)
|
||||
{
|
||||
if (capabilities.HasFlag(reqCap))
|
||||
{
|
||||
matchedCaps |= reqCap;
|
||||
score += 0.3;
|
||||
evidenceStrings.Add($"Has capability: {reqCap}");
|
||||
}
|
||||
}
|
||||
|
||||
// Must have all required capabilities
|
||||
var hasAllRequired = rule.RequiredCapabilities.All(c => capabilities.HasFlag(c));
|
||||
if (!hasAllRequired && rule.RequiredCapabilities.Count > 0)
|
||||
{
|
||||
return RuleMatchResult.NoMatch;
|
||||
}
|
||||
|
||||
// Check optional capabilities (boost confidence)
|
||||
foreach (var optCap in rule.OptionalCapabilities)
|
||||
{
|
||||
if (capabilities.HasFlag(optCap))
|
||||
{
|
||||
matchedCaps |= optCap;
|
||||
score += 0.1;
|
||||
evidenceStrings.Add($"Has optional capability: {optCap}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check intent match
|
||||
if (rule.RequiredIntents.Contains(intent))
|
||||
{
|
||||
score += 0.2;
|
||||
evidenceStrings.Add($"Intent matches: {intent}");
|
||||
}
|
||||
else if (rule.RequiredIntents.Count > 0 && !rule.RequiredIntents.Contains(ApplicationIntent.Unknown))
|
||||
{
|
||||
// Intent mismatch reduces confidence but doesn't eliminate
|
||||
score *= 0.7;
|
||||
}
|
||||
|
||||
// Check for specific evidence patterns
|
||||
foreach (var ev in evidence)
|
||||
{
|
||||
if (rule.EvidencePatterns.Any(p => ev.Artifact.Contains(p, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
score += 0.15;
|
||||
evidenceStrings.Add($"Evidence pattern: {ev.Artifact}");
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize and apply base weight
|
||||
var finalScore = Math.Min(1.0, score * rule.BaseWeight);
|
||||
|
||||
return new RuleMatchResult
|
||||
{
|
||||
Matches = finalScore >= 0.3,
|
||||
Confidence = finalScore,
|
||||
MatchedCapabilities = matchedCaps,
|
||||
Evidence = evidenceStrings.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static double CalculateRiskScore(List<ThreatVector> threats)
|
||||
{
|
||||
if (threats.Count == 0)
|
||||
return 0.0;
|
||||
|
||||
// Weighted sum with diminishing returns for multiple threats
|
||||
var score = 0.0;
|
||||
for (var i = 0; i < threats.Count; i++)
|
||||
{
|
||||
var weight = 1.0 / (i + 1); // Diminishing returns
|
||||
score += threats[i].Confidence * weight * GetSeverityWeight(threats[i].Type);
|
||||
}
|
||||
|
||||
return Math.Min(1.0, score);
|
||||
}
|
||||
|
||||
private static double GetSeverityWeight(ThreatVectorType type) => type switch
|
||||
{
|
||||
ThreatVectorType.Rce => 1.0,
|
||||
ThreatVectorType.ContainerEscape => 1.0,
|
||||
ThreatVectorType.PrivilegeEscalation => 0.95,
|
||||
ThreatVectorType.SqlInjection => 0.9,
|
||||
ThreatVectorType.CommandInjection => 0.9,
|
||||
ThreatVectorType.InsecureDeserialization => 0.85,
|
||||
ThreatVectorType.PathTraversal => 0.8,
|
||||
ThreatVectorType.Ssrf => 0.8,
|
||||
ThreatVectorType.AuthenticationBypass => 0.85,
|
||||
ThreatVectorType.AuthorizationBypass => 0.8,
|
||||
ThreatVectorType.XxeInjection => 0.75,
|
||||
ThreatVectorType.TemplateInjection => 0.75,
|
||||
ThreatVectorType.Xss => 0.7,
|
||||
ThreatVectorType.LdapInjection => 0.7,
|
||||
ThreatVectorType.Idor => 0.65,
|
||||
ThreatVectorType.Csrf => 0.6,
|
||||
ThreatVectorType.OpenRedirect => 0.5,
|
||||
ThreatVectorType.InformationDisclosure => 0.5,
|
||||
ThreatVectorType.LogInjection => 0.4,
|
||||
ThreatVectorType.HeaderInjection => 0.4,
|
||||
ThreatVectorType.DenialOfService => 0.3,
|
||||
ThreatVectorType.ReDoS => 0.3,
|
||||
ThreatVectorType.MassAssignment => 0.5,
|
||||
ThreatVectorType.CryptoWeakness => 0.5,
|
||||
_ => 0.5
|
||||
};
|
||||
|
||||
private static SemanticConfidence CalculateConfidence(List<ThreatVector> threats)
|
||||
{
|
||||
if (threats.Count == 0)
|
||||
return SemanticConfidence.Unknown();
|
||||
|
||||
var avgConfidence = threats.Average(t => t.Confidence);
|
||||
var reasons = threats.Select(t => $"{t.Type}: {t.Confidence:P0}").ToArray();
|
||||
|
||||
return SemanticConfidence.FromScore(avgConfidence, reasons.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static FrozenDictionary<ThreatVectorType, ThreatVectorRule> BuildRules() =>
|
||||
new Dictionary<ThreatVectorType, ThreatVectorRule>
|
||||
{
|
||||
[ThreatVectorType.SqlInjection] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.DatabaseSql, CapabilityClass.UserInput],
|
||||
OptionalCapabilities = [CapabilityClass.NetworkListen],
|
||||
RequiredIntents = [ApplicationIntent.WebServer, ApplicationIntent.RpcServer, ApplicationIntent.GraphQlServer],
|
||||
EvidencePatterns = ["sql", "query", "database", "orm"],
|
||||
BaseWeight = 1.0
|
||||
},
|
||||
[ThreatVectorType.Ssrf] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.NetworkConnect, CapabilityClass.UserInput],
|
||||
OptionalCapabilities = [CapabilityClass.ExternalHttpApi, CapabilityClass.CloudSdk],
|
||||
RequiredIntents = [ApplicationIntent.WebServer],
|
||||
EvidencePatterns = ["http", "url", "request", "fetch"],
|
||||
BaseWeight = 0.9
|
||||
},
|
||||
[ThreatVectorType.Rce] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.ShellExecution],
|
||||
OptionalCapabilities = [CapabilityClass.ProcessSpawn, CapabilityClass.DynamicCodeEval, CapabilityClass.UserInput],
|
||||
RequiredIntents = [],
|
||||
EvidencePatterns = ["exec", "spawn", "system", "shell", "eval"],
|
||||
BaseWeight = 1.0
|
||||
},
|
||||
[ThreatVectorType.CommandInjection] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.ProcessSpawn, CapabilityClass.UserInput],
|
||||
OptionalCapabilities = [CapabilityClass.ShellExecution],
|
||||
RequiredIntents = [ApplicationIntent.WebServer, ApplicationIntent.CliTool],
|
||||
EvidencePatterns = ["command", "exec", "run", "subprocess"],
|
||||
BaseWeight = 1.0
|
||||
},
|
||||
[ThreatVectorType.PathTraversal] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.FileRead, CapabilityClass.UserInput],
|
||||
OptionalCapabilities = [CapabilityClass.FileWrite, CapabilityClass.FileUpload],
|
||||
RequiredIntents = [ApplicationIntent.WebServer],
|
||||
EvidencePatterns = ["path", "file", "download", "upload"],
|
||||
BaseWeight = 0.85
|
||||
},
|
||||
[ThreatVectorType.Xss] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.TemplateRendering, CapabilityClass.UserInput],
|
||||
OptionalCapabilities = [CapabilityClass.NetworkListen],
|
||||
RequiredIntents = [ApplicationIntent.WebServer],
|
||||
EvidencePatterns = ["template", "html", "render", "view"],
|
||||
BaseWeight = 0.8
|
||||
},
|
||||
[ThreatVectorType.InsecureDeserialization] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.UnsafeDeserialization],
|
||||
OptionalCapabilities = [CapabilityClass.UserInput, CapabilityClass.MessageQueue],
|
||||
RequiredIntents = [],
|
||||
EvidencePatterns = ["pickle", "serialize", "unmarshal", "deserialize", "jackson"],
|
||||
BaseWeight = 0.95
|
||||
},
|
||||
[ThreatVectorType.TemplateInjection] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.TemplateRendering, CapabilityClass.UserInput],
|
||||
OptionalCapabilities = [CapabilityClass.DynamicCodeEval],
|
||||
RequiredIntents = [ApplicationIntent.WebServer],
|
||||
EvidencePatterns = ["jinja", "template", "render", "ssti"],
|
||||
BaseWeight = 0.9
|
||||
},
|
||||
[ThreatVectorType.XxeInjection] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.XmlExternalEntities],
|
||||
OptionalCapabilities = [CapabilityClass.UserInput, CapabilityClass.FileRead],
|
||||
RequiredIntents = [],
|
||||
EvidencePatterns = ["xml", "parse", "dom", "sax"],
|
||||
BaseWeight = 0.85
|
||||
},
|
||||
[ThreatVectorType.AuthenticationBypass] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.Authentication],
|
||||
OptionalCapabilities = [CapabilityClass.SessionManagement, CapabilityClass.UserInput],
|
||||
RequiredIntents = [ApplicationIntent.WebServer, ApplicationIntent.RpcServer],
|
||||
EvidencePatterns = ["auth", "login", "jwt", "session", "token"],
|
||||
BaseWeight = 0.7
|
||||
},
|
||||
[ThreatVectorType.AuthorizationBypass] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.Authorization],
|
||||
OptionalCapabilities = [CapabilityClass.UserInput],
|
||||
RequiredIntents = [ApplicationIntent.WebServer, ApplicationIntent.RpcServer],
|
||||
EvidencePatterns = ["rbac", "permission", "role", "access"],
|
||||
BaseWeight = 0.7
|
||||
},
|
||||
[ThreatVectorType.ContainerEscape] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.ContainerEscape],
|
||||
OptionalCapabilities = [CapabilityClass.SystemPrivileged, CapabilityClass.KernelModule],
|
||||
RequiredIntents = [],
|
||||
EvidencePatterns = ["docker.sock", "privileged", "hostpid", "hostnetwork"],
|
||||
BaseWeight = 1.0
|
||||
},
|
||||
[ThreatVectorType.PrivilegeEscalation] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.SystemPrivileged],
|
||||
OptionalCapabilities = [CapabilityClass.ProcessSpawn, CapabilityClass.FileWrite],
|
||||
RequiredIntents = [],
|
||||
EvidencePatterns = ["sudo", "setuid", "capabilities", "root"],
|
||||
BaseWeight = 0.9
|
||||
},
|
||||
[ThreatVectorType.LdapInjection] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.NetworkConnect, CapabilityClass.UserInput],
|
||||
OptionalCapabilities = [CapabilityClass.Authentication],
|
||||
RequiredIntents = [ApplicationIntent.WebServer],
|
||||
EvidencePatterns = ["ldap", "ldap3", "directory"],
|
||||
BaseWeight = 0.8
|
||||
},
|
||||
[ThreatVectorType.Csrf] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.SessionManagement, CapabilityClass.UserInput],
|
||||
OptionalCapabilities = [CapabilityClass.NetworkListen],
|
||||
RequiredIntents = [ApplicationIntent.WebServer],
|
||||
EvidencePatterns = ["form", "post", "session", "cookie"],
|
||||
BaseWeight = 0.6
|
||||
},
|
||||
[ThreatVectorType.OpenRedirect] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.UserInput, CapabilityClass.NetworkListen],
|
||||
OptionalCapabilities = [],
|
||||
RequiredIntents = [ApplicationIntent.WebServer],
|
||||
EvidencePatterns = ["redirect", "url", "return", "next"],
|
||||
BaseWeight = 0.5
|
||||
},
|
||||
[ThreatVectorType.InformationDisclosure] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.LogEmit],
|
||||
OptionalCapabilities = [CapabilityClass.SecretAccess, CapabilityClass.ConfigLoad],
|
||||
RequiredIntents = [],
|
||||
EvidencePatterns = ["log", "debug", "error", "stack"],
|
||||
BaseWeight = 0.4
|
||||
},
|
||||
[ThreatVectorType.DenialOfService] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.NetworkListen],
|
||||
OptionalCapabilities = [CapabilityClass.UserInput],
|
||||
RequiredIntents = [ApplicationIntent.WebServer],
|
||||
EvidencePatterns = ["rate", "limit", "timeout"],
|
||||
BaseWeight = 0.3
|
||||
},
|
||||
[ThreatVectorType.ReDoS] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.UserInput],
|
||||
OptionalCapabilities = [],
|
||||
RequiredIntents = [],
|
||||
EvidencePatterns = ["regex", "pattern", "match", "replace"],
|
||||
BaseWeight = 0.4
|
||||
},
|
||||
[ThreatVectorType.MassAssignment] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.UserInput, CapabilityClass.DatabaseSql],
|
||||
OptionalCapabilities = [],
|
||||
RequiredIntents = [ApplicationIntent.WebServer],
|
||||
EvidencePatterns = ["model", "bind", "update", "create"],
|
||||
BaseWeight = 0.6
|
||||
},
|
||||
[ThreatVectorType.Idor] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.UserInput, CapabilityClass.DatabaseSql],
|
||||
OptionalCapabilities = [CapabilityClass.Authorization],
|
||||
RequiredIntents = [ApplicationIntent.WebServer, ApplicationIntent.RpcServer],
|
||||
EvidencePatterns = ["id", "user", "object", "reference"],
|
||||
BaseWeight = 0.6
|
||||
},
|
||||
[ThreatVectorType.HeaderInjection] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.UserInput, CapabilityClass.NetworkListen],
|
||||
OptionalCapabilities = [],
|
||||
RequiredIntents = [ApplicationIntent.WebServer],
|
||||
EvidencePatterns = ["header", "response", "set"],
|
||||
BaseWeight = 0.5
|
||||
},
|
||||
[ThreatVectorType.LogInjection] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.LogEmit, CapabilityClass.UserInput],
|
||||
OptionalCapabilities = [],
|
||||
RequiredIntents = [],
|
||||
EvidencePatterns = ["log", "logger", "print"],
|
||||
BaseWeight = 0.4
|
||||
},
|
||||
[ThreatVectorType.CryptoWeakness] = new()
|
||||
{
|
||||
RequiredCapabilities = [CapabilityClass.CryptoEncrypt],
|
||||
OptionalCapabilities = [CapabilityClass.SecretAccess],
|
||||
RequiredIntents = [],
|
||||
EvidencePatterns = ["md5", "sha1", "des", "ecb", "weak"],
|
||||
BaseWeight = 0.5
|
||||
},
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
private sealed record ThreatVectorRule
|
||||
{
|
||||
public required List<CapabilityClass> RequiredCapabilities { get; init; }
|
||||
public required List<CapabilityClass> OptionalCapabilities { get; init; }
|
||||
public required List<ApplicationIntent> RequiredIntents { get; init; }
|
||||
public required List<string> EvidencePatterns { get; init; }
|
||||
public required double BaseWeight { get; init; }
|
||||
}
|
||||
|
||||
private sealed record RuleMatchResult
|
||||
{
|
||||
public required bool Matches { get; init; }
|
||||
public required double Confidence { get; init; }
|
||||
public required CapabilityClass MatchedCapabilities { get; init; }
|
||||
public required ImmutableArray<string> Evidence { get; init; }
|
||||
|
||||
public static RuleMatchResult NoMatch => new()
|
||||
{
|
||||
Matches = false,
|
||||
Confidence = 0,
|
||||
MatchedCapabilities = CapabilityClass.None,
|
||||
Evidence = ImmutableArray<string>.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of threat vector inference.
|
||||
/// </summary>
|
||||
public sealed record ThreatInferenceResult
|
||||
{
|
||||
public required ImmutableArray<ThreatVector> ThreatVectors { get; init; }
|
||||
public required double OverallRiskScore { get; init; }
|
||||
public required SemanticConfidence Confidence { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic;
|
||||
|
||||
/// <summary>
|
||||
/// High-level application intent inferred from entrypoint analysis.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 2).
|
||||
/// Intent classification enables risk prioritization and attack surface modeling.
|
||||
/// </remarks>
|
||||
public enum ApplicationIntent
|
||||
{
|
||||
/// <summary>Intent could not be determined.</summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>HTTP/HTTPS web server (Django, Express, ASP.NET, etc.).</summary>
|
||||
WebServer = 1,
|
||||
|
||||
/// <summary>Command-line interface tool (Click, Cobra, etc.).</summary>
|
||||
CliTool = 2,
|
||||
|
||||
/// <summary>One-shot batch data processing job.</summary>
|
||||
BatchJob = 3,
|
||||
|
||||
/// <summary>Background job processor (Celery, Sidekiq, etc.).</summary>
|
||||
Worker = 4,
|
||||
|
||||
/// <summary>FaaS handler (Lambda, Azure Functions, Cloud Functions).</summary>
|
||||
Serverless = 5,
|
||||
|
||||
/// <summary>Long-running background daemon service.</summary>
|
||||
Daemon = 6,
|
||||
|
||||
/// <summary>Process manager/init system (systemd, s6, tini).</summary>
|
||||
InitSystem = 7,
|
||||
|
||||
/// <summary>Child process supervisor (supervisord).</summary>
|
||||
Supervisor = 8,
|
||||
|
||||
/// <summary>Database engine (PostgreSQL, MySQL, MongoDB).</summary>
|
||||
DatabaseServer = 9,
|
||||
|
||||
/// <summary>Message broker (RabbitMQ, Kafka, Redis pub/sub).</summary>
|
||||
MessageBroker = 10,
|
||||
|
||||
/// <summary>Cache/session store (Redis, Memcached).</summary>
|
||||
CacheServer = 11,
|
||||
|
||||
/// <summary>Reverse proxy or API gateway (nginx, Envoy, Kong).</summary>
|
||||
ProxyGateway = 12,
|
||||
|
||||
/// <summary>Test framework execution (pytest, jest).</summary>
|
||||
TestRunner = 13,
|
||||
|
||||
/// <summary>Development-only server (hot reload, debug).</summary>
|
||||
DevServer = 14,
|
||||
|
||||
/// <summary>RPC server (gRPC, Thrift, JSON-RPC).</summary>
|
||||
RpcServer = 15,
|
||||
|
||||
/// <summary>GraphQL server (Apollo, Strawberry).</summary>
|
||||
GraphQlServer = 16,
|
||||
|
||||
/// <summary>Stream processor (Kafka Streams, Flink).</summary>
|
||||
StreamProcessor = 17,
|
||||
|
||||
/// <summary>Machine learning inference server.</summary>
|
||||
MlInferenceServer = 18,
|
||||
|
||||
/// <summary>Scheduled task executor (cron, Celery Beat).</summary>
|
||||
ScheduledTask = 19,
|
||||
|
||||
/// <summary>File/object storage server (MinIO, SeaweedFS).</summary>
|
||||
StorageServer = 20,
|
||||
|
||||
/// <summary>Service mesh sidecar (Envoy, Linkerd).</summary>
|
||||
Sidecar = 21,
|
||||
|
||||
/// <summary>Metrics/monitoring collector (Prometheus, Telegraf).</summary>
|
||||
MetricsCollector = 22,
|
||||
|
||||
/// <summary>Log aggregator (Fluentd, Logstash).</summary>
|
||||
LogCollector = 23,
|
||||
|
||||
/// <summary>Container orchestration agent (kubelet, containerd).</summary>
|
||||
ContainerAgent = 24,
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic;
|
||||
|
||||
/// <summary>
|
||||
/// Flags representing capabilities inferred from entrypoint analysis.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 3).
|
||||
/// Capabilities map to potential attack surfaces and threat vectors.
|
||||
/// </remarks>
|
||||
[Flags]
|
||||
public enum CapabilityClass : long
|
||||
{
|
||||
/// <summary>No capabilities detected.</summary>
|
||||
None = 0,
|
||||
|
||||
// Network capabilities
|
||||
/// <summary>Opens listening socket for incoming connections.</summary>
|
||||
NetworkListen = 1L << 0,
|
||||
/// <summary>Makes outbound network connections.</summary>
|
||||
NetworkConnect = 1L << 1,
|
||||
/// <summary>Uses raw sockets or low-level network access.</summary>
|
||||
NetworkRaw = 1L << 2,
|
||||
/// <summary>Performs DNS resolution.</summary>
|
||||
NetworkDns = 1L << 3,
|
||||
|
||||
// Filesystem capabilities
|
||||
/// <summary>Reads from filesystem.</summary>
|
||||
FileRead = 1L << 4,
|
||||
/// <summary>Writes to filesystem.</summary>
|
||||
FileWrite = 1L << 5,
|
||||
/// <summary>Executes files or modifies permissions.</summary>
|
||||
FileExecute = 1L << 6,
|
||||
/// <summary>Watches filesystem for changes.</summary>
|
||||
FileWatch = 1L << 7,
|
||||
|
||||
// Process capabilities
|
||||
/// <summary>Spawns child processes.</summary>
|
||||
ProcessSpawn = 1L << 8,
|
||||
/// <summary>Sends signals to processes.</summary>
|
||||
ProcessSignal = 1L << 9,
|
||||
/// <summary>Uses ptrace or debugging capabilities.</summary>
|
||||
ProcessTrace = 1L << 10,
|
||||
|
||||
// Cryptography capabilities
|
||||
/// <summary>Performs encryption/decryption.</summary>
|
||||
CryptoEncrypt = 1L << 11,
|
||||
/// <summary>Performs signing/verification.</summary>
|
||||
CryptoSign = 1L << 12,
|
||||
/// <summary>Generates cryptographic keys or random numbers.</summary>
|
||||
CryptoKeyGen = 1L << 13,
|
||||
|
||||
// Data store capabilities
|
||||
/// <summary>Accesses relational databases.</summary>
|
||||
DatabaseSql = 1L << 14,
|
||||
/// <summary>Accesses NoSQL databases.</summary>
|
||||
DatabaseNoSql = 1L << 15,
|
||||
/// <summary>Accesses message queues.</summary>
|
||||
MessageQueue = 1L << 16,
|
||||
/// <summary>Accesses cache stores.</summary>
|
||||
CacheAccess = 1L << 17,
|
||||
/// <summary>Accesses object/blob storage.</summary>
|
||||
ObjectStorage = 1L << 18,
|
||||
|
||||
// External service capabilities
|
||||
/// <summary>Makes HTTP API calls to external services.</summary>
|
||||
ExternalHttpApi = 1L << 19,
|
||||
/// <summary>Uses cloud provider SDKs (AWS, GCP, Azure).</summary>
|
||||
CloudSdk = 1L << 20,
|
||||
/// <summary>Sends emails.</summary>
|
||||
EmailSend = 1L << 21,
|
||||
/// <summary>Sends SMS or push notifications.</summary>
|
||||
NotificationSend = 1L << 22,
|
||||
|
||||
// Input/Output capabilities
|
||||
/// <summary>Accepts user input (forms, API bodies).</summary>
|
||||
UserInput = 1L << 23,
|
||||
/// <summary>Processes file uploads.</summary>
|
||||
FileUpload = 1L << 24,
|
||||
/// <summary>Loads configuration files.</summary>
|
||||
ConfigLoad = 1L << 25,
|
||||
/// <summary>Accesses secrets or credentials.</summary>
|
||||
SecretAccess = 1L << 26,
|
||||
/// <summary>Accesses environment variables.</summary>
|
||||
EnvironmentRead = 1L << 27,
|
||||
|
||||
// Observability capabilities
|
||||
/// <summary>Emits structured logs.</summary>
|
||||
LogEmit = 1L << 28,
|
||||
/// <summary>Emits metrics or telemetry.</summary>
|
||||
MetricsEmit = 1L << 29,
|
||||
/// <summary>Emits distributed traces.</summary>
|
||||
TracingEmit = 1L << 30,
|
||||
|
||||
// System capabilities
|
||||
/// <summary>Makes privileged system calls.</summary>
|
||||
SystemPrivileged = 1L << 31,
|
||||
/// <summary>Capabilities enabling container escape.</summary>
|
||||
ContainerEscape = 1L << 32,
|
||||
/// <summary>Loads kernel modules or eBPF programs.</summary>
|
||||
KernelModule = 1L << 33,
|
||||
/// <summary>Modifies system time.</summary>
|
||||
SystemTime = 1L << 34,
|
||||
/// <summary>Modifies network configuration.</summary>
|
||||
NetworkAdmin = 1L << 35,
|
||||
|
||||
// Serialization (security-relevant)
|
||||
/// <summary>Deserializes untrusted data unsafely.</summary>
|
||||
UnsafeDeserialization = 1L << 36,
|
||||
/// <summary>Uses XML parsing with external entities.</summary>
|
||||
XmlExternalEntities = 1L << 37,
|
||||
/// <summary>Evaluates dynamic code (eval, exec).</summary>
|
||||
DynamicCodeEval = 1L << 38,
|
||||
/// <summary>Uses template engines with expression evaluation.</summary>
|
||||
TemplateRendering = 1L << 39,
|
||||
/// <summary>Executes shell commands.</summary>
|
||||
ShellExecution = 1L << 40,
|
||||
|
||||
// Authentication/Authorization
|
||||
/// <summary>Performs authentication operations.</summary>
|
||||
Authentication = 1L << 41,
|
||||
/// <summary>Performs authorization/access control.</summary>
|
||||
Authorization = 1L << 42,
|
||||
/// <summary>Manages sessions or tokens.</summary>
|
||||
SessionManagement = 1L << 43,
|
||||
|
||||
// Convenience combinations
|
||||
/// <summary>Full network access.</summary>
|
||||
NetworkFull = NetworkListen | NetworkConnect,
|
||||
/// <summary>Full filesystem access.</summary>
|
||||
FileSystemFull = FileRead | FileWrite | FileExecute,
|
||||
/// <summary>Any database access.</summary>
|
||||
DatabaseAny = DatabaseSql | DatabaseNoSql,
|
||||
/// <summary>Any cryptographic operation.</summary>
|
||||
CryptoAny = CryptoEncrypt | CryptoSign | CryptoKeyGen,
|
||||
/// <summary>Security-sensitive serialization patterns.</summary>
|
||||
UnsafeSerialization = UnsafeDeserialization | XmlExternalEntities | DynamicCodeEval,
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic;
|
||||
|
||||
/// <summary>
|
||||
/// Types of data flow boundaries in application execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 5).
|
||||
/// </remarks>
|
||||
public enum DataFlowBoundaryType
|
||||
{
|
||||
/// <summary>Incoming HTTP request data.</summary>
|
||||
HttpRequest = 1,
|
||||
/// <summary>Outgoing HTTP response data.</summary>
|
||||
HttpResponse = 2,
|
||||
/// <summary>File input (reading from disk).</summary>
|
||||
FileInput = 3,
|
||||
/// <summary>File output (writing to disk).</summary>
|
||||
FileOutput = 4,
|
||||
/// <summary>SQL query execution.</summary>
|
||||
DatabaseQuery = 5,
|
||||
/// <summary>Database result set.</summary>
|
||||
DatabaseResult = 6,
|
||||
/// <summary>Message queue receive.</summary>
|
||||
MessageReceive = 7,
|
||||
/// <summary>Message queue send.</summary>
|
||||
MessageSend = 8,
|
||||
/// <summary>Environment variable read.</summary>
|
||||
EnvironmentVar = 9,
|
||||
/// <summary>Command-line argument.</summary>
|
||||
CommandLineArg = 10,
|
||||
/// <summary>Standard input stream.</summary>
|
||||
StandardInput = 11,
|
||||
/// <summary>Standard output stream.</summary>
|
||||
StandardOutput = 12,
|
||||
/// <summary>Standard error stream.</summary>
|
||||
StandardError = 13,
|
||||
/// <summary>Network socket read.</summary>
|
||||
SocketRead = 14,
|
||||
/// <summary>Network socket write.</summary>
|
||||
SocketWrite = 15,
|
||||
/// <summary>Process spawn with arguments.</summary>
|
||||
ProcessSpawn = 16,
|
||||
/// <summary>Shared memory read.</summary>
|
||||
SharedMemoryRead = 17,
|
||||
/// <summary>Shared memory write.</summary>
|
||||
SharedMemoryWrite = 18,
|
||||
/// <summary>Cache read operation.</summary>
|
||||
CacheRead = 19,
|
||||
/// <summary>Cache write operation.</summary>
|
||||
CacheWrite = 20,
|
||||
/// <summary>External API call.</summary>
|
||||
ExternalApiCall = 21,
|
||||
/// <summary>External API response.</summary>
|
||||
ExternalApiResponse = 22,
|
||||
/// <summary>Configuration file read.</summary>
|
||||
ConfigRead = 23,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direction of data flow at a boundary.
|
||||
/// </summary>
|
||||
public enum DataFlowDirection
|
||||
{
|
||||
/// <summary>Data entering the application.</summary>
|
||||
Inbound = 1,
|
||||
/// <summary>Data leaving the application.</summary>
|
||||
Outbound = 2,
|
||||
/// <summary>Bidirectional data flow.</summary>
|
||||
Bidirectional = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sensitivity classification for data at boundaries.
|
||||
/// </summary>
|
||||
public enum DataSensitivity
|
||||
{
|
||||
/// <summary>Sensitivity not determined.</summary>
|
||||
Unknown = 0,
|
||||
/// <summary>Public, non-sensitive data.</summary>
|
||||
Public = 1,
|
||||
/// <summary>Internal data, not for external exposure.</summary>
|
||||
Internal = 2,
|
||||
/// <summary>Confidential data requiring protection.</summary>
|
||||
Confidential = 3,
|
||||
/// <summary>Highly restricted data (credentials, keys, PII).</summary>
|
||||
Restricted = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a data flow boundary in the application.
|
||||
/// </summary>
|
||||
public sealed record DataFlowBoundary
|
||||
{
|
||||
/// <summary>Type of boundary.</summary>
|
||||
public required DataFlowBoundaryType Type { get; init; }
|
||||
|
||||
/// <summary>Direction of data flow.</summary>
|
||||
public required DataFlowDirection Direction { get; init; }
|
||||
|
||||
/// <summary>Inferred sensitivity of data at this boundary.</summary>
|
||||
public required DataSensitivity Sensitivity { get; init; }
|
||||
|
||||
/// <summary>Confidence in the boundary detection (0.0-1.0).</summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>Code location where boundary was detected.</summary>
|
||||
public string? Location { get; init; }
|
||||
|
||||
/// <summary>Evidence strings for this boundary detection.</summary>
|
||||
public ImmutableArray<string> Evidence { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>Framework or library providing this boundary.</summary>
|
||||
public string? Framework { get; init; }
|
||||
|
||||
/// <summary>Additional metadata.</summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for DataFlowBoundaryType.
|
||||
/// </summary>
|
||||
public static class DataFlowBoundaryTypeExtensions
|
||||
{
|
||||
/// <summary>Gets the default direction for this boundary type.</summary>
|
||||
public static DataFlowDirection GetDefaultDirection(this DataFlowBoundaryType type) => type switch
|
||||
{
|
||||
DataFlowBoundaryType.HttpRequest => DataFlowDirection.Inbound,
|
||||
DataFlowBoundaryType.HttpResponse => DataFlowDirection.Outbound,
|
||||
DataFlowBoundaryType.FileInput => DataFlowDirection.Inbound,
|
||||
DataFlowBoundaryType.FileOutput => DataFlowDirection.Outbound,
|
||||
DataFlowBoundaryType.DatabaseQuery => DataFlowDirection.Outbound,
|
||||
DataFlowBoundaryType.DatabaseResult => DataFlowDirection.Inbound,
|
||||
DataFlowBoundaryType.MessageReceive => DataFlowDirection.Inbound,
|
||||
DataFlowBoundaryType.MessageSend => DataFlowDirection.Outbound,
|
||||
DataFlowBoundaryType.EnvironmentVar => DataFlowDirection.Inbound,
|
||||
DataFlowBoundaryType.CommandLineArg => DataFlowDirection.Inbound,
|
||||
DataFlowBoundaryType.StandardInput => DataFlowDirection.Inbound,
|
||||
DataFlowBoundaryType.StandardOutput => DataFlowDirection.Outbound,
|
||||
DataFlowBoundaryType.StandardError => DataFlowDirection.Outbound,
|
||||
DataFlowBoundaryType.SocketRead => DataFlowDirection.Inbound,
|
||||
DataFlowBoundaryType.SocketWrite => DataFlowDirection.Outbound,
|
||||
DataFlowBoundaryType.ProcessSpawn => DataFlowDirection.Outbound,
|
||||
DataFlowBoundaryType.SharedMemoryRead => DataFlowDirection.Inbound,
|
||||
DataFlowBoundaryType.SharedMemoryWrite => DataFlowDirection.Outbound,
|
||||
DataFlowBoundaryType.CacheRead => DataFlowDirection.Inbound,
|
||||
DataFlowBoundaryType.CacheWrite => DataFlowDirection.Outbound,
|
||||
DataFlowBoundaryType.ExternalApiCall => DataFlowDirection.Outbound,
|
||||
DataFlowBoundaryType.ExternalApiResponse => DataFlowDirection.Inbound,
|
||||
DataFlowBoundaryType.ConfigRead => DataFlowDirection.Inbound,
|
||||
_ => DataFlowDirection.Bidirectional
|
||||
};
|
||||
|
||||
/// <summary>Determines if this boundary type is security-sensitive by default.</summary>
|
||||
public static bool IsSecuritySensitive(this DataFlowBoundaryType type) => type switch
|
||||
{
|
||||
DataFlowBoundaryType.HttpRequest => true,
|
||||
DataFlowBoundaryType.DatabaseQuery => true,
|
||||
DataFlowBoundaryType.ProcessSpawn => true,
|
||||
DataFlowBoundaryType.CommandLineArg => true,
|
||||
DataFlowBoundaryType.EnvironmentVar => true,
|
||||
DataFlowBoundaryType.ExternalApiCall => true,
|
||||
DataFlowBoundaryType.ConfigRead => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using StellaOps.Scanner.EntryTrace.FileSystem;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for semantic entrypoint analyzers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 7).
|
||||
/// Implementations analyze entrypoints to infer intent, capabilities, and attack surface.
|
||||
/// </remarks>
|
||||
public interface ISemanticEntrypointAnalyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// Analyzes an entrypoint to produce semantic understanding.
|
||||
/// </summary>
|
||||
/// <param name="context">Analysis context with entrypoint and language data.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Semantic entrypoint analysis result.</returns>
|
||||
Task<SemanticEntrypoint> AnalyzeAsync(
|
||||
SemanticAnalysisContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the languages this analyzer supports.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> SupportedLanguages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the priority of this analyzer (higher = processed first).
|
||||
/// </summary>
|
||||
int Priority => 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for semantic analysis containing all relevant data.
|
||||
/// </summary>
|
||||
public sealed record SemanticAnalysisContext
|
||||
{
|
||||
/// <summary>The entrypoint specification to analyze.</summary>
|
||||
public required EntrypointSpecification Specification { get; init; }
|
||||
|
||||
/// <summary>Entry trace result from initial analysis.</summary>
|
||||
public required EntryTraceResult EntryTraceResult { get; init; }
|
||||
|
||||
/// <summary>Root filesystem accessor.</summary>
|
||||
public required IRootFileSystem FileSystem { get; init; }
|
||||
|
||||
/// <summary>Detected primary language.</summary>
|
||||
public string? PrimaryLanguage { get; init; }
|
||||
|
||||
/// <summary>All detected languages in the image.</summary>
|
||||
public IReadOnlyList<string> DetectedLanguages { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Package manager manifests found.</summary>
|
||||
public IReadOnlyDictionary<string, string> ManifestPaths { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>Import/dependency information from language analyzers.</summary>
|
||||
public IReadOnlyDictionary<string, IReadOnlyList<string>> Dependencies { get; init; } = new Dictionary<string, IReadOnlyList<string>>();
|
||||
|
||||
/// <summary>Image digest for correlation.</summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>Scan ID for tracing.</summary>
|
||||
public string? ScanId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of semantic analysis that can be partial/incremental.
|
||||
/// </summary>
|
||||
public sealed record SemanticAnalysisResult
|
||||
{
|
||||
/// <summary>Whether analysis completed successfully.</summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>The semantic entrypoint if successful.</summary>
|
||||
public SemanticEntrypoint? Entrypoint { get; init; }
|
||||
|
||||
/// <summary>Partial results if analysis was incomplete.</summary>
|
||||
public PartialSemanticResult? PartialResult { get; init; }
|
||||
|
||||
/// <summary>Diagnostics from analysis.</summary>
|
||||
public IReadOnlyList<SemanticDiagnostic> Diagnostics { get; init; } = Array.Empty<SemanticDiagnostic>();
|
||||
|
||||
/// <summary>Creates successful result.</summary>
|
||||
public static SemanticAnalysisResult Successful(SemanticEntrypoint entrypoint) => new()
|
||||
{
|
||||
Success = true,
|
||||
Entrypoint = entrypoint
|
||||
};
|
||||
|
||||
/// <summary>Creates failed result with diagnostics.</summary>
|
||||
public static SemanticAnalysisResult Failed(params SemanticDiagnostic[] diagnostics) => new()
|
||||
{
|
||||
Success = false,
|
||||
Diagnostics = diagnostics
|
||||
};
|
||||
|
||||
/// <summary>Creates partial result.</summary>
|
||||
public static SemanticAnalysisResult Partial(PartialSemanticResult partial, params SemanticDiagnostic[] diagnostics) => new()
|
||||
{
|
||||
Success = false,
|
||||
PartialResult = partial,
|
||||
Diagnostics = diagnostics
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Partial semantic analysis results when full analysis isn't possible.
|
||||
/// </summary>
|
||||
public sealed record PartialSemanticResult
|
||||
{
|
||||
/// <summary>Inferred intent if determined.</summary>
|
||||
public ApplicationIntent? Intent { get; init; }
|
||||
|
||||
/// <summary>Capabilities detected so far.</summary>
|
||||
public CapabilityClass Capabilities { get; init; } = CapabilityClass.None;
|
||||
|
||||
/// <summary>Confidence in partial results.</summary>
|
||||
public SemanticConfidence? Confidence { get; init; }
|
||||
|
||||
/// <summary>Reason analysis couldn't complete.</summary>
|
||||
public string? IncompleteReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic from semantic analysis.
|
||||
/// </summary>
|
||||
public sealed record SemanticDiagnostic
|
||||
{
|
||||
/// <summary>Severity of the diagnostic.</summary>
|
||||
public required DiagnosticSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>Diagnostic code.</summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>Human-readable message.</summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>Location in code if applicable.</summary>
|
||||
public string? Location { get; init; }
|
||||
|
||||
/// <summary>Creates info diagnostic.</summary>
|
||||
public static SemanticDiagnostic Info(string code, string message, string? location = null) => new()
|
||||
{
|
||||
Severity = DiagnosticSeverity.Info,
|
||||
Code = code,
|
||||
Message = message,
|
||||
Location = location
|
||||
};
|
||||
|
||||
/// <summary>Creates warning diagnostic.</summary>
|
||||
public static SemanticDiagnostic Warning(string code, string message, string? location = null) => new()
|
||||
{
|
||||
Severity = DiagnosticSeverity.Warning,
|
||||
Code = code,
|
||||
Message = message,
|
||||
Location = location
|
||||
};
|
||||
|
||||
/// <summary>Creates error diagnostic.</summary>
|
||||
public static SemanticDiagnostic Error(string code, string message, string? location = null) => new()
|
||||
{
|
||||
Severity = DiagnosticSeverity.Error,
|
||||
Code = code,
|
||||
Message = message,
|
||||
Location = location
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels for semantic diagnostics.
|
||||
/// </summary>
|
||||
public enum DiagnosticSeverity
|
||||
{
|
||||
/// <summary>Informational.</summary>
|
||||
Info = 0,
|
||||
/// <summary>Warning.</summary>
|
||||
Warning = 1,
|
||||
/// <summary>Error.</summary>
|
||||
Error = 2,
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using StellaOps.Scanner.EntryTrace.FileSystem;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for IRootFileSystem to support semantic analysis.
|
||||
/// </summary>
|
||||
public static class RootFileSystemExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Asynchronously checks if a directory exists.
|
||||
/// </summary>
|
||||
public static Task<bool> DirectoryExistsAsync(this IRootFileSystem fs, string path, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(fs.DirectoryExists(path));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously lists files matching a pattern in a directory.
|
||||
/// </summary>
|
||||
public static Task<IReadOnlyList<string>> ListFilesAsync(
|
||||
this IRootFileSystem fs,
|
||||
string path,
|
||||
string pattern,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var results = new List<string>();
|
||||
|
||||
if (!fs.DirectoryExists(path))
|
||||
return Task.FromResult<IReadOnlyList<string>>(results);
|
||||
|
||||
var entries = fs.EnumerateDirectory(path);
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.IsDirectory)
|
||||
continue;
|
||||
|
||||
var fileName = Path.GetFileName(entry.Path);
|
||||
if (MatchesPattern(fileName, pattern))
|
||||
{
|
||||
results.Add(entry.Path);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<string>>(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously reads a file as text.
|
||||
/// </summary>
|
||||
public static Task<string> ReadFileAsync(
|
||||
this IRootFileSystem fs,
|
||||
string path,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (fs.TryReadAllText(path, out _, out var content))
|
||||
{
|
||||
return Task.FromResult(content);
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"File not found: {path}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously tries to read a file as text.
|
||||
/// </summary>
|
||||
public static Task<string?> TryReadFileAsync(
|
||||
this IRootFileSystem fs,
|
||||
string path,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (fs.TryReadAllText(path, out _, out var content))
|
||||
{
|
||||
return Task.FromResult<string?>(content);
|
||||
}
|
||||
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a file exists.
|
||||
/// </summary>
|
||||
public static bool FileExists(this IRootFileSystem fs, string path)
|
||||
{
|
||||
return fs.TryReadBytes(path, 0, out _, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously checks if a file exists.
|
||||
/// </summary>
|
||||
public static Task<bool> FileExistsAsync(this IRootFileSystem fs, string path, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(fs.FileExists(path));
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string fileName, string pattern)
|
||||
{
|
||||
// Simple glob pattern matching (supports * and ?)
|
||||
if (string.IsNullOrEmpty(pattern))
|
||||
return true;
|
||||
|
||||
if (pattern == "*")
|
||||
return true;
|
||||
|
||||
// Handle *.ext pattern
|
||||
if (pattern.StartsWith("*."))
|
||||
{
|
||||
var ext = pattern[1..]; // Include the dot
|
||||
return fileName.EndsWith(ext, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Handle prefix* pattern
|
||||
if (pattern.EndsWith("*"))
|
||||
{
|
||||
var prefix = pattern[..^1];
|
||||
return fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Exact match
|
||||
return fileName.Equals(pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence tier for semantic inference.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 6).
|
||||
/// </remarks>
|
||||
public enum ConfidenceTier
|
||||
{
|
||||
/// <summary>Cannot determine with available evidence.</summary>
|
||||
Unknown = 0,
|
||||
/// <summary>Low confidence; heuristic-based with limited signals.</summary>
|
||||
Low = 1,
|
||||
/// <summary>Medium confidence; multiple signals agree.</summary>
|
||||
Medium = 2,
|
||||
/// <summary>High confidence; strong evidence from framework patterns.</summary>
|
||||
High = 3,
|
||||
/// <summary>Definitive; explicit declaration or unambiguous signature.</summary>
|
||||
Definitive = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents confidence in a semantic inference with supporting evidence.
|
||||
/// </summary>
|
||||
public sealed record SemanticConfidence
|
||||
{
|
||||
/// <summary>Numeric confidence score (0.0-1.0).</summary>
|
||||
public required double Score { get; init; }
|
||||
|
||||
/// <summary>Confidence tier classification.</summary>
|
||||
public required ConfidenceTier Tier { get; init; }
|
||||
|
||||
/// <summary>Chain of reasoning that led to this confidence.</summary>
|
||||
public required ImmutableArray<string> ReasoningChain { get; init; }
|
||||
|
||||
/// <summary>Number of signals that contributed to this inference.</summary>
|
||||
public int SignalCount { get; init; }
|
||||
|
||||
/// <summary>Whether conflicting signals were detected.</summary>
|
||||
public bool HasConflicts { get; init; }
|
||||
|
||||
/// <summary>Creates unknown confidence.</summary>
|
||||
public static SemanticConfidence Unknown() => new()
|
||||
{
|
||||
Score = 0.0,
|
||||
Tier = ConfidenceTier.Unknown,
|
||||
ReasoningChain = ImmutableArray.Create("No signals detected"),
|
||||
SignalCount = 0,
|
||||
HasConflicts = false
|
||||
};
|
||||
|
||||
/// <summary>Creates low confidence with reasoning.</summary>
|
||||
public static SemanticConfidence Low(params string[] reasons) => new()
|
||||
{
|
||||
Score = 0.25,
|
||||
Tier = ConfidenceTier.Low,
|
||||
ReasoningChain = reasons.ToImmutableArray(),
|
||||
SignalCount = reasons.Length,
|
||||
HasConflicts = false
|
||||
};
|
||||
|
||||
/// <summary>Creates medium confidence with reasoning.</summary>
|
||||
public static SemanticConfidence Medium(params string[] reasons) => new()
|
||||
{
|
||||
Score = 0.5,
|
||||
Tier = ConfidenceTier.Medium,
|
||||
ReasoningChain = reasons.ToImmutableArray(),
|
||||
SignalCount = reasons.Length,
|
||||
HasConflicts = false
|
||||
};
|
||||
|
||||
/// <summary>Creates high confidence with reasoning.</summary>
|
||||
public static SemanticConfidence High(params string[] reasons) => new()
|
||||
{
|
||||
Score = 0.75,
|
||||
Tier = ConfidenceTier.High,
|
||||
ReasoningChain = reasons.ToImmutableArray(),
|
||||
SignalCount = reasons.Length,
|
||||
HasConflicts = false
|
||||
};
|
||||
|
||||
/// <summary>Creates definitive confidence with reasoning.</summary>
|
||||
public static SemanticConfidence Definitive(params string[] reasons) => new()
|
||||
{
|
||||
Score = 1.0,
|
||||
Tier = ConfidenceTier.Definitive,
|
||||
ReasoningChain = reasons.ToImmutableArray(),
|
||||
SignalCount = reasons.Length,
|
||||
HasConflicts = false
|
||||
};
|
||||
|
||||
/// <summary>Creates confidence from score with auto-tiering.</summary>
|
||||
public static SemanticConfidence FromScore(double score, ImmutableArray<string> reasoning, bool hasConflicts = false)
|
||||
{
|
||||
var tier = score switch
|
||||
{
|
||||
>= 0.95 => ConfidenceTier.Definitive,
|
||||
>= 0.70 => ConfidenceTier.High,
|
||||
>= 0.40 => ConfidenceTier.Medium,
|
||||
>= 0.15 => ConfidenceTier.Low,
|
||||
_ => ConfidenceTier.Unknown
|
||||
};
|
||||
|
||||
return new()
|
||||
{
|
||||
Score = Math.Clamp(score, 0.0, 1.0),
|
||||
Tier = tier,
|
||||
ReasoningChain = reasoning,
|
||||
SignalCount = reasoning.Length,
|
||||
HasConflicts = hasConflicts
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Combines multiple confidence values with weighted average.</summary>
|
||||
public static SemanticConfidence Combine(IEnumerable<SemanticConfidence> confidences)
|
||||
{
|
||||
var list = confidences.ToList();
|
||||
if (list.Count == 0)
|
||||
return Unknown();
|
||||
|
||||
var totalScore = list.Sum(c => c.Score);
|
||||
var avgScore = totalScore / list.Count;
|
||||
var allReasons = list.SelectMany(c => c.ReasoningChain).ToImmutableArray();
|
||||
var hasConflicts = list.Any(c => c.HasConflicts) || HasConflictingTiers(list);
|
||||
|
||||
return FromScore(avgScore, allReasons, hasConflicts);
|
||||
}
|
||||
|
||||
private static bool HasConflictingTiers(List<SemanticConfidence> confidences)
|
||||
{
|
||||
if (confidences.Count < 2)
|
||||
return false;
|
||||
|
||||
var tiers = confidences.Select(c => c.Tier).Distinct().ToList();
|
||||
return tiers.Count > 1 && tiers.Max() - tiers.Min() > 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.EntryTrace.FileSystem;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic;
|
||||
|
||||
/// <summary>
|
||||
/// Entry trace analyzer with integrated semantic analysis.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 17).
|
||||
/// Wraps the base EntryTraceAnalyzer and adds semantic understanding.
|
||||
/// </remarks>
|
||||
public sealed class SemanticEntryTraceAnalyzer : ISemanticEntryTraceAnalyzer
|
||||
{
|
||||
private readonly IEntryTraceAnalyzer _baseAnalyzer;
|
||||
private readonly SemanticEntrypointOrchestrator _orchestrator;
|
||||
private readonly ILogger<SemanticEntryTraceAnalyzer> _logger;
|
||||
|
||||
public SemanticEntryTraceAnalyzer(
|
||||
IEntryTraceAnalyzer baseAnalyzer,
|
||||
SemanticEntrypointOrchestrator orchestrator,
|
||||
ILogger<SemanticEntryTraceAnalyzer> logger)
|
||||
{
|
||||
_baseAnalyzer = baseAnalyzer ?? throw new ArgumentNullException(nameof(baseAnalyzer));
|
||||
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public SemanticEntryTraceAnalyzer(
|
||||
IEntryTraceAnalyzer baseAnalyzer,
|
||||
ILogger<SemanticEntryTraceAnalyzer> logger)
|
||||
: this(baseAnalyzer, new SemanticEntrypointOrchestrator(), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<SemanticEntryTraceResult> ResolveWithSemanticsAsync(
|
||||
EntryTrace.EntrypointSpecification entrypoint,
|
||||
EntryTraceContext context,
|
||||
ContainerMetadata? containerMetadata = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entrypoint);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// Step 1: Run base entry trace analysis
|
||||
_logger.LogDebug("Starting entry trace resolution for scan {ScanId}", context.ScanId);
|
||||
var graph = await _baseAnalyzer.ResolveAsync(entrypoint, context, cancellationToken);
|
||||
|
||||
// Step 2: Build the full entry trace result
|
||||
var traceResult = new EntryTraceResult(
|
||||
context.ScanId,
|
||||
context.ImageDigest,
|
||||
DateTimeOffset.UtcNow,
|
||||
graph,
|
||||
SerializeToNdjson(graph));
|
||||
|
||||
// Step 3: Run semantic analysis
|
||||
_logger.LogDebug("Starting semantic analysis for scan {ScanId}", context.ScanId);
|
||||
SemanticEntrypoint? semanticResult = null;
|
||||
SemanticAnalysisResult? analysisResult = null;
|
||||
|
||||
try
|
||||
{
|
||||
var semanticContext = CreateSemanticContext(
|
||||
traceResult,
|
||||
context.FileSystem,
|
||||
containerMetadata);
|
||||
|
||||
analysisResult = await _orchestrator.AnalyzeAsync(semanticContext, cancellationToken);
|
||||
|
||||
if (analysisResult.Success && analysisResult.Entrypoint is not null)
|
||||
{
|
||||
semanticResult = analysisResult.Entrypoint;
|
||||
_logger.LogInformation(
|
||||
"Semantic analysis complete for scan {ScanId}: Intent={Intent}, Capabilities={CapCount}, Threats={ThreatCount}",
|
||||
context.ScanId,
|
||||
semanticResult.Intent,
|
||||
CountCapabilities(semanticResult.Capabilities),
|
||||
semanticResult.AttackSurface.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Semantic analysis incomplete for scan {ScanId}: {DiagnosticCount} diagnostics",
|
||||
context.ScanId,
|
||||
analysisResult.Diagnostics.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Semantic analysis failed for scan {ScanId}", context.ScanId);
|
||||
}
|
||||
|
||||
return new SemanticEntryTraceResult
|
||||
{
|
||||
TraceResult = traceResult,
|
||||
SemanticEntrypoint = semanticResult,
|
||||
AnalysisResult = analysisResult,
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<EntryTraceGraph> ResolveAsync(
|
||||
EntryTrace.EntrypointSpecification entrypoint,
|
||||
EntryTraceContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _baseAnalyzer.ResolveAsync(entrypoint, context, cancellationToken);
|
||||
}
|
||||
|
||||
private SemanticAnalysisContext CreateSemanticContext(
|
||||
EntryTraceResult traceResult,
|
||||
IRootFileSystem fileSystem,
|
||||
ContainerMetadata? containerMetadata)
|
||||
{
|
||||
var metadata = containerMetadata ?? ContainerMetadata.Empty;
|
||||
|
||||
// Convert base EntrypointSpecification to semantic version
|
||||
var plan = traceResult.Graph.Plans.FirstOrDefault();
|
||||
var spec = new Semantic.EntrypointSpecification
|
||||
{
|
||||
Entrypoint = plan?.Command ?? ImmutableArray<string>.Empty,
|
||||
Cmd = ImmutableArray<string>.Empty,
|
||||
WorkingDirectory = plan?.WorkingDirectory,
|
||||
User = plan?.User,
|
||||
Shell = metadata.Shell,
|
||||
Environment = metadata.Environment?.ToImmutableDictionary(),
|
||||
ExposedPorts = metadata.ExposedPorts,
|
||||
Volumes = metadata.Volumes,
|
||||
Labels = metadata.Labels?.ToImmutableDictionary(),
|
||||
ImageDigest = traceResult.ImageDigest,
|
||||
ImageReference = metadata.ImageReference
|
||||
};
|
||||
|
||||
return new SemanticAnalysisContext
|
||||
{
|
||||
Specification = spec,
|
||||
EntryTraceResult = traceResult,
|
||||
FileSystem = fileSystem,
|
||||
PrimaryLanguage = InferPrimaryLanguage(traceResult),
|
||||
DetectedLanguages = InferDetectedLanguages(traceResult),
|
||||
ManifestPaths = metadata.ManifestPaths ?? new Dictionary<string, string>(),
|
||||
Dependencies = metadata.Dependencies ?? new Dictionary<string, IReadOnlyList<string>>(),
|
||||
ImageDigest = traceResult.ImageDigest,
|
||||
ScanId = traceResult.ScanId
|
||||
};
|
||||
}
|
||||
|
||||
private static string? InferPrimaryLanguage(EntryTraceResult result)
|
||||
{
|
||||
var terminal = result.Graph.Terminals.FirstOrDefault();
|
||||
if (terminal?.Runtime is not null)
|
||||
{
|
||||
return terminal.Runtime.ToLowerInvariant() switch
|
||||
{
|
||||
var r when r.Contains("python") => "python",
|
||||
var r when r.Contains("node") => "node",
|
||||
var r when r.Contains("java") => "java",
|
||||
var r when r.Contains("dotnet") || r.Contains(".net") => "dotnet",
|
||||
var r when r.Contains("go") => "go",
|
||||
_ => terminal.Runtime
|
||||
};
|
||||
}
|
||||
|
||||
var interpreterNode = result.Graph.Nodes.FirstOrDefault(n => n.Kind == EntryTraceNodeKind.Interpreter);
|
||||
return interpreterNode?.InterpreterKind switch
|
||||
{
|
||||
EntryTraceInterpreterKind.Python => "python",
|
||||
EntryTraceInterpreterKind.Node => "node",
|
||||
EntryTraceInterpreterKind.Java => "java",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> InferDetectedLanguages(EntryTraceResult result)
|
||||
{
|
||||
var languages = new HashSet<string>();
|
||||
|
||||
foreach (var terminal in result.Graph.Terminals)
|
||||
{
|
||||
if (terminal.Runtime is not null)
|
||||
{
|
||||
var lang = terminal.Runtime.ToLowerInvariant() switch
|
||||
{
|
||||
var r when r.Contains("python") => "python",
|
||||
var r when r.Contains("node") => "node",
|
||||
var r when r.Contains("java") => "java",
|
||||
var r when r.Contains("dotnet") => "dotnet",
|
||||
var r when r.Contains("go") => "go",
|
||||
var r when r.Contains("ruby") => "ruby",
|
||||
var r when r.Contains("rust") => "rust",
|
||||
_ => null
|
||||
};
|
||||
if (lang is not null) languages.Add(lang);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var node in result.Graph.Nodes)
|
||||
{
|
||||
var lang = node.InterpreterKind switch
|
||||
{
|
||||
EntryTraceInterpreterKind.Python => "python",
|
||||
EntryTraceInterpreterKind.Node => "node",
|
||||
EntryTraceInterpreterKind.Java => "java",
|
||||
_ => null
|
||||
};
|
||||
if (lang is not null) languages.Add(lang);
|
||||
}
|
||||
|
||||
return languages.ToList();
|
||||
}
|
||||
|
||||
private static int CountCapabilities(CapabilityClass caps)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (CapabilityClass flag in Enum.GetValues<CapabilityClass>())
|
||||
{
|
||||
if (flag != CapabilityClass.None && !IsCompositeFlag(flag) && caps.HasFlag(flag))
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static bool IsCompositeFlag(CapabilityClass flag)
|
||||
{
|
||||
var val = (long)flag;
|
||||
return val != 0 && (val & (val - 1)) != 0;
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> SerializeToNdjson(EntryTraceGraph graph)
|
||||
{
|
||||
// Simplified serialization - full implementation would use proper JSON serialization
|
||||
var lines = new List<string>();
|
||||
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
lines.Add($"{{\"type\":\"node\",\"id\":{node.Id},\"kind\":\"{node.Kind}\",\"name\":\"{node.DisplayName}\"}}");
|
||||
}
|
||||
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
lines.Add($"{{\"type\":\"edge\",\"from\":{edge.FromNodeId},\"to\":{edge.ToNodeId},\"rel\":\"{edge.Relationship}\"}}");
|
||||
}
|
||||
|
||||
return lines.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for semantic-aware entry trace analysis.
|
||||
/// </summary>
|
||||
public interface ISemanticEntryTraceAnalyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves entrypoint graph (delegates to base analyzer).
|
||||
/// </summary>
|
||||
ValueTask<EntryTraceGraph> ResolveAsync(
|
||||
EntryTrace.EntrypointSpecification entrypoint,
|
||||
EntryTraceContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves entrypoint and performs semantic analysis.
|
||||
/// </summary>
|
||||
ValueTask<SemanticEntryTraceResult> ResolveWithSemanticsAsync(
|
||||
EntryTrace.EntrypointSpecification entrypoint,
|
||||
EntryTraceContext context,
|
||||
ContainerMetadata? containerMetadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combined result of entry trace resolution and semantic analysis.
|
||||
/// </summary>
|
||||
public sealed record SemanticEntryTraceResult
|
||||
{
|
||||
/// <summary>Base entry trace result.</summary>
|
||||
public required EntryTraceResult TraceResult { get; init; }
|
||||
|
||||
/// <summary>Semantic analysis result, if successful.</summary>
|
||||
public SemanticEntrypoint? SemanticEntrypoint { get; init; }
|
||||
|
||||
/// <summary>Full analysis result with diagnostics.</summary>
|
||||
public SemanticAnalysisResult? AnalysisResult { get; init; }
|
||||
|
||||
/// <summary>When the analysis was performed.</summary>
|
||||
public required DateTimeOffset AnalyzedAt { get; init; }
|
||||
|
||||
/// <summary>Whether semantic analysis succeeded.</summary>
|
||||
public bool HasSemantics => SemanticEntrypoint is not null;
|
||||
|
||||
/// <summary>Quick access to inferred intent.</summary>
|
||||
public ApplicationIntent Intent => SemanticEntrypoint?.Intent ?? ApplicationIntent.Unknown;
|
||||
|
||||
/// <summary>Quick access to detected capabilities.</summary>
|
||||
public CapabilityClass Capabilities => SemanticEntrypoint?.Capabilities ?? CapabilityClass.None;
|
||||
|
||||
/// <summary>Quick access to attack surface.</summary>
|
||||
public ImmutableArray<ThreatVector> AttackSurface =>
|
||||
SemanticEntrypoint?.AttackSurface ?? ImmutableArray<ThreatVector>.Empty;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an entrypoint with semantic understanding of intent, capabilities, and attack surface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 1).
|
||||
/// This is the core record that captures semantic analysis results for an entrypoint.
|
||||
/// </remarks>
|
||||
public sealed record SemanticEntrypoint
|
||||
{
|
||||
/// <summary>Unique identifier for this semantic analysis result.</summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>Reference to the underlying entrypoint specification.</summary>
|
||||
public required EntrypointSpecification Specification { get; init; }
|
||||
|
||||
/// <summary>Inferred application intent.</summary>
|
||||
public required ApplicationIntent Intent { get; init; }
|
||||
|
||||
/// <summary>Inferred capabilities (flags).</summary>
|
||||
public required CapabilityClass Capabilities { get; init; }
|
||||
|
||||
/// <summary>Identified threat vectors with confidence.</summary>
|
||||
public required ImmutableArray<ThreatVector> AttackSurface { get; init; }
|
||||
|
||||
/// <summary>Data flow boundaries detected.</summary>
|
||||
public required ImmutableArray<DataFlowBoundary> DataBoundaries { get; init; }
|
||||
|
||||
/// <summary>Overall confidence in the semantic analysis.</summary>
|
||||
public required SemanticConfidence Confidence { get; init; }
|
||||
|
||||
/// <summary>Language of the primary entrypoint code.</summary>
|
||||
public string? Language { get; init; }
|
||||
|
||||
/// <summary>Framework detected (e.g., "Django", "Spring Boot", "Express").</summary>
|
||||
public string? Framework { get; init; }
|
||||
|
||||
/// <summary>Framework version if detected.</summary>
|
||||
public string? FrameworkVersion { get; init; }
|
||||
|
||||
/// <summary>Runtime version if detected.</summary>
|
||||
public string? RuntimeVersion { get; init; }
|
||||
|
||||
/// <summary>Additional metadata.</summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
/// <summary>Timestamp when analysis was performed (UTC ISO-8601).</summary>
|
||||
public required string AnalyzedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specification of the entrypoint being analyzed.
|
||||
/// </summary>
|
||||
public sealed record EntrypointSpecification
|
||||
{
|
||||
/// <summary>Container ENTRYPOINT command array.</summary>
|
||||
public ImmutableArray<string> Entrypoint { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>Container CMD command array.</summary>
|
||||
public ImmutableArray<string> Cmd { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>Working directory for entrypoint execution.</summary>
|
||||
public string? WorkingDirectory { get; init; }
|
||||
|
||||
/// <summary>User context for execution.</summary>
|
||||
public string? User { get; init; }
|
||||
|
||||
/// <summary>Shell used for shell-form commands.</summary>
|
||||
public string? Shell { get; init; }
|
||||
|
||||
/// <summary>Environment variables set in the image.</summary>
|
||||
public ImmutableDictionary<string, string>? Environment { get; init; }
|
||||
|
||||
/// <summary>Exposed ports in the image.</summary>
|
||||
public ImmutableArray<int> ExposedPorts { get; init; } = ImmutableArray<int>.Empty;
|
||||
|
||||
/// <summary>Volumes defined in the image.</summary>
|
||||
public ImmutableArray<string> Volumes { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>Labels set in the image.</summary>
|
||||
public ImmutableDictionary<string, string>? Labels { get; init; }
|
||||
|
||||
/// <summary>Image digest (sha256).</summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>Image reference (registry/repo:tag).</summary>
|
||||
public string? ImageReference { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating SemanticEntrypoint instances.
|
||||
/// </summary>
|
||||
public sealed class SemanticEntrypointBuilder
|
||||
{
|
||||
private string? _id;
|
||||
private EntrypointSpecification? _specification;
|
||||
private ApplicationIntent _intent = ApplicationIntent.Unknown;
|
||||
private CapabilityClass _capabilities = CapabilityClass.None;
|
||||
private readonly List<ThreatVector> _attackSurface = new();
|
||||
private readonly List<DataFlowBoundary> _dataBoundaries = new();
|
||||
private SemanticConfidence? _confidence;
|
||||
private string? _language;
|
||||
private string? _framework;
|
||||
private string? _frameworkVersion;
|
||||
private string? _runtimeVersion;
|
||||
private readonly Dictionary<string, string> _metadata = new();
|
||||
|
||||
public SemanticEntrypointBuilder WithId(string id)
|
||||
{
|
||||
_id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticEntrypointBuilder WithSpecification(EntrypointSpecification specification)
|
||||
{
|
||||
_specification = specification;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticEntrypointBuilder WithIntent(ApplicationIntent intent)
|
||||
{
|
||||
_intent = intent;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticEntrypointBuilder WithCapabilities(CapabilityClass capabilities)
|
||||
{
|
||||
_capabilities = capabilities;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticEntrypointBuilder AddCapability(CapabilityClass capability)
|
||||
{
|
||||
_capabilities |= capability;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticEntrypointBuilder AddThreatVector(ThreatVector vector)
|
||||
{
|
||||
_attackSurface.Add(vector);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticEntrypointBuilder AddDataBoundary(DataFlowBoundary boundary)
|
||||
{
|
||||
_dataBoundaries.Add(boundary);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticEntrypointBuilder WithConfidence(SemanticConfidence confidence)
|
||||
{
|
||||
_confidence = confidence;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticEntrypointBuilder WithLanguage(string language)
|
||||
{
|
||||
_language = language;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticEntrypointBuilder WithFramework(string framework, string? version = null)
|
||||
{
|
||||
_framework = framework;
|
||||
_frameworkVersion = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticEntrypointBuilder WithRuntimeVersion(string version)
|
||||
{
|
||||
_runtimeVersion = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticEntrypointBuilder AddMetadata(string key, string value)
|
||||
{
|
||||
_metadata[key] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SemanticEntrypoint Build()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_id))
|
||||
throw new InvalidOperationException("Id is required");
|
||||
if (_specification is null)
|
||||
throw new InvalidOperationException("Specification is required");
|
||||
|
||||
return new SemanticEntrypoint
|
||||
{
|
||||
Id = _id,
|
||||
Specification = _specification,
|
||||
Intent = _intent,
|
||||
Capabilities = _capabilities,
|
||||
AttackSurface = _attackSurface.ToImmutableArray(),
|
||||
DataBoundaries = _dataBoundaries.ToImmutableArray(),
|
||||
Confidence = _confidence ?? SemanticConfidence.Unknown(),
|
||||
Language = _language,
|
||||
Framework = _framework,
|
||||
FrameworkVersion = _frameworkVersion,
|
||||
RuntimeVersion = _runtimeVersion,
|
||||
Metadata = _metadata.Count > 0 ? _metadata.ToImmutableDictionary() : null,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace.FileSystem;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic.Adapters;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic.Analysis;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates semantic analysis by composing adapters, detectors, and inferrers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 16).
|
||||
/// Provides unified semantic analysis pipeline for all supported languages.
|
||||
/// </remarks>
|
||||
public sealed class SemanticEntrypointOrchestrator
|
||||
{
|
||||
private readonly IReadOnlyList<ISemanticEntrypointAnalyzer> _adapters;
|
||||
private readonly CapabilityDetector _capabilityDetector;
|
||||
private readonly ThreatVectorInferrer _threatInferrer;
|
||||
private readonly DataBoundaryMapper _boundaryMapper;
|
||||
|
||||
public SemanticEntrypointOrchestrator()
|
||||
: this(CreateDefaultAdapters(), new CapabilityDetector(), new ThreatVectorInferrer(), new DataBoundaryMapper())
|
||||
{
|
||||
}
|
||||
|
||||
public SemanticEntrypointOrchestrator(
|
||||
IReadOnlyList<ISemanticEntrypointAnalyzer> adapters,
|
||||
CapabilityDetector capabilityDetector,
|
||||
ThreatVectorInferrer threatInferrer,
|
||||
DataBoundaryMapper boundaryMapper)
|
||||
{
|
||||
_adapters = adapters.OrderByDescending(a => a.Priority).ToList();
|
||||
_capabilityDetector = capabilityDetector;
|
||||
_threatInferrer = threatInferrer;
|
||||
_boundaryMapper = boundaryMapper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs full semantic analysis on an entrypoint.
|
||||
/// </summary>
|
||||
public async Task<SemanticAnalysisResult> AnalyzeAsync(
|
||||
SemanticAnalysisContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var diagnostics = new List<SemanticDiagnostic>();
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Run capability detection
|
||||
var capabilityResult = _capabilityDetector.Detect(context);
|
||||
diagnostics.Add(SemanticDiagnostic.Info(
|
||||
"CAP-001",
|
||||
$"Detected {CountCapabilities(capabilityResult.Capabilities)} capabilities"));
|
||||
|
||||
// Step 2: Find matching language adapter
|
||||
var adapter = FindAdapter(context);
|
||||
if (adapter is null)
|
||||
{
|
||||
diagnostics.Add(SemanticDiagnostic.Warning(
|
||||
"ADAPT-001",
|
||||
$"No adapter found for language: {context.PrimaryLanguage ?? "unknown"}"));
|
||||
|
||||
// Return partial result with just capability detection
|
||||
return CreatePartialResult(context, capabilityResult, diagnostics);
|
||||
}
|
||||
|
||||
// Step 3: Run language-specific adapter
|
||||
var adapterResult = await adapter.AnalyzeAsync(context, cancellationToken);
|
||||
diagnostics.Add(SemanticDiagnostic.Info(
|
||||
"ADAPT-002",
|
||||
$"Adapter {adapter.GetType().Name} inferred intent: {adapterResult.Intent}"));
|
||||
|
||||
// Step 4: Merge capabilities from adapter and detector
|
||||
var mergedCapabilities = adapterResult.Capabilities | capabilityResult.Capabilities;
|
||||
|
||||
// Step 5: Run threat vector inference
|
||||
var threatResult = _threatInferrer.Infer(
|
||||
mergedCapabilities,
|
||||
adapterResult.Intent,
|
||||
capabilityResult.Evidence.ToList());
|
||||
diagnostics.Add(SemanticDiagnostic.Info(
|
||||
"THREAT-001",
|
||||
$"Inferred {threatResult.ThreatVectors.Length} threat vectors, risk score: {threatResult.OverallRiskScore:P0}"));
|
||||
|
||||
// Step 6: Map data boundaries
|
||||
var boundaryResult = _boundaryMapper.Map(
|
||||
context,
|
||||
adapterResult.Intent,
|
||||
mergedCapabilities,
|
||||
capabilityResult.Evidence.ToList());
|
||||
diagnostics.Add(SemanticDiagnostic.Info(
|
||||
"BOUND-001",
|
||||
$"Mapped {boundaryResult.Boundaries.Length} data boundaries " +
|
||||
$"({boundaryResult.InboundCount} inbound, {boundaryResult.OutboundCount} outbound)"));
|
||||
|
||||
// Step 7: Combine all results into final semantic entrypoint
|
||||
var semanticEntrypoint = BuildFinalResult(
|
||||
context,
|
||||
adapterResult,
|
||||
mergedCapabilities,
|
||||
threatResult,
|
||||
boundaryResult,
|
||||
capabilityResult);
|
||||
|
||||
return SemanticAnalysisResult.Successful(semanticEntrypoint);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
diagnostics.Add(SemanticDiagnostic.Error("ERR-001", $"Analysis failed: {ex.Message}"));
|
||||
return SemanticAnalysisResult.Failed(diagnostics.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs quick analysis returning only intent and capabilities.
|
||||
/// </summary>
|
||||
public async Task<QuickSemanticResult> AnalyzeQuickAsync(
|
||||
SemanticAnalysisContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var capabilityResult = _capabilityDetector.Detect(context);
|
||||
var adapter = FindAdapter(context);
|
||||
|
||||
if (adapter is null)
|
||||
{
|
||||
return new QuickSemanticResult
|
||||
{
|
||||
Intent = ApplicationIntent.Unknown,
|
||||
Capabilities = capabilityResult.Capabilities,
|
||||
Confidence = capabilityResult.Confidence,
|
||||
Language = context.PrimaryLanguage
|
||||
};
|
||||
}
|
||||
|
||||
var adapterResult = await adapter.AnalyzeAsync(context, cancellationToken);
|
||||
|
||||
return new QuickSemanticResult
|
||||
{
|
||||
Intent = adapterResult.Intent,
|
||||
Capabilities = adapterResult.Capabilities | capabilityResult.Capabilities,
|
||||
Confidence = adapterResult.Confidence,
|
||||
Language = adapterResult.Language,
|
||||
Framework = adapterResult.Framework
|
||||
};
|
||||
}
|
||||
|
||||
private ISemanticEntrypointAnalyzer? FindAdapter(SemanticAnalysisContext context)
|
||||
{
|
||||
var language = context.PrimaryLanguage?.ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(language))
|
||||
{
|
||||
// Try to infer from detected languages
|
||||
language = context.DetectedLanguages.FirstOrDefault()?.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(language))
|
||||
return null;
|
||||
|
||||
return _adapters.FirstOrDefault(a =>
|
||||
a.SupportedLanguages.Any(l =>
|
||||
l.Equals(language, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
private SemanticAnalysisResult CreatePartialResult(
|
||||
SemanticAnalysisContext context,
|
||||
CapabilityDetectionResult capabilityResult,
|
||||
List<SemanticDiagnostic> diagnostics)
|
||||
{
|
||||
var partial = new PartialSemanticResult
|
||||
{
|
||||
Intent = null,
|
||||
Capabilities = capabilityResult.Capabilities,
|
||||
Confidence = capabilityResult.Confidence,
|
||||
IncompleteReason = "No matching language adapter found"
|
||||
};
|
||||
|
||||
return SemanticAnalysisResult.Partial(partial, diagnostics.ToArray());
|
||||
}
|
||||
|
||||
private SemanticEntrypoint BuildFinalResult(
|
||||
SemanticAnalysisContext context,
|
||||
SemanticEntrypoint adapterResult,
|
||||
CapabilityClass mergedCapabilities,
|
||||
ThreatInferenceResult threatResult,
|
||||
DataBoundaryMappingResult boundaryResult,
|
||||
CapabilityDetectionResult capabilityResult)
|
||||
{
|
||||
// Combine confidence from all sources
|
||||
var combinedConfidence = SemanticConfidence.Combine(new[]
|
||||
{
|
||||
adapterResult.Confidence,
|
||||
capabilityResult.Confidence,
|
||||
threatResult.Confidence,
|
||||
boundaryResult.Confidence
|
||||
});
|
||||
|
||||
// Build metadata
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["risk_score"] = threatResult.OverallRiskScore.ToString("F3"),
|
||||
["capability_count"] = CountCapabilities(mergedCapabilities).ToString(),
|
||||
["threat_count"] = threatResult.ThreatVectors.Length.ToString(),
|
||||
["boundary_count"] = boundaryResult.Boundaries.Length.ToString(),
|
||||
["security_sensitive_boundaries"] = boundaryResult.SecuritySensitiveCount.ToString()
|
||||
};
|
||||
|
||||
if (context.ScanId is not null)
|
||||
metadata["scan_id"] = context.ScanId;
|
||||
|
||||
return new SemanticEntrypoint
|
||||
{
|
||||
Id = adapterResult.Id,
|
||||
Specification = context.Specification,
|
||||
Intent = adapterResult.Intent,
|
||||
Capabilities = mergedCapabilities,
|
||||
AttackSurface = threatResult.ThreatVectors,
|
||||
DataBoundaries = boundaryResult.Boundaries,
|
||||
Confidence = combinedConfidence,
|
||||
Language = adapterResult.Language,
|
||||
Framework = adapterResult.Framework,
|
||||
FrameworkVersion = adapterResult.FrameworkVersion,
|
||||
RuntimeVersion = adapterResult.RuntimeVersion,
|
||||
Metadata = metadata.ToImmutableDictionary(),
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
}
|
||||
|
||||
private static int CountCapabilities(CapabilityClass caps)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (CapabilityClass flag in Enum.GetValues<CapabilityClass>())
|
||||
{
|
||||
if (flag != CapabilityClass.None && !IsCompositeFlag(flag) && caps.HasFlag(flag))
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static bool IsCompositeFlag(CapabilityClass flag)
|
||||
{
|
||||
// Composite flags have multiple bits set
|
||||
var val = (long)flag;
|
||||
return val != 0 && (val & (val - 1)) != 0;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ISemanticEntrypointAnalyzer> CreateDefaultAdapters()
|
||||
{
|
||||
return new ISemanticEntrypointAnalyzer[]
|
||||
{
|
||||
new PythonSemanticAdapter(),
|
||||
new JavaSemanticAdapter(),
|
||||
new NodeSemanticAdapter(),
|
||||
new DotNetSemanticAdapter(),
|
||||
new GoSemanticAdapter(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quick semantic analysis result with just intent and capabilities.
|
||||
/// </summary>
|
||||
public sealed record QuickSemanticResult
|
||||
{
|
||||
public required ApplicationIntent Intent { get; init; }
|
||||
public required CapabilityClass Capabilities { get; init; }
|
||||
public required SemanticConfidence Confidence { get; init; }
|
||||
public string? Language { get; init; }
|
||||
public string? Framework { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for semantic orchestrator.
|
||||
/// </summary>
|
||||
public static class SemanticEntrypointOrchestratorExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a context from an entry trace result and container metadata.
|
||||
/// </summary>
|
||||
public static SemanticAnalysisContext CreateContext(
|
||||
this SemanticEntrypointOrchestrator _,
|
||||
EntryTraceResult entryTraceResult,
|
||||
IRootFileSystem fileSystem,
|
||||
ContainerMetadata? containerMetadata = null)
|
||||
{
|
||||
var metadata = containerMetadata ?? ContainerMetadata.Empty;
|
||||
|
||||
// Build specification from trace result and container metadata
|
||||
var spec = new EntrypointSpecification
|
||||
{
|
||||
Entrypoint = ExtractEntrypoint(entryTraceResult),
|
||||
Cmd = ExtractCmd(entryTraceResult),
|
||||
WorkingDirectory = ExtractWorkingDirectory(entryTraceResult),
|
||||
User = ExtractUser(entryTraceResult),
|
||||
Shell = metadata.Shell,
|
||||
Environment = metadata.Environment?.ToImmutableDictionary(),
|
||||
ExposedPorts = metadata.ExposedPorts,
|
||||
Volumes = metadata.Volumes,
|
||||
Labels = metadata.Labels?.ToImmutableDictionary(),
|
||||
ImageDigest = entryTraceResult.ImageDigest,
|
||||
ImageReference = metadata.ImageReference
|
||||
};
|
||||
|
||||
return new SemanticAnalysisContext
|
||||
{
|
||||
Specification = spec,
|
||||
EntryTraceResult = entryTraceResult,
|
||||
FileSystem = fileSystem,
|
||||
PrimaryLanguage = InferPrimaryLanguage(entryTraceResult),
|
||||
DetectedLanguages = InferDetectedLanguages(entryTraceResult),
|
||||
ManifestPaths = metadata.ManifestPaths ?? new Dictionary<string, string>(),
|
||||
Dependencies = metadata.Dependencies ?? new Dictionary<string, IReadOnlyList<string>>(),
|
||||
ImageDigest = entryTraceResult.ImageDigest,
|
||||
ScanId = entryTraceResult.ScanId
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ExtractEntrypoint(EntryTraceResult result)
|
||||
{
|
||||
// Extract from first plan if available
|
||||
var plan = result.Graph.Plans.FirstOrDefault();
|
||||
return plan?.Command ?? ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ExtractCmd(EntryTraceResult result)
|
||||
{
|
||||
// CMD is typically the arguments after entrypoint
|
||||
var plan = result.Graph.Plans.FirstOrDefault();
|
||||
if (plan is null || plan.Command.Length <= 1)
|
||||
return ImmutableArray<string>.Empty;
|
||||
|
||||
return plan.Command.Skip(1).ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string? ExtractWorkingDirectory(EntryTraceResult result)
|
||||
{
|
||||
var plan = result.Graph.Plans.FirstOrDefault();
|
||||
return plan?.WorkingDirectory;
|
||||
}
|
||||
|
||||
private static string? ExtractUser(EntryTraceResult result)
|
||||
{
|
||||
var plan = result.Graph.Plans.FirstOrDefault();
|
||||
return plan?.User;
|
||||
}
|
||||
|
||||
private static string? InferPrimaryLanguage(EntryTraceResult result)
|
||||
{
|
||||
// Infer from terminal runtime or interpreter nodes
|
||||
var terminal = result.Graph.Terminals.FirstOrDefault();
|
||||
if (terminal?.Runtime is not null)
|
||||
{
|
||||
return terminal.Runtime.ToLowerInvariant() switch
|
||||
{
|
||||
var r when r.Contains("python") => "python",
|
||||
var r when r.Contains("node") => "node",
|
||||
var r when r.Contains("java") => "java",
|
||||
var r when r.Contains("dotnet") || r.Contains(".net") => "dotnet",
|
||||
var r when r.Contains("go") => "go",
|
||||
_ => terminal.Runtime
|
||||
};
|
||||
}
|
||||
|
||||
// Check interpreter nodes
|
||||
var interpreterNode = result.Graph.Nodes.FirstOrDefault(n => n.Kind == EntryTraceNodeKind.Interpreter);
|
||||
return interpreterNode?.InterpreterKind switch
|
||||
{
|
||||
EntryTraceInterpreterKind.Python => "python",
|
||||
EntryTraceInterpreterKind.Node => "node",
|
||||
EntryTraceInterpreterKind.Java => "java",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> InferDetectedLanguages(EntryTraceResult result)
|
||||
{
|
||||
var languages = new HashSet<string>();
|
||||
|
||||
foreach (var terminal in result.Graph.Terminals)
|
||||
{
|
||||
if (terminal.Runtime is not null)
|
||||
{
|
||||
var lang = terminal.Runtime.ToLowerInvariant() switch
|
||||
{
|
||||
var r when r.Contains("python") => "python",
|
||||
var r when r.Contains("node") => "node",
|
||||
var r when r.Contains("java") => "java",
|
||||
var r when r.Contains("dotnet") => "dotnet",
|
||||
var r when r.Contains("go") => "go",
|
||||
var r when r.Contains("ruby") => "ruby",
|
||||
var r when r.Contains("rust") => "rust",
|
||||
_ => null
|
||||
};
|
||||
if (lang is not null) languages.Add(lang);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var node in result.Graph.Nodes)
|
||||
{
|
||||
var lang = node.InterpreterKind switch
|
||||
{
|
||||
EntryTraceInterpreterKind.Python => "python",
|
||||
EntryTraceInterpreterKind.Node => "node",
|
||||
EntryTraceInterpreterKind.Java => "java",
|
||||
_ => null
|
||||
};
|
||||
if (lang is not null) languages.Add(lang);
|
||||
}
|
||||
|
||||
return languages.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Container metadata not present in EntryTraceResult.
|
||||
/// </summary>
|
||||
public sealed record ContainerMetadata
|
||||
{
|
||||
public string? Shell { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Environment { get; init; }
|
||||
public ImmutableArray<int> ExposedPorts { get; init; } = ImmutableArray<int>.Empty;
|
||||
public ImmutableArray<string> Volumes { get; init; } = ImmutableArray<string>.Empty;
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
public string? ImageReference { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? ManifestPaths { get; init; }
|
||||
public IReadOnlyDictionary<string, IReadOnlyList<string>>? Dependencies { get; init; }
|
||||
|
||||
public static ContainerMetadata Empty => new();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Semantic;
|
||||
|
||||
/// <summary>
|
||||
/// Types of security threat vectors inferred from entrypoint analysis.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 4).
|
||||
/// </remarks>
|
||||
public enum ThreatVectorType
|
||||
{
|
||||
/// <summary>Server-Side Request Forgery.</summary>
|
||||
Ssrf = 1,
|
||||
/// <summary>SQL Injection.</summary>
|
||||
SqlInjection = 2,
|
||||
/// <summary>Cross-Site Scripting.</summary>
|
||||
Xss = 3,
|
||||
/// <summary>Remote Code Execution.</summary>
|
||||
Rce = 4,
|
||||
/// <summary>Path Traversal.</summary>
|
||||
PathTraversal = 5,
|
||||
/// <summary>Insecure Deserialization.</summary>
|
||||
InsecureDeserialization = 6,
|
||||
/// <summary>Template Injection.</summary>
|
||||
TemplateInjection = 7,
|
||||
/// <summary>Authentication Bypass.</summary>
|
||||
AuthenticationBypass = 8,
|
||||
/// <summary>Authorization Bypass.</summary>
|
||||
AuthorizationBypass = 9,
|
||||
/// <summary>Information Disclosure.</summary>
|
||||
InformationDisclosure = 10,
|
||||
/// <summary>Denial of Service.</summary>
|
||||
DenialOfService = 11,
|
||||
/// <summary>Command Injection.</summary>
|
||||
CommandInjection = 12,
|
||||
/// <summary>LDAP Injection.</summary>
|
||||
LdapInjection = 13,
|
||||
/// <summary>XML External Entity.</summary>
|
||||
XxeInjection = 14,
|
||||
/// <summary>Open Redirect.</summary>
|
||||
OpenRedirect = 15,
|
||||
/// <summary>Insecure Direct Object Reference.</summary>
|
||||
Idor = 16,
|
||||
/// <summary>Cross-Site Request Forgery.</summary>
|
||||
Csrf = 17,
|
||||
/// <summary>Cryptographic Weakness.</summary>
|
||||
CryptoWeakness = 18,
|
||||
/// <summary>Container Escape.</summary>
|
||||
ContainerEscape = 19,
|
||||
/// <summary>Privilege Escalation.</summary>
|
||||
PrivilegeEscalation = 20,
|
||||
/// <summary>Mass Assignment.</summary>
|
||||
MassAssignment = 21,
|
||||
/// <summary>Log Injection.</summary>
|
||||
LogInjection = 22,
|
||||
/// <summary>Header Injection.</summary>
|
||||
HeaderInjection = 23,
|
||||
/// <summary>Regex Denial of Service.</summary>
|
||||
ReDoS = 24,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an inferred threat vector with confidence and evidence.
|
||||
/// </summary>
|
||||
public sealed record ThreatVector
|
||||
{
|
||||
/// <summary>The type of threat vector.</summary>
|
||||
public required ThreatVectorType Type { get; init; }
|
||||
|
||||
/// <summary>Confidence in the inference (0.0-1.0).</summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>Capabilities that contributed to this inference.</summary>
|
||||
public required CapabilityClass ContributingCapabilities { get; init; }
|
||||
|
||||
/// <summary>Evidence strings explaining why this was inferred.</summary>
|
||||
public required ImmutableArray<string> Evidence { get; init; }
|
||||
|
||||
/// <summary>Entry paths where this threat vector is reachable.</summary>
|
||||
public ImmutableArray<string> EntryPaths { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>Additional metadata.</summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for ThreatVectorType.
|
||||
/// </summary>
|
||||
public static class ThreatVectorTypeExtensions
|
||||
{
|
||||
/// <summary>Gets the OWASP Top 10 category.</summary>
|
||||
public static string? GetOwaspCategory(this ThreatVectorType type) => type switch
|
||||
{
|
||||
ThreatVectorType.SqlInjection => "A03:2021-Injection",
|
||||
ThreatVectorType.CommandInjection => "A03:2021-Injection",
|
||||
ThreatVectorType.LdapInjection => "A03:2021-Injection",
|
||||
ThreatVectorType.XxeInjection => "A03:2021-Injection",
|
||||
ThreatVectorType.TemplateInjection => "A03:2021-Injection",
|
||||
ThreatVectorType.Xss => "A03:2021-Injection",
|
||||
ThreatVectorType.AuthenticationBypass => "A07:2021-Identification and Authentication Failures",
|
||||
ThreatVectorType.AuthorizationBypass => "A01:2021-Broken Access Control",
|
||||
ThreatVectorType.Idor => "A01:2021-Broken Access Control",
|
||||
ThreatVectorType.PathTraversal => "A01:2021-Broken Access Control",
|
||||
ThreatVectorType.InsecureDeserialization => "A08:2021-Software and Data Integrity Failures",
|
||||
ThreatVectorType.CryptoWeakness => "A02:2021-Cryptographic Failures",
|
||||
ThreatVectorType.InformationDisclosure => "A02:2021-Cryptographic Failures",
|
||||
ThreatVectorType.Ssrf => "A10:2021-Server-Side Request Forgery",
|
||||
ThreatVectorType.Csrf => "A01:2021-Broken Access Control",
|
||||
ThreatVectorType.Rce => "A03:2021-Injection",
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>Gets the CWE ID.</summary>
|
||||
public static int? GetCweId(this ThreatVectorType type) => type switch
|
||||
{
|
||||
ThreatVectorType.Ssrf => 918,
|
||||
ThreatVectorType.SqlInjection => 89,
|
||||
ThreatVectorType.Xss => 79,
|
||||
ThreatVectorType.Rce => 94,
|
||||
ThreatVectorType.PathTraversal => 22,
|
||||
ThreatVectorType.InsecureDeserialization => 502,
|
||||
ThreatVectorType.TemplateInjection => 1336,
|
||||
ThreatVectorType.AuthenticationBypass => 287,
|
||||
ThreatVectorType.AuthorizationBypass => 862,
|
||||
ThreatVectorType.InformationDisclosure => 200,
|
||||
ThreatVectorType.DenialOfService => 400,
|
||||
ThreatVectorType.CommandInjection => 78,
|
||||
ThreatVectorType.LdapInjection => 90,
|
||||
ThreatVectorType.XxeInjection => 611,
|
||||
ThreatVectorType.OpenRedirect => 601,
|
||||
ThreatVectorType.Idor => 639,
|
||||
ThreatVectorType.Csrf => 352,
|
||||
ThreatVectorType.CryptoWeakness => 327,
|
||||
ThreatVectorType.ContainerEscape => 1022,
|
||||
ThreatVectorType.PrivilegeEscalation => 269,
|
||||
ThreatVectorType.MassAssignment => 915,
|
||||
ThreatVectorType.LogInjection => 117,
|
||||
ThreatVectorType.HeaderInjection => 113,
|
||||
ThreatVectorType.ReDoS => 1333,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,9 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.EntryTrace.Diagnostics;
|
||||
using StellaOps.Scanner.EntryTrace.Runtime;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic.Adapters;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic.Analysis;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
@@ -29,4 +32,83 @@ public static class ServiceCollectionExtensions
|
||||
services.TryAddSingleton<IEntryTraceResultStore, NullEntryTraceResultStore>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds entry trace analyzer with integrated semantic analysis.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 17).
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddSemanticEntryTraceAnalyzer(
|
||||
this IServiceCollection services,
|
||||
Action<EntryTraceAnalyzerOptions>? configure = null,
|
||||
Action<SemanticAnalysisOptions>? configureSemantic = null)
|
||||
{
|
||||
if (services is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
// Add base entry trace analyzer
|
||||
services.AddEntryTraceAnalyzer(configure);
|
||||
|
||||
// Add semantic analysis options
|
||||
services.AddOptions<SemanticAnalysisOptions>()
|
||||
.BindConfiguration(SemanticAnalysisOptions.SectionName);
|
||||
|
||||
if (configureSemantic is not null)
|
||||
{
|
||||
services.Configure(configureSemantic);
|
||||
}
|
||||
|
||||
// Register semantic analysis components
|
||||
services.TryAddSingleton<CapabilityDetector>();
|
||||
services.TryAddSingleton<ThreatVectorInferrer>();
|
||||
services.TryAddSingleton<DataBoundaryMapper>();
|
||||
|
||||
// Register language adapters
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISemanticEntrypointAnalyzer, PythonSemanticAdapter>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISemanticEntrypointAnalyzer, JavaSemanticAdapter>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISemanticEntrypointAnalyzer, NodeSemanticAdapter>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISemanticEntrypointAnalyzer, DotNetSemanticAdapter>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISemanticEntrypointAnalyzer, GoSemanticAdapter>());
|
||||
|
||||
// Register orchestrator
|
||||
services.TryAddSingleton<SemanticEntrypointOrchestrator>(sp =>
|
||||
{
|
||||
var adapters = sp.GetServices<ISemanticEntrypointAnalyzer>().ToList();
|
||||
var capabilityDetector = sp.GetRequiredService<CapabilityDetector>();
|
||||
var threatInferrer = sp.GetRequiredService<ThreatVectorInferrer>();
|
||||
var boundaryMapper = sp.GetRequiredService<DataBoundaryMapper>();
|
||||
return new SemanticEntrypointOrchestrator(adapters, capabilityDetector, threatInferrer, boundaryMapper);
|
||||
});
|
||||
|
||||
// Register semantic entry trace analyzer
|
||||
services.TryAddSingleton<ISemanticEntryTraceAnalyzer, SemanticEntryTraceAnalyzer>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for semantic analysis behavior.
|
||||
/// </summary>
|
||||
public sealed class SemanticAnalysisOptions
|
||||
{
|
||||
public const string SectionName = "Scanner:EntryTrace:Semantic";
|
||||
|
||||
/// <summary>Whether semantic analysis is enabled.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Minimum confidence threshold for threat vectors (0.0-1.0).</summary>
|
||||
public double ThreatConfidenceThreshold { get; set; } = 0.3;
|
||||
|
||||
/// <summary>Maximum number of threat vectors to emit per entrypoint.</summary>
|
||||
public int MaxThreatVectors { get; set; } = 50;
|
||||
|
||||
/// <summary>Whether to include low-confidence capabilities.</summary>
|
||||
public bool IncludeLowConfidenceCapabilities { get; set; } = false;
|
||||
|
||||
/// <summary>Languages to include in semantic analysis (empty = all).</summary>
|
||||
public IReadOnlyList<string> EnabledLanguages { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user