218 lines
7.2 KiB
Go
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), "\\/@")
|
|
}
|