mirror of
https://github.com/8damon/Blackbird-Platform
synced 2026-06-21 13:41:12 +00:00
548 lines
22 KiB
C#
548 lines
22 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.ComponentModel;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Linq;
|
|
using System.Windows.Media;
|
|
|
|
namespace BlackbirdInterface
|
|
{
|
|
internal sealed class ProcessGraphSnapshot
|
|
{
|
|
public ProcessGraphSnapshot(
|
|
IReadOnlyList<ProcessGraphNodeView> roots,
|
|
IReadOnlyList<ProcessGraphNodeView> inboundHandles,
|
|
IReadOnlyList<ProcessGraphNodeView> outboundHandles,
|
|
int processCount,
|
|
int launchEdges,
|
|
int actionEdges,
|
|
uint rootPid,
|
|
string summaryText)
|
|
{
|
|
Roots = roots;
|
|
InboundHandles = inboundHandles;
|
|
OutboundHandles = outboundHandles;
|
|
ProcessCount = processCount;
|
|
LaunchEdges = launchEdges;
|
|
ActionEdges = actionEdges;
|
|
RootPid = rootPid;
|
|
SummaryText = summaryText ?? string.Empty;
|
|
}
|
|
|
|
public IReadOnlyList<ProcessGraphNodeView> Roots { get; }
|
|
public IReadOnlyList<ProcessGraphNodeView> InboundHandles { get; }
|
|
public IReadOnlyList<ProcessGraphNodeView> OutboundHandles { get; }
|
|
public int ProcessCount { get; }
|
|
public int LaunchEdges { get; }
|
|
public int ActionEdges { get; }
|
|
public uint RootPid { get; }
|
|
public string SummaryText { get; }
|
|
}
|
|
|
|
internal sealed class ProcessGraphNodeView : INotifyPropertyChanged
|
|
{
|
|
private bool _isExpanded;
|
|
|
|
public ProcessGraphNodeView(
|
|
string key,
|
|
uint pid,
|
|
bool isProcess,
|
|
string title,
|
|
string meta,
|
|
Brush dotBrush,
|
|
Brush titleBrush,
|
|
IReadOnlyList<ProcessGraphNodeView>? children = null,
|
|
bool isBlackbirdInternal = false)
|
|
{
|
|
Key = key ?? string.Empty;
|
|
Pid = pid;
|
|
IsProcess = isProcess;
|
|
Title = title ?? string.Empty;
|
|
Meta = meta ?? string.Empty;
|
|
DotBrush = dotBrush ?? Brushes.Gray;
|
|
TitleBrush = titleBrush ?? Brushes.White;
|
|
Children = children ?? Array.Empty<ProcessGraphNodeView>();
|
|
IsBlackbirdInternal = isBlackbirdInternal;
|
|
}
|
|
|
|
public string Key { get; }
|
|
public uint Pid { get; }
|
|
public bool IsProcess { get; }
|
|
public string Title { get; }
|
|
public string Meta { get; }
|
|
public Brush DotBrush { get; }
|
|
public Brush TitleBrush { get; }
|
|
public IReadOnlyList<ProcessGraphNodeView> Children { get; }
|
|
public bool IsBlackbirdInternal { get; }
|
|
public bool IsExpanded
|
|
{
|
|
get => _isExpanded;
|
|
set
|
|
{
|
|
if (_isExpanded == value)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_isExpanded = value;
|
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsExpanded)));
|
|
}
|
|
}
|
|
|
|
public event PropertyChangedEventHandler? PropertyChanged;
|
|
}
|
|
|
|
internal static class ProcessGraphProjectionBuilder
|
|
{
|
|
private static readonly Brush ProcessBrush = CreateBrush(0xD2, 0xB8, 0x55);
|
|
private static readonly Brush ActionHandleBrush = CreateBrush(0xD8, 0x70, 0x4A);
|
|
private static readonly Brush ActionThreadBrush = CreateBrush(0xD2, 0x55, 0x55);
|
|
private static readonly Brush ActionCriticalBrush = CreateBrush(0xFF, 0x8F, 0x8F);
|
|
private static readonly Brush ActionMediumBrush = CreateBrush(0xF0, 0xCB, 0x67);
|
|
private static readonly Brush ActionInfoBrush = CreateBrush(0xB7, 0xC3, 0xD0);
|
|
|
|
public static ProcessGraphSnapshot Build(IEnumerable<GroupedEventRow> rows, uint rootPid)
|
|
{
|
|
var processNodes = new Dictionary<uint, MutableProcessNode>();
|
|
var childParentByPid = new Dictionary<uint, uint>();
|
|
var inboundHandles = new List<ProcessGraphNodeView>();
|
|
var outboundHandles = new List<ProcessGraphNodeView>();
|
|
int launchEdges = 0;
|
|
int actionEdges = 0;
|
|
|
|
MutableProcessNode EnsureProcessNode(uint pid, string displayName, string imagePath, DateTime firstSeenUtc, DateTime lastSeenUtc)
|
|
{
|
|
if (!processNodes.TryGetValue(pid, out MutableProcessNode? node))
|
|
{
|
|
node = new MutableProcessNode(pid);
|
|
processNodes[pid] = node;
|
|
}
|
|
|
|
node.DisplayName = PickPreferred(node.DisplayName, displayName);
|
|
node.ImagePath = PickPreferred(node.ImagePath, imagePath);
|
|
node.Observe(firstSeenUtc, lastSeenUtc);
|
|
return node;
|
|
}
|
|
|
|
if (rootPid != 0)
|
|
{
|
|
EnsureProcessNode(rootPid, ProcessIdentityResolver.Describe(rootPid), string.Empty, default, default);
|
|
}
|
|
|
|
foreach (GroupedEventRow row in rows ?? Array.Empty<GroupedEventRow>())
|
|
{
|
|
ProcessGraphEdge? edge = BuildProcessGraphEdge(row);
|
|
if (edge == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
MutableProcessNode actorNode = EnsureProcessNode(
|
|
edge.ActorPid,
|
|
edge.ActorName,
|
|
string.Empty,
|
|
edge.FirstSeenUtc,
|
|
edge.LastSeenUtc);
|
|
|
|
if (string.Equals(edge.RelationType, "ProcessCreate", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
MutableProcessNode childNode = EnsureProcessNode(
|
|
edge.TargetPid,
|
|
edge.TargetName,
|
|
edge.ImagePath,
|
|
edge.FirstSeenUtc,
|
|
edge.LastSeenUtc);
|
|
childNode.LaunchMeta = BuildLaunchMeta(edge);
|
|
if (!childParentByPid.ContainsKey(edge.TargetPid))
|
|
{
|
|
childParentByPid[edge.TargetPid] = edge.ActorPid;
|
|
actorNode.ProcessChildren.Add(childNode);
|
|
}
|
|
|
|
launchEdges += edge.Hits;
|
|
continue;
|
|
}
|
|
|
|
if (!ShouldShowActionInTree(edge))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (string.Equals(edge.RelationType, "HandleOpen", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (rootPid != 0 && edge.TargetPid == rootPid)
|
|
{
|
|
inboundHandles.Add(BuildHandleNode(edge, rootPid, inbound: true));
|
|
}
|
|
else if (rootPid != 0 && edge.ActorPid == rootPid)
|
|
{
|
|
outboundHandles.Add(BuildHandleNode(edge, rootPid, inbound: false));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
actorNode.ActionChildren.Add(new ProcessGraphNodeView(
|
|
BuildActionKey(edge),
|
|
0,
|
|
false,
|
|
BuildActionTitle(edge),
|
|
BuildActionMeta(edge),
|
|
BrushForSeverity(edge.Severity),
|
|
ActionThreadBrush));
|
|
}
|
|
actionEdges += edge.Hits;
|
|
}
|
|
|
|
IEnumerable<MutableProcessNode> roots = processNodes.Values
|
|
.Where(x => !childParentByPid.ContainsKey(x.Pid))
|
|
.OrderBy(x => x.Pid);
|
|
|
|
if (rootPid != 0 && processNodes.TryGetValue(rootPid, out MutableProcessNode? rootNode))
|
|
{
|
|
roots = new[] { rootNode }.Concat(roots.Where(x => x.Pid != rootPid));
|
|
}
|
|
|
|
List<ProcessGraphNodeView> finalizedRoots = roots
|
|
.Select(FinalizeProcessNode)
|
|
.ToList();
|
|
List<ProcessGraphNodeView> finalizedInboundHandles = inboundHandles
|
|
.OrderByDescending(x => x.Meta, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(x => x.Title, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
List<ProcessGraphNodeView> finalizedOutboundHandles = outboundHandles
|
|
.OrderByDescending(x => x.Meta, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(x => x.Title, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
string summaryText;
|
|
if (rootPid == 0 && processNodes.Count == 0 && finalizedInboundHandles.Count == 0 && finalizedOutboundHandles.Count == 0)
|
|
{
|
|
summaryText = "No monitored descendants or pivots yet";
|
|
}
|
|
else
|
|
{
|
|
string rootText = rootPid != 0 ? $"root={rootPid} " : string.Empty;
|
|
summaryText = $"{rootText}processes={processNodes.Count} launches={launchEdges} pivots={actionEdges} inboundHandles={finalizedInboundHandles.Count} outboundHandles={finalizedOutboundHandles.Count}";
|
|
}
|
|
|
|
return new ProcessGraphSnapshot(finalizedRoots, finalizedInboundHandles, finalizedOutboundHandles, processNodes.Count, launchEdges, actionEdges, rootPid, summaryText);
|
|
}
|
|
|
|
private static ProcessGraphNodeView FinalizeProcessNode(MutableProcessNode node)
|
|
{
|
|
List<ProcessGraphNodeView> processChildren = node.ProcessChildren
|
|
.OrderBy(x => x.Pid)
|
|
.Select(FinalizeProcessNode)
|
|
.ToList();
|
|
List<ProcessGraphNodeView> actionChildren = node.ActionChildren
|
|
.OrderBy(x => x.Title, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
var children = new List<ProcessGraphNodeView>(processChildren.Count + actionChildren.Count);
|
|
children.AddRange(processChildren);
|
|
children.AddRange(actionChildren);
|
|
|
|
int childProcesses = processChildren.Count;
|
|
int actionLeaves = actionChildren.Count;
|
|
string identity = DecorateIdentity(node.DisplayName, node.Pid);
|
|
string image = SafeFileName(node.ImagePath);
|
|
string imageSuffix = string.IsNullOrWhiteSpace(image) ? string.Empty : $" image={image}";
|
|
string launchSuffix = string.IsNullOrWhiteSpace(node.LaunchMeta) ? string.Empty : $" {node.LaunchMeta}";
|
|
string meta = $"{FormatRange(node.FirstSeenUtc, node.LastSeenUtc)} children={childProcesses} pivots={actionLeaves}{imageSuffix}{launchSuffix}";
|
|
|
|
return new ProcessGraphNodeView(BuildProcessKey(node.Pid), node.Pid, true, identity, meta, ProcessBrush, Brushes.White, children);
|
|
}
|
|
|
|
private static ProcessGraphEdge? BuildProcessGraphEdge(GroupedEventRow row)
|
|
{
|
|
GroupedEventDetailRow? detail = row.Details
|
|
.OrderByDescending(x => x.TimestampUtc)
|
|
.FirstOrDefault();
|
|
if (detail == null || detail.ActorPid == 0 || detail.TargetPid == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Dictionary<string, string> fields = EventDetailsParsing.ParseRawFields(detail.Details);
|
|
DateTime firstSeenUtc = row.Details.Count > 0
|
|
? row.Details.Min(x => x.TimestampUtc)
|
|
: detail.TimestampUtc;
|
|
DateTime lastSeenUtc = row.LastSeenUtc != default
|
|
? row.LastSeenUtc
|
|
: detail.TimestampUtc;
|
|
|
|
fields.TryGetValue("imagePath", out string? imagePath);
|
|
fields.TryGetValue("access", out string? access);
|
|
fields.TryGetValue("flags", out string? flags);
|
|
fields.TryGetValue("createStatus", out string? createStatus);
|
|
fields.TryGetValue("startKey", out string? startKey);
|
|
fields.TryGetValue("originModule", out string? originModule);
|
|
|
|
return new ProcessGraphEdge
|
|
{
|
|
RelationType = row.Event ?? detail.Event,
|
|
Severity = row.Severity,
|
|
Hits = Math.Max(1, row.Hits),
|
|
ActorPid = detail.ActorPid,
|
|
TargetPid = detail.TargetPid,
|
|
ActorName = detail.Actor,
|
|
TargetName = detail.Target,
|
|
FirstSeenUtc = firstSeenUtc,
|
|
LastSeenUtc = lastSeenUtc,
|
|
AccessText = access ?? string.Empty,
|
|
FlagsText = flags ?? string.Empty,
|
|
ImagePath = imagePath ?? string.Empty,
|
|
CreateStatus = createStatus ?? string.Empty,
|
|
ProcessStartKey = startKey ?? string.Empty,
|
|
OriginModule = originModule ?? string.Empty,
|
|
IsSr71Handle = EventDetailFormatting.IsSr71Module(originModule)
|
|
};
|
|
}
|
|
|
|
private static bool ShouldShowActionInTree(ProcessGraphEdge edge)
|
|
{
|
|
if (string.Equals(edge.RelationType, "ThreadCreate", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!string.Equals(edge.RelationType, "HandleOpen", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return SeverityRank(edge.Severity) >= 2;
|
|
}
|
|
|
|
private static string BuildLaunchMeta(ProcessGraphEdge edge)
|
|
{
|
|
string image = SafeFileName(edge.ImagePath);
|
|
string imageSuffix = string.IsNullOrWhiteSpace(image) ? string.Empty : $" image={image}";
|
|
string startKeySuffix = string.IsNullOrWhiteSpace(edge.ProcessStartKey) ? string.Empty : $" start={edge.ProcessStartKey}";
|
|
string statusSuffix = string.IsNullOrWhiteSpace(edge.CreateStatus) ? string.Empty : $" status={edge.CreateStatus}";
|
|
return $"spawned {FormatRange(edge.FirstSeenUtc, edge.LastSeenUtc)}{imageSuffix}{startKeySuffix}{statusSuffix}";
|
|
}
|
|
|
|
private static string BuildActionTitle(ProcessGraphEdge edge)
|
|
{
|
|
string target = DecorateIdentity(edge.TargetName, edge.TargetPid);
|
|
if (string.Equals(edge.RelationType, "ThreadCreate", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return $"Remote thread into {target}";
|
|
}
|
|
|
|
return $"High-right handle into {target}";
|
|
}
|
|
|
|
private static string BuildActionMeta(ProcessGraphEdge edge)
|
|
{
|
|
string qualifier = string.Equals(edge.RelationType, "ThreadCreate", StringComparison.OrdinalIgnoreCase)
|
|
? EventDetailsParsing.FallbackText(edge.FlagsText)
|
|
: EventDetailsParsing.FallbackText(edge.AccessText);
|
|
return $"{FormatRange(edge.FirstSeenUtc, edge.LastSeenUtc)} hits={edge.Hits} {qualifier}";
|
|
}
|
|
|
|
private static bool IsBlackbirdInternalProcess(string name)
|
|
=> !string.IsNullOrWhiteSpace(name) &&
|
|
(name.IndexOf("BlackbirdController", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
|
name.IndexOf("BlackbirdInterface", StringComparison.OrdinalIgnoreCase) >= 0);
|
|
|
|
private static ProcessGraphNodeView BuildHandleNode(ProcessGraphEdge edge, uint rootPid, bool inbound)
|
|
{
|
|
uint otherPid = inbound ? edge.ActorPid : edge.TargetPid;
|
|
string otherName = inbound ? edge.ActorName : edge.TargetName;
|
|
string other = DecorateIdentity(otherName, otherPid);
|
|
bool isInternal = inbound && IsBlackbirdInternalProcess(edge.ActorName);
|
|
string title = inbound
|
|
? $"Inbound handle from {other}"
|
|
: $"{(edge.IsSr71Handle ? "SR71" : "Target")} opens handle to {other}";
|
|
string relation = inbound
|
|
? "another process -> target"
|
|
: "target -> another process";
|
|
string owner = edge.IsSr71Handle
|
|
? "SR71 instrumentation"
|
|
: "target process";
|
|
string via = !string.IsNullOrWhiteSpace(edge.OriginModule) &&
|
|
!string.Equals(edge.OriginModule, "unknown", StringComparison.OrdinalIgnoreCase)
|
|
? $" via {edge.OriginModule}"
|
|
: string.Empty;
|
|
string meta = $"{FormatRange(edge.FirstSeenUtc, edge.LastSeenUtc)} hits={edge.Hits} {relation} access={EventDetailsParsing.FallbackText(edge.AccessText)} owner={owner}{via}";
|
|
return new ProcessGraphNodeView(
|
|
BuildHandleKey(edge, rootPid, inbound),
|
|
0,
|
|
false,
|
|
title,
|
|
meta,
|
|
BrushForSeverity(edge.Severity),
|
|
ActionHandleBrush,
|
|
isBlackbirdInternal: isInternal);
|
|
}
|
|
|
|
private static string BuildProcessKey(uint pid)
|
|
=> $"proc:{pid.ToString(CultureInfo.InvariantCulture)}";
|
|
|
|
private static string BuildActionKey(ProcessGraphEdge edge)
|
|
{
|
|
return $"action:{edge.ActorPid.ToString(CultureInfo.InvariantCulture)}:{edge.TargetPid.ToString(CultureInfo.InvariantCulture)}:{edge.RelationType}:{edge.AccessText}:{edge.FlagsText}";
|
|
}
|
|
|
|
private static string BuildHandleKey(ProcessGraphEdge edge, uint rootPid, bool inbound)
|
|
{
|
|
return $"handle:{rootPid.ToString(CultureInfo.InvariantCulture)}:{(inbound ? "in" : "out")}:{edge.ActorPid.ToString(CultureInfo.InvariantCulture)}:{edge.TargetPid.ToString(CultureInfo.InvariantCulture)}:{edge.AccessText}:{edge.FlagsText}:{edge.OriginModule}";
|
|
}
|
|
|
|
private static string DecorateIdentity(string displayName, uint pid)
|
|
{
|
|
string fallback = pid != 0
|
|
? $"PID {pid.ToString(CultureInfo.InvariantCulture)}"
|
|
: "Process";
|
|
string value = string.IsNullOrWhiteSpace(displayName) ? fallback : displayName.Trim();
|
|
string pidText = pid.ToString(CultureInfo.InvariantCulture);
|
|
if (pid != 0 && !value.Contains(pidText, StringComparison.Ordinal))
|
|
{
|
|
value = $"{value} ({pidText})";
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
private static string PickPreferred(string current, string incoming)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(incoming))
|
|
{
|
|
return current;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(current))
|
|
{
|
|
return incoming.Trim();
|
|
}
|
|
|
|
if (current.StartsWith("PID ", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return incoming.Trim();
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
private static string SafeFileName(string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
try
|
|
{
|
|
string name = Path.GetFileName(path.Trim());
|
|
return string.IsNullOrWhiteSpace(name) ? path.Trim() : name;
|
|
}
|
|
catch
|
|
{
|
|
return path.Trim();
|
|
}
|
|
}
|
|
|
|
private static string FormatRange(DateTime firstSeenUtc, DateTime lastSeenUtc)
|
|
{
|
|
if (firstSeenUtc == default && lastSeenUtc == default)
|
|
{
|
|
return "time=unknown";
|
|
}
|
|
|
|
DateTime first = firstSeenUtc == default ? lastSeenUtc : firstSeenUtc;
|
|
DateTime last = lastSeenUtc == default ? firstSeenUtc : lastSeenUtc;
|
|
if (first == default)
|
|
{
|
|
return "time=unknown";
|
|
}
|
|
|
|
return first == last
|
|
? $"seen={first:HH:mm:ss}"
|
|
: $"seen={first:HH:mm:ss}->{last:HH:mm:ss}";
|
|
}
|
|
|
|
private static Brush BrushForSeverity(string severity)
|
|
{
|
|
return SeverityRank(severity) switch
|
|
{
|
|
>= 3 => ActionCriticalBrush,
|
|
2 => ActionMediumBrush,
|
|
_ => ActionInfoBrush
|
|
};
|
|
}
|
|
|
|
private static int SeverityRank(string severity)
|
|
{
|
|
return severity?.Trim().ToUpperInvariant() switch
|
|
{
|
|
"CRITICAL" => 4,
|
|
"HIGH" => 3,
|
|
"MEDIUM" => 2,
|
|
"LOW" => 1,
|
|
_ => 0
|
|
};
|
|
}
|
|
|
|
private static Brush CreateBrush(byte r, byte g, byte b)
|
|
{
|
|
var brush = new SolidColorBrush(Color.FromRgb(r, g, b));
|
|
brush.Freeze();
|
|
return brush;
|
|
}
|
|
|
|
private sealed class MutableProcessNode
|
|
{
|
|
public MutableProcessNode(uint pid)
|
|
{
|
|
Pid = pid;
|
|
}
|
|
|
|
public uint Pid { get; }
|
|
public string DisplayName { get; set; } = string.Empty;
|
|
public string ImagePath { get; set; } = string.Empty;
|
|
public string LaunchMeta { get; set; } = string.Empty;
|
|
public DateTime FirstSeenUtc { get; private set; }
|
|
public DateTime LastSeenUtc { get; private set; }
|
|
public List<MutableProcessNode> ProcessChildren { get; } = new();
|
|
public List<ProcessGraphNodeView> ActionChildren { get; } = new();
|
|
|
|
public void Observe(DateTime firstSeenUtc, DateTime lastSeenUtc)
|
|
{
|
|
if (firstSeenUtc != default && (FirstSeenUtc == default || firstSeenUtc < FirstSeenUtc))
|
|
{
|
|
FirstSeenUtc = firstSeenUtc;
|
|
}
|
|
|
|
if (lastSeenUtc != default && lastSeenUtc > LastSeenUtc)
|
|
{
|
|
LastSeenUtc = lastSeenUtc;
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class ProcessGraphEdge
|
|
{
|
|
public string RelationType { get; init; } = string.Empty;
|
|
public string Severity { get; init; } = string.Empty;
|
|
public int Hits { get; init; }
|
|
public uint ActorPid { get; init; }
|
|
public uint TargetPid { get; init; }
|
|
public string ActorName { get; init; } = string.Empty;
|
|
public string TargetName { get; init; } = string.Empty;
|
|
public DateTime FirstSeenUtc { get; init; }
|
|
public DateTime LastSeenUtc { get; init; }
|
|
public string AccessText { get; init; } = string.Empty;
|
|
public string FlagsText { get; init; } = string.Empty;
|
|
public string ImagePath { get; init; } = string.Empty;
|
|
public string CreateStatus { get; init; } = string.Empty;
|
|
public string ProcessStartKey { get; init; } = string.Empty;
|
|
public string OriginModule { get; init; } = string.Empty;
|
|
public bool IsSr71Handle { get; init; }
|
|
}
|
|
}
|
|
}
|