fa9cde3e2f
Adds the highest-leverage reverse-engineering primitive from the roadmap: one path to turn a live operation buffer into a committable golden fixture. Unblocks every capture-tier item (R0.5, R1.x, R2.1). - ProtocolCaptureSanitizer: redacts identity-bearing values (host, tag, user, machine) from a native buffer in BOTH ASCII and UTF-16LE, overwriting in place with an 'X' fill so length and every field offset are preserved (keeps the fixture useful for byte-layout RE). ASCII-letter matching is case-insensitive; secrets < 3 chars are skipped to avoid collision corruption. AssertNoSecretsRemain is a fail-closed safety net that refuses to emit if any value survives. - ProtocolFixtureWriter: serializes a capture to fixtures/protocol/<op>/<name>.json with sanitized hex, length, SHA-256 of the sanitized bytes, and a scrub report. Timestamps are passed in (deterministic / testable). - capture-tag-info CLI command: captures a live GetTagInfoFromName response and writes the fixture. The same native bytes ride inside 2023 R2 gRPC GetTagInfosFromName, so the fixture is transport-agnostic. - 11 unit tests for the sanitizer/writer (test project now references the RE tool). - First real fixture: get-tag-info/analog-*.json — a 98-byte Int4 CTagMetadata buffer captured live from the local Historian 2020 server, tag name redacted, verified to contain no identity (descriptor 03 c3 00 31 = Int4, as documented). 180 non-live unit tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
90 lines
3.5 KiB
C#
90 lines
3.5 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace AVEVA.Historian.ReverseEngineering.Capture;
|
|
|
|
/// <summary>One captured operation: the (optional) request buffer and the response buffer, raw.</summary>
|
|
public sealed record ProtocolCapture(string Op, byte[]? Request, byte[]? Response, string? Notes = null);
|
|
|
|
/// <summary>
|
|
/// CW-1 fixture writer: takes a live <see cref="ProtocolCapture"/>, redacts it with
|
|
/// <see cref="ProtocolCaptureSanitizer"/>, and writes a committable JSON fixture under
|
|
/// <c>fixtures/protocol/<op>/</c>. The fixture records sanitized hex, lengths, SHA-256 of the
|
|
/// sanitized bytes, and the scrub report — never the original identity-bearing bytes.
|
|
///
|
|
/// Timestamps are passed in (never generated here) so the writer stays deterministic and testable.
|
|
/// </summary>
|
|
public static class ProtocolFixtureWriter
|
|
{
|
|
public static string BuildFixtureJson(
|
|
ProtocolCapture capture,
|
|
IReadOnlyList<CaptureSecret> secrets,
|
|
string capturedUtcIso)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(capture);
|
|
|
|
BufferSection? request = BuildSection(capture.Request, secrets);
|
|
BufferSection? response = BuildSection(capture.Response, secrets);
|
|
|
|
var document = new
|
|
{
|
|
op = capture.Op,
|
|
capturedUtc = capturedUtcIso,
|
|
notes = capture.Notes,
|
|
request,
|
|
response,
|
|
};
|
|
|
|
return JsonSerializer.Serialize(document, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
});
|
|
}
|
|
|
|
/// <summary>Serializes the fixture and writes it to <paramref name="fixtureRoot"/>/<op>/<name>.json. Returns the path.</summary>
|
|
public static string Write(
|
|
string fixtureRoot,
|
|
string name,
|
|
ProtocolCapture capture,
|
|
IReadOnlyList<CaptureSecret> secrets,
|
|
string capturedUtcIso)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(fixtureRoot);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
|
ArgumentNullException.ThrowIfNull(capture);
|
|
|
|
string json = BuildFixtureJson(capture, secrets, capturedUtcIso);
|
|
string directory = Path.Combine(fixtureRoot, capture.Op);
|
|
Directory.CreateDirectory(directory);
|
|
string path = Path.Combine(directory, name + ".json");
|
|
File.WriteAllText(path, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
|
return path;
|
|
}
|
|
|
|
private static BufferSection? BuildSection(byte[]? raw, IReadOnlyList<CaptureSecret> secrets)
|
|
{
|
|
if (raw is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
SanitizeResult result = ProtocolCaptureSanitizer.Sanitize(raw, secrets);
|
|
ProtocolCaptureSanitizer.AssertNoSecretsRemain(result.Sanitized, secrets);
|
|
|
|
return new BufferSection(
|
|
Length: raw.Length,
|
|
Sha256: Convert.ToHexString(SHA256.HashData(result.Sanitized)).ToLowerInvariant(),
|
|
Hex: Convert.ToHexString(result.Sanitized).ToLowerInvariant(),
|
|
Redactions: result.Report
|
|
.Where(r => r.Total > 0)
|
|
.Select(r => new RedactionEntry(r.Name, r.AsciiMatches, r.Utf16Matches))
|
|
.ToArray());
|
|
}
|
|
|
|
private sealed record BufferSection(int Length, string Sha256, string Hex, IReadOnlyList<RedactionEntry> Redactions);
|
|
|
|
private sealed record RedactionEntry(string Secret, int AsciiMatches, int Utf16Matches);
|
|
}
|