Files

218 lines
7.2 KiB
Go

package event_ingress
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"regexp"
"strconv"
"strings"
"time"
)
var (
ErrInvalidEvent = errors.New("invalid_event")
ErrUnsupportedSchema = errors.New("unsupported_schema_version")
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
hexDigestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
)
func ParseEvent(raw []byte) (ParsedEvent, error) {
var event Event
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&event); err != nil {
return ParsedEvent{}, fmt.Errorf("%w: malformed or unknown member", ErrInvalidEvent)
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return ParsedEvent{}, fmt.Errorf("%w: trailing JSON value", ErrInvalidEvent)
}
if event.SchemaVersion == "" {
return ParsedEvent{}, ErrInvalidEvent
}
if event.SchemaVersion != EventSchemaVersion {
return ParsedEvent{}, ErrUnsupportedSchema
}
occurred, err := time.Parse("2006-01-02T15:04:05.000Z", event.OccurredAt)
if err != nil || !validID(event.ProducerID) || !validID(event.SourceEventID) || !validID(event.SiteRef) ||
!validID(event.DeviceRef) || !validID(event.ProfileRef) || !validID(event.Rule.RuleID) ||
!validID(event.Observation.TrackID) || !validID(event.Region.RegionID) || event.Rule.Version == "" ||
len(event.Rule.Version) > 64 || event.Model.Name == "" || len(event.Model.Name) > 128 ||
event.Model.Version == "" || len(event.Model.Version) > 64 || event.Observation.Confidence < 0 ||
event.Observation.Confidence > 1 || math.IsNaN(event.Observation.Confidence) || math.IsInf(event.Observation.Confidence, 0) {
return ParsedEvent{}, ErrInvalidEvent
}
if event.EventType != "dangerous_area_entered" && event.EventType != "directional_line_crossed" {
return ParsedEvent{}, ErrInvalidEvent
}
if event.Severity != "low" && event.Severity != "medium" && event.Severity != "high" && event.Severity != "critical" {
return ParsedEvent{}, ErrInvalidEvent
}
if event.Observation.Category != "person" && event.Observation.Category != "vehicle" && event.Observation.Category != "other" {
return ParsedEvent{}, ErrInvalidEvent
}
if len(event.Observation.BBoxNormalized) != 0 && len(event.Observation.BBoxNormalized) != 4 {
return ParsedEvent{}, ErrInvalidEvent
}
for _, value := range event.Observation.BBoxNormalized {
if value < 0 || value > 1 || math.IsNaN(value) || math.IsInf(value, 0) {
return ParsedEvent{}, ErrInvalidEvent
}
}
if (event.EventType == "dangerous_area_entered" && (event.Region.Kind != "area" || event.Region.CrossingDirection != "")) ||
(event.EventType == "directional_line_crossed" && (event.Region.Kind != "line" || (event.Region.CrossingDirection != "a_to_b" && event.Region.CrossingDirection != "b_to_a"))) {
return ParsedEvent{}, ErrInvalidEvent
}
if event.Evidence == nil || len(event.Evidence) > 8 {
return ParsedEvent{}, ErrInvalidEvent
}
seenEvidence := map[string]bool{}
for _, evidence := range event.Evidence {
evidenceJSON, marshalErr := json.Marshal(evidence)
canonicalEvidence, canonicalErr := canonicalJSON(evidenceJSON)
if err := validateEvidence(evidence); err != nil || marshalErr != nil || canonicalErr != nil || seenEvidence[string(canonicalEvidence)] {
return ParsedEvent{}, ErrInvalidEvent
}
seenEvidence[string(canonicalEvidence)] = true
}
canonical, err := canonicalJSON(raw)
if err != nil {
return ParsedEvent{}, ErrInvalidEvent
}
if containsExplicitNull(raw) {
return ParsedEvent{}, fmt.Errorf("%w: optional members must be omitted", ErrInvalidEvent)
}
digest := sha256.Sum256(canonical)
return ParsedEvent{Event: event, Canonical: canonical, Digest: hex.EncodeToString(digest[:]), Occurred: occurred}, nil
}
func validateEvidence(value Evidence) error {
if value.SchemaVersion != EvidenceSchemaVersion || !validID(value.EvidenceID) || !validID(value.OwnerID) ||
(value.Type != "snapshot" && value.Type != "clip") {
return ErrInvalidEvent
}
if _, err := time.Parse(time.RFC3339Nano, value.CapturedAt); err != nil {
return ErrInvalidEvent
}
if _, err := time.Parse(time.RFC3339Nano, value.StatusUpdatedAt); err != nil {
return ErrInvalidEvent
}
if value.ExpiresAt != "" {
if _, err := time.Parse(time.RFC3339Nano, value.ExpiresAt); err != nil {
return ErrInvalidEvent
}
}
switch value.Status {
case "pending", "processing":
if value.ContentType != "" || value.Integrity != nil || value.Failure != nil {
return ErrInvalidEvent
}
case "success":
if value.Integrity == nil || value.Failure != nil || (value.ContentType != "image/jpeg" && value.ContentType != "image/png" && value.ContentType != "video/mp4") ||
value.Integrity.Algorithm != "sha256" || !hexDigestPattern.MatchString(value.Integrity.Digest) || value.Integrity.SizeBytes < 0 {
return ErrInvalidEvent
}
case "failed":
if value.Failure == nil || value.ContentType != "" || value.Integrity != nil ||
(value.Failure.Code != "capture_failed" && value.Failure.Code != "processing_failed" && value.Failure.Code != "expired" && value.Failure.Code != "unavailable") {
return ErrInvalidEvent
}
default:
return ErrInvalidEvent
}
return nil
}
func canonicalJSON(raw []byte) ([]byte, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return nil, err
}
value, err := normalizeJCSNumbers(value)
if err != nil {
return nil, err
}
var buffer bytes.Buffer
encoder := json.NewEncoder(&buffer)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
return nil, err
}
canonical := bytes.TrimSuffix(buffer.Bytes(), []byte("\n"))
canonical = bytes.ReplaceAll(canonical, []byte(`\u2028`), []byte("\u2028"))
canonical = bytes.ReplaceAll(canonical, []byte(`\u2029`), []byte("\u2029"))
return canonical, nil
}
func normalizeJCSNumbers(value any) (any, error) {
switch typed := value.(type) {
case json.Number:
number, err := strconv.ParseFloat(string(typed), 64)
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) {
return nil, errors.New("JSON number is outside the RFC 8785 domain")
}
if number == 0 {
return float64(0), nil
}
return number, nil
case []any:
for index, item := range typed {
normalized, err := normalizeJCSNumbers(item)
if err != nil {
return nil, err
}
typed[index] = normalized
}
case map[string]any:
for key, item := range typed {
normalized, err := normalizeJCSNumbers(item)
if err != nil {
return nil, err
}
typed[key] = normalized
}
}
return value, nil
}
func containsExplicitNull(raw []byte) bool {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if decoder.Decode(&value) != nil {
return true
}
return hasNull(value)
}
func hasNull(value any) bool {
switch typed := value.(type) {
case nil:
return true
case []any:
for _, item := range typed {
if hasNull(item) {
return true
}
}
case map[string]any:
for _, item := range typed {
if hasNull(item) {
return true
}
}
}
return false
}
func validID(value string) bool {
return identifierPattern.MatchString(value) && !strings.ContainsAny(strings.ToLower(value), "\\/@")
}