Files
lserp_cs_6.0/插件库/Lskj.AgentBridge/ModuleDiagnosticEvidenceStore.cs
T
2026-08-14 14:28:28 +08:00

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();
}
}
}
}