Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly. - Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps. - Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges. - Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges. - Set up project file for the test project with necessary dependencies and configurations. - Include JSON fixture files for testing purposes.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Telemetry.Core\\StellaOps.Telemetry.Core.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\AirGap\\StellaOps.AirGap.Policy\\StellaOps.AirGap.Policy\\StellaOps.AirGap.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Telemetry.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Telemetry.Core.Tests;
|
||||
|
||||
public sealed class TelemetryExporterGuardTests
|
||||
{
|
||||
[Fact]
|
||||
public void AllowsExporterWhenPolicyMissing()
|
||||
{
|
||||
var loggerFactory = CreateLoggerFactory();
|
||||
var guard = new TelemetryExporterGuard(loggerFactory.CreateLogger<TelemetryExporterGuard>());
|
||||
var descriptor = new TelemetryServiceDescriptor("TestService", "1.0.0");
|
||||
var collectorOptions = new StellaOpsTelemetryOptions.CollectorOptions
|
||||
{
|
||||
Component = "test-service",
|
||||
Intent = "telemetry-export",
|
||||
};
|
||||
|
||||
var allowed = guard.IsExporterAllowed(
|
||||
descriptor,
|
||||
collectorOptions,
|
||||
TelemetrySignal.Traces,
|
||||
new Uri("https://collector.internal"),
|
||||
out var decision);
|
||||
|
||||
Assert.True(allowed);
|
||||
Assert.Null(decision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlocksRemoteEndpointWhenSealed()
|
||||
{
|
||||
var policyOptions = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
AllowLoopback = true,
|
||||
};
|
||||
|
||||
var policy = new EgressPolicy(policyOptions);
|
||||
var provider = new CollectingLoggerProvider();
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(provider));
|
||||
|
||||
var guard = new TelemetryExporterGuard(loggerFactory.CreateLogger<TelemetryExporterGuard>(), policy);
|
||||
var descriptor = new TelemetryServiceDescriptor("PolicyEngine", "1.2.3");
|
||||
var collectorOptions = new StellaOpsTelemetryOptions.CollectorOptions
|
||||
{
|
||||
Component = "policy-engine",
|
||||
Intent = "telemetry-export",
|
||||
};
|
||||
|
||||
var allowed = guard.IsExporterAllowed(
|
||||
descriptor,
|
||||
collectorOptions,
|
||||
TelemetrySignal.Metrics,
|
||||
new Uri("https://telemetry.example.com"),
|
||||
out var decision);
|
||||
|
||||
Assert.False(allowed);
|
||||
Assert.NotNull(decision);
|
||||
Assert.Contains(provider.Entries, entry => entry.Level == LogLevel.Warning && entry.Message.Contains("disabled", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static ILoggerFactory CreateLoggerFactory()
|
||||
=> LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
|
||||
private sealed class CollectingLoggerProvider : ILoggerProvider
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = new();
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new CollectingLogger(Entries);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class CollectingLogger : ILogger
|
||||
{
|
||||
private readonly List<(LogLevel Level, string Message)> _entries;
|
||||
|
||||
public CollectingLogger(List<(LogLevel Level, string Message)> entries)
|
||||
{
|
||||
_entries = entries;
|
||||
}
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
_entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static NullScope Instance { get; } = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\..\\AirGap\\StellaOps.AirGap.Policy\\StellaOps.AirGap.Policy\\StellaOps.AirGap.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling how StellaOps services emit telemetry.
|
||||
/// </summary>
|
||||
public sealed class StellaOpsTelemetryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets telemetry collector-specific options.
|
||||
/// </summary>
|
||||
public CollectorOptions Collector { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Options describing how the OTLP collector exporter should be configured.
|
||||
/// </summary>
|
||||
public sealed class CollectorOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the collector exporter is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the collector endpoint (absolute URI).
|
||||
/// </summary>
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OTLP protocol used when contacting the collector.
|
||||
/// </summary>
|
||||
public TelemetryCollectorProtocol Protocol { get; set; } = TelemetryCollectorProtocol.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the component identifier used when evaluating egress policy requests.
|
||||
/// </summary>
|
||||
public string Component { get; set; } = "telemetry";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the intent label used when evaluating egress policy requests.
|
||||
/// </summary>
|
||||
public string Intent { get; set; } = "telemetry-export";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the exporter should be disabled when policy blocks the endpoint.
|
||||
/// </summary>
|
||||
public bool DisableOnViolation { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse the configured endpoint into a <see cref="Uri"/>.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">Resolved endpoint when parsing succeeded.</param>
|
||||
/// <returns><c>true</c> when the endpoint was parsed successfully.</returns>
|
||||
public bool TryGetEndpoint(out Uri? endpoint)
|
||||
{
|
||||
endpoint = null;
|
||||
if (string.IsNullOrWhiteSpace(Endpoint))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Uri.TryCreate(Endpoint.Trim(), UriKind.Absolute, out endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supported OTLP protocols when exporting telemetry to the collector.
|
||||
/// </summary>
|
||||
public enum TelemetryCollectorProtocol
|
||||
{
|
||||
/// <summary>
|
||||
/// OTLP over gRPC.
|
||||
/// </summary>
|
||||
Grpc = 0,
|
||||
|
||||
/// <summary>
|
||||
/// OTLP over HTTP/protobuf.
|
||||
/// </summary>
|
||||
HttpProtobuf = 1,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the air-gap egress policy to telemetry exporters.
|
||||
/// </summary>
|
||||
public sealed class TelemetryExporterGuard
|
||||
{
|
||||
private readonly IEgressPolicy? _egressPolicy;
|
||||
private readonly ILogger<TelemetryExporterGuard> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TelemetryExporterGuard"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger used to report enforcement results.</param>
|
||||
/// <param name="egressPolicy">Optional air-gap egress policy.</param>
|
||||
public TelemetryExporterGuard(ILogger<TelemetryExporterGuard> logger, IEgressPolicy? egressPolicy = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_egressPolicy = egressPolicy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the configured exporter endpoint may be used.
|
||||
/// </summary>
|
||||
/// <param name="descriptor">Service descriptor.</param>
|
||||
/// <param name="options">Collector options.</param>
|
||||
/// <param name="signal">Signal the exporter targets.</param>
|
||||
/// <param name="endpoint">Endpoint that will be contacted.</param>
|
||||
/// <param name="decision">Decision returned by the policy (if evaluated).</param>
|
||||
/// <returns><c>true</c> when the exporter may be used.</returns>
|
||||
public bool IsExporterAllowed(
|
||||
TelemetryServiceDescriptor descriptor,
|
||||
StellaOpsTelemetryOptions.CollectorOptions options,
|
||||
TelemetrySignal signal,
|
||||
Uri endpoint,
|
||||
out EgressDecision? decision)
|
||||
{
|
||||
decision = null;
|
||||
|
||||
if (_egressPolicy is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var component = string.IsNullOrWhiteSpace(options.Component)
|
||||
? descriptor.ServiceName
|
||||
: options.Component.Trim();
|
||||
|
||||
var intent = string.IsNullOrWhiteSpace(options.Intent)
|
||||
? $"telemetry-{signal.ToString().ToLowerInvariant()}"
|
||||
: options.Intent.Trim();
|
||||
|
||||
decision = _egressPolicy.Evaluate(
|
||||
new EgressRequest(component, endpoint, intent, operation: $"{signal}-export"));
|
||||
|
||||
if (decision.IsAllowed)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
EmitDenialLog(signal, endpoint, decision);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void EmitDenialLog(TelemetrySignal signal, Uri endpoint, EgressDecision decision)
|
||||
{
|
||||
var reason = string.IsNullOrWhiteSpace(decision.Reason)
|
||||
? "Destination blocked by egress policy."
|
||||
: decision.Reason!;
|
||||
|
||||
var remediation = string.IsNullOrWhiteSpace(decision.Remediation)
|
||||
? "Review airgap.egressAllowlist configuration before enabling remote telemetry exporters."
|
||||
: decision.Remediation!;
|
||||
|
||||
if (_egressPolicy?.IsSealed == true)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sealed mode telemetry exporter disabled for {Signal} endpoint {Endpoint}: {Reason} Remediation: {Remediation}",
|
||||
signal,
|
||||
endpoint,
|
||||
reason,
|
||||
remediation);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Telemetry exporter for {Signal} denied by egress policy for endpoint {Endpoint}: {Reason} Remediation: {Remediation}",
|
||||
signal,
|
||||
endpoint,
|
||||
reason,
|
||||
remediation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Exporter;
|
||||
using OpenTelemetry.Logs;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Service collection extensions for configuring StellaOps telemetry.
|
||||
/// </summary>
|
||||
public static class TelemetryServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the StellaOps telemetry stack with sealed-mode enforcement.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection to mutate.</param>
|
||||
/// <param name="configuration">Application configuration.</param>
|
||||
/// <param name="serviceName">Service name advertised to OpenTelemetry.</param>
|
||||
/// <param name="serviceVersion">Optional service version.</param>
|
||||
/// <param name="configureOptions">Optional options mutator.</param>
|
||||
/// <param name="configureMetrics">Optional additional metrics configuration.</param>
|
||||
/// <param name="configureTracing">Optional additional tracing configuration.</param>
|
||||
/// <returns>The <see cref="OpenTelemetryBuilder"/> for further chaining.</returns>
|
||||
public static OpenTelemetryBuilder AddStellaOpsTelemetry(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string serviceName,
|
||||
string? serviceVersion = null,
|
||||
Action<StellaOpsTelemetryOptions>? configureOptions = null,
|
||||
Action<MeterProviderBuilder>? configureMetrics = null,
|
||||
Action<TracerProviderBuilder>? configureTracing = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentException.ThrowIfNullOrEmpty(serviceName);
|
||||
|
||||
services.AddOptions<StellaOpsTelemetryOptions>()
|
||||
.Bind(configuration.GetSection("Telemetry"))
|
||||
.Configure(options => configureOptions?.Invoke(options))
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Collector.Component))
|
||||
{
|
||||
options.Collector.Component = serviceName;
|
||||
}
|
||||
});
|
||||
|
||||
services.TryAddSingleton(_ => new TelemetryServiceDescriptor(serviceName, serviceVersion));
|
||||
services.TryAddSingleton<TelemetryExporterGuard>();
|
||||
|
||||
var builder = services.AddOpenTelemetry();
|
||||
builder.ConfigureResource(resource => resource.AddService(serviceName, serviceVersion: serviceVersion));
|
||||
builder.WithTracing();
|
||||
builder.WithMetrics();
|
||||
builder.WithLogging();
|
||||
|
||||
Action<MeterProviderBuilder> metricsSetup = configureMetrics ?? DefaultMetricsSetup;
|
||||
Action<TracerProviderBuilder> tracingSetup = configureTracing ?? DefaultTracingSetup;
|
||||
|
||||
services.ConfigureOpenTelemetryTracerProvider((sp, tracerBuilder) =>
|
||||
{
|
||||
tracingSetup(tracerBuilder);
|
||||
ConfigureCollectorExporter(sp, tracerBuilder, TelemetrySignal.Traces);
|
||||
});
|
||||
|
||||
services.ConfigureOpenTelemetryMeterProvider((sp, meterBuilder) =>
|
||||
{
|
||||
metricsSetup(meterBuilder);
|
||||
ConfigureCollectorExporter(sp, meterBuilder, TelemetrySignal.Metrics);
|
||||
});
|
||||
|
||||
services.Configure<OpenTelemetryLoggerOptions>((sp, options) =>
|
||||
{
|
||||
var configure = BuildExporterConfiguration(sp, TelemetrySignal.Logs);
|
||||
if (configure is not null)
|
||||
{
|
||||
options.AddOtlpExporter(configure);
|
||||
}
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static void DefaultMetricsSetup(MeterProviderBuilder builder)
|
||||
{
|
||||
builder.AddRuntimeInstrumentation();
|
||||
}
|
||||
|
||||
private static void DefaultTracingSetup(TracerProviderBuilder builder)
|
||||
{
|
||||
builder.AddAspNetCoreInstrumentation();
|
||||
builder.AddHttpClientInstrumentation();
|
||||
}
|
||||
|
||||
private static void ConfigureCollectorExporter(
|
||||
IServiceProvider serviceProvider,
|
||||
TracerProviderBuilder tracerBuilder,
|
||||
TelemetrySignal signal)
|
||||
{
|
||||
var configure = BuildExporterConfiguration(serviceProvider, signal);
|
||||
if (configure is not null)
|
||||
{
|
||||
tracerBuilder.AddOtlpExporter(configure);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureCollectorExporter(
|
||||
IServiceProvider serviceProvider,
|
||||
MeterProviderBuilder meterBuilder,
|
||||
TelemetrySignal signal)
|
||||
{
|
||||
var configure = BuildExporterConfiguration(serviceProvider, signal);
|
||||
if (configure is not null)
|
||||
{
|
||||
meterBuilder.AddOtlpExporter(configure);
|
||||
}
|
||||
}
|
||||
|
||||
private static Action<OtlpExporterOptions>? BuildExporterConfiguration(IServiceProvider serviceProvider, TelemetrySignal signal)
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<IOptions<StellaOpsTelemetryOptions>>().Value;
|
||||
var collector = options.Collector;
|
||||
if (!collector.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!collector.TryGetEndpoint(out var endpoint) || endpoint is null)
|
||||
{
|
||||
serviceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger(nameof(TelemetryServiceCollectionExtensions))
|
||||
.LogDebug("Telemetry collector endpoint not configured; {Signal} exporter disabled.", signal);
|
||||
return null;
|
||||
}
|
||||
|
||||
var descriptor = serviceProvider.GetRequiredService<TelemetryServiceDescriptor>();
|
||||
var guard = serviceProvider.GetRequiredService<TelemetryExporterGuard>();
|
||||
if (!guard.IsExporterAllowed(descriptor, collector, signal, endpoint, out _) &&
|
||||
collector.DisableOnViolation)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var egressPolicy = serviceProvider.GetService<IEgressPolicy>();
|
||||
return exporterOptions =>
|
||||
{
|
||||
exporterOptions.Endpoint = endpoint;
|
||||
exporterOptions.Protocol = collector.Protocol switch
|
||||
{
|
||||
TelemetryCollectorProtocol.HttpProtobuf => OtlpExportProtocol.HttpProtobuf,
|
||||
_ => OtlpExportProtocol.Grpc,
|
||||
};
|
||||
|
||||
if (egressPolicy is not null)
|
||||
{
|
||||
exporterOptions.HttpClientFactory = () => EgressHttpClientFactory.Create(
|
||||
egressPolicy,
|
||||
new EgressRequest(
|
||||
collector.Component,
|
||||
endpoint,
|
||||
collector.Intent,
|
||||
operation: $"{signal}-export"));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the hosting service emitting telemetry.
|
||||
/// </summary>
|
||||
public sealed record TelemetryServiceDescriptor(string ServiceName, string? ServiceVersion);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry signal classification used when applying exporter policy.
|
||||
/// </summary>
|
||||
public enum TelemetrySignal
|
||||
{
|
||||
/// <summary>
|
||||
/// Metrics signal.
|
||||
/// </summary>
|
||||
Metrics,
|
||||
|
||||
/// <summary>
|
||||
/// Traces signal.
|
||||
/// </summary>
|
||||
Traces,
|
||||
|
||||
/// <summary>
|
||||
/// Logs signal.
|
||||
/// </summary>
|
||||
Logs,
|
||||
}
|
||||
Reference in New Issue
Block a user