372 lines
15 KiB
Go
372 lines
15 KiB
Go
package alertreceiverconformance
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/hex"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"regexp"
|
||
|
|
"slices"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
CaptureSchema = "alert-receiver-capture-v1"
|
||
|
|
ReportSchema = "alert-receiver-conformance-report-v1"
|
||
|
|
)
|
||
|
|
|
||
|
|
var buildDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||
|
|
|
||
|
|
type CloudRoutes struct {
|
||
|
|
Items []CloudRoute `json:"items"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type CloudRoute struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
TenantID string `json:"tenant_id"`
|
||
|
|
Category string `json:"category"`
|
||
|
|
Severity string `json:"severity"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
ReceiverGroup string `json:"receiver_group"`
|
||
|
|
EscalationReceiverGroup string `json:"escalation_receiver_group"`
|
||
|
|
RunbookRef string `json:"runbook_ref"`
|
||
|
|
CreatedAt time.Time `json:"created_at"`
|
||
|
|
UpdatedAt time.Time `json:"updated_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type CloudTests struct {
|
||
|
|
Items []CloudTest `json:"items"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type CloudTest struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
TestRef string `json:"test_ref"`
|
||
|
|
RoutingPolicyID string `json:"routing_policy_id"`
|
||
|
|
ReceiverGroup string `json:"receiver_group"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
AttemptCount int `json:"attempt_count"`
|
||
|
|
CreatedAt time.Time `json:"created_at"`
|
||
|
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type CloudIncident struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
ReceiverGroup *string `json:"receiver_group,omitempty"`
|
||
|
|
FirstDetected time.Time `json:"first_detected_at"`
|
||
|
|
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||
|
|
Actions []CloudAction `json:"actions"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type CloudAction struct {
|
||
|
|
ActionType string `json:"action_type"`
|
||
|
|
CreatedAt time.Time `json:"created_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type ReceiverCapture struct {
|
||
|
|
SchemaVersion string `json:"schema_version"`
|
||
|
|
EvidenceSource string `json:"evidence_source"`
|
||
|
|
CandidateVersion string `json:"candidate_version"`
|
||
|
|
BuildDigest string `json:"build_digest"`
|
||
|
|
ReceiverSystem string `json:"receiver_system_ref"`
|
||
|
|
CollectedAt time.Time `json:"collected_at"`
|
||
|
|
GroupMappings []GroupMapping `json:"group_mappings"`
|
||
|
|
TestReceipts []TestReceipt `json:"test_receipts"`
|
||
|
|
FormalDrill FormalDrillCapture `json:"formal_drill"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type GroupMapping struct {
|
||
|
|
ReceiverGroup string `json:"receiver_group"`
|
||
|
|
MappedTargetRefs []string `json:"mapped_target_refs"`
|
||
|
|
VerifiedAt time.Time `json:"verified_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type TestReceipt struct {
|
||
|
|
TestRef string `json:"test_ref"`
|
||
|
|
EventID string `json:"event_id"`
|
||
|
|
ReceiverGroup string `json:"receiver_group"`
|
||
|
|
DeliveryCount int `json:"delivery_count"`
|
||
|
|
SignatureVerified bool `json:"signature_verified"`
|
||
|
|
ReceivedAt time.Time `json:"received_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type FormalDrillCapture struct {
|
||
|
|
IncidentID string `json:"incident_id"`
|
||
|
|
ReceiverGroup string `json:"receiver_group"`
|
||
|
|
DeliveryCount int `json:"delivery_count"`
|
||
|
|
SignatureVerified bool `json:"signature_verified"`
|
||
|
|
ControlledDrill bool `json:"controlled_drill"`
|
||
|
|
ReceivedAt time.Time `json:"received_at"`
|
||
|
|
AcknowledgedAt time.Time `json:"acknowledged_at"`
|
||
|
|
AcknowledgementRef string `json:"acknowledgement_ref"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type RouteCoverage struct {
|
||
|
|
RoutingPolicyID string `json:"routing_policy_id"`
|
||
|
|
ReceiverGroup string `json:"receiver_group"`
|
||
|
|
TestRef string `json:"test_ref"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type Check struct {
|
||
|
|
Code string `json:"code"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
Detail string `json:"detail"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type Report struct {
|
||
|
|
SchemaVersion string `json:"schema_version"`
|
||
|
|
Result string `json:"result"`
|
||
|
|
GeneratedAt time.Time `json:"generated_at"`
|
||
|
|
CandidateVersion string `json:"candidate_version"`
|
||
|
|
BuildDigest string `json:"build_digest"`
|
||
|
|
EvidenceScope string `json:"evidence_scope"`
|
||
|
|
ExternalValidationRequired bool `json:"external_validation_required"`
|
||
|
|
TenantID string `json:"tenant_id"`
|
||
|
|
ReceiverSystemRef string `json:"receiver_system_ref"`
|
||
|
|
ActiveRouteCount int `json:"active_route_count"`
|
||
|
|
MappedGroupCount int `json:"mapped_group_count"`
|
||
|
|
TestReceiptCount int `json:"test_receipt_count"`
|
||
|
|
FormalDrillVerified bool `json:"formal_drill_verified"`
|
||
|
|
RouteCoverage []RouteCoverage `json:"route_coverage"`
|
||
|
|
InputSHA256 map[string]string `json:"input_sha256"`
|
||
|
|
Checks []Check `json:"checks"`
|
||
|
|
Disclaimer string `json:"disclaimer"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func ParseRoutes(content []byte) (CloudRoutes, error) {
|
||
|
|
var routes CloudRoutes
|
||
|
|
if err := decodeEnvelope(content, &routes); err != nil {
|
||
|
|
return CloudRoutes{}, fmt.Errorf("decode alert routes: %w", err)
|
||
|
|
}
|
||
|
|
return routes, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func ParseTests(content []byte) (CloudTests, error) {
|
||
|
|
var tests CloudTests
|
||
|
|
if err := decodeEnvelope(content, &tests); err != nil {
|
||
|
|
return CloudTests{}, fmt.Errorf("decode alert delivery tests: %w", err)
|
||
|
|
}
|
||
|
|
return tests, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func ParseIncident(content []byte) (CloudIncident, error) {
|
||
|
|
var incident CloudIncident
|
||
|
|
if err := decodeEnvelope(content, &incident); err != nil {
|
||
|
|
return CloudIncident{}, fmt.Errorf("decode alert incident: %w", err)
|
||
|
|
}
|
||
|
|
return incident, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func ParseCapture(content []byte) (ReceiverCapture, error) {
|
||
|
|
var capture ReceiverCapture
|
||
|
|
if err := decodeStrict(content, &capture); err != nil {
|
||
|
|
return ReceiverCapture{}, fmt.Errorf("decode alert receiver capture: %w", err)
|
||
|
|
}
|
||
|
|
return capture, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func ParseReport(content []byte) (Report, error) {
|
||
|
|
var report Report
|
||
|
|
if err := decodeStrict(content, &report); err != nil {
|
||
|
|
return Report{}, fmt.Errorf("decode alert receiver report: %w", err)
|
||
|
|
}
|
||
|
|
return report, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func Evaluate(routes CloudRoutes, tests CloudTests, incident CloudIncident, capture ReceiverCapture, candidateVersion, buildDigest string, generatedAt time.Time) Report {
|
||
|
|
candidateVersion = strings.TrimSpace(candidateVersion)
|
||
|
|
buildDigest = strings.ToLower(strings.TrimSpace(buildDigest))
|
||
|
|
report := Report{
|
||
|
|
SchemaVersion: ReportSchema, Result: "PASS", GeneratedAt: generatedAt.UTC(), CandidateVersion: candidateVersion,
|
||
|
|
BuildDigest: buildDigest, EvidenceScope: "MOCK_RECEIVER", ExternalValidationRequired: true,
|
||
|
|
ReceiverSystemRef: capture.ReceiverSystem, MappedGroupCount: len(capture.GroupMappings), TestReceiptCount: len(capture.TestReceipts),
|
||
|
|
RouteCoverage: []RouteCoverage{}, InputSHA256: map[string]string{}, Checks: []Check{},
|
||
|
|
Disclaimer: "联通一致性报告只证明所提供的活动路由、云端投递、接收端验签回执和值班组映射相互匹配;不替代业务发布审批。",
|
||
|
|
}
|
||
|
|
if capture.EvidenceSource == "REAL" {
|
||
|
|
report.EvidenceScope = "REAL_RECEIVER"
|
||
|
|
}
|
||
|
|
add := func(code string, ok bool, detail string) {
|
||
|
|
status := "PASS"
|
||
|
|
if !ok {
|
||
|
|
status, report.Result = "FAIL", "FAIL"
|
||
|
|
}
|
||
|
|
report.Checks = append(report.Checks, Check{Code: code, Status: status, Detail: detail})
|
||
|
|
}
|
||
|
|
add("CAPTURE_SCHEMA", capture.SchemaVersion == CaptureSchema, "接收端证据必须使用"+CaptureSchema)
|
||
|
|
add("EVIDENCE_SOURCE", capture.EvidenceSource == "REAL" || capture.EvidenceSource == "MOCK", "证据来源必须明确为REAL或MOCK")
|
||
|
|
add("CANDIDATE_IDENTITY", candidateVersion != "" && buildDigestPattern.MatchString(buildDigest), "候选版本和sha256构建摘要必须完整")
|
||
|
|
add("CAPTURE_CANDIDATE_BINDING", capture.CandidateVersion == candidateVersion && strings.ToLower(capture.BuildDigest) == buildDigest, "接收端证据必须绑定命令指定的同一候选版本和构建摘要")
|
||
|
|
add("RECEIVER_IDENTITY", strings.TrimSpace(capture.ReceiverSystem) != "" && len(capture.ReceiverSystem) <= 200, "接收系统必须使用不含凭据的稳定引用")
|
||
|
|
|
||
|
|
active := make([]CloudRoute, 0, len(routes.Items))
|
||
|
|
tenant := ""
|
||
|
|
routesOK := true
|
||
|
|
for _, route := range routes.Items {
|
||
|
|
if route.Status != "ACTIVE" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
active = append(active, route)
|
||
|
|
if tenant == "" {
|
||
|
|
tenant = route.TenantID
|
||
|
|
}
|
||
|
|
if !validUUID(route.ID) || !validUUID(route.TenantID) || route.TenantID != tenant || route.ReceiverGroup == "" || route.EscalationReceiverGroup == "" || route.RunbookRef == "" || route.CreatedAt.IsZero() || route.UpdatedAt.IsZero() {
|
||
|
|
routesOK = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
sort.Slice(active, func(i, j int) bool { return active[i].ID < active[j].ID })
|
||
|
|
report.TenantID, report.ActiveRouteCount = tenant, len(active)
|
||
|
|
add("ACTIVE_ROUTES", routesOK && len(active) > 0, "必须导出同一租户至少一条字段完整的活动路由")
|
||
|
|
|
||
|
|
mappings, mappingsOK := validateMappings(capture.GroupMappings, capture.CollectedAt)
|
||
|
|
allGroupsMapped := mappingsOK
|
||
|
|
for _, route := range active {
|
||
|
|
if !mappings[route.ReceiverGroup] || !mappings[route.EscalationReceiverGroup] {
|
||
|
|
allGroupsMapped = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
add("RECEIVER_GROUP_MAPPING", allGroupsMapped, "每条活动路由的主接收组和升级接收组必须映射到至少一个脱敏目标引用")
|
||
|
|
|
||
|
|
receipts, receiptsOK := validateReceipts(capture.TestReceipts, capture.CollectedAt)
|
||
|
|
coverageOK := routesOK && len(active) > 0 && receiptsOK
|
||
|
|
correlationOK := true
|
||
|
|
for _, route := range active {
|
||
|
|
test, found := latestDeliveredTest(tests.Items, route)
|
||
|
|
coverage := RouteCoverage{RoutingPolicyID: route.ID, ReceiverGroup: route.ReceiverGroup, Status: "FAIL"}
|
||
|
|
if !found {
|
||
|
|
coverageOK = false
|
||
|
|
report.RouteCoverage = append(report.RouteCoverage, coverage)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
coverage.TestRef = test.TestRef
|
||
|
|
receipt, found := receipts[test.TestRef]
|
||
|
|
if !found || receipt.ReceiverGroup != route.ReceiverGroup || receipt.DeliveryCount != 1 || !receipt.SignatureVerified {
|
||
|
|
coverageOK = false
|
||
|
|
} else {
|
||
|
|
coverage.Status = "PASS"
|
||
|
|
if test.CompletedAt == nil || absoluteDuration(receipt.ReceivedAt.Sub(*test.CompletedAt)) > 10*time.Minute {
|
||
|
|
correlationOK = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
report.RouteCoverage = append(report.RouteCoverage, coverage)
|
||
|
|
}
|
||
|
|
coverageOK = coverageOK && len(receipts) == len(active)
|
||
|
|
add("DELIVERY_TEST_COVERAGE", coverageOK, "每条活动路由必须有一条DELIVERED云端测试和唯一接收端验签回执")
|
||
|
|
add("DELIVERY_TIME_CORRELATION", coverageOK && correlationOK, "云端完成时间与接收端时间差不得超过10分钟")
|
||
|
|
|
||
|
|
drillOK := validateFormalDrill(incident, capture.FormalDrill, mappings, capture.CollectedAt)
|
||
|
|
report.FormalDrillVerified = drillOK
|
||
|
|
add("FORMAL_ALERT_DRILL", drillOK, "必须绑定一条已解决、已确认、唯一验签送达且具有接收人确认引用的正式受控告警演练")
|
||
|
|
if capture.EvidenceSource == "REAL" && drillOK && coverageOK && allGroupsMapped && correlationOK {
|
||
|
|
report.ExternalValidationRequired = false
|
||
|
|
}
|
||
|
|
return report
|
||
|
|
}
|
||
|
|
|
||
|
|
func SHA256(content []byte) string {
|
||
|
|
digest := sha256.Sum256(content)
|
||
|
|
return hex.EncodeToString(digest[:])
|
||
|
|
}
|
||
|
|
|
||
|
|
func decodeEnvelope(content []byte, target any) error {
|
||
|
|
var envelope struct {
|
||
|
|
Data json.RawMessage `json:"data"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(content, &envelope); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
payload := content
|
||
|
|
if len(envelope.Data) > 0 && string(envelope.Data) != "null" {
|
||
|
|
payload = envelope.Data
|
||
|
|
}
|
||
|
|
return json.Unmarshal(payload, target)
|
||
|
|
}
|
||
|
|
|
||
|
|
func decodeStrict(content []byte, target any) error {
|
||
|
|
decoder := json.NewDecoder(bytes.NewReader(content))
|
||
|
|
decoder.DisallowUnknownFields()
|
||
|
|
if err := decoder.Decode(target); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
var trailing any
|
||
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||
|
|
return errors.New("trailing JSON data")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateMappings(items []GroupMapping, collectedAt time.Time) (map[string]bool, bool) {
|
||
|
|
result, ok := map[string]bool{}, !collectedAt.IsZero()
|
||
|
|
for _, item := range items {
|
||
|
|
if item.ReceiverGroup == "" || result[item.ReceiverGroup] || item.VerifiedAt.IsZero() || item.VerifiedAt.After(collectedAt) || len(item.MappedTargetRefs) == 0 {
|
||
|
|
ok = false
|
||
|
|
}
|
||
|
|
targets := slices.Clone(item.MappedTargetRefs)
|
||
|
|
sort.Strings(targets)
|
||
|
|
for index, target := range targets {
|
||
|
|
if strings.TrimSpace(target) == "" || len(target) > 200 || (index > 0 && target == targets[index-1]) {
|
||
|
|
ok = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
result[item.ReceiverGroup] = true
|
||
|
|
}
|
||
|
|
return result, ok
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateReceipts(items []TestReceipt, collectedAt time.Time) (map[string]TestReceipt, bool) {
|
||
|
|
result, ok := map[string]TestReceipt{}, !collectedAt.IsZero()
|
||
|
|
for _, item := range items {
|
||
|
|
if !validUUID(item.TestRef) || !validUUID(item.EventID) || item.ReceiverGroup == "" || item.DeliveryCount != 1 || !item.SignatureVerified || item.ReceivedAt.IsZero() || item.ReceivedAt.After(collectedAt) || result[item.TestRef].TestRef != "" {
|
||
|
|
ok = false
|
||
|
|
}
|
||
|
|
result[item.TestRef] = item
|
||
|
|
}
|
||
|
|
return result, ok
|
||
|
|
}
|
||
|
|
|
||
|
|
func latestDeliveredTest(items []CloudTest, route CloudRoute) (CloudTest, bool) {
|
||
|
|
var selected CloudTest
|
||
|
|
for _, item := range items {
|
||
|
|
if item.RoutingPolicyID != route.ID || item.Status != "DELIVERED" || item.ReceiverGroup != route.ReceiverGroup || !validUUID(item.ID) || !validUUID(item.TestRef) || item.AttemptCount < 1 || item.CreatedAt.IsZero() || item.CompletedAt == nil || item.CompletedAt.Before(item.CreatedAt) || item.CreatedAt.Before(route.CreatedAt) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if selected.ID == "" || item.CompletedAt.After(*selected.CompletedAt) {
|
||
|
|
selected = item
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return selected, selected.ID != ""
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateFormalDrill(incident CloudIncident, drill FormalDrillCapture, mappings map[string]bool, collectedAt time.Time) bool {
|
||
|
|
if !validUUID(incident.ID) || incident.ID != drill.IncidentID || incident.Status != "RESOLVED" || incident.ReceiverGroup == nil || *incident.ReceiverGroup != drill.ReceiverGroup || !mappings[drill.ReceiverGroup] || incident.FirstDetected.IsZero() || incident.ResolvedAt == nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
acknowledged := false
|
||
|
|
for _, action := range incident.Actions {
|
||
|
|
if action.ActionType == "ACKNOWLEDGED" && !action.CreatedAt.IsZero() {
|
||
|
|
acknowledged = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return acknowledged && drill.ControlledDrill && drill.DeliveryCount == 1 && drill.SignatureVerified && !drill.ReceivedAt.IsZero() && !drill.AcknowledgedAt.IsZero() && drill.ReceivedAt.Before(drill.AcknowledgedAt.Add(time.Nanosecond)) && !drill.AcknowledgedAt.After(collectedAt) && strings.TrimSpace(drill.AcknowledgementRef) != ""
|
||
|
|
}
|
||
|
|
|
||
|
|
func validUUID(value string) bool {
|
||
|
|
_, err := uuid.Parse(value)
|
||
|
|
return err == nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func absoluteDuration(value time.Duration) time.Duration {
|
||
|
|
if value < 0 {
|
||
|
|
return -value
|
||
|
|
}
|
||
|
|
return value
|
||
|
|
}
|