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 ToDictionary() { return new Dictionary { { "evidenceType", "module_initialization_diagnosis" }, { "schemaVersion", "1.1" }, { "evidenceId", EvidenceId }, { "contentSha256", ContentHash }, { "capturedAtUtc", CapturedAtUtc }, { "erpScope", new Dictionary { { "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 完整性通过不代表证据已由可信证书签名。" } }; } } /// /// 严格验证脱敏诊断证据。该入口只读本地文件,不连接 ERP 或数据库。 /// 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( @"(? SafeOperations = new HashSet( new[] { "ExecuteDataSet", "ExecuteNonQuery", "ExecuteNonQueryTransaction", "ExecuteReader", "ExecuteCommand", "ExecuteScalar", "ExecuteScalarTransaction" }, StringComparer.Ordinal); private static readonly ISet SafeCommandTypes = new HashSet( new[] { "Text", "StoredProcedure", "TableDirect", "Unknown" }, StringComparer.Ordinal); private static readonly ISet SafeDbTypes = new HashSet( 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 SafeDirections = new HashSet( new[] { "Input", "Output", "InputOutput", "ReturnValue", "Unknown" }, StringComparer.Ordinal); private static readonly ISet SafeSqlKeywords = new HashSet( 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 SafeStaticSources = new HashSet( 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 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 ValidateEvents( JArray events, int failedExpected, int slowExpected) { ISet sequences = new HashSet(); 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 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() < 1 || sequence.Value() > 1000000 || !knownEventSequences.Contains(sequence.Value())) 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(); 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(); } 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(); if (value < minimum || value > maximum) throw Schema(name + " 超出允许范围。"); return value; } private static void EnsureExact(JObject source, params string[] allowed) { ISet names = new HashSet(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 staticDiagnosis, CommandExecutionContext context); } /// /// 每次追踪写一个不可覆盖的脱敏证据文件。文件只包含分析器安全投影, /// 不包含原始异常、参数值、连接串或模块启动参数。 /// 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 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 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(); } } } }