diff --git a/README.md b/README.md index 8dc0f6c..06a76c3 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ ## Introduction This library provides definitions of structures used in the output of the THOR APT Forensic Scanner. These structures can be used for different use cases: -- generate a schema for THOR JSON logs + +- generate schemas for THOR JSON logs and the THOR audit trail - convert JSON logs into text logs - parse JSON logs @@ -11,10 +12,10 @@ This library provides definitions of structures used in the output of the THOR A There are three versions of the THOR log format: - - v1: The original THOR log format, used up to and including THOR version 10.7. This is equivalent to the THOR text format, simply serialized as JSON. - - v2: The format used in THOR version 10.7 with the `--jsonv2` flag. This format introduced a more structured approach to logging, - with subobjects for reasons, files, and other entities. It is largely open-ended and allows for custom fields. - - v3: The format used in THOR 11 and later. This format is more strict and versioned, with a defined schema. It introduces the concept of _reportable objects_. +- v1: The original THOR log format, used up to and including THOR version 10.7. This is equivalent to the THOR text format, simply serialized as JSON. +- v2: The format used in THOR version 10.7 with the `--jsonv2` flag. This format introduced a more structured approach to logging, + with subobjects for reasons, files, and other entities. It is largely open-ended and allows for custom fields. +- v3: The format used in THOR 11 and later. This format is more strict and versioned, with a defined schema. It introduces the concept of _reportable objects_. ## Parsing Events @@ -39,25 +40,52 @@ This type determines how the object should be interpreted and what fields it con ### Event Types The object types contained in a THOR log are `THOR finding` and `THOR message`: - - Findings are the results of THOR's analysis, such as detected threats or anomalies. - - Messages are informational or status updates from THOR, such as progress updates. + +- Findings are the results of THOR's analysis, such as detected threats or anomalies. +- Messages are informational or status updates from THOR, such as progress updates. Both findings and messages are together called _events_. +### Audit Entry Types + +The THOR audit trail is a separate log that documents which objects a scan examined, +regardless of whether THOR reported anything about them. +The object types contained in this log are `THOR audit record` and `THOR audit message`: + +- Audit records document a single object that THOR observed, together with the timestamps known for + it, any indicators that matched on it, and its relations to other audit records. +- Audit messages are the messages that THOR printed during the scan, in a less verbose form than the + `THOR message` events. + +Both audit records and audit messages are together called _audit entries_. + ### Reportable Objects -Findings may contain more objects, e.g. as a subject that they report. +Findings may contain more objects, e.g. as a subject that they report. Object types that can appear as subjects are called _reportable objects_. The most common reportable objects are: + - `file` - `process` Reportable objects should contain only fields that relate directly to the object itself. -E.g. when extracting a file from an archive, the file object should contain only fields +E.g. when extracting a file from an archive, the file object should contain only fields that relate to the file itself, not to the archive. The archive data will instead appear in the _context_ of the finding. ## Schema -A schema for the version 3 format is attached to each release. -It can also be generated using the `thorlog/jsonschema` package. +There are two schemas for the version 3 format: + +- `thor-event.json` describes the events in a THOR log. +- `thor-audit-entry.json` describes the entries in the THOR audit trail. + +The schemas are attached to each release. +They can also be generated using the `thorlog/jsonschema` package. +The generator takes the schema to generate as its only argument and must be run from within its directory: + +```sh +cd thorlog/jsonschema +go run . log > thor-event.json +go run . audittrail > thor-audit-entry.json +``` \ No newline at end of file diff --git a/thorlog/jsonschema/generateschema.go b/thorlog/jsonschema/generateschema.go index c35c507..a05bc8c 100644 --- a/thorlog/jsonschema/generateschema.go +++ b/thorlog/jsonschema/generateschema.go @@ -11,6 +11,7 @@ import ( "github.com/NextronSystems/jsonlog" "github.com/NextronSystems/jsonlog/thorlog/v3" + _ "github.com/NextronSystems/jsonlog/thorlog/v3/audittrail" "github.com/invopop/jsonschema" orderedmap "github.com/wk8/go-ordered-map/v2" ) @@ -22,13 +23,14 @@ func makeObjectSchema() (mainEntry string, defs map[string]*jsonschema.Schema) { var logObjectTypes []any var reflector jsonschema.Reflector reflector.AllowAdditionalProperties = true + // Walks subdirectories too, so this also covers audittrail. err := reflector.AddGoComments("github.com/NextronSystems/jsonlog/thorlog/v3", "../v3") if err != nil { panic(err) } defs = map[string]*jsonschema.Schema{} - // Sort the object type names to have a stable output + // Sort the object type names to have a stable output. var objectTypeNames = slices.Collect(maps.Keys(thorlog.LogObjectTypes)) slices.Sort(objectTypeNames) @@ -90,26 +92,49 @@ func makeObjectSchema() (mainEntry string, defs map[string]*jsonschema.Schema) { } func main() { - logEventSchema := jsonschema.Schema{ - Version: jsonschema.Version, - ID: "https://www.nextron-systems.com/schemas/thorlog/v3/thor-event.json", - Definitions: map[string]*jsonschema.Schema{}, - Title: "ThorEvent", - OneOf: []*jsonschema.Schema{ - { - Ref: "#/$defs/Assessment", + var logEventSchema jsonschema.Schema + if len(os.Args) == 2 && os.Args[1] == "log" { + logEventSchema = jsonschema.Schema{ + Version: jsonschema.Version, + ID: "https://www.nextron-systems.com/schemas/thorlog/v3/thor-event.json", + Definitions: map[string]*jsonschema.Schema{}, + Title: "ThorEvent", + OneOf: []*jsonschema.Schema{ + { + Ref: "#/$defs/Assessment", + }, + { + Ref: "#/$defs/Message", + }, }, - { - Ref: "#/$defs/Message", + } + } else if len(os.Args) == 2 && os.Args[1] == "audittrail" { + logEventSchema = jsonschema.Schema{ + Version: jsonschema.Version, + ID: "https://www.nextron-systems.com/schemas/thorlog/v3/thor-audit-entry.json", + Definitions: map[string]*jsonschema.Schema{}, + Title: "ThorAuditEntry", + OneOf: []*jsonschema.Schema{ + { + Ref: "#/$defs/AuditMessage", + }, + { + Ref: "#/$defs/AuditRecord", + }, }, - }, + } + } else { + fmt.Fprintf(os.Stderr, "Usage: %s (log|audittrail)\n", os.Args[0]) + os.Exit(2) } + entry, defs := makeObjectSchema() for key, value := range defs { logEventSchema.Definitions[key] = value } flatten(logEventSchema.Definitions[entry], logEventSchema.Definitions) + prune(&logEventSchema) encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") @@ -144,3 +169,46 @@ func flatten(schema *jsonschema.Schema, definitions jsonschema.Definitions) { flatten(subschema, definitions) } } + +// prune removes all definitions that are not reachable from the root schema via $ref. +func prune(root *jsonschema.Schema) { + reachable := map[string]bool{} + var visit func(schema *jsonschema.Schema) + visit = func(schema *jsonschema.Schema) { + // Most subschema fields are nil. + if schema == nil { + return + } + // Check !reachable[name] so we won't run into an endless loop + // and we only visit each definition once. + if name, ok := strings.CutPrefix(schema.Ref, "#/$defs/"); ok && !reachable[name] { + def, ok := root.Definitions[name] + if !ok { + panic("dangling reference " + schema.Ref) + } + // Mark the definition before the recursive call. + reachable[name] = true + visit(def) + } + // Definitions are not visited, since they are only reachable via $ref. + // Visit all applicators https://www.learnjsonschema.com/2020-12/applicator/ + // and contentSchema, which could also contain references. + children := slices.Concat( + []*jsonschema.Schema{ + schema.Not, schema.If, schema.Then, schema.Else, schema.Items, schema.Contains, + schema.AdditionalProperties, schema.PropertyNames, schema.ContentSchema, + }, + schema.AllOf, schema.AnyOf, schema.OneOf, schema.PrefixItems, + slices.Collect(maps.Values(schema.PatternProperties)), + slices.Collect(maps.Values(schema.DependentSchemas)), + ) + for pair := schema.Properties.Oldest(); pair != nil; pair = pair.Next() { + children = append(children, pair.Value) + } + for _, child := range children { + visit(child) + } + } + visit(root) + maps.DeleteFunc(root.Definitions, func(name string, _ *jsonschema.Schema) bool { return !reachable[name] }) +} diff --git a/thorlog/v3/event.go b/thorlog/v3/assessment.go similarity index 63% rename from thorlog/v3/event.go rename to thorlog/v3/assessment.go index 8adfb11..653ebe4 100644 --- a/thorlog/v3/event.go +++ b/thorlog/v3/assessment.go @@ -1,7 +1,6 @@ package thorlog import ( - "bytes" "encoding/json" "fmt" "reflect" @@ -32,16 +31,16 @@ type Assessment struct { // Reasons describes the indicators that contributed to the score. // This list is not necessarily comprehensive; THOR may cut off all reasons after the first few. // If this is the case, an Issue with category IssueCategoryTruncated pointing to this field will be present. - Reasons []Reason `json:"reasons" textlog:",expand"` + Reasons []Reason `json:"reasons" textlog:",expand" jsonschema:"nullable"` // ReasonCount contains the total number of reasons (before any truncations). ReasonCount int `json:"reason_count,omitempty" textlog:"reasons_count,omitempty"` // Ancestors contains information about objects that are the subject's ancestors. // // E.g. if the subject is a file in a nested ZIP, all the ZIPs (the topmost one and each nested one) are ancestors. // Ancestors does not necessarily include information about all ancestors, but it will always include information about at least the topmost ancestor and the parent. - Ancestors Ancestors `json:"ancestors" textlog:",expand"` + Ancestors Ancestors `json:"ancestors" textlog:",expand" jsonschema:"nullable"` // Derivatives contains information about objects that have been referenced in the subject. - Derivatives []Derivative `json:"derivatives" textlog:"file,expand"` + Derivatives []Derivative `json:"derivatives" textlog:"file,expand" jsonschema:"nullable"` // Issues lists any problems that THOR encountered when trying to create a JSON struct for this assessment. // This may include e.g. overly long fields that were truncated, fields that could not be rendered to JSON, // or similar problems. @@ -92,11 +91,11 @@ func (a *Assessment) UnmarshalJSON(data []byte) error { if a.Reasons[i].StringMatches[j].Field == nil { continue } - target, err := jsonpointer.Resolve(a.Subject, a.Reasons[i].StringMatches[j].Field.ToJsonPointer()) + target, err := jsonpointer.Resolve(subject, a.Reasons[i].StringMatches[j].Field.ToJsonPointer()) if err != nil { return err } - a.Reasons[i].StringMatches[j].Field = jsonlog.NewReference(a.Subject, target) + a.Reasons[i].StringMatches[j].Field = jsonlog.NewReference(subject, target) } } for i := range a.Derivatives { @@ -235,192 +234,6 @@ func NewAssessment(subject ObservedObject, message string) *Assessment { }, Text: message, Subject: subject, - LogVersion: currentVersion, + LogVersion: CurrentVersion, } } - -// Message describes a THOR message printed during the scan. -// Unlike Assessment, this does not describe an analysis' result, -// but rather something about the scan itself (e.g. how many IOCs were loaded). -type Message struct { - jsonlog.ObjectHeader - Meta LogEventMetadata `json:"meta" textlog:",expand"` - // Text is the message that was logged. - Text string `json:"message" textlog:"message"` - // Fields contains additional structured fields that were logged. These - // contain details about the Text displayed. - Fields MessageFields `json:"fields" textlog:",expand" jsonschema:"nullable"` - LogVersion common.Version `json:"log_version"` -} - -func (m *Message) Message() string { - return m.Text -} - -func (m *Message) Version() common.Version { - return m.LogVersion -} - -func (m *Message) Metadata() *LogEventMetadata { - return &m.Meta -} - -var _ common.Event = (*Message)(nil) - -const typeMessage = "THOR message" - -func init() { AddLogObjectType(typeMessage, &Message{}) } - -func NewMessage(meta LogEventMetadata, message string, kvs ...any) *Message { - msg := &Message{ - ObjectHeader: LogObjectHeader{ - Type: typeMessage, - }, - Text: message, - Meta: meta, - LogVersion: currentVersion, - } - if len(kvs)%2 != 0 { - panic("uneven number of key-value pairs") - } - for i := 0; i < len(kvs); i += 2 { - msg.Fields = append(msg.Fields, MessageField{ - Key: kvs[i].(string), - Value: kvs[i+1], - }) - } - return msg -} - -type MessageField struct { - Key string - Value any -} - -type MessageFields []MessageField - -func (o MessageFields) MarshalJSON() ([]byte, error) { - var buf bytes.Buffer - - buf.WriteString("{") - for i, kv := range o { - if i != 0 { - buf.WriteString(",") - } - key, err := json.Marshal(kv.Key) - if err != nil { - return nil, err - } - buf.Write(key) - buf.WriteString(":") - // marshal value - val, err := json.Marshal(kv.Value) - if err != nil { - return nil, err - } - buf.Write(val) - } - - buf.WriteString("}") - return buf.Bytes(), nil -} - -func (o *MessageFields) UnmarshalJSON(data []byte) error { - value, err := unmarshalJsonValue(data) - if err != nil { - return err - } - if value == nil { - return nil - } - details, isDetails := value.(MessageFields) - if !isDetails { - return &json.UnmarshalTypeError{ - Value: fmt.Sprint(value), - Type: reflect.TypeOf(o).Elem(), - Offset: 0, - } - } - *o = details - return nil -} - -func (o MessageFields) JSONSchemaAlias() any { - return map[string]any{} -} - -func unmarshalJsonValue(data []byte) (any, error) { - decoder := json.NewDecoder(bytes.NewReader(data)) - startToken, err := decoder.Token() - if err != nil { - return nil, err - } - switch t := startToken.(type) { - case bool, string, float64, json.Number, nil: - return t, nil - } - if startToken == json.Delim('[') { - var values []any - for decoder.More() { - var value json.RawMessage - if err := decoder.Decode(&value); err != nil { - return nil, err - } - parsedValue, err := unmarshalJsonValue(value) - if err != nil { - return nil, err - } - values = append(values, parsedValue) - } - return values, nil - } else if startToken == json.Delim('{') { - var details MessageFields - for decoder.More() { - keyToken, err := decoder.Token() - if err != nil { - return nil, err - } - key, isString := keyToken.(string) - if !isString { - return nil, fmt.Errorf("key %v is not a string", keyToken) - } - var value json.RawMessage - if err := decoder.Decode(&value); err != nil { - return nil, err - } - parsedValue, err := unmarshalJsonValue(value) - if err != nil { - return nil, err - } - details = append(details, MessageField{ - Key: key, - Value: parsedValue, - }) - } - return details, nil - } else { - return nil, fmt.Errorf("invalid JSON token %v", startToken) - } -} - -func (m MessageFields) MarshalTextLog(t jsonlog.TextlogFormatter) jsonlog.TextlogEntry { - var result jsonlog.TextlogEntry - for _, kv := range m { - expandedValues := t.Format(kv.Value) - if len(expandedValues) == 0 { // FIXME: Better distinguish between types that are expanded and those that aren't - var formattedValue string - if t.FormatValue != nil { - formattedValue = t.FormatValue(kv.Value, nil) - } else { - formattedValue = fmt.Sprint(kv.Value) - } - result = append(result, jsonlog.TextlogValuePair{ - Key: kv.Key, - Value: formattedValue, - }) - } else { - result = append(result, expandedValues...) - } - } - return result -} diff --git a/thorlog/v3/audittrail/auditentry.go b/thorlog/v3/audittrail/auditentry.go new file mode 100644 index 0000000..3f50d00 --- /dev/null +++ b/thorlog/v3/audittrail/auditentry.go @@ -0,0 +1,13 @@ +package audittrail + +import ( + "time" + + "github.com/NextronSystems/jsonlog/thorlog/common" +) + +// AuditEntry describes an entry in the audit trail log. +type AuditEntry interface { + Timestamps() map[string]time.Time + Version() common.Version +} diff --git a/thorlog/v3/audittrail/auditmessage.go b/thorlog/v3/audittrail/auditmessage.go new file mode 100644 index 0000000..e331d5c --- /dev/null +++ b/thorlog/v3/audittrail/auditmessage.go @@ -0,0 +1,59 @@ +package audittrail + +import ( + "time" + + "github.com/NextronSystems/jsonlog" + "github.com/NextronSystems/jsonlog/thorlog/common" + "github.com/NextronSystems/jsonlog/thorlog/v3" +) + +// AuditMessage is a less verbose variant of THOR Message that is written to the audit trail. +// It is derived from a THOR Message, with its metadata flattened and reduced to the time, +// level and module. +type AuditMessage struct { + jsonlog.ObjectHeader + + // Time is the time at which the message was logged. + Time time.Time `json:"time"` + // Lvl is the level at which the message was logged. + Lvl thorlog.LogLevel `json:"level"` + // Mod is the THOR module that logged the message. + Mod string `json:"module"` + // Text is the message that was logged. + Text string `json:"message"` + // Fields contains additional structured fields that were logged. These + // contain details about the Text displayed. + Fields thorlog.MessageFields `json:"fields" jsonschema:"nullable"` + // LogVersion describes the jsonlog version that this message was created with. + LogVersion common.Version `json:"log_version"` +} + +const TypeAuditMessage = "THOR audit message" + +func init() { thorlog.AddLogObjectType(TypeAuditMessage, &AuditMessage{}) } + +func NewAuditMessage(message *thorlog.Message) *AuditMessage { + msg := &AuditMessage{ + ObjectHeader: thorlog.LogObjectHeader{ + Type: TypeAuditMessage, + }, + Time: message.Meta.Time, + Lvl: message.Meta.Lvl, + Mod: message.Meta.Mod, + Text: message.Text, + Fields: message.Fields, + LogVersion: message.LogVersion, + } + return msg +} + +func (a *AuditMessage) Timestamps() map[string]time.Time { + return map[string]time.Time{ + "PRINTED": a.Time, + } +} + +func (a *AuditMessage) Version() common.Version { + return a.LogVersion +} diff --git a/thorlog/v3/audittrail/auditrecord.go b/thorlog/v3/audittrail/auditrecord.go new file mode 100644 index 0000000..bebbbc6 --- /dev/null +++ b/thorlog/v3/audittrail/auditrecord.go @@ -0,0 +1,111 @@ +package audittrail + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/NextronSystems/jsonlog" + "github.com/NextronSystems/jsonlog/jsonpointer" + "github.com/NextronSystems/jsonlog/thorlog/common" + "github.com/NextronSystems/jsonlog/thorlog/v3" +) + +// AuditRecord describes a single object that THOR observed during a scan. +// Audit records are written regardless of whether THOR reported anything about the +// object. Audit records are written to the audit trail and not +// to THOR's regular output. +type AuditRecord struct { + jsonlog.ObjectHeader + + // ID identifies this record within its scan. + ID string `json:"id"` + // Object is the object that THOR observed. + Object thorlog.ObservedObject `json:"object"` + // Times contains the timestamps that are associated with the Object. + // This is a list of all timestamps found anywhere in the Object. + // It is guaranteed that Times is not empty: + // if the object does not contain any timestamps, the time at which the Object + // was scanned is listed. + Times map[string]time.Time `json:"timestamps"` + // Reasons describes the indicators that THOR found for the Object. + Reasons []thorlog.Reason `json:"reasons" jsonschema:"nullable"` + // References points to the other audit records that the Object is related to. + References []AuditReference `json:"references" jsonschema:"nullable"` + // LogVersion describes the jsonlog version that this record was created with. + LogVersion common.Version `json:"log_version"` +} + +// AuditReference describes a relation of an audit record to another one. +type AuditReference struct { + // TargetID is the ID of the referenced record. + TargetID string `json:"target_id"` + // Relation is the type of the relation, either "child of" or "points to". + Relation Relation `json:"relation"` +} + +// Relation describes how an audit record relates to the record referenced by an AuditReference. +type Relation string + +const ( + RelationChildOf Relation = "child of" + RelationPointsTo Relation = "points to" +) + +const TypeAuditRecord = "THOR audit record" + +func init() { thorlog.AddLogObjectType(TypeAuditRecord, &AuditRecord{}) } + +func NewAuditRecord(id string, object thorlog.ObservedObject) *AuditRecord { + return &AuditRecord{ + ObjectHeader: thorlog.LogObjectHeader{ + Type: TypeAuditRecord, + }, + ID: id, + Object: object, + LogVersion: thorlog.CurrentVersion, + } +} + +func (a *AuditRecord) UnmarshalJSON(data []byte) error { + type plainAuditRecord AuditRecord + var rawAuditRecord struct { + plainAuditRecord + Object thorlog.EmbeddedObject `json:"object"` + } + if err := json.Unmarshal(data, &rawAuditRecord); err != nil { + return err + } + *a = AuditRecord(rawAuditRecord.plainAuditRecord) + object, isObserved := rawAuditRecord.Object.Object.(thorlog.ObservedObject) + if !isObserved { + return fmt.Errorf("object of type %T must implement the ObservedObject interface", + rawAuditRecord.Object.Object) + } + a.Object = object + + // Resolve all references + // When the audit record is unmarshalled, the references are not resolved yet and only contain + // the JSON pointers. Resolve them to the actual values to be able to use them afterwards. + for i := range a.Reasons { + for j := range a.Reasons[i].StringMatches { + if a.Reasons[i].StringMatches[j].Field == nil { + continue + } + target, err := jsonpointer.Resolve(object, a.Reasons[i].StringMatches[j].Field.ToJsonPointer()) + if err != nil { + return err + } + a.Reasons[i].StringMatches[j].Field = jsonlog.NewReference(object, target) + } + } + return nil +} + +func (a *AuditRecord) Version() common.Version { + return a.LogVersion +} + +func (a *AuditRecord) Timestamps() map[string]time.Time { + return a.Times +} diff --git a/thorlog/v3/common.go b/thorlog/v3/common.go index e863143..0e6afc5 100644 --- a/thorlog/v3/common.go +++ b/thorlog/v3/common.go @@ -22,4 +22,4 @@ const ( Debug = common.Debug ) -const currentVersion = "v3.0.0" +const CurrentVersion = "v3.0.0" diff --git a/thorlog/v3/message.go b/thorlog/v3/message.go new file mode 100644 index 0000000..1081fbe --- /dev/null +++ b/thorlog/v3/message.go @@ -0,0 +1,197 @@ +package thorlog + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + + "github.com/NextronSystems/jsonlog" + "github.com/NextronSystems/jsonlog/thorlog/common" +) + +// Message describes a THOR message printed during the scan. +// Unlike Assessment, this does not describe an analysis' result, +// but rather something about the scan itself (e.g. how many IOCs were loaded). +type Message struct { + jsonlog.ObjectHeader + Meta LogEventMetadata `json:"meta" textlog:",expand"` + // Text is the message that was logged. + Text string `json:"message" textlog:"message"` + // Fields contains additional structured fields that were logged. These + // contain details about the Text displayed. + Fields MessageFields `json:"fields" textlog:",expand" jsonschema:"nullable"` + LogVersion common.Version `json:"log_version"` +} + +func (m *Message) Message() string { + return m.Text +} + +func (m *Message) Version() common.Version { + return m.LogVersion +} + +func (m *Message) Metadata() *LogEventMetadata { + return &m.Meta +} + +var _ common.Event = (*Message)(nil) + +const typeMessage = "THOR message" + +func init() { AddLogObjectType(typeMessage, &Message{}) } + +func NewMessage(meta LogEventMetadata, message string, kvs ...any) *Message { + msg := &Message{ + ObjectHeader: LogObjectHeader{ + Type: typeMessage, + }, + Text: message, + Meta: meta, + LogVersion: CurrentVersion, + } + if len(kvs)%2 != 0 { + panic("uneven number of key-value pairs") + } + for i := 0; i < len(kvs); i += 2 { + msg.Fields = append(msg.Fields, MessageField{ + Key: kvs[i].(string), + Value: kvs[i+1], + }) + } + return msg +} + +type MessageField struct { + Key string + Value any +} + +type MessageFields []MessageField + +func (o MessageFields) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + + buf.WriteString("{") + for i, kv := range o { + if i != 0 { + buf.WriteString(",") + } + key, err := json.Marshal(kv.Key) + if err != nil { + return nil, err + } + buf.Write(key) + buf.WriteString(":") + // marshal value + val, err := json.Marshal(kv.Value) + if err != nil { + return nil, err + } + buf.Write(val) + } + + buf.WriteString("}") + return buf.Bytes(), nil +} + +func (o *MessageFields) UnmarshalJSON(data []byte) error { + value, err := unmarshalJsonValue(data) + if err != nil { + return err + } + if value == nil { + return nil + } + details, isDetails := value.(MessageFields) + if !isDetails { + return &json.UnmarshalTypeError{ + Value: fmt.Sprint(value), + Type: reflect.TypeOf(o).Elem(), + Offset: 0, + } + } + *o = details + return nil +} + +func (o MessageFields) JSONSchemaAlias() any { + return map[string]any{} +} + +func unmarshalJsonValue(data []byte) (any, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + startToken, err := decoder.Token() + if err != nil { + return nil, err + } + switch t := startToken.(type) { + case bool, string, float64, json.Number, nil: + return t, nil + } + if startToken == json.Delim('[') { + var values []any + for decoder.More() { + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return nil, err + } + parsedValue, err := unmarshalJsonValue(value) + if err != nil { + return nil, err + } + values = append(values, parsedValue) + } + return values, nil + } else if startToken == json.Delim('{') { + var details MessageFields + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return nil, err + } + key, isString := keyToken.(string) + if !isString { + return nil, fmt.Errorf("key %v is not a string", keyToken) + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return nil, err + } + parsedValue, err := unmarshalJsonValue(value) + if err != nil { + return nil, err + } + details = append(details, MessageField{ + Key: key, + Value: parsedValue, + }) + } + return details, nil + } else { + return nil, fmt.Errorf("invalid JSON token %v", startToken) + } +} + +func (m MessageFields) MarshalTextLog(t jsonlog.TextlogFormatter) jsonlog.TextlogEntry { + var result jsonlog.TextlogEntry + for _, kv := range m { + expandedValues := t.Format(kv.Value) + if len(expandedValues) == 0 { // FIXME: Better distinguish between types that are expanded and those that aren't + var formattedValue string + if t.FormatValue != nil { + formattedValue = t.FormatValue(kv.Value, nil) + } else { + formattedValue = fmt.Sprint(kv.Value) + } + result = append(result, jsonlog.TextlogValuePair{ + Key: kv.Key, + Value: formattedValue, + }) + } else { + result = append(result, expandedValues...) + } + } + return result +}