CW-1: reusable capture -> sanitize -> golden-fixture pipeline
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>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AVEVA.Historian.ReverseEngineering.Capture;
|
||||
|
||||
namespace AVEVA.Historian.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit coverage for the CW-1 capture sanitizer and fixture writer — the reusable
|
||||
/// "redact identity → emit committable fixture" core that all capture-tier work depends on.
|
||||
/// </summary>
|
||||
public sealed class ProtocolCaptureSanitizerTests
|
||||
{
|
||||
private static byte[] Ascii(string s) => Encoding.ASCII.GetBytes(s);
|
||||
|
||||
private static byte[] Utf16(string s) => Encoding.Unicode.GetBytes(s);
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_RedactsAsciiOccurrence_PreservingLength()
|
||||
{
|
||||
byte[] buffer = [0x01, 0x02, .. Ascii("SECRETTAG"), 0x03];
|
||||
SanitizeResult result = ProtocolCaptureSanitizer.Sanitize(buffer, [new CaptureSecret("tag", "SECRETTAG")]);
|
||||
|
||||
Assert.Equal(buffer.Length, result.Sanitized.Length);
|
||||
Assert.Equal(0x01, result.Sanitized[0]);
|
||||
Assert.Equal(0x03, result.Sanitized[^1]);
|
||||
Assert.DoesNotContain(Ascii("SECRETTAG"), result.Sanitized); // value gone
|
||||
Assert.Equal(1, result.Report[0].AsciiMatches);
|
||||
Assert.Equal(0, result.Report[0].Utf16Matches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_RedactsUtf16Occurrence()
|
||||
{
|
||||
byte[] buffer = [0xAA, .. Utf16("HostName"), 0xBB];
|
||||
SanitizeResult result = ProtocolCaptureSanitizer.Sanitize(buffer, [new CaptureSecret("host", "HostName")]);
|
||||
|
||||
Assert.Equal(0, result.Report[0].AsciiMatches);
|
||||
Assert.Equal(1, result.Report[0].Utf16Matches);
|
||||
Assert.Equal(0xAA, result.Sanitized[0]);
|
||||
Assert.Equal(0xBB, result.Sanitized[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_IsCaseInsensitiveForAsciiLetters()
|
||||
{
|
||||
byte[] buffer = Ascii("myserver01");
|
||||
SanitizeResult result = ProtocolCaptureSanitizer.Sanitize(buffer, [new CaptureSecret("host", "MyServer01")]);
|
||||
|
||||
Assert.Equal(1, result.Report[0].AsciiMatches);
|
||||
Assert.All(result.Sanitized, b => Assert.Equal(ProtocolCaptureSanitizer.FillByte, b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_RedactsMultipleOccurrences()
|
||||
{
|
||||
byte[] buffer = [.. Ascii("TagA"), 0x00, .. Ascii("TagA"), 0x00, .. Ascii("TagA")];
|
||||
SanitizeResult result = ProtocolCaptureSanitizer.Sanitize(buffer, [new CaptureSecret("tag", "TagA")]);
|
||||
|
||||
Assert.Equal(3, result.Report[0].AsciiMatches);
|
||||
Assert.Equal(3, result.TotalRedactions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_IgnoresShortSecrets_ToAvoidCollisionCorruption()
|
||||
{
|
||||
byte[] buffer = [0x41, 0x42, 0x43]; // "ABC"
|
||||
SanitizeResult result = ProtocolCaptureSanitizer.Sanitize(buffer, [new CaptureSecret("x", "AB")]); // length 2 < MinSecretLength
|
||||
|
||||
Assert.Equal(buffer, result.Sanitized); // untouched
|
||||
Assert.Equal(0, result.TotalRedactions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_LeavesUnrelatedBytesUntouched()
|
||||
{
|
||||
byte[] buffer = [.. Ascii("keepme"), .. Ascii("DROPME"), .. Ascii("keepme")];
|
||||
SanitizeResult result = ProtocolCaptureSanitizer.Sanitize(buffer, [new CaptureSecret("s", "DROPME")]);
|
||||
|
||||
Assert.Equal(Ascii("keepme"), result.Sanitized[..6]);
|
||||
Assert.Equal(Ascii("keepme"), result.Sanitized[^6..]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AssertNoSecretsRemain_Passes_WhenRedacted()
|
||||
{
|
||||
byte[] buffer = Ascii("prefix-SECRET-suffix");
|
||||
SanitizeResult result = ProtocolCaptureSanitizer.Sanitize(buffer, [new CaptureSecret("s", "SECRET")]);
|
||||
ProtocolCaptureSanitizer.AssertNoSecretsRemain(result.Sanitized, [new CaptureSecret("s", "SECRET")]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AssertNoSecretsRemain_Throws_WhenSecretSurvives()
|
||||
{
|
||||
byte[] buffer = Ascii("prefix-SECRET-suffix");
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => ProtocolCaptureSanitizer.AssertNoSecretsRemain(buffer, [new CaptureSecret("s", "SECRET")]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixtureWriter_BuildJson_OmitsRawIdentity_AndRecordsScrubReport()
|
||||
{
|
||||
byte[] response = [0x4E, .. Utf16("CustomerTag.PV"), 0xFE, 0x00];
|
||||
var capture = new ProtocolCapture("get-tag-info", Request: null, Response: response, Notes: "live 2020 server");
|
||||
var secrets = new[] { new CaptureSecret("tag", "CustomerTag.PV") };
|
||||
|
||||
string json = ProtocolFixtureWriter.BuildFixtureJson(capture, secrets, "2026-06-19T00:00:00Z");
|
||||
|
||||
Assert.DoesNotContain("CustomerTag", json); // identity scrubbed from hex
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
JsonElement root = doc.RootElement;
|
||||
Assert.Equal("get-tag-info", root.GetProperty("op").GetString());
|
||||
Assert.Equal("2026-06-19T00:00:00Z", root.GetProperty("capturedUtc").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, root.GetProperty("request").ValueKind);
|
||||
JsonElement resp = root.GetProperty("response");
|
||||
Assert.Equal(response.Length, resp.GetProperty("length").GetInt32());
|
||||
Assert.Equal(64, resp.GetProperty("sha256").GetString()!.Length);
|
||||
Assert.Equal("tag", resp.GetProperty("redactions")[0].GetProperty("secret").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixtureWriter_Write_CreatesOpSubdirectoryFile()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "histsdk-fixture-test-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var capture = new ProtocolCapture("get-tag-info", Request: null, Response: [0x01, 0x02, 0x03], Notes: null);
|
||||
string path = ProtocolFixtureWriter.Write(root, "sample", capture, [], "2026-06-19T00:00:00Z");
|
||||
|
||||
Assert.True(File.Exists(path));
|
||||
Assert.EndsWith(Path.Combine("get-tag-info", "sample.json"), path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user