Files
Marc Schier e8afa4237b Security hardening: 25-finding pass against SFI / SDL / OPC UA Part 2 baseline
Addresses 25 of 39 findings from a branch security review (assessment 5).

CRITICAL: F1 gate MCP key-disclosure tools, F2 0600 keylog/pcap files, F3 per-user BaseFolder, F16 ChannelKeyMaterial IDisposable + ZeroMemory, F17 PcapFileReader MaxPacketBytes (64MB) bound, F18 FlowBuffer MaxBufferBytes (256MB) cap, F19 NodeSet export path validation, F20 ReplaySession listenPort range.

HIGH: F4 IPcapAuditSink + LoggerPcapAuditSink + rate-limited frame-capture audit, F5 AES-256-GCM EncryptedKeyLogStream + SessionKeyManager (sibling .key file 0600), F14 IKeyEscrowProvider abstraction + DiskKeyEscrowProvider default (DI TryAddSingleton so consumers can route to KMS), F21 ChannelManagerOptions.MaxChannels (256) cap, F22 CaptureSessionManager.SessionFolder path-traversal guard, F23 PacketDecodeTools pcapPath/keyLogPath validation, F24 replay_pcap speed validation (NaN/Inf/<=0), F25 explicit permissions blocks on .github/workflows.

MEDIUM: F6 OnTokenActivated [EditorBrowsable(Never)] + key-disclosure doc, F7 IDiagnosticsChannelMutation accessor + stack-walk caller guard (replaces internal OfflineLoadTokens), F8 PcapOptions.AllowMockClientReplay + AllowedReplayEndpoints, F9 PcapFileWriter / UaKeyLog*Writer rotation (.NNN suffix) with MaxBytes / MaxArtifactsPerSession, F13 keylog-isolation gap acknowledgment doc, F15 HashChainedAuditFileSink (HMAC-chained JSONL ledger, VerifyChain), F11 replay URL scheme allow-list (opc.tcp/opc.tcps/opc.https).

LOW/INFO: F10 SBOM/CVE governance comments on PacketDotNet/SharpPcap + Docs/PacketCapture.md, F12 Security model section at top of PacketCapture.md.

Tests: 100+ new NUnit tests across Audit/, Capture/, Frame/, KeyLog/, McpServerTools/, Replay/, Stack/Tcp/, DependencyInjection/. All file-mode/HMAC-chain/encryption tests are [Platform(Linux,MacOSX)]-gated where Unix-specific.

Build: 0 warnings / 0 errors on Stack/Opc.Ua.Bindings.Pcap for net8.0 and net9.0. net10.0 build is environmentally blocked by a corrupted local SDK 10.0.300 workload manifest (microsoft.net.workload.mono.toolchain.net6 10.0.108 requires Emscripten.net6 >=10.0.108, only 9.0.17 installed); requires 'dotnet workload repair' from an elevated shell to validate the third TFM. No tests were run pending the SDK fix. 5 CA1508/CA2000 false positives suppressed with documented #pragma blocks (ownership transfer in escrow factory / IAsyncEnumerable iterator / unreachable catch path).

Deferred to follow-up GitHub issues: 9 MEDIUM (M-SEC-07/09/10/11/12/13/16/22/31 — exception sanitization, reconnect defaults, cert-rotation races, nonce-suppress deprecation, parser hardening) and 10 LOW/INFO (L-SEC-14/15/17/23/24/33/34/35/36/37 — handler cleanup, EventSource endpoint masking, key-log schema validation, EOL deps, SAST). Full reasoning in session plan.md Round 10.
2026-06-11 13:10:09 +02:00

286 lines
11 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua.Bindings.Pcap.Audit;
using Opc.Ua.Bindings.Pcap.Capture;
using Opc.Ua.Bindings.Pcap.Capture.Sources;
using Opc.Ua.Bindings.Pcap.DependencyInjection;
using Opc.Ua.Bindings.Pcap.Models;
namespace Opc.Ua.Bindings.Pcap.Replay
{
/// <summary>
/// Singleton manager for active replay sessions.
/// </summary>
public sealed class ReplaySessionManager : IAsyncDisposable
{
/// <summary>
/// Constructs a replay session manager.
/// </summary>
public ReplaySessionManager(
ILoggerFactory? loggerFactory = null,
IPcapAuditSink? auditSink = null,
PcapOptions? options = null)
{
m_loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
m_auditSink = auditSink;
m_options = options ?? new PcapOptions();
}
/// <summary>
/// Creates and starts a replay session.
/// </summary>
[SuppressMessage(
"Reliability",
"CA2000:Dispose objects before losing scope",
Justification = "Ownership is transferred to ReplaySession; TODO: add analyzer-recognized ownership helpers.")]
public async ValueTask<ReplaySession> StartAsync(StartReplayRequest request, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.PcapFilePath);
ValidateSpeed(request.Speed);
string id = Guid.NewGuid().ToString("N");
ReplayCaptureSource source = new(m_loggerFactory);
await source.StartAsync(
new StartCaptureRequest
{
Source = CaptureSourceKind.Replay,
PcapFilePath = request.PcapFilePath,
KeyLogFilePath = request.KeyLogFilePath
},
ct).ConfigureAwait(false);
ReplaySession session = CreateSession(id, request, source);
try
{
await session.StartAsync(ct).ConfigureAwait(false);
m_sessions[id] = session;
await AuditAsync(
PcapAuditEventKind.StartReplay,
id,
request.PcapFilePath,
session.TargetEndpointUrl ?? session.ListenUri?.ToString(),
properties: CreateStartReplayProperties(request),
ct).ConfigureAwait(false);
return session;
}
catch
{
await session.DisposeAsync().ConfigureAwait(false);
throw;
}
}
/// <summary>
/// Looks up a replay session by id.
/// </summary>
/// <exception cref="PcapDiagnosticsException"></exception>
public ReplaySession Get(string id)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
if (!m_sessions.TryGetValue(id, out ReplaySession? session))
{
throw new PcapDiagnosticsException($"Replay session '{id}' was not found.");
}
return session;
}
/// <summary>
/// Lists known replay sessions.
/// </summary>
public IReadOnlyList<ReplaySession> List()
{
return [.. m_sessions.Values];
}
/// <summary>
/// Stops a replay session by id.
/// </summary>
public async ValueTask StopAsync(string id, CancellationToken ct)
{
ReplaySession session = Get(id);
await session.StopAsync(ct).ConfigureAwait(false);
await AuditAsync(
PcapAuditEventKind.StopReplay,
session.Id,
resourcePath: null,
remoteEndpoint: session.TargetEndpointUrl ?? session.ListenUri?.ToString(),
properties: CreateStopReplayProperties(session),
ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
foreach (ReplaySession session in m_sessions.Values)
{
bool shouldAuditStop = session.IsRunning;
await session.DisposeAsync().ConfigureAwait(false);
if (shouldAuditStop)
{
await AuditAsync(
PcapAuditEventKind.StopReplay,
session.Id,
resourcePath: null,
remoteEndpoint: session.TargetEndpointUrl ?? session.ListenUri?.ToString(),
properties: CreateStopReplayProperties(session),
CancellationToken.None).ConfigureAwait(false);
}
}
m_sessions.Clear();
GC.SuppressFinalize(this);
}
[SuppressMessage(
"Reliability",
"CA2000:Dispose objects before losing scope",
Justification = "Ownership is transferred to ReplaySession; TODO: add analyzer-recognized ownership helpers.")]
private ReplaySession CreateSession(
string id,
StartReplayRequest request,
ReplayCaptureSource source)
{
switch (request.Mode)
{
case ReplayMode.MockServer:
var server = new MockServerReplay(source, m_loggerFactory)
{
Speed = request.Speed
};
return new ReplaySession(
id,
server,
request.ListenScheme ?? "opc.tcp",
request.ListenPort);
case ReplayMode.MockClient:
if (string.IsNullOrWhiteSpace(request.TargetEndpointUrl))
{
throw new PcapDiagnosticsException("Mock-client replay requires 'targetEndpointUrl'.");
}
var client = new MockClientReplay(source, request.TargetEndpointUrl, m_options, m_loggerFactory)
{
Speed = request.Speed
};
return new ReplaySession(id, client, request.TargetEndpointUrl);
default:
throw new PcapDiagnosticsException($"Unsupported replay mode '{request.Mode}'.");
}
}
private ValueTask AuditAsync(
PcapAuditEventKind kind,
string sessionId,
string? resourcePath,
string? remoteEndpoint,
IReadOnlyDictionary<string, string>? properties,
CancellationToken ct)
{
if (m_auditSink is null)
{
return ValueTask.CompletedTask;
}
return m_auditSink.OnEventAsync(
new PcapAuditEvent(
kind,
DateTimeOffset.UtcNow,
sessionId,
resourcePath,
remoteEndpoint,
properties),
ct);
}
private static Dictionary<string, string> CreateStartReplayProperties(StartReplayRequest request)
{
var properties = new Dictionary<string, string>
{
["Mode"] = request.Mode.ToString(),
["Speed"] = request.Speed.ToString(CultureInfo.InvariantCulture)
};
AddIfNotEmpty(properties, "KeyLogFilePath", request.KeyLogFilePath);
if (request.ListenPort.HasValue)
{
properties["ListenPort"] = request.ListenPort.Value.ToString(
CultureInfo.InvariantCulture);
}
return properties;
}
private static Dictionary<string, string> CreateStopReplayProperties(ReplaySession session)
{
return new Dictionary<string, string>
{
["Mode"] = session.Mode.ToString(),
["IsRunning"] = session.IsRunning.ToString(CultureInfo.InvariantCulture)
};
}
private static void AddIfNotEmpty(Dictionary<string, string> properties, string name, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
properties[name] = value;
}
}
private static void ValidateSpeed(double speed)
{
if (double.IsNaN(speed) || double.IsInfinity(speed) || speed <= 0d)
{
throw new ArgumentException(
$"Replay speed {speed} is not a finite positive number. " +
"Provide a value > 0 (e.g., 1.0 for real-time, 2.0 for 2× faster).",
nameof(speed));
}
}
private readonly ConcurrentDictionary<string, ReplaySession> m_sessions = new(
StringComparer.OrdinalIgnoreCase);
private readonly ILoggerFactory m_loggerFactory;
private readonly IPcapAuditSink? m_auditSink;
private readonly PcapOptions m_options;
}
}