package validator import ( "context" "fmt" "regexp" "slices" "strings" ) // Rule is a single check applied by RuleValidator. Rules must be pure // -- no network calls, no state mutation. Individual Rules produce // RuleResult; RuleValidator aggregates those into a Verdict with // worst-case Severity and pass-ratio Score. // // Rule 是 RuleValidator 调用的单个检查项. Rule 必须是纯函数 -- 不做 // 网络调用或变更状态. 单 Rule 产出 RuleResult, RuleValidator 聚合为 // Verdict 并取最严格 Severity 与通过率 Score. type Rule interface { // Name returns a stable identifier used as the key in Verdict.Details. // Rules within one RuleValidator must not share a Name. // // Name 返回稳定标识符, 作为 Verdict.Details 的 key. 同 RuleValidator // 内多个 Rule 的 Name 不能重复. Name() string // Apply runs the check on the diff. Pure: no I/O, no state. // // Apply 在 diff 上执行检查. 纯函数: 无 I/O 无状态. Apply(diff DiffInput) RuleResult } // RuleResult is the per-rule decision. Approved=true means this rule // passed; when Approved=false, Reason is filled for debuggability and // Severity decides whether the failure escalates (Block) or merely // flags a warning sample (Warn). // // RuleResult 是单 Rule 决策. Approved=true 表示本 rule 通过; Approved= // false 时 Reason 填理由便于调试, Severity 决定失败是升级阻断 (Block) // 还是仅作为 warning 样本标记 (Warn). type RuleResult struct { Approved bool Severity Severity Reason string } // RuleValidator combines multiple Rules into a single Validator. The // aggregated Verdict is Approved only if every Rule approves; Severity // is the worst tier among failed rules (Block beats Warn); Score is // the pass ratio passed_rules/total_rules, giving downstream circuit // breakers a continuous signal instead of a binary flip. // // RuleValidator 将多个 Rule 组合成单个 Validator. 仅当所有 Rule 都通 // 过时聚合 Verdict 才 Approved; Severity 取失败 Rule 中最严格档 (Block // 优先于 Warn); Score 为通过率 通过数/总数, 给下游熔断器连续信号而非 // 二值翻转. type RuleValidator struct { name string version string rules []Rule } // NewRuleValidator constructs a RuleValidator. name identifies this // instance for audit / VerdictSink routing; version is the policy- // bundle tag used by replay audits (pass empty string if the caller // does not track versions). // // NewRuleValidator 构造 RuleValidator. name 标识本实例用于审计 / // VerdictSink 路由; version 是策略包标签供回放审计使用 (不跟踪版本 // 可传空串). func NewRuleValidator(name, version string, rules ...Rule) *RuleValidator { return &RuleValidator{name: name, version: version, rules: rules} } // Name returns the Validator's stable identifier. // // Name 返回 Validator 稳定标识符. func (v *RuleValidator) Name() string { return v.name } // Validate runs every Rule against the diff and returns the aggregated // Verdict. ctx.Err() is honoured up-front: a cancelled context returns // a Block verdict wrapping ctx.Err(). // // Validate 在 diff 上运行所有 Rule 返回聚合 Verdict. 先查 ctx.Err() -- // 被取消时返回 Block verdict 并返回 ctx.Err(). func (v *RuleValidator) Validate(ctx context.Context, diff DiffInput) (Verdict, error) { if err := ctx.Err(); err != nil { return Verdict{ Approved: false, Severity: SeverityBlock, Reason: "context cancelled", ValidatorName: v.name, PolicyVersion: v.version, }, err } details := make(map[string]any, len(v.rules)) approved := true worst := SeverityWarn passed := 0 reasons := make([]string, 0, len(v.rules)) for _, r := range v.rules { res := r.Apply(diff) details[r.Name()] = map[string]any{ "approved": res.Approved, "severity": string(res.Severity), "reason": res.Reason, } if res.Approved { passed++ continue } approved = false if res.Reason != "" { reasons = append(reasons, r.Name()+": "+res.Reason) } if res.Severity == SeverityBlock { worst = SeverityBlock } } score := 1.0 if len(v.rules) > 0 { score = float64(passed) / float64(len(v.rules)) } reason := "" if !approved { reason = strings.Join(reasons, "; ") } return Verdict{ Approved: approved, Score: score, Reason: reason, Severity: worst, Details: details, ValidatorName: v.name, PolicyVersion: v.version, }, nil } // -- built-in rules -- // DiffSizeRule blocks when Metadata["affected_rows"] exceeds MaxRows. // Rule consults Metadata (not Raw) to stay schema-agnostic: the // upstream Decorator is responsible for extracting row counts from // tool-specific Result payloads and populating this hint. // // DiffSizeRule 在 Metadata["affected_rows"] 超过 MaxRows 时阻断. 本 // Rule 查 Metadata (不解 Raw) 保持 schema-agnostic: 由上游 Decorator // 从工具特定 Result 载荷提 row count 并填入此 hint. type DiffSizeRule struct { MaxRows int } // Name returns the rule identifier. // // Name 返回 rule 标识符. func (r *DiffSizeRule) Name() string { return "diff_size" } // Apply checks Metadata["affected_rows"] against MaxRows. // // Apply 检查 Metadata["affected_rows"] 是否超过 MaxRows. func (r *DiffSizeRule) Apply(diff DiffInput) RuleResult { raw, ok := diff.Metadata["affected_rows"] if !ok { return RuleResult{ Approved: true, Severity: SeverityWarn, Reason: "diff_size skipped: no affected_rows in metadata", } } n, ok := toInt(raw) if !ok { return RuleResult{ Approved: false, Severity: SeverityWarn, Reason: fmt.Sprintf("diff_size: affected_rows has non-numeric type %T", raw), } } if n > r.MaxRows { return RuleResult{ Approved: false, Severity: SeverityBlock, Reason: fmt.Sprintf("affected rows %d exceeds limit %d", n, r.MaxRows), } } return RuleResult{Approved: true} } // TableWhitelistRule blocks when Metadata["table_name"] is not in the // Allowed list. Missing metadata skips the rule with a Warn signal -- // the absence is suspicious but not conclusive (another rule may // already have caught an unknown source). // // TableWhitelistRule 在 Metadata["table_name"] 不在 Allowed 列表时阻断. // metadata 缺失按 skip + Warn 处理 -- 缺失可疑但非铁证 (未知来源可能 // 被其他 rule 捕捉). type TableWhitelistRule struct { Allowed []string } // Name returns the rule identifier. // // Name 返回 rule 标识符. func (r *TableWhitelistRule) Name() string { return "table_whitelist" } // Apply checks Metadata["table_name"] against Allowed. // // Apply 检查 Metadata["table_name"] 是否在 Allowed 中. func (r *TableWhitelistRule) Apply(diff DiffInput) RuleResult { raw, ok := diff.Metadata["table_name"] if !ok { return RuleResult{ Approved: true, Severity: SeverityWarn, Reason: "table_whitelist skipped: no table_name in metadata", } } table, ok := raw.(string) if !ok { return RuleResult{ Approved: false, Severity: SeverityBlock, Reason: fmt.Sprintf("table_whitelist: table_name has non-string type %T", raw), } } if slices.Contains(r.Allowed, table) { return RuleResult{Approved: true} } return RuleResult{ Approved: false, Severity: SeverityBlock, Reason: fmt.Sprintf("table %q not in whitelist", table), } } // PatternRule blocks when Raw matches any forbidden regex Pattern. // Typical use: catch destructive SQL keywords (DROP / TRUNCATE / GRANT // / ALTER) that should never appear in staging writes. // // PatternRule 在 Raw 匹配任一禁止的 regex Pattern 时阻断. 典型用途: // 捕获不应在 staging 写操作中出现的破坏性 SQL 关键字 (DROP / TRUNCATE // / GRANT / ALTER). type PatternRule struct { Patterns []*regexp.Regexp } // Name returns the rule identifier. // // Name 返回 rule 标识符. func (r *PatternRule) Name() string { return "pattern" } // Apply scans Raw for any forbidden Pattern. // // Apply 扫描 Raw 是否匹配任一禁止 Pattern. func (r *PatternRule) Apply(diff DiffInput) RuleResult { for _, p := range r.Patterns { if p.Match(diff.Raw) { return RuleResult{ Approved: false, Severity: SeverityBlock, Reason: fmt.Sprintf("diff matches forbidden pattern %q", p.String()), } } } return RuleResult{Approved: true} } // toInt coerces common numeric types (int, int32, int64, float32, // float64) into int. Returns (0, false) for other types, including // string. Used by rules that consult numeric Metadata hints populated // by upstream tooling (e.g. a Decorator extracting row counts from a // tool Result). // // toInt 将常见数值类型 (int, int32, int64, float32, float64) 转为 // int. 其他类型 (包括 string) 返回 (0, false). 供规则读取上游工具填入 // Metadata 的数值 hint 使用 (如 Decorator 从工具 Result 提取 row count). func toInt(v any) (int, bool) { switch n := v.(type) { case int: return n, true case int32: return int(n), true case int64: return int(n), true case float32: return int(n), true case float64: return int(n), true default: return 0, false } }