1066 lines
54 KiB
C#
1066 lines
54 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using Lskj.CommandKernel;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
namespace Lskj.AgentBridge
|
|
{
|
|
public sealed class ModuleDiagnosticEvidenceReceipt
|
|
{
|
|
public string EvidenceId { get; set; }
|
|
public string ContentHash { get; set; }
|
|
}
|
|
|
|
public sealed class ModuleDiagnosticEvidenceException : Exception
|
|
{
|
|
public ModuleDiagnosticEvidenceException(string code, string message)
|
|
: base(message)
|
|
{
|
|
Code = code;
|
|
}
|
|
|
|
public string Code { get; private set; }
|
|
}
|
|
|
|
public sealed class ModuleDiagnosticEvidenceVerificationResult
|
|
{
|
|
public string EvidenceId { get; set; }
|
|
public string ContentHash { get; set; }
|
|
public DateTime CapturedAtUtc { get; set; }
|
|
public string CorrelationId { get; set; }
|
|
public string ClientSessionId { get; set; }
|
|
public string UserId { get; set; }
|
|
public string UserName { get; set; }
|
|
public string AccountBook { get; set; }
|
|
public string SubSystemId { get; set; }
|
|
public string DatabaseScopeFingerprint { get; set; }
|
|
public string ModuleCode { get; set; }
|
|
public string Outcome { get; set; }
|
|
public string PrimaryFindingCode { get; set; }
|
|
public bool ModuleOpenSucceeded { get; set; }
|
|
public bool Truncated { get; set; }
|
|
public int EventCount { get; set; }
|
|
public int FailedEventCount { get; set; }
|
|
public int SlowEventCount { get; set; }
|
|
|
|
public IDictionary<string, object> ToDictionary()
|
|
{
|
|
return new Dictionary<string, object>
|
|
{
|
|
{ "evidenceType", "module_initialization_diagnosis" },
|
|
{ "schemaVersion", "1.1" },
|
|
{ "evidenceId", EvidenceId },
|
|
{ "contentSha256", ContentHash },
|
|
{ "capturedAtUtc", CapturedAtUtc },
|
|
{ "erpScope", new Dictionary<string, object>
|
|
{
|
|
{ "correlationId", CorrelationId },
|
|
{ "clientSessionId", ClientSessionId },
|
|
{ "userId", UserId },
|
|
{ "userNameSha256",
|
|
WorkflowUatAuthorizationVerifier.Sha256(
|
|
Encoding.UTF8.GetBytes(UserName)) },
|
|
{ "accountBook", AccountBook },
|
|
{ "subSystemId", SubSystemId },
|
|
{ "databaseScopeFingerprint",
|
|
DatabaseScopeFingerprint },
|
|
{ "moduleCode", ModuleCode }
|
|
}
|
|
},
|
|
{ "outcome", Outcome },
|
|
{ "primaryFindingCode", PrimaryFindingCode },
|
|
{ "moduleOpenSucceeded", ModuleOpenSucceeded },
|
|
{ "truncated", Truncated },
|
|
{ "eventCount", EventCount },
|
|
{ "failedEventCount", FailedEventCount },
|
|
{ "slowEventCount", SlowEventCount },
|
|
{ "integrityValid", true },
|
|
{ "signatureVerified", false },
|
|
{ "note", "SHA-256 完整性通过不代表证据已由可信证书签名。" }
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 严格验证脱敏诊断证据。该入口只读本地文件,不连接 ERP 或数据库。
|
|
/// </summary>
|
|
public static class ModuleDiagnosticEvidenceVerifier
|
|
{
|
|
private const int MaximumEvidenceBytes = 2 * 1024 * 1024;
|
|
private static readonly Regex SafeEvidenceId = new Regex(
|
|
@"^diag-[a-f0-9]{32}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeModuleCode = new Regex(
|
|
@"^[A-Za-z0-9_.:\-]{1,64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeCorrelationId = new Regex(
|
|
@"^[A-Za-z0-9_.:\-]{8,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeFingerprint = new Regex(
|
|
@"^[a-f0-9]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeCode = new Regex(
|
|
@"^[a-z0-9_.\-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeOperationAlias = new Regex(
|
|
@"^operation_(?:[0-9]{4}|overflow)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeCommandAlias = new Regex(
|
|
@"^command_(?:[0-9]{4}|overflow)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeCallerAlias = new Regex(
|
|
@"^caller_(?:[0-9]{4}|overflow)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeParameterAlias = new Regex(
|
|
@"^@p_(?:[0-9]{4}|overflow)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeSqlIdentifierAlias = new Regex(
|
|
@"^(?:id|p)_(?:[0-9]{4}|overflow)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SqlWord = new Regex(
|
|
@"(?<![A-Za-z0-9_])@?[A-Za-z_][A-Za-z0-9_]*",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex UnsafeNumberLiteral = new Regex(
|
|
@"(?<![A-Za-z0-9_])\d+(?![A-Za-z0-9_])",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex UnsafeHexLiteral = new Regex(
|
|
@"\b0x[0-9a-f]+\b",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
|
private static readonly Regex UnsafeCredential = new Regex(
|
|
@"\b(password|pwd|user\s*id|uid|server|data\s*source|database|initial\s*catalog|access\s*token|api\s*key)\s*[:=]\s*[^?\s]",
|
|
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
|
private static readonly ISet<string> SafeOperations = new HashSet<string>(
|
|
new[]
|
|
{
|
|
"ExecuteDataSet", "ExecuteNonQuery", "ExecuteNonQueryTransaction",
|
|
"ExecuteReader", "ExecuteCommand", "ExecuteScalar",
|
|
"ExecuteScalarTransaction"
|
|
},
|
|
StringComparer.Ordinal);
|
|
private static readonly ISet<string> SafeCommandTypes = new HashSet<string>(
|
|
new[] { "Text", "StoredProcedure", "TableDirect", "Unknown" },
|
|
StringComparer.Ordinal);
|
|
private static readonly ISet<string> SafeDbTypes = new HashSet<string>(
|
|
new[]
|
|
{
|
|
"AnsiString", "Binary", "Byte", "Boolean", "Currency", "Date",
|
|
"DateTime", "Decimal", "Double", "Guid", "Int16", "Int32", "Int64",
|
|
"Object", "SByte", "Single", "String", "Time", "UInt16", "UInt32",
|
|
"UInt64", "VarNumeric", "AnsiStringFixedLength", "StringFixedLength",
|
|
"Xml", "DateTime2", "DateTimeOffset", "Unknown"
|
|
},
|
|
StringComparer.Ordinal);
|
|
private static readonly ISet<string> SafeDirections = new HashSet<string>(
|
|
new[] { "Input", "Output", "InputOutput", "ReturnValue", "Unknown" },
|
|
StringComparer.Ordinal);
|
|
private static readonly ISet<string> SafeSqlKeywords = new HashSet<string>(
|
|
new[]
|
|
{
|
|
"ADD", "ALL", "ALTER", "AND", "ANY", "AS", "ASC", "BEGIN", "BETWEEN",
|
|
"BY", "CASE", "CHECK", "COLUMN", "COMMIT", "CONSTRAINT", "CONVERT",
|
|
"CREATE", "CROSS", "CURRENT", "DATABASE", "DECLARE", "DEFAULT", "DELETE",
|
|
"DESC", "DISTINCT", "DROP", "ELSE", "END", "EXCEPT", "EXEC", "EXECUTE",
|
|
"EXISTS", "FOR", "FOREIGN", "FROM", "FULL", "FUNCTION", "GRANT", "GROUP",
|
|
"HAVING", "IF", "IN", "INDEX", "INNER", "INSERT", "INTERSECT", "INTO",
|
|
"IS", "JOIN", "KEY", "LEFT", "LIKE", "MERGE", "NOT", "NULL", "ON", "OPEN",
|
|
"OPTION", "OR", "ORDER", "OUTER", "OVER", "PIVOT", "PRIMARY", "PROC",
|
|
"PROCEDURE", "REFERENCES", "RETURN", "RIGHT", "ROLLBACK", "SCHEMA", "SELECT",
|
|
"SET", "TABLE", "THEN", "TO", "TOP", "TRAN", "TRANSACTION", "TRIGGER",
|
|
"TRUNCATE", "UNION", "UNIQUE", "UNPIVOT", "UPDATE", "USE", "VALUES", "VIEW",
|
|
"WHEN", "WHERE", "WHILE", "WITH", "COUNT", "SUM", "AVG", "MIN", "MAX",
|
|
"CAST", "COALESCE", "DATEADD", "DATEDIFF", "GETDATE", "GETUTCDATE", "ISNULL",
|
|
"LEN", "LOWER", "LTRIM", "NEWID", "NULLIF", "REPLACE", "ROUND", "RTRIM",
|
|
"SUBSTRING", "UPPER", "BIGINT", "BINARY", "BIT", "CHAR", "DATE", "DATETIME",
|
|
"DATETIME2", "DATETIMEOFFSET", "DECIMAL", "FLOAT", "IMAGE", "INT", "MONEY",
|
|
"NCHAR", "NTEXT", "NUMERIC", "NVARCHAR", "REAL", "SMALLDATETIME", "SMALLINT",
|
|
"SMALLMONEY", "TEXT", "TIME", "TIMESTAMP", "TINYINT", "UNIQUEIDENTIFIER",
|
|
"VARBINARY", "VARCHAR", "XML"
|
|
},
|
|
StringComparer.Ordinal);
|
|
private static readonly ISet<string> SafeStaticSources = new HashSet<string>(
|
|
new[]
|
|
{
|
|
"bill_header_config", "bill_query_config", "bill_form_config",
|
|
"bill_control_config", "bill_detail_config", "base_header_config",
|
|
"base_query_config", "base_form_config", "base_field_config",
|
|
"master_field_config", "detail_field_config", "configuration",
|
|
"diagnostic_coverage"
|
|
},
|
|
StringComparer.Ordinal);
|
|
|
|
public static ModuleDiagnosticEvidenceVerificationResult VerifyFile(string path)
|
|
{
|
|
return VerifyFile(path, null, null, null);
|
|
}
|
|
|
|
public static ModuleDiagnosticEvidenceVerificationResult VerifyFile(
|
|
string path,
|
|
string expectedEvidenceId,
|
|
string expectedModuleCode,
|
|
CommandExecutionContext expectedContext)
|
|
{
|
|
JObject envelope = LoadStrict(path);
|
|
EnsureExact(envelope,
|
|
"evidenceType", "schemaVersion", "evidenceId",
|
|
"contentHashAlgorithm", "contentHash", "content");
|
|
if (envelope.Properties().Count() != 6
|
|
|| RequiredString(envelope, "evidenceType", 1, 64)
|
|
!= "module_initialization_diagnosis"
|
|
|| RequiredString(envelope, "schemaVersion", 1, 16) != "1.1"
|
|
|| RequiredString(envelope, "contentHashAlgorithm", 1, 16) != "sha256")
|
|
throw Schema("诊断证据顶层类型或版本无效。");
|
|
|
|
string evidenceId = RequiredString(envelope, "evidenceId", 1, 64);
|
|
string expectedHash = RequiredString(envelope, "contentHash", 64, 64);
|
|
if (!SafeEvidenceId.IsMatch(evidenceId) || !SafeFingerprint.IsMatch(expectedHash))
|
|
throw Schema("诊断证据编号或 SHA-256 格式无效。");
|
|
JObject content = RequiredObject(envelope, "content");
|
|
EnsureExact(content,
|
|
"evidenceId", "capturedAtUtc", "correlationId", "clientSessionId",
|
|
"userId", "userName", "accountBook", "subSystemId",
|
|
"databaseScopeFingerprint", "moduleCode", "outcome",
|
|
"primaryFindingCode", "moduleOpenSucceeded", "truncated", "eventCount",
|
|
"failedEventCount", "slowEventCount", "findings", "events", "staticDiagnosis");
|
|
if (content.Properties().Count() != 20
|
|
|| RequiredString(content, "evidenceId", 1, 64) != evidenceId)
|
|
throw Schema("诊断证据内容编号或结构无效。");
|
|
|
|
DateTime capturedAtUtc;
|
|
if (!DateTime.TryParse(
|
|
RequiredString(content, "capturedAtUtc", 1, 64),
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
System.Globalization.DateTimeStyles.RoundtripKind,
|
|
out capturedAtUtc))
|
|
throw Schema("诊断证据采集时间无效。");
|
|
string correlationId = RequiredString(content, "correlationId", 8, 128);
|
|
string clientSessionId = RequiredString(content, "clientSessionId", 1, 128);
|
|
string userId = RequiredString(content, "userId", 1, 128);
|
|
string userName = RequiredString(content, "userName", 1, 128);
|
|
string accountBook = RequiredString(content, "accountBook", 1, 128);
|
|
string subSystemId = RequiredString(content, "subSystemId", 1, 128);
|
|
string databaseScopeFingerprint = RequiredString(
|
|
content,
|
|
"databaseScopeFingerprint",
|
|
64,
|
|
64);
|
|
string moduleCode = RequiredString(content, "moduleCode", 1, 64);
|
|
string outcome = RequiredString(content, "outcome", 1, 16);
|
|
string primaryFindingCode = RequiredString(content, "primaryFindingCode", 1, 128);
|
|
if (capturedAtUtc.Kind != DateTimeKind.Utc
|
|
|| !SafeCorrelationId.IsMatch(correlationId)
|
|
|| userName != userName.Trim()
|
|
|| !SafeFingerprint.IsMatch(databaseScopeFingerprint)
|
|
|| !SafeModuleCode.IsMatch(moduleCode)
|
|
|| !SafeCode.IsMatch(primaryFindingCode)
|
|
|| (outcome != "healthy" && outcome != "degraded" && outcome != "failed"))
|
|
throw Schema("诊断证据模块、结果或主要结论无效。");
|
|
|
|
bool moduleOpenSucceeded = RequiredBoolean(content, "moduleOpenSucceeded");
|
|
bool truncated = RequiredBoolean(content, "truncated");
|
|
int eventCount = RequiredInteger(content, "eventCount", 0, 200);
|
|
int failedEventCount = RequiredInteger(content, "failedEventCount", 0, eventCount);
|
|
int slowEventCount = RequiredInteger(content, "slowEventCount", 0, eventCount);
|
|
JArray findings = RequiredArray(content, "findings", 1, 201);
|
|
JArray events = RequiredArray(content, "events", eventCount, eventCount);
|
|
ISet<int> eventSequences = ValidateEvents(
|
|
events, failedEventCount, slowEventCount);
|
|
string firstFindingCode;
|
|
bool hasError;
|
|
bool hasWarning;
|
|
ValidateFindings(
|
|
findings, eventSequences, out firstFindingCode, out hasError, out hasWarning);
|
|
if (firstFindingCode != primaryFindingCode)
|
|
throw Schema("主要诊断代码与首项结论不一致。");
|
|
string calculatedOutcome = !moduleOpenSucceeded || hasError
|
|
? "failed"
|
|
: hasWarning ? "degraded" : "healthy";
|
|
if (calculatedOutcome != outcome)
|
|
throw Schema("诊断 outcome 与证据内容不一致。");
|
|
ValidateStaticDiagnosis(RequiredObject(content, "staticDiagnosis"), moduleCode);
|
|
|
|
string actualHash = Sha256(content.ToString(Formatting.None));
|
|
if (!string.Equals(expectedHash, actualHash, StringComparison.Ordinal))
|
|
throw Error(
|
|
"diagnostic_evidence_hash_mismatch",
|
|
"诊断证据内容与 SHA-256 不一致。");
|
|
if (!string.IsNullOrWhiteSpace(expectedEvidenceId)
|
|
&& !string.Equals(expectedEvidenceId, evidenceId, StringComparison.Ordinal))
|
|
throw Scope();
|
|
if (!string.IsNullOrWhiteSpace(expectedModuleCode)
|
|
&& !string.Equals(expectedModuleCode, moduleCode, StringComparison.OrdinalIgnoreCase))
|
|
throw Scope();
|
|
if (expectedContext != null
|
|
&& (!Same(expectedContext.CorrelationId, correlationId)
|
|
|| !Same(expectedContext.ClientSessionId, clientSessionId)
|
|
|| !Same(expectedContext.UserId, userId)
|
|
|| !Same(expectedContext.UserName, userName)
|
|
|| !Same(expectedContext.AccountBook, accountBook)
|
|
|| !Same(expectedContext.SubSystemId, subSystemId)
|
|
|| !string.Equals(
|
|
expectedContext.DatabaseScopeFingerprint,
|
|
databaseScopeFingerprint,
|
|
StringComparison.Ordinal)))
|
|
throw Scope();
|
|
|
|
return new ModuleDiagnosticEvidenceVerificationResult
|
|
{
|
|
EvidenceId = evidenceId,
|
|
ContentHash = expectedHash,
|
|
CapturedAtUtc = capturedAtUtc,
|
|
CorrelationId = correlationId,
|
|
ClientSessionId = clientSessionId,
|
|
UserId = userId,
|
|
UserName = userName,
|
|
AccountBook = accountBook,
|
|
SubSystemId = subSystemId,
|
|
DatabaseScopeFingerprint = databaseScopeFingerprint,
|
|
ModuleCode = moduleCode,
|
|
Outcome = outcome,
|
|
PrimaryFindingCode = primaryFindingCode,
|
|
ModuleOpenSucceeded = moduleOpenSucceeded,
|
|
Truncated = truncated,
|
|
EventCount = eventCount,
|
|
FailedEventCount = failedEventCount,
|
|
SlowEventCount = slowEventCount
|
|
};
|
|
}
|
|
|
|
private static JObject LoadStrict(string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
throw Error("diagnostic_evidence_input_required", "请使用 --input <诊断证据.json>。");
|
|
try
|
|
{
|
|
FileInfo file = new FileInfo(Path.GetFullPath(path));
|
|
if (!file.Exists)
|
|
throw Error("diagnostic_evidence_not_found", "诊断证据文件不存在。");
|
|
if (file.Length <= 0 || file.Length > MaximumEvidenceBytes
|
|
|| (file.Attributes & FileAttributes.ReparsePoint) != 0)
|
|
throw Error(
|
|
"diagnostic_evidence_size_invalid",
|
|
"诊断证据必须是 2 MB 内的普通非空文件。");
|
|
using (FileStream stream = new FileStream(
|
|
file.FullName, FileMode.Open, FileAccess.Read, FileShare.Read))
|
|
using (StreamReader text = new StreamReader(
|
|
stream, new UTF8Encoding(false, true), true, 4096))
|
|
using (RejectCommentsJsonReader json = new RejectCommentsJsonReader(text))
|
|
{
|
|
json.DateParseHandling = DateParseHandling.None;
|
|
json.FloatParseHandling = FloatParseHandling.Decimal;
|
|
JObject value = JObject.Load(json, new JsonLoadSettings
|
|
{
|
|
DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error,
|
|
CommentHandling = CommentHandling.Ignore,
|
|
LineInfoHandling = LineInfoHandling.Ignore
|
|
});
|
|
if (json.Read())
|
|
throw Error(
|
|
"diagnostic_evidence_json_invalid",
|
|
"诊断证据包含多个 JSON 根值。");
|
|
return value;
|
|
}
|
|
}
|
|
catch (ModuleDiagnosticEvidenceException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
throw Error(
|
|
"diagnostic_evidence_read_denied",
|
|
"当前用户无权读取诊断证据文件。");
|
|
}
|
|
catch (IOException)
|
|
{
|
|
throw Error(
|
|
"diagnostic_evidence_read_failed",
|
|
"诊断证据文件读取失败。");
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
throw Error(
|
|
"diagnostic_evidence_input_invalid",
|
|
"诊断证据输入路径无效。");
|
|
}
|
|
catch (NotSupportedException)
|
|
{
|
|
throw Error(
|
|
"diagnostic_evidence_input_invalid",
|
|
"诊断证据输入路径无效。");
|
|
}
|
|
catch
|
|
{
|
|
throw Error(
|
|
"diagnostic_evidence_json_invalid",
|
|
"诊断证据不是严格 UTF-8 JSON 对象。");
|
|
}
|
|
}
|
|
|
|
private static ISet<int> ValidateEvents(
|
|
JArray events,
|
|
int failedExpected,
|
|
int slowExpected)
|
|
{
|
|
ISet<int> sequences = new HashSet<int>();
|
|
int failed = 0;
|
|
int slow = 0;
|
|
foreach (JToken token in events)
|
|
{
|
|
JObject item = token as JObject;
|
|
if (item == null) throw Schema("诊断事件必须是对象。");
|
|
EnsureExact(item,
|
|
"sequence", "durationMilliseconds", "operation", "commandType",
|
|
"commandName", "sqlFingerprint", "sqlTemplate", "caller",
|
|
"parameters", "success", "errorCode");
|
|
if (item.Properties().Count() != 11)
|
|
throw Schema("诊断事件结构无效。");
|
|
int sequence = RequiredInteger(item, "sequence", 1, 1000000);
|
|
if (!sequences.Add(sequence)) throw Schema("诊断事件序号重复。");
|
|
long duration = RequiredLong(item, "durationMilliseconds", 0, 3600000);
|
|
string operation = RequiredOptionalString(item, "operation", 64);
|
|
string commandType = RequiredOptionalString(item, "commandType", 32);
|
|
string commandName = RequiredOptionalString(item, "commandName", 256);
|
|
if (operation != null && !SafeOperations.Contains(operation)
|
|
&& !SafeOperationAlias.IsMatch(operation))
|
|
throw Schema("诊断事件 operation 不是受控别名。");
|
|
if (commandType == null || !SafeCommandTypes.Contains(commandType))
|
|
throw Schema("诊断事件 commandType 无效。");
|
|
if (commandName != null && !SafeCommandAlias.IsMatch(commandName))
|
|
throw Schema("诊断事件 commandName 不是受控别名。");
|
|
RequiredOptionalHash(item, "sqlFingerprint");
|
|
string sqlTemplate = RequiredOptionalString(item, "sqlTemplate", 2049);
|
|
ValidateSqlShape(sqlTemplate);
|
|
string caller = RequiredOptionalString(item, "caller", 256);
|
|
if (caller != null && !SafeCallerAlias.IsMatch(caller))
|
|
throw Schema("诊断事件 caller 不是受控别名。");
|
|
ValidateParameters(RequiredArray(item, "parameters", 0, 100));
|
|
bool success = RequiredBoolean(item, "success");
|
|
string errorCode = RequiredOptionalString(item, "errorCode", 128);
|
|
if (success && errorCode != null
|
|
|| !success && (string.IsNullOrWhiteSpace(errorCode) || !SafeCode.IsMatch(errorCode)))
|
|
throw Schema("诊断事件成功状态与错误代码不一致。");
|
|
if (!success) failed += 1;
|
|
if (success && duration >= 2000) slow += 1;
|
|
}
|
|
if (failed != failedExpected || slow != slowExpected)
|
|
throw Schema("诊断事件统计与汇总不一致。");
|
|
return sequences;
|
|
}
|
|
|
|
private static void ValidateParameters(JArray parameters)
|
|
{
|
|
foreach (JToken token in parameters)
|
|
{
|
|
JObject item = token as JObject;
|
|
if (item == null) throw Schema("诊断参数必须是对象。");
|
|
EnsureExact(item, "name", "dbType", "direction", "size");
|
|
if (item.Properties().Count() != 4)
|
|
throw Schema("诊断参数结构无效。");
|
|
string name = RequiredString(item, "name", 1, 128);
|
|
string dbType = RequiredString(item, "dbType", 1, 64);
|
|
string direction = RequiredString(item, "direction", 1, 32);
|
|
if (!SafeParameterAlias.IsMatch(name)
|
|
|| !SafeDbTypes.Contains(dbType)
|
|
|| !SafeDirections.Contains(direction))
|
|
throw Schema("诊断参数元数据格式无效。");
|
|
RequiredInteger(item, "size", 0, 1048576);
|
|
}
|
|
}
|
|
|
|
private static void ValidateFindings(
|
|
JArray findings,
|
|
ISet<int> knownEventSequences,
|
|
out string firstCode,
|
|
out bool hasError,
|
|
out bool hasWarning)
|
|
{
|
|
firstCode = null;
|
|
hasError = false;
|
|
hasWarning = false;
|
|
foreach (JToken token in findings)
|
|
{
|
|
JObject item = token as JObject;
|
|
if (item == null) throw Schema("诊断结论必须是对象。");
|
|
EnsureExact(item,
|
|
"severity", "code", "category", "stage", "confidence",
|
|
"message", "recommendation", "occurrenceCount", "eventSequences",
|
|
"sqlFingerprint", "caller");
|
|
if (item.Properties().Count() != 11)
|
|
throw Schema("诊断结论结构无效。");
|
|
string severity = RequiredString(item, "severity", 1, 16);
|
|
string code = RequiredString(item, "code", 1, 128);
|
|
string category = RequiredString(item, "category", 1, 128);
|
|
string stage = RequiredString(item, "stage", 1, 128);
|
|
string confidence = RequiredString(item, "confidence", 1, 16);
|
|
if ((severity != "error" && severity != "warning" && severity != "info")
|
|
|| !SafeCode.IsMatch(code) || !SafeCode.IsMatch(category)
|
|
|| !SafeCode.IsMatch(stage)
|
|
|| (confidence != "observed" && confidence != "inferred"))
|
|
throw Schema("诊断结论分类字段无效。");
|
|
string message = RequiredString(item, "message", 1, 500);
|
|
string recommendation = RequiredString(item, "recommendation", 1, 500);
|
|
if (!IsKnownFindingText(code, message, recommendation))
|
|
throw Schema("诊断结论文本不是稳定脱敏模板。");
|
|
RequiredInteger(item, "occurrenceCount", 1, 200);
|
|
JArray sequences = RequiredArray(item, "eventSequences", 0, 20);
|
|
foreach (JToken sequence in sequences)
|
|
{
|
|
if (sequence.Type != JTokenType.Integer
|
|
|| sequence.Value<int>() < 1 || sequence.Value<int>() > 1000000
|
|
|| !knownEventSequences.Contains(sequence.Value<int>()))
|
|
throw Schema("诊断结论事件序号无效。");
|
|
}
|
|
RequiredOptionalHash(item, "sqlFingerprint");
|
|
string caller = RequiredOptionalString(item, "caller", 256);
|
|
if (caller != null && !SafeCallerAlias.IsMatch(caller))
|
|
throw Schema("诊断结论 caller 不是受控别名。");
|
|
if (firstCode == null) firstCode = code;
|
|
if (severity == "error") hasError = true;
|
|
if (severity == "warning") hasWarning = true;
|
|
}
|
|
}
|
|
|
|
private static void ValidateStaticDiagnosis(JObject value, string moduleCode)
|
|
{
|
|
EnsureExact(value,
|
|
"moduleCode", "moduleKind", "healthy", "issueCount",
|
|
"issues", "sqlHooks", "note");
|
|
if (value.Properties().Count() != 7
|
|
|| RequiredString(value, "moduleCode", 1, 64) != moduleCode)
|
|
throw Schema("静态诊断模块或结构无效。");
|
|
string kind = RequiredString(value, "moduleKind", 1, 16);
|
|
if (kind != "bill" && kind != "base")
|
|
throw Schema("静态诊断模块类型无效。");
|
|
bool healthy = RequiredBoolean(value, "healthy");
|
|
int issueCount = RequiredInteger(value, "issueCount", 0, 10000);
|
|
JArray issues = RequiredArray(value, "issues", issueCount, issueCount);
|
|
bool hasError = false;
|
|
foreach (JToken token in issues)
|
|
{
|
|
JObject issue = token as JObject;
|
|
if (issue == null) throw Schema("静态诊断问题必须是对象。");
|
|
EnsureExact(issue, "severity", "code", "message", "source");
|
|
if (issue.Properties().Count() != 4)
|
|
throw Schema("静态诊断问题结构无效。");
|
|
string severity = RequiredString(issue, "severity", 1, 16);
|
|
if (severity != "error" && severity != "warning" && severity != "info")
|
|
throw Schema("静态诊断问题级别无效。");
|
|
string code = RequiredString(issue, "code", 1, 128);
|
|
if (!SafeCode.IsMatch(code)) throw Schema("静态诊断问题代码无效。");
|
|
string message = RequiredString(issue, "message", 1, 500);
|
|
string source = RequiredString(issue, "source", 1, 128);
|
|
if (UnsafeCredential.IsMatch(message)
|
|
|| !IsKnownStaticMessage(code, message)
|
|
|| !SafeStaticSources.Contains(source))
|
|
throw Schema("静态诊断问题包含未脱敏标识符。");
|
|
if (severity == "error") hasError = true;
|
|
}
|
|
if (healthy == hasError)
|
|
throw Schema("静态诊断健康状态与问题列表不一致。");
|
|
JArray hooks = RequiredArray(value, "sqlHooks", 1, 32);
|
|
foreach (JToken token in hooks)
|
|
{
|
|
JObject hook = token as JObject;
|
|
if (hook == null) throw Schema("SQL Hook 摘要必须是对象。");
|
|
EnsureExact(hook, "name", "configured", "length");
|
|
if (hook.Properties().Count() != 3)
|
|
throw Schema("SQL Hook 摘要结构无效。");
|
|
string hookName = RequiredString(hook, "name", 1, 64);
|
|
if (!IsKnownHook(kind, hookName))
|
|
throw Schema("SQL Hook 名称不属于当前模块类型。");
|
|
RequiredBoolean(hook, "configured");
|
|
RequiredInteger(hook, "length", 0, 1000000);
|
|
}
|
|
string note = RequiredString(value, "note", 1, 500);
|
|
if (note != "SQL 内容默认不输出;正式运行时诊断应使用带关联 ID 的受控跟踪器。"
|
|
&& note != "SQL 内容默认不输出。")
|
|
throw Schema("静态诊断 note 不是稳定脱敏模板。");
|
|
}
|
|
|
|
private static bool IsKnownFindingText(
|
|
string code,
|
|
string message,
|
|
string recommendation)
|
|
{
|
|
switch (code ?? string.Empty)
|
|
{
|
|
case "missing_object":
|
|
return message == "初始化引用的数据库对象不存在。"
|
|
&& recommendation == "检查账套升级脚本、表/视图/存储过程名称和数据库版本。";
|
|
case "missing_column":
|
|
return message == "初始化引用的数据库字段不存在。"
|
|
&& recommendation == "检查低代码字段映射、客户扩展字段和账套升级版本。";
|
|
case "procedure_parameter":
|
|
return message == "初始化存储过程参数合同不匹配。"
|
|
&& recommendation == "检查客户端版本、存储过程版本和动态参数配置。";
|
|
case "database_permission":
|
|
return message == "当前账套连接用户缺少所需数据库权限。"
|
|
&& recommendation == "检查当前账套连接用户对目标对象的读取或执行权限。";
|
|
case "timeout":
|
|
return message == "初始化 SQL 执行超时。"
|
|
&& recommendation == "检查锁等待、查询条件、索引、执行计划和数据量。";
|
|
case "connection":
|
|
return message == "初始化期间数据库连接异常。"
|
|
&& recommendation == "检查客户端网络、数据库服务状态和账套连接配置。";
|
|
case "conversion":
|
|
return message == "初始化期间发生数据类型转换失败。"
|
|
&& recommendation == "检查字段类型、默认值和低代码控件绑定类型。";
|
|
case "constraint":
|
|
return message == "初始化期间发生数据约束冲突。"
|
|
&& recommendation == "检查重复配置、唯一键和初始化过程中的写入逻辑。";
|
|
case "database_error":
|
|
return message == "初始化 SQL 执行失败。"
|
|
&& recommendation == "结合脱敏 SQL 形状、调用位置和关联 ID 检查模块配置。";
|
|
case "slow_initialization_query":
|
|
return message == "初始化查询耗时超过 2 秒。"
|
|
&& recommendation == "检查执行计划、索引、锁等待、数据量和低代码查询条件。";
|
|
case "module_initialization_error":
|
|
return message == "模块初始化失败,但本次没有捕获到可归因的 SQL 异常。"
|
|
&& recommendation == "检查 DLL/类型名、程序集版本、控件字段绑定、初始化事件和客户扩展配置。";
|
|
case "trace_truncated":
|
|
return message == "初始化追踪达到时间或事件数量上限,证据可能不完整。"
|
|
&& recommendation == "缩小复现场景并重新追踪;不要提高商用环境的安全上限。";
|
|
case "unclassified_module_error":
|
|
return message == "检测到模块错误,但现有安全证据不足以确定具体配置项。"
|
|
&& recommendation == "使用相同账号、账套、子系统和业务数据复现,并检查客户端扩展配置。";
|
|
case "no_failure_observed":
|
|
return message == "本次复现未捕获初始化故障。"
|
|
&& recommendation == "如果问题为偶发,请使用相同账号、账套、子系统和业务数据重新复现。";
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static void ValidateSqlShape(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)) return;
|
|
if (UnsafeCredential.IsMatch(value))
|
|
throw Schema("诊断事件 SQL 形状包含未脱敏连接属性。");
|
|
if (UnsafeNumberLiteral.IsMatch(value) || UnsafeHexLiteral.IsMatch(value))
|
|
throw Schema("诊断事件 SQL 形状包含未脱敏字面量。");
|
|
const string punctuation = "_@#(),.;=*<>+-/%!|&^~:'?[]`\" ";
|
|
for (int index = 0; index < value.Length; index++)
|
|
{
|
|
char item = value[index];
|
|
if (item == '…' && index == value.Length - 1) continue;
|
|
if (item > 127 || char.IsControl(item)
|
|
|| !(char.IsLetterOrDigit(item) || punctuation.IndexOf(item) >= 0))
|
|
throw Schema("诊断事件 SQL 形状包含未允许字符。");
|
|
}
|
|
foreach (Match match in SqlWord.Matches(value))
|
|
{
|
|
string token = match.Value;
|
|
if (SafeSqlKeywords.Contains(token)) continue;
|
|
if (token.StartsWith("@", StringComparison.Ordinal))
|
|
{
|
|
if (SafeParameterAlias.IsMatch(token)) continue;
|
|
}
|
|
else if (SafeSqlIdentifierAlias.IsMatch(token))
|
|
{
|
|
continue;
|
|
}
|
|
throw Schema("诊断事件 SQL 形状包含未脱敏标识符。");
|
|
}
|
|
}
|
|
|
|
private static bool IsKnownStaticMessage(string code, string message)
|
|
{
|
|
switch (code ?? string.Empty)
|
|
{
|
|
case "bill.master_fields_load_failed":
|
|
return message == "单据主表控件配置加载失败,原始错误已脱敏。";
|
|
case "bill.detail_fields_load_failed":
|
|
return message == "单据明细列配置加载失败,原始错误已脱敏。";
|
|
case "base.fields_load_failed":
|
|
return message == "基础档案字段配置加载失败,原始错误已脱敏。";
|
|
case "configuration.load_failed":
|
|
return message == "配置加载失败,原始错误已脱敏。";
|
|
case "bill.master_table_missing": return message == "单据主表未配置。";
|
|
case "bill.detail_table_missing": return message == "单据明细表未配置。";
|
|
case "bill.master_query_missing": return message == "单据主表查询 SQL 未配置。";
|
|
case "bill.detail_query_missing": return message == "单据明细查询 SQL 未配置。";
|
|
case "bill.form_key_missing": return message == "单据 FormKey 未配置。";
|
|
case "bill.master_fields_missing": return message == "没有加载到单据主表控件配置。";
|
|
case "bill.detail_fields_missing": return message == "没有加载到单据明细列配置。";
|
|
case "base.table_missing": return message == "基础档案数据表未配置。";
|
|
case "base.query_missing": return message == "基础档案查询 SQL 未配置。";
|
|
case "base.form_key_missing": return message == "基础档案 FormKey 未配置。";
|
|
case "base.fields_missing": return message == "没有加载到基础档案字段配置。";
|
|
case "base.primary_key_missing": return message == "模块没有配置首个主键字段。";
|
|
case "base.primary_key_check_failed":
|
|
return message == "配置引用的数据库对象不存在,原始错误已脱敏。"
|
|
|| message == "配置引用的数据库字段不存在,原始错误已脱敏。"
|
|
|| message == "配置读取所需数据库权限不足,原始错误已脱敏。"
|
|
|| message == "配置读取超时,原始错误已脱敏。"
|
|
|| message == "配置数据库连接异常,连接信息已脱敏。"
|
|
|| message == "配置读取失败,原始错误已脱敏。";
|
|
case "field.duplicate":
|
|
return Regex.IsMatch(
|
|
message,
|
|
@"^(master|detail) 存在重复字段,字段标记:field_(?:[0-9]{4}|overflow|invalid)$",
|
|
RegexOptions.CultureInvariant);
|
|
case "field.lookup_mapping_incomplete":
|
|
return Regex.IsMatch(
|
|
message,
|
|
@"^字段标记 field_(?:[0-9]{4}|overflow|invalid) 配置了数据源,但值列或显示列不完整。$",
|
|
RegexOptions.CultureInvariant);
|
|
case "diagnosis.issue_limit_reached":
|
|
return message == "配置问题数量达到安全上限,当前结果仅保留前 199 项。";
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool IsKnownHook(string kind, string name)
|
|
{
|
|
if (kind == "bill")
|
|
{
|
|
return name == "MasterSql" || name == "DetailSql"
|
|
|| name == "loadBeforeSql" || name == "beforeEvent"
|
|
|| name == "afterEvent";
|
|
}
|
|
return name == "preSQL" || name == "LoadBeforeSql"
|
|
|| name == "CloseBeforeSql" || name == "BeforeStored"
|
|
|| name == "AfterStored" || name == "RefreshSql"
|
|
|| name == "afterSql" || name == "afterimportSql";
|
|
}
|
|
|
|
private static JObject RequiredObject(JObject source, string name)
|
|
{
|
|
JObject value = source[name] as JObject;
|
|
if (value == null) throw Schema(name + " 必须是对象。");
|
|
return value;
|
|
}
|
|
|
|
private static JArray RequiredArray(
|
|
JObject source,
|
|
string name,
|
|
int minimum,
|
|
int maximum)
|
|
{
|
|
JArray value = source[name] as JArray;
|
|
if (value == null || value.Count < minimum || value.Count > maximum)
|
|
throw Schema(name + " 数组长度无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredString(
|
|
JObject source,
|
|
string name,
|
|
int minimum,
|
|
int maximum)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null || token.Type != JTokenType.String)
|
|
throw Schema(name + " 必须是字符串。");
|
|
string value = token.Value<string>();
|
|
if (value == null || value.Length < minimum || value.Length > maximum
|
|
|| value.Any(char.IsControl))
|
|
throw Schema(name + " 字符串格式无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredOptionalString(
|
|
JObject source,
|
|
string name,
|
|
int maximum)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null) throw Schema("缺少字段:" + name);
|
|
if (token.Type == JTokenType.Null) return null;
|
|
return RequiredString(source, name, 1, maximum);
|
|
}
|
|
|
|
private static void RequiredOptionalHash(JObject source, string name)
|
|
{
|
|
string value = RequiredOptionalString(source, name, 64);
|
|
if (value != null && !SafeFingerprint.IsMatch(value))
|
|
throw Schema(name + " SHA-256 格式无效。");
|
|
}
|
|
|
|
private static bool RequiredBoolean(JObject source, string name)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null || token.Type != JTokenType.Boolean)
|
|
throw Schema(name + " 必须是布尔值。");
|
|
return token.Value<bool>();
|
|
}
|
|
|
|
private static int RequiredInteger(
|
|
JObject source,
|
|
string name,
|
|
int minimum,
|
|
int maximum)
|
|
{
|
|
long value = RequiredLong(source, name, minimum, maximum);
|
|
return Convert.ToInt32(value);
|
|
}
|
|
|
|
private static long RequiredLong(
|
|
JObject source,
|
|
string name,
|
|
long minimum,
|
|
long maximum)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null || token.Type != JTokenType.Integer)
|
|
throw Schema(name + " 必须是整数。");
|
|
long value = token.Value<long>();
|
|
if (value < minimum || value > maximum)
|
|
throw Schema(name + " 超出允许范围。");
|
|
return value;
|
|
}
|
|
|
|
private static void EnsureExact(JObject source, params string[] allowed)
|
|
{
|
|
ISet<string> names = new HashSet<string>(allowed, StringComparer.Ordinal);
|
|
JProperty unknown = source.Properties()
|
|
.FirstOrDefault(item => !names.Contains(item.Name));
|
|
if (unknown != null)
|
|
throw Schema("诊断证据包含未知字段:" + unknown.Name);
|
|
}
|
|
|
|
internal static string Sha256(string value)
|
|
{
|
|
using (SHA256 sha = SHA256.Create())
|
|
{
|
|
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
|
StringBuilder builder = new StringBuilder(hash.Length * 2);
|
|
foreach (byte item in hash) builder.Append(item.ToString("x2"));
|
|
return builder.ToString();
|
|
}
|
|
}
|
|
|
|
private static bool Same(string left, string right)
|
|
{
|
|
return string.Equals(left ?? string.Empty, right ?? string.Empty,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static ModuleDiagnosticEvidenceException Schema(string message)
|
|
{
|
|
return Error("diagnostic_evidence_schema_invalid", message);
|
|
}
|
|
|
|
private static ModuleDiagnosticEvidenceException Scope()
|
|
{
|
|
return Error(
|
|
"diagnostic_evidence_scope_mismatch",
|
|
"诊断证据与预期 ERP 身份范围不一致。");
|
|
}
|
|
|
|
private static ModuleDiagnosticEvidenceException Error(string code, string message)
|
|
{
|
|
return new ModuleDiagnosticEvidenceException(code, message);
|
|
}
|
|
|
|
private sealed class RejectCommentsJsonReader : JsonTextReader
|
|
{
|
|
public RejectCommentsJsonReader(TextReader reader) : base(reader)
|
|
{
|
|
}
|
|
|
|
public override bool Read()
|
|
{
|
|
bool result = base.Read();
|
|
if (result && TokenType == JsonToken.Comment)
|
|
throw Error(
|
|
"diagnostic_evidence_json_invalid",
|
|
"诊断证据禁止 JSON 注释。");
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
|
|
public interface IModuleDiagnosticEvidenceStore
|
|
{
|
|
ModuleDiagnosticEvidenceReceipt Save(
|
|
string evidenceId,
|
|
string moduleCode,
|
|
ModuleInitializationDiagnosticReport report,
|
|
IDictionary<string, object> staticDiagnosis,
|
|
CommandExecutionContext context);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 每次追踪写一个不可覆盖的脱敏证据文件。文件只包含分析器安全投影,
|
|
/// 不包含原始异常、参数值、连接串或模块启动参数。
|
|
/// </summary>
|
|
public sealed class JsonFileModuleDiagnosticEvidenceStore : IModuleDiagnosticEvidenceStore
|
|
{
|
|
private const int MaximumEvidenceBytes = 2 * 1024 * 1024;
|
|
private static readonly Regex SafeEvidenceId = new Regex(
|
|
@"^diag-[a-f0-9]{32}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeModuleCode = new Regex(
|
|
@"^[A-Za-z0-9_.:\-]{1,64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeCorrelationId = new Regex(
|
|
@"^[A-Za-z0-9_.:\-]{8,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeFingerprint = new Regex(
|
|
@"^[a-f0-9]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
|
|
private readonly string _directory;
|
|
private readonly object _syncRoot = new object();
|
|
|
|
public JsonFileModuleDiagnosticEvidenceStore(string directory)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(directory))
|
|
throw new ArgumentException("诊断证据目录不能为空。", "directory");
|
|
_directory = Path.GetFullPath(directory);
|
|
}
|
|
|
|
public ModuleDiagnosticEvidenceReceipt Save(
|
|
string evidenceId,
|
|
string moduleCode,
|
|
ModuleInitializationDiagnosticReport report,
|
|
IDictionary<string, object> staticDiagnosis,
|
|
CommandExecutionContext context)
|
|
{
|
|
Validate(evidenceId, moduleCode, report, context);
|
|
JObject content = BuildContent(
|
|
evidenceId, moduleCode, report, staticDiagnosis, context);
|
|
string canonicalContent = content.ToString(Formatting.None);
|
|
string contentHash = Hash(canonicalContent);
|
|
JObject envelope = new JObject
|
|
{
|
|
{ "evidenceType", "module_initialization_diagnosis" },
|
|
{ "schemaVersion", "1.1" },
|
|
{ "evidenceId", evidenceId },
|
|
{ "contentHashAlgorithm", "sha256" },
|
|
{ "contentHash", contentHash },
|
|
{ "content", content }
|
|
};
|
|
byte[] bytes = new UTF8Encoding(false).GetBytes(
|
|
envelope.ToString(Formatting.None));
|
|
if (bytes.Length > MaximumEvidenceBytes)
|
|
throw new InvalidOperationException("诊断证据超过安全大小上限。");
|
|
|
|
lock (_syncRoot)
|
|
{
|
|
Directory.CreateDirectory(_directory);
|
|
string path = Path.Combine(_directory, evidenceId + ".json");
|
|
if (File.Exists(path))
|
|
return ReadExisting(path, evidenceId, moduleCode, context);
|
|
using (FileStream stream = new FileStream(
|
|
path, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
|
{
|
|
stream.Write(bytes, 0, bytes.Length);
|
|
stream.Flush();
|
|
}
|
|
try
|
|
{
|
|
return ReadExisting(path, evidenceId, moduleCode, context);
|
|
}
|
|
catch
|
|
{
|
|
try { File.Delete(path); }
|
|
catch { }
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static JObject BuildContent(
|
|
string evidenceId,
|
|
string moduleCode,
|
|
ModuleInitializationDiagnosticReport report,
|
|
IDictionary<string, object> staticDiagnosis,
|
|
CommandExecutionContext context)
|
|
{
|
|
return new JObject
|
|
{
|
|
{ "evidenceId", evidenceId },
|
|
{ "capturedAtUtc", DateTime.UtcNow },
|
|
{ "correlationId", context.CorrelationId },
|
|
{ "clientSessionId", SafeOptional(context.ClientSessionId, 128) },
|
|
{ "userId", SafeOptional(context.UserId, 128) },
|
|
{ "userName", SafeOptional(context.UserName, 128) },
|
|
{ "accountBook", SafeOptional(context.AccountBook, 128) },
|
|
{ "subSystemId", SafeOptional(context.SubSystemId, 128) },
|
|
{ "databaseScopeFingerprint",
|
|
SafeFingerprintValue(context.DatabaseScopeFingerprint) },
|
|
{ "moduleCode", moduleCode },
|
|
{ "outcome", report.Outcome },
|
|
{ "primaryFindingCode", report.PrimaryFindingCode },
|
|
{ "moduleOpenSucceeded", report.ModuleOpenSucceeded },
|
|
{ "truncated", report.Truncated },
|
|
{ "eventCount", report.EventCount },
|
|
{ "failedEventCount", report.FailedEventCount },
|
|
{ "slowEventCount", report.SlowEventCount },
|
|
{ "findings", JArray.FromObject(
|
|
report.Findings.Select(item => item.ToDictionary()).ToList()) },
|
|
{ "events", JArray.FromObject(
|
|
report.Events.Select(item => item.ToDictionary()).ToList()) },
|
|
{ "staticDiagnosis", staticDiagnosis == null
|
|
? new JObject()
|
|
: JObject.FromObject(staticDiagnosis) }
|
|
};
|
|
}
|
|
|
|
private static ModuleDiagnosticEvidenceReceipt ReadExisting(
|
|
string path,
|
|
string evidenceId,
|
|
string moduleCode,
|
|
CommandExecutionContext context)
|
|
{
|
|
ModuleDiagnosticEvidenceVerificationResult verified =
|
|
ModuleDiagnosticEvidenceVerifier.VerifyFile(
|
|
path, evidenceId, moduleCode, context);
|
|
return new ModuleDiagnosticEvidenceReceipt
|
|
{
|
|
EvidenceId = evidenceId,
|
|
ContentHash = verified.ContentHash
|
|
};
|
|
}
|
|
|
|
private static void Validate(
|
|
string evidenceId,
|
|
string moduleCode,
|
|
ModuleInitializationDiagnosticReport report,
|
|
CommandExecutionContext context)
|
|
{
|
|
if (!SafeEvidenceId.IsMatch(evidenceId ?? string.Empty))
|
|
throw new ArgumentException("诊断证据编号格式无效。", "evidenceId");
|
|
if (!SafeModuleCode.IsMatch(moduleCode ?? string.Empty))
|
|
throw new ArgumentException("模块编号格式无效。", "moduleCode");
|
|
if (report == null) throw new ArgumentNullException("report");
|
|
if (context == null) throw new ArgumentNullException("context");
|
|
if (!SafeCorrelationId.IsMatch(context.CorrelationId ?? string.Empty))
|
|
throw new ArgumentException("关联 ID 格式无效。", "context");
|
|
if (string.IsNullOrWhiteSpace(context.ClientSessionId)
|
|
|| string.IsNullOrWhiteSpace(context.UserId)
|
|
|| string.IsNullOrWhiteSpace(context.UserName)
|
|
|| string.IsNullOrWhiteSpace(context.AccountBook)
|
|
|| string.IsNullOrWhiteSpace(context.SubSystemId)
|
|
|| !SafeFingerprint.IsMatch(
|
|
context.DatabaseScopeFingerprint ?? string.Empty))
|
|
throw new ArgumentException("诊断证据缺少 ERP 身份范围。", "context");
|
|
if (report.Events.Count > 200 || report.Findings.Count > 201)
|
|
throw new InvalidOperationException("诊断报告超过安全事件上限。");
|
|
}
|
|
|
|
private static string SafeOptional(string value, int maximumLength)
|
|
{
|
|
value = (value ?? string.Empty).Trim();
|
|
if (value.Length == 0) return null;
|
|
if (value.Length > maximumLength || value.Any(char.IsControl))
|
|
throw new ArgumentException("诊断证据身份范围字段格式无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string SafeFingerprintValue(string value)
|
|
{
|
|
value = (value ?? string.Empty).Trim();
|
|
if (!SafeFingerprint.IsMatch(value))
|
|
throw new ArgumentException("诊断证据数据库作用域指纹格式无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string Hash(string value)
|
|
{
|
|
using (SHA256 sha = SHA256.Create())
|
|
{
|
|
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
|
StringBuilder builder = new StringBuilder(hash.Length * 2);
|
|
foreach (byte item in hash) builder.Append(item.ToString("x2"));
|
|
return builder.ToString();
|
|
}
|
|
}
|
|
}
|
|
}
|