diff --git a/.golangci.yml b/.golangci.yml index 6edd2ac5..e7c44cfe 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -19,4 +19,4 @@ issues: exclude-rules: - path: magefile\.go linters: - - deadcode + - deadcode \ No newline at end of file diff --git a/coraza.conf-recommended b/coraza.conf-recommended index ce311ecd..ccc5884e 100644 --- a/coraza.conf-recommended +++ b/coraza.conf-recommended @@ -161,8 +161,11 @@ SecAuditLogParts ABIJDEFHZ # SecAuditLogType Serial -# The following settings are not supported by Coraza +# The format used to write the audit log. +# Can be one of JSON|JsonLegacy|Native|OCSF +SecAuditLogFormat Native +# The following settings are not supported by Coraza # SecCookieFormat 0 # SecArgumentSeparator & # SecRule MULTIPART_UNMATCHED_BOUNDARY "@eq 1" \ diff --git a/experimental/plugins/plugintypes/auditlog.go b/experimental/plugins/plugintypes/auditlog.go index b64911da..268ec893 100644 --- a/experimental/plugins/plugintypes/auditlog.go +++ b/experimental/plugins/plugintypes/auditlog.go @@ -6,6 +6,7 @@ package plugintypes import ( "io/fs" + "github.com/corazawaf/coraza/v3/internal/collections" "github.com/corazawaf/coraza/v3/types" ) @@ -31,6 +32,8 @@ type AuditLogTransaction interface { Response() AuditLogTransactionResponse HasResponse() bool Producer() AuditLogTransactionProducer + HighestSeverity() string // The highest severity of the matched rules for the transaction + IsInterrupted() bool // True if the transaction was interrupted } // AuditLogTransactionResponse contains response specific information @@ -61,6 +64,8 @@ type AuditLogTransactionRequest interface { Headers() map[string][]string Body() string Files() []AuditLogTransactionRequestFiles + Args() *collections.ConcatKeyed // A string representation of all request agruments in the format 'k=v,' + Length() int32 // The total size of the request in bytes } // AuditLogTransactionRequestFiles contains information for the diff --git a/go.mod b/go.mod index 08b43bd3..fcb8f475 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ go 1.22 // - aho-corasick // - gjson // - binaryregexp +// - ocsf-schema-golang require ( github.com/anuraaga/go-modsecurity v0.0.0-20220824035035-b9a4099778df @@ -23,6 +24,7 @@ require ( github.com/mccutchen/go-httpbin/v2 v2.14.0 github.com/petar-dambovaliev/aho-corasick v0.0.0-20240411101913-e07a1f0e8eb4 github.com/tidwall/gjson v1.17.3 + github.com/valllabh/ocsf-schema-golang v1.0.3 golang.org/x/net v0.28.0 golang.org/x/sync v0.8.0 rsc.io/binaryregexp v0.2.0 @@ -35,4 +37,5 @@ require ( golang.org/x/mod v0.17.0 // indirect golang.org/x/sys v0.23.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + google.golang.org/protobuf v1.34.1 // indirect ) diff --git a/go.sum b/go.sum index d7107363..c4593576 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/corazawaf/libinjection-go v0.2.1 h1:vNJ7L6c4xkhRgYU6sIO0Tl54TmeCQv/yf github.com/corazawaf/libinjection-go v0.2.1/go.mod h1:OP4TM7xdJ2skyXqNX1AN1wN5nNZEmJNuWbNPOItn7aw= github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mccutchen/go-httpbin/v2 v2.14.0 h1:9N7GUf8+JunYMFd+yHPIVYApC6KYgqtF0pHIcTGYcVQ= @@ -21,6 +23,8 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw= +github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -86,5 +90,7 @@ golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/internal/auditlog/auditlog.go b/internal/auditlog/auditlog.go index 9ff0e35d..0cf84ece 100644 --- a/internal/auditlog/auditlog.go +++ b/internal/auditlog/auditlog.go @@ -7,6 +7,7 @@ import ( "encoding/json" "github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes" + "github.com/corazawaf/coraza/v3/internal/collections" "github.com/corazawaf/coraza/v3/types" ) @@ -77,13 +78,15 @@ type Transaction struct { // Client IP Address string representation ClientIP_ string `json:"client_ip"` - ClientPort_ int `json:"client_port"` - HostIP_ string `json:"host_ip"` - HostPort_ int `json:"host_port"` - ServerID_ string `json:"server_id"` - Request_ *TransactionRequest `json:"request,omitempty"` - Response_ *TransactionResponse `json:"response,omitempty"` - Producer_ *TransactionProducer `json:"producer,omitempty"` + ClientPort_ int `json:"client_port"` + HostIP_ string `json:"host_ip"` + HostPort_ int `json:"host_port"` + ServerID_ string `json:"server_id"` + Request_ *TransactionRequest `json:"request,omitempty"` + Response_ *TransactionResponse `json:"response,omitempty"` + Producer_ *TransactionProducer `json:"producer,omitempty"` + HighestSeverity_ string `json:"highest_severity"` + IsInterrupted_ bool `json:"is_interrupted"` } var _ plugintypes.AuditLogTransaction = Transaction{} @@ -140,6 +143,14 @@ func (t Transaction) Producer() plugintypes.AuditLogTransactionProducer { return t.Producer_ } +func (t Transaction) HighestSeverity() string { + return t.HighestSeverity_ +} + +func (t Transaction) IsInterrupted() bool { + return t.IsInterrupted_ +} + // TransactionResponse contains response specific // information type TransactionResponse struct { @@ -197,26 +208,50 @@ type TransactionProducer struct { var _ plugintypes.AuditLogTransactionProducer = (*TransactionProducer)(nil) func (tp *TransactionProducer) Connector() string { + if tp == nil { + return "" + } + return tp.Connector_ } func (tp *TransactionProducer) Version() string { + if tp == nil { + return "" + } + return tp.Version_ } func (tp *TransactionProducer) Server() string { + if tp == nil { + return "" + } + return tp.Server_ } func (tp *TransactionProducer) RuleEngine() string { + if tp == nil { + return "" + } + return tp.RuleEngine_ } func (tp *TransactionProducer) Stopwatch() string { + if tp == nil { + return "" + } + return tp.Stopwatch_ } func (tp *TransactionProducer) Rulesets() []string { + if tp == nil { + return nil + } + return tp.Rulesets_ } @@ -230,6 +265,8 @@ type TransactionRequest struct { Headers_ map[string][]string `json:"headers"` Body_ string `json:"body"` Files_ []plugintypes.AuditLogTransactionRequestFiles `json:"files"` + Args_ *collections.ConcatKeyed `json:"args"` + Length_ int32 `json:"length"` } var _ plugintypes.AuditLogTransactionRequest = (*TransactionRequest)(nil) @@ -286,6 +323,21 @@ func (tr *TransactionRequest) Files() []plugintypes.AuditLogTransactionRequestFi return tr.Files_ } +func (tr *TransactionRequest) Args() *collections.ConcatKeyed { + if tr == nil { + return &collections.ConcatKeyed{} + } + + return tr.Args_ +} + +func (tr *TransactionRequest) Length() int32 { + if tr == nil { + return 0 + } + return tr.Length_ +} + // TransactionRequestFiles contains information // for the uploaded files using multipart forms type TransactionRequestFiles struct { diff --git a/internal/auditlog/auditlog_test.go b/internal/auditlog/auditlog_test.go index d7b1950b..462ae571 100644 --- a/internal/auditlog/auditlog_test.go +++ b/internal/auditlog/auditlog_test.go @@ -6,20 +6,188 @@ package auditlog -import "testing" +import ( + "slices" + "testing" +) + +func TestAuditLogUnmarshalInvalidJSON(t *testing.T) { + // Improper JSON data (missing a closing '}') + invalidSerializedLog := []byte(`{ + "transaction": { + "id": + }`) + + log := &Log{} + + // Verify a error is returned for invalidly formatted JSON data + if err := log.UnmarshalJSON(invalidSerializedLog); err != nil { + if err.Error() != "invalid character '}' looking for beginning of value" { + t.Errorf("failed to match error message, \ngot: %s, \nexpected: %s", err, "invalid character '}' looking for beginning of value") + } + + } +} + +func TestAuditLogUnmarshalEmptyJSON(t *testing.T) { + serializedLog := []byte(`{ + "transaction": { + } + }`) + + log := &Log{} + + // Validate proper results for nil values + if err := log.UnmarshalJSON(serializedLog); err != nil { + t.Error(err) + } + + if want, have := "", log.Transaction().ID(); want != have { + t.Errorf("failed to match transaction id, got: %s, expected: %s", have, want) + } + + // Validate Transaction Request parameters + if want, have := false, log.Transaction().HasRequest(); want != have { + t.Errorf("failed to match transaction has request, got: %t, expected: %t", have, want) + } + + if want, have := "", log.Transaction().Request().Method(); want != have { + t.Errorf("failed to match transaction request method, got: %s, expected: %s", have, want) + } + + if want, have := "", log.Transaction().Request().HTTPVersion(); want != have { + t.Errorf("failed to match transaction request method, got: %s, expected: %s", have, want) + } + + // Validate Transaction Response parameters + if want, have := false, log.Transaction().HasResponse(); want != have { + t.Errorf("failed to match transaction has response, got: %t, expected: %t", have, want) + } + + if want, have := 0, log.Transaction().Response().Status(); want != have { + t.Errorf("failed to match transaction has response, got: %d, expected: %d", have, want) + } + + if want, have := "", log.Transaction().Response().Protocol(); want != have { + t.Errorf("failed to match transaction has response, got: %s, expected: %s", have, want) + } + + // Validate log messages + if want, have := 0, len(log.Messages()); want != have { + t.Errorf("failed to match messages length, got: %d, expected: %d", have, want) + } + + // Validaate Transaction Producer parameters + if want, have := "", log.Transaction().Producer().Connector(); want != have { + t.Errorf("failed to match producer connector, got: %s, expected: %s", have, want) + } + + if want, have := "", log.Transaction().Producer().Version(); want != have { + t.Errorf("failed to match producer version, got: %s, expected: %s", have, want) + } + + if want, have := "", log.Transaction().Producer().Server(); want != have { + t.Errorf("failed to match producer server, got: %s, expected: %s", have, want) + } + + if want, have := "", log.Transaction().Producer().RuleEngine(); want != have { + t.Errorf("failed to match producer rule engine, got: %s, expected: %s", have, want) + } + + if want, have := "", log.Transaction().Producer().Stopwatch(); want != have { + t.Errorf("failed to match producer stopwatch, got: %s, expected: %s", have, want) + } + + if have := log.Transaction().Producer().Rulesets(); nil != have { + t.Errorf("failed to match producer ruleset, got: %s, expected: nil", have) + } + + if want, have := false, log.Transaction_.HasResponse(); want != have { + t.Errorf("failed to match transaction has response, got: %t, expected: %t", have, want) + } + + if want, have := 0, log.Transaction().Response().Status(); want != have { + t.Errorf("failed to match producer server, got: %d, expected: %d", have, want) + } + + if want, have := false, log.Transaction_.HasRequest(); want != have { + t.Errorf("failed to match transaction has request, got: %t, expected: %t", have, want) + } +} func TestAuditLogUnmarshalJSON(t *testing.T) { + serializedLog := []byte(`{ "transaction": { - "id": "abc123" + "id": "abc123", + "producer": { + "connector": "c", + "version": "d", + "server": "e", + "rule_engine": "f", + "stopwatch": "g", + "rulesets": [ + "h", + "i" + ] + }, + "request": { + "method": "", + "protocol": "", + "uri": "", + "http_version": "", + "body": "", + "length": 123, + "uid": "", + "headers":{ + "request_header_key": [ + "request_header_value" + ] + } + + }, + "response": { + "status": 200, + "protocol": "p", + "body": "b", + "headers":{ + "response_header_key": [ + "response_header_value" + ] + } + } }, "messages": [ { "actionset": "a", - "message": "b" + "message": "b", + "data": { + "file": "/etc/coraza-spoa/rules/REQUEST-930-APPLICATION-ATTACK-LFI.conf", + "line": 4468, + "id": 930130, + "rev": "1.3", + "msg": "Restricted File Access Attempt", + "data": "Matched Data: /.git/ found within REQUEST_FILENAME: /.git/config", + "severity": 2, + "ver": "OWASP_CRS/4.4.0-dev", + "maturity": 3, + "accuracy": 8, + "tags": [ + "application-multi", + "language-multi", + "platform-multi", + "attack-lfi", + "paranoia-level/1", + "OWASP_CRS", + "capec/1000/255/153/126", + "PCI/6.5.4" + ], + "raw": "SecRule REQUEST_FILENAME \"@pmFromFile restricted-files.data\" \"id: 930130,phase: 1,block,capture,t:none,t:utf8toUnicode,t:urlDecodeUni,t:normalizePathWin,msg:\"Restricted File Access Attempt\",logdata:\"Matched Data: %{TX.0} found within %{MATCHED_VAR_NAME}: %{MATCHED_VAR}\",tag:\"application-multi\",tag:\"language-multi\",tag:\"platform-multi\",tag:\"attack-lfi\",tag:\"paranoia-level/1\",tag:\"OWASP_CRS\",tag:\"capec/1000/255/153/126\",tag:\"PCI/6.5.4\",ver:\"OWASP_CRS/4.4.0-dev\",severity:\"CRITICAL\",setvar:\"tx.lfi_score=+%{tx.critical_anomaly_score}\",setvar:\"tx.inbound_anomaly_score_pl1=+%{tx.critical_anomaly_score}\"\"" + } } ] }`) + log := &Log{} if err := log.UnmarshalJSON(serializedLog); err != nil { t.Error(err) @@ -29,18 +197,41 @@ func TestAuditLogUnmarshalJSON(t *testing.T) { t.Errorf("failed to match transaction id, got: %s, expected: %s", have, want) } - if want, have := false, log.Transaction_.HasRequest(); want != have { + // Validate Transaction Request parameters + if want, have := true, log.Transaction().HasRequest(); want != have { t.Errorf("failed to match transaction has request, got: %t, expected: %t", have, want) } - if want, have := "", log.Transaction_.Request().Method(); want != have { + if want, have := "", log.Transaction().Request().Method(); want != have { t.Errorf("failed to match transaction request method, got: %s, expected: %s", have, want) } - if want, have := false, log.Transaction_.HasResponse(); want != have { + if want, have := "request_header_value", log.Transaction().Request().Headers()["request_header_key"]; !slices.Contains(have, want) { + t.Errorf("failed to match message data tags, expected tag: %s not found in array", want) + } + + // Validate Transaction Response parameters + if want, have := true, log.Transaction().HasResponse(); want != have { t.Errorf("failed to match transaction has response, got: %t, expected: %t", have, want) } + if want, have := 200, log.Transaction().Response().Status(); want != have { + t.Errorf("failed to match transaction response status, got: %d, expected: %d", have, want) + } + + if want, have := "p", log.Transaction().Response().Protocol(); want != have { + t.Errorf("failed to match transaction response protocol, got: %s, expected: %s", have, want) + } + + if want, have := "b", log.Transaction().Response().Body(); want != have { + t.Errorf("failed to match transaction response body, got: %s, expected: %s", have, want) + } + + if want, have := "response_header_value", log.Transaction().Response().Headers()["response_header_key"]; !slices.Contains(have, want) { + t.Errorf("failed to match transaction response header response_header_key, expected value: %s not found in array", want) + } + + // Validate log messages if want, have := 1, len(log.Messages()); want != have { t.Errorf("failed to match messages length, got: %d, expected: %d", have, want) } @@ -52,4 +243,90 @@ func TestAuditLogUnmarshalJSON(t *testing.T) { if want, have := "b", log.Messages()[0].Message(); want != have { t.Errorf("failed to match message, got: %s, expected: %s", have, want) } + + if want, have := "/etc/coraza-spoa/rules/REQUEST-930-APPLICATION-ATTACK-LFI.conf", log.Messages()[0].Data().File(); want != have { + t.Errorf("failed to match message data file, got: %s, expected: %s", have, want) + } + + if want, have := 4468, log.Messages()[0].Data().Line(); want != have { + t.Errorf("failed to match message data line, got: %d, expected: %d", have, want) + } + + if want, have := 930130, log.Messages()[0].Data().ID(); want != have { + t.Errorf("failed to match message data id, got: %d, expected: %d", have, want) + } + + if want, have := "1.3", log.Messages()[0].Data().Rev(); want != have { + t.Errorf("failed to match message data rev, got: %s, expected: %s", have, want) + } + + if want, have := "Restricted File Access Attempt", log.Messages()[0].Data().Msg(); want != have { + t.Errorf("failed to match message data msg, got: %s, expected: %s", have, want) + } + + if want, have := "Matched Data: /.git/ found within REQUEST_FILENAME: /.git/config", log.Messages()[0].Data().Data(); want != have { + t.Errorf("failed to match message data data, got: %s, expected: %s", have, want) + } + + if want, have := 2, log.Messages()[0].Data().Severity().Int(); want != have { + t.Errorf("failed to match message data severity, got: %d, expected: %d", have, want) + } + + if want, have := "OWASP_CRS/4.4.0-dev", log.Messages()[0].Data().Ver(); want != have { + t.Errorf("failed to match message data ver, got: %s, expected: %s", have, want) + } + + if want, have := 3, log.Messages()[0].Data().Maturity(); want != have { + t.Errorf("failed to match message data maturity, got: %d, expected: %d", have, want) + } + + if want, have := 8, log.Messages()[0].Data().Accuracy(); want != have { + t.Errorf("failed to match message data accuracy, got: %d, expected: %d", have, want) + } + + if want, have := "application-multi", log.Messages()[0].Data().Tags(); !slices.Contains(have, want) { + t.Errorf("failed to match message data tags, expected tag: %s not found in array", want) + } + + if want, have := "paranoia-level/1", log.Messages()[0].Data().Tags(); !slices.Contains(have, want) { + t.Errorf("failed to match message data tags, expected tag: %s not found in array", want) + } + + if want, have := "capec/1000/255/153/126", log.Messages()[0].Data().Tags(); !slices.Contains(have, want) { + t.Errorf("failed to match message data tags, expected tag: %s not found in array", want) + } + + if want, have := "PCI/6.5.4", log.Messages()[0].Data().Tags(); !slices.Contains(have, want) { + t.Errorf("failed to match message data tags, expected tag: %s not found in array", want) + } + + // Validate Transaction Producer parameters + if want, have := "c", log.Transaction().Producer().Connector(); want != have { + t.Errorf("failed to match producer connector, got: %s, expected: %s", have, want) + } + + if want, have := "d", log.Transaction().Producer().Version(); want != have { + t.Errorf("failed to match producer version, got: %s, expected: %s", have, want) + } + + if want, have := "e", log.Transaction().Producer().Server(); want != have { + t.Errorf("failed to match producer server, got: %s, expected: %s", have, want) + } + + if want, have := "f", log.Transaction().Producer().RuleEngine(); want != have { + t.Errorf("failed to match producer rule engine, got: %s, expected: %s", have, want) + } + + if want, have := "g", log.Transaction().Producer().Stopwatch(); want != have { + t.Errorf("failed to match producer stopwatch, got: %s, expected: %s", have, want) + } + + if want, have := "h", log.Transaction().Producer().Rulesets(); !slices.Contains(have, want) { + t.Errorf("failed to match transaction producer rulesets, expected tag: %s not found in array", want) + } + + if want, have := "i", log.Transaction().Producer().Rulesets(); !slices.Contains(have, want) { + t.Errorf("failed to match transaction producer rulesets, expected tag: %s not found in array", want) + } + } diff --git a/internal/auditlog/formats_ocsf.go b/internal/auditlog/formats_ocsf.go new file mode 100644 index 00000000..2379a3c2 --- /dev/null +++ b/internal/auditlog/formats_ocsf.go @@ -0,0 +1,257 @@ +// Copyright 2022 Juan Pablo Tosso and the OWASP Coraza contributors +// SPDX-License-Identifier: Apache-2.0 + +// OCSF log format +// OCSF (Open Cybersecurity Schema Framework) (https://github.com/ocsf) is an open-source framework with the goal of providing an open standard for logging security events. +// This log format will produce a JSON log which adheres to the OCSF schema (https://schema.ocsf.io/) + +package auditlog + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/valllabh/ocsf-schema-golang/ocsf/v1_2_0/events/application" + "github.com/valllabh/ocsf-schema-golang/ocsf/v1_2_0/events/application/enums" + "github.com/valllabh/ocsf-schema-golang/ocsf/v1_2_0/objects" + ocsf_object_enums "github.com/valllabh/ocsf-schema-golang/ocsf/v1_2_0/objects/enums" + + "github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes" + "github.com/corazawaf/coraza/v3/types" +) + +type ocsfFormatter struct{} + +func (f ocsfFormatter) getRequestArguments(al plugintypes.AuditLog) string { + argString := &strings.Builder{} + if al.Transaction().Request().Args() != nil { + args := al.Transaction().Request().Args().FindAll() + + argCount := len(args) + for i, arg := range al.Transaction().Request().Args().FindAll() { + argString.WriteString(fmt.Sprintf("%s=%s", arg.Key(), arg.Value())) + if i < argCount { + argString.WriteString(",") + } + } + } + return argString.String() +} + +func (f ocsfFormatter) getRequestHeaders(al plugintypes.AuditLog) []*objects.HttpHeader { + requestHeaders := []*objects.HttpHeader{} + for key, values := range al.Transaction().Request().Headers() { + for _, value := range values { + requestHeaders = append(requestHeaders, &objects.HttpHeader{ + Name: key, + Value: value, + }) + } + } + return requestHeaders +} + +func (f ocsfFormatter) getResponseHeaders(al plugintypes.AuditLog) []*objects.HttpHeader { + responseHeaders := []*objects.HttpHeader{} + for key, values := range al.Transaction().Response().Headers() { + for _, value := range values { + responseHeaders = append(responseHeaders, &objects.HttpHeader{ + Name: key, + Value: value, + }) + } + } + return responseHeaders +} + +func (f ocsfFormatter) getAffectedWebResources(al plugintypes.AuditLog) []*objects.WebResource { + // Create an array of web Resources affected by this activity + webResources := []*objects.WebResource{} + webResources = append(webResources, &objects.WebResource{ + UrlString: al.Transaction().Request().URI(), + }) + + return webResources +} + +// Returns an array of Enrichment objects containing the details of each message in AuditLog.Messages +func (f ocsfFormatter) getMatchDetails(al plugintypes.AuditLog) []*objects.Enrichment { + matchDetails := []*objects.Enrichment{} + + for _, match := range al.Messages() { + matchData, _ := json.Marshal(match.Data()) + matchDetails = append(matchDetails, &objects.Enrichment{ + Data: string(matchData), + Name: match.Data().Msg(), + Value: match.Data().Data(), + }) + } + + return matchDetails +} + +// Returns an array of Observable objects +func (f ocsfFormatter) getObservables(al plugintypes.AuditLog) []*objects.Observable { + observables := []*objects.Observable{} + + if al.Transaction().ServerID() != "" { + observables = append(observables, &objects.Observable{ + Name: "ServerID", + Type: "ServerID", + TypeId: ocsf_object_enums.OBSERVABLE_TYPE_ID_OBSERVABLE_TYPE_ID_OTHER, + Value: al.Transaction().ServerID(), + }) + } + + for _, file := range al.Transaction().Request().Files() { + observables = append(observables, &objects.Observable{ + Name: file.Name(), + Type: "File Name", + TypeId: 7, + Value: file.Name(), + }) + observables = append(observables, &objects.Observable{ + Name: file.Name(), + Type: "Size", + TypeId: ocsf_object_enums.OBSERVABLE_TYPE_ID_OBSERVABLE_TYPE_ID_OTHER, + Value: fmt.Sprint(file.Size()), + }) + observables = append(observables, &objects.Observable{ + Name: file.Name(), + Type: "Mime", + TypeId: ocsf_object_enums.OBSERVABLE_TYPE_ID_OBSERVABLE_TYPE_ID_OTHER, + Value: file.Mime(), + }) + } + + return observables +} + +func (f ocsfFormatter) Format(al plugintypes.AuditLog) ([]byte, error) { + + // Determine the Action/ActionID based on whether the transaction was interrutped + ActionID := enums.WEB_RESOURCES_ACTIVITY_ACTION_ID_WEB_RESOURCES_ACTIVITY_ACTION_ID_ALLOWED + Action := "Allowed" + if al.Transaction().IsInterrupted() { + ActionID = enums.WEB_RESOURCES_ACTIVITY_ACTION_ID_WEB_RESOURCES_ACTIVITY_ACTION_ID_DENIED + Action = "Denied" + + } + + // Populate the required fields for the WebRecourcesActivity + webResourcesActivity := application.WebResourcesActivity{ + ActivityId: enums.WEB_RESOURCES_ACTIVITY_ACTIVITY_ID_WEB_RESOURCES_ACTIVITY_ACTIVITY_ID_READ, + ActivityName: "Read", + CategoryName: "Application Activity", + ClassName: "Web Resources Activity", + CategoryUid: enums.WEB_RESOURCES_ACTIVITY_CATEGORY_UID_WEB_RESOURCES_ACTIVITY_CATEGORY_UID_APPLICATION_ACTIVITY, + ClassUid: enums.WEB_RESOURCES_ACTIVITY_CLASS_UID_WEB_RESOURCES_ACTIVITY_CLASS_UID_WEB_RESOURCES_ACTIVITY, + Time: al.Transaction().UnixTimestamp(), + ActionId: ActionID, + Action: Action, + Metadata: &objects.Metadata{ + CorrelationUid: "", + EventCode: "", + //Labels: [2]string{"", ""}, + LogLevel: "", + LogName: "", + //LogProvider: "OWASP Coraza Web Application Firewall", + LogProvider: al.Transaction().Producer().Connector(), + LogVersion: al.Transaction().Producer().Version(), + LoggedTime: time.Now().UnixMicro(), + Product: &objects.Product{ + VendorName: "OWASP Coraza Web Application Firewall", + }, + Version: "1.2.0", + }, + TypeUid: enums.WEB_RESOURCES_ACTIVITY_TYPE_UID_WEB_RESOURCES_ACTIVITY_TYPE_UID_WEB_RESOURCES_ACTIVITY_READ, + Enrichments: f.getMatchDetails(al), + HttpRequest: &objects.HttpRequest{ + Version: al.Transaction().Request().Protocol(), + Args: f.getRequestArguments(al), + HttpMethod: al.Transaction().Request().Method(), + Uid: al.Transaction().ID(), + Url: &objects.Url{UrlString: al.Transaction().Request().URI()}, + HttpHeaders: f.getRequestHeaders(al), + Length: al.Transaction().Request().Length(), + }, + HttpResponse: &objects.HttpResponse{ + Code: int32(al.Transaction().Response().Status()), + HttpHeaders: f.getResponseHeaders(al), + }, + SrcEndpoint: &objects.NetworkEndpoint{ + Ip: al.Transaction().ClientIP(), + Port: int32(al.Transaction().ClientPort()), + }, + DstEndpoint: &objects.NetworkEndpoint{ + Ip: al.Transaction().HostIP(), + Port: int32(al.Transaction().HostPort()), + }, + WebResources: f.getAffectedWebResources(al), + } + + userAgent := al.Transaction().Request().Headers()["user-agent"] + if len(userAgent) > 0 { + webResourcesActivity.HttpRequest.UserAgent = userAgent[0] + } + + // Note: 'referer' is a misspelling of 'referrer' but was incorporated into the HTTP specification with this misspelling + // see https://en.wikipedia.org/wiki/HTTP_referer + referrer := al.Transaction().Request().Headers()["referer"] + if len(referrer) > 0 { + webResourcesActivity.HttpRequest.Referrer = referrer[0] + } + + xForwardedFor := al.Transaction().Request().Headers()["x-forwarded-for"] + if len(xForwardedFor) > 0 { + webResourcesActivity.HttpRequest.XForwardedFor = xForwardedFor + } + + if len(al.Messages()) > 0 { + message := al.Messages()[0] + webResourcesActivity.Message = message.Message() + } + + _, offset := time.Now().Zone() + webResourcesActivity.TimezoneOffset = int32(offset) + + // The WebResource Activity Severity ID is not to be confused by the Transaction severity. The Transaction severity has to do with Coraza error/debug severity, + // while WebResource Activity Severity is defined by OCSF to represent the severity of the security event. + // For now, we're setting severityID to 'Other' and setting Severity to the Highest severity of the matched rules. + // A future update should map/translate rule severity to OCSF severity if possible. + highestSeverity, _ := types.ParseRuleSeverity(al.Transaction().HighestSeverity()) + webResourcesActivity.Severity = highestSeverity.String() + webResourcesActivity.SeverityId = enums.WEB_RESOURCES_ACTIVITY_SEVERITY_ID_WEB_RESOURCES_ACTIVITY_SEVERITY_ID_OTHER + + webResourcesActivity.StartTime = al.Transaction().UnixTimestamp() + webResourcesActivity.TypeName = "Read" + + webResourcesActivity.Observables = f.getObservables(al) + + // Not implemented + // webResourcesActivity.Count = 0 + // webResourcesActivity.Duration = 0 + // webResourcesActivity.EndTime = 0 + // webResourcesActivity.RawData = "" + // webResourcesActivity.Status = "" + // webResourcesActivity.StatusCode = "" + // webResourcesActivity.StatusDetail = "" + // webResourcesActivity.StatusId = enums.WEB_RESOURCES_ACTIVITY_STATUS_ID_WEB_RESOURCES_ACTIVITY_STATUS_ID_UNKNOWN + // webResourcesActivity.Tls = &objects.Tls{} + // webResourcesActivity.WebResourcesResult = + // webResourcesActivity.Unmapped = nil + + logJson, _ := json.Marshal(&webResourcesActivity) + + return logJson, nil +} + +func (ocsfFormatter) MIME() string { + return "application/json" +} + +var ( + _ plugintypes.AuditLogFormatter = (*ocsfFormatter)(nil) +) diff --git a/internal/auditlog/formats_ocsf_test.go b/internal/auditlog/formats_ocsf_test.go new file mode 100644 index 00000000..5424ae36 --- /dev/null +++ b/internal/auditlog/formats_ocsf_test.go @@ -0,0 +1,411 @@ +// Copyright 2022 Juan Pablo Tosso and the OWASP Coraza contributors +// SPDX-License-Identifier: Apache-2.0 + +package auditlog + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes" + "github.com/corazawaf/coraza/v3/internal/collections" + "github.com/corazawaf/coraza/v3/types" + "github.com/corazawaf/coraza/v3/types/variables" + "github.com/valllabh/ocsf-schema-golang/ocsf/v1_2_0/events/application" + "github.com/valllabh/ocsf-schema-golang/ocsf/v1_2_0/events/application/enums" +) + +func TestOCSFFormatter(t *testing.T) { + for _, al := range createAuditLogs() { + f := &ocsfFormatter{} + data, err := f.Format(al) + if err != nil { + t.Error(err) + } + if !strings.Contains(f.MIME(), "json") { + t.Errorf("failed to match MIME, expected json and got %s", f.MIME()) + } + + var wra application.WebResourcesActivity + if err := json.Unmarshal(data, &wra); err != nil { + t.Error(err) + } + + // validate MIME type + if f.MIME() != "application/json" { + t.Errorf("failed to match ocsfFormatter MIME type, \ngot: %s\nexpected: %s", fmt.Sprint(f.MIME()), "application/json") + } + + // validate Unix Timestamp + if wra.Time != al.Transaction().UnixTimestamp() { + t.Errorf("failed to match audit log Unix Timestamp, \ngot: %s\nexpected: %s", fmt.Sprint(wra.Time), fmt.Sprint(al.Transaction().UnixTimestamp())) + } + + // validate transation interruption + if al.Transaction().IsInterrupted() { + if wra.Action != "Denied" { + t.Errorf("failed to match audit log Action, \ngot: %s\nexpected: %s", wra.Action, "Denied") + } + + if wra.ActionId != enums.WEB_RESOURCES_ACTIVITY_ACTION_ID_WEB_RESOURCES_ACTIVITY_ACTION_ID_DENIED { + t.Errorf("failed to match audit log Action ID, \ngot: %s\nexpected: %s", wra.ActionId, enums.WEB_RESOURCES_ACTIVITY_ACTION_ID_WEB_RESOURCES_ACTIVITY_ACTION_ID_DENIED) + } + } else { + if wra.Action != "Allowed" { + t.Errorf("failed to match audit log Action, \ngot: %s\nexpected: %s", wra.Action, "Allowed") + } + + if wra.ActionId != enums.WEB_RESOURCES_ACTIVITY_ACTION_ID_WEB_RESOURCES_ACTIVITY_ACTION_ID_ALLOWED { + t.Errorf("failed to match audit log Action ID, \ngot: %s\nexpected: %s", wra.ActionId, enums.WEB_RESOURCES_ACTIVITY_ACTION_ID_WEB_RESOURCES_ACTIVITY_ACTION_ID_ALLOWED) + } + } + + // validate Server ID + for _, observable := range wra.Observables { + if observable.Name == "ServerID" { + if observable.Value != al.Transaction().ServerID() { + t.Errorf("failed to match audit log Server ID, \ngot: %s\nexpected: %s", observable.Value, al.Transaction().ServerID()) + } + } + } + + // validate transaction requests + if al.Transaction().HasRequest() { + // validate Request URI + if wra.HttpRequest.Url.UrlString != al.Transaction().Request().URI() { + t.Errorf("failed to match audit log URI, \ngot: %s\nexpected: %s", wra.HttpRequest.Url.UrlString, al.Transaction().Request().URI()) + } + // validate Request Method + if wra.HttpRequest.HttpMethod != al.Transaction().Request().Method() { + t.Errorf("failed to match audit log HTTP Request Method, \ngot: %s\nexpected: %s", wra.HttpRequest.HttpMethod, al.Transaction().Request().Method()) + } + // validate Request Headers + for _, header := range wra.HttpRequest.HttpHeaders { + if header.Value != al.Transaction().Request().Headers()[header.Name][0] { + t.Errorf("failed to match audit log Request Header, \ngot: %s\nexpected: %s", header.Value, al.Transaction().Request().Headers()[header.Name][0]) + } + } + // validate Request Files + for _, file := range al.Transaction().Request().Files() { + for _, observable := range wra.Observables { + if observable.Name == file.Name() { + if observable.Type == "File Name" { + if file.Name() != observable.Value { + t.Errorf("failed to match audit log Request File Name, \ngot: %s\nexpected: %s", observable.Value, file.Name()) + } + } + if observable.Type == "Mime" { + if file.Mime() != observable.Value { + t.Errorf("failed to match audit log Request File Mime, \ngot: %s\nexpected: %s", observable.Value, file.Mime()) + } + } + if observable.Type == "Size" { + if fmt.Sprint(file.Size()) != observable.Value { + t.Errorf("failed to match audit log Request File Size, \ngot: %s\nexpected: %s", observable.Value, fmt.Sprint(file.Size())) + } + } + } + } + } + + // validate Request Protocol + if wra.HttpRequest.Version != al.Transaction().Request().Protocol() { + t.Errorf("failed to match audit log HTTP Request Protocol, \ngot: %s\nexpected: %s", wra.HttpRequest.Version, al.Transaction().Request().Protocol()) + } + + // validate Request Arguments + if al.Transaction().Request().Args() != nil { + for _, arg := range al.Transaction().Request().Args().FindAll() { + if strings.Contains(wra.HttpRequest.Args, fmt.Sprintf("%s=%s", arg.Key(), arg.Value())) == false { + t.Errorf("failed to match audit log Request arguments, \n%s not found in: %s", fmt.Sprintf("%s=%s", arg.Key(), arg.Value()), wra.HttpRequest.Args) + } + } + } + + // validate Request UID + if wra.HttpRequest.Uid != al.Transaction().ID() { + t.Errorf("failed to match audit log HTTP Request UID, \ngot: %s\nexpected: %s", wra.HttpRequest.Uid, al.Transaction().ID()) + } + + // validate Request Length + if wra.HttpRequest.Length != al.Transaction().Request().Length() { + t.Errorf("failed to match audit log HTTP Request Length, \ngot: %d\nexpected: %d", wra.HttpRequest.Length, al.Transaction().Request().Length()) + } + } + + if al.Transaction().HasResponse() { + // validate Response Status + if int(wra.HttpResponse.Code) != al.Transaction().Response().Status() { + t.Errorf("failed to match audit log HTTP Response Status, \ngot: %s\nexpected: %s", fmt.Sprint(wra.HttpResponse.Code), fmt.Sprint(al.Transaction().Response().Status())) + } + + // validate Response Headers + for _, header := range wra.HttpResponse.HttpHeaders { + if header.Value != al.Transaction().Response().Headers()[header.Name][0] { + t.Errorf("failed to match audit log Response Header, \ngot: %s\nexpected: %s", header.Value, al.Transaction().Response().Headers()[header.Name][0]) + } + } + } + + // validate Enrichments (Rule Matches) + if wra.Enrichments[0].Name != al.Messages()[0].Data().Msg() { + t.Errorf("failed to match audit log data, \ngot: %s\nexpected: %s", wra.Enrichments[0].Name, al.Messages()[0].Data().Msg()) + } + + // validate Schema + // ocsf-schema-golang appears to have a bug and is not validating against the OCSF 1.2 Schema. + // It would be nice to include this validation as part of the test suite, but for now it must be disabled until this bug is fixed. + // if ocsfvalidate_1_2.Validate("web_resources_activity", data) != nil { + // t.Errorf("failed to validate audit log schema, \ngot: %s\nexpected: %s", ocsfvalidate_1_2.Validate("web_resources_activity", data), "") + // } + } +} + +func createAuditLogs() []*Log { + + transactionLogs := []*Log{} + + // Test case for "normal" / "typical" transaction + getArgs := collections.NewMap(variables.ArgsGet) + postArgs := collections.NewMap(variables.ArgsPost) + pathArgs := collections.NewMap(variables.ArgsPath) + args := collections.NewConcatKeyed(variables.Args, getArgs, postArgs, pathArgs) + getArgs.Add("qkey", "qvalue") + postArgs.Add("pkey", "pvalue") + transactionLogs = append(transactionLogs, &Log{ + Parts_: []types.AuditLogPart{ + types.AuditLogPartRequestHeaders, + types.AuditLogPartRequestBody, + types.AuditLogPartIntermediaryResponseBody, + types.AuditLogPartResponseHeaders, + types.AuditLogPartAuditLogTrailer, + types.AuditLogPartRulesMatched, + }, + Transaction_: Transaction{ + Timestamp_: "02/Jan/2006:15:04:20 -0700", + UnixTimestamp_: 1136239460, + ID_: "123", + IsInterrupted_: true, + Request_: &TransactionRequest{ + URI_: "/test.php?qkey=qvalue", + Method_: "GET", + Headers_: map[string][]string{ + "host": { + "test.coraza.null", + }, + }, + Body_: "pkey=pvalue", + Protocol_: "HTTP/1.1", + Args_: args, + Length_: 112345, + Files_: []plugintypes.AuditLogTransactionRequestFiles{ + &TransactionRequestFiles{ + Name_: "dummyfile.txt", + Mime_: "text/plain", + Size_: 12345, + }, + }, + }, + Response_: &TransactionResponse{ + Status_: 200, + Headers_: map[string][]string{ + "connection": { + "close", + }, + }, + Body_: "some response body", + }, + Producer_: &TransactionProducer{ + Connector_: "some connector", + Version_: "1.2.3", + }, + }, + Messages_: []plugintypes.AuditLogMessage{ + &Message{ + Message_: "some message", + Data_: &MessageData{ + Msg_: "some message", + Raw_: "SecAction \"id:100\"", + }, + }, + }, + }) + + // Test case for abnormal transaction (all empty values, no arguments) + getArgs = collections.NewMap(variables.ArgsGet) + postArgs = collections.NewMap(variables.ArgsPost) + pathArgs = collections.NewMap(variables.ArgsPath) + args = collections.NewConcatKeyed(variables.Args, getArgs, postArgs, pathArgs) + transactionLogs = append(transactionLogs, &Log{ + Parts_: []types.AuditLogPart{ + types.AuditLogPartRequestHeaders, + types.AuditLogPartRequestBody, + types.AuditLogPartIntermediaryResponseBody, + types.AuditLogPartResponseHeaders, + types.AuditLogPartAuditLogTrailer, + types.AuditLogPartRulesMatched, + }, + Transaction_: Transaction{ + Timestamp_: "", + UnixTimestamp_: 0, + ID_: "", + IsInterrupted_: true, + ServerID_: "someServer", + Request_: &TransactionRequest{ + URI_: "", + Method_: "", + Headers_: map[string][]string{ + "host": { + "", + }, + "accept": { + "", + }, + "accept-encoding": { + "", + }, + "accept-language": { + "", + }, + "cache-control": { + "", + }, + "connection": { + "", + }, + "upgrade-insecure-requests": { + "", + }, + "user-agent": { + "", + }, + "x-forwarded-for": { + "", + }, + "referer": { + "", + }, + }, + Body_: "", + Protocol_: "", + Args_: args, + Length_: 0, + }, + Response_: &TransactionResponse{ + Status_: 200, + Headers_: map[string][]string{ + "connection": { + "", + }, + "content-type": { + "", + }, + "referrer-policy": { + "", + }, + "strict-transport-security": { + "", + }, + "transfer-encoding": { + "", + }, + "referer": { + "", + }, + }, + Body_: "", + }, + Producer_: &TransactionProducer{ + Connector_: "", + Version_: "", + }, + }, + Messages_: []plugintypes.AuditLogMessage{ + &Message{ + Message_: "some message", + Data_: &MessageData{ + Msg_: "some message", + Raw_: "SecAction \"id:100\"", + }, + }, + }, + }) + + // Test case for abnormal transaction (all empty values, no arguments, no reponse) + getArgs = collections.NewMap(variables.ArgsGet) + postArgs = collections.NewMap(variables.ArgsPost) + pathArgs = collections.NewMap(variables.ArgsPath) + args = collections.NewConcatKeyed(variables.Args, getArgs, postArgs, pathArgs) + transactionLogs = append(transactionLogs, &Log{ + Parts_: []types.AuditLogPart{ + types.AuditLogPartRequestHeaders, + types.AuditLogPartRequestBody, + types.AuditLogPartIntermediaryResponseBody, + types.AuditLogPartResponseHeaders, + types.AuditLogPartAuditLogTrailer, + types.AuditLogPartRulesMatched, + }, + Transaction_: Transaction{ + Timestamp_: "", + UnixTimestamp_: 0, + ID_: "", + IsInterrupted_: true, + Request_: &TransactionRequest{ + URI_: "", + Method_: "", + Headers_: map[string][]string{ + "host": { + "", + }, + "accept": { + "", + }, + "accept-encoding": { + "", + }, + "accept-language": { + "", + }, + "cache-control": { + "", + }, + "connection": { + "", + }, + "upgrade-insecure-requests": { + "", + }, + "user-agent": { + "", + }, + "x-forwarded-for": { + "", + }, + "referer": { + "", + }, + }, + Body_: "", + Protocol_: "", + Args_: args, + }, + Producer_: &TransactionProducer{ + Connector_: "", + Version_: "", + }, + }, + Messages_: []plugintypes.AuditLogMessage{ + &Message{ + Message_: "some message", + Data_: &MessageData{ + Msg_: "some message", + Raw_: "SecAction \"id:100\"", + }, + }, + }, + }) + + return transactionLogs +} diff --git a/internal/auditlog/https_writer_test.go b/internal/auditlog/https_writer_test.go index 21c478d5..ca61946e 100644 --- a/internal/auditlog/https_writer_test.go +++ b/internal/auditlog/https_writer_test.go @@ -98,3 +98,28 @@ func TestJSONAuditHTTPS(t *testing.T) { t.Fatal(err) } } + +func TestOCSFAuditHTTPS(t *testing.T) { + writer := &httpsWriter{} + formatter := &ocsfFormatter{} + // we create a test http server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if r.ContentLength == 0 { + t.Fatal("ContentLength is 0") + } + if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("Content-Type is not application/json, got %s", ct) + } + })) + defer server.Close() + if err := writer.Init(plugintypes.AuditLogConfig{ + Target: server.URL, + Formatter: formatter, + }); err != nil { + t.Fatal(err) + } + if err := writer.Write(sampleHttpsAuditLog); err != nil { + t.Fatal(err) + } +} diff --git a/internal/auditlog/init.go b/internal/auditlog/init.go index 6984da43..41a16e63 100644 --- a/internal/auditlog/init.go +++ b/internal/auditlog/init.go @@ -22,4 +22,5 @@ func init() { RegisterFormatter("json", &jsonFormatter{}) RegisterFormatter("jsonlegacy", &legacyJSONFormatter{}) RegisterFormatter("native", &nativeFormatter{}) + RegisterFormatter("ocsf", &ocsfFormatter{}) } diff --git a/internal/auditlog/init_tinygo.go b/internal/auditlog/init_tinygo.go index ebf5244e..b10fd797 100644 --- a/internal/auditlog/init_tinygo.go +++ b/internal/auditlog/init_tinygo.go @@ -22,4 +22,5 @@ func init() { RegisterFormatter("json", &jsonFormatter{}) RegisterFormatter("jsonlegacy", &legacyJSONFormatter{}) RegisterFormatter("native", &nativeFormatter{}) + RegisterFormatter("ocsf", &ocsfFormatter{}) } diff --git a/internal/corazawaf/transaction.go b/internal/corazawaf/transaction.go index 880c6220..36159c6e 100644 --- a/internal/corazawaf/transaction.go +++ b/internal/corazawaf/transaction.go @@ -67,6 +67,7 @@ type Transaction struct { // Copies from the WAF instance that may be overwritten by the ctl action AuditEngine types.AuditEngineStatus AuditLogParts types.AuditLogParts + AuditLogFormat string ForceRequestBodyVariable bool RequestBodyAccess bool RequestBodyLimit int64 @@ -1372,6 +1373,18 @@ func (tx *Transaction) AuditLog() *auditlog.Log { clientPort, _ := strconv.Atoi(tx.variables.remotePort.Get()) hostPort, _ := strconv.Atoi(tx.variables.serverPort.Get()) + + // Convert the transaction fullRequestLength to Int32 + requestLength, err := strconv.ParseInt(tx.variables.fullRequestLength.Get(), 10, 32) + if err != nil { + requestLength = 0 + tx.DebugLogger().Error(). + Str("transaction", "AuditLog"). + Str("value", tx.variables.fullRequestLength.Get()). + Err(err). + Msg("Error converting request length to integer") + } + // YYYY/MM/DD HH:mm:ss ts := time.Unix(0, tx.Timestamp).Format("2006/01/02 15:04:05") al.Transaction_ = auditlog.Transaction{ @@ -1387,7 +1400,10 @@ func (tx *Transaction) AuditLog() *auditlog.Log { Method_: tx.variables.requestMethod.Get(), URI_: tx.variables.requestURI.Get(), Protocol_: tx.variables.requestProtocol.Get(), + Args_: tx.variables.args, + Length_: int32(requestLength), }, + IsInterrupted_: tx.IsInterrupted(), } for _, part := range tx.AuditLogParts { diff --git a/internal/corazawaf/waf.go b/internal/corazawaf/waf.go index e6946a3d..e4430a81 100644 --- a/internal/corazawaf/waf.go +++ b/internal/corazawaf/waf.go @@ -119,6 +119,9 @@ type WAF struct { // Array of logging parts to be used AuditLogParts types.AuditLogParts + // Audit log format + AuditLogFormat string + // Contains the regular expression for relevant status audit logging AuditLogRelevantStatus *regexp.Regexp @@ -174,6 +177,7 @@ func (w *WAF) newTransaction(opts Options) *Transaction { tx.SkipAfter = "" tx.AuditEngine = w.AuditEngine tx.AuditLogParts = w.AuditLogParts + tx.AuditLogFormat = w.AuditLogFormat tx.ForceRequestBodyVariable = false tx.RequestBodyAccess = w.RequestBodyAccess tx.RequestBodyLimit = int64(w.RequestBodyLimit) @@ -305,8 +309,9 @@ func NewWAF() *WAF { types.AuditLogPartResponseHeaders, types.AuditLogPartAuditLogTrailer, }, - Logger: logger, - ArgumentLimit: 1000, + AuditLogFormat: "Native", + Logger: logger, + ArgumentLimit: 1000, } if environment.HasAccessToFS { diff --git a/internal/seclang/directives.go b/internal/seclang/directives.go index 4d2350f2..b6670c21 100644 --- a/internal/seclang/directives.go +++ b/internal/seclang/directives.go @@ -646,9 +646,9 @@ func directiveSecAuditLogType(options *DirectiveOptions) error { return nil } -// Description: Select the output format of the AuditLogs. The format can be either -// the native AuditLogs format or JSON. -// Syntax: SecAuditLogFormat JSON|Native +// Description: Select the output format of the AuditLogs. The format can be +// the native AuditLogs format, JSON, or OCSF (Open CyberSecurity Schema Framework). +// Syntax: SecAuditLogFormat JSON|JsonLegacy|Native|OCSF // Default: Native func directiveSecAuditLogFormat(options *DirectiveOptions) error { if len(options.Opts) == 0 { diff --git a/testing/coreruleset/go.mod b/testing/coreruleset/go.mod index 0e556d55..a6ccd641 100644 --- a/testing/coreruleset/go.mod +++ b/testing/coreruleset/go.mod @@ -38,9 +38,11 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/petar-dambovaliev/aho-corasick v0.0.0-20240411101913-e07a1f0e8eb4 // indirect + github.com/stretchr/testify v1.9.0 // indirect github.com/tidwall/gjson v1.17.1 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect + github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect github.com/yargevad/filepathx v1.0.0 // indirect golang.org/x/crypto v0.26.0 // indirect golang.org/x/net v0.28.0 // indirect diff --git a/testing/coreruleset/go.sum b/testing/coreruleset/go.sum index 6b843f0f..da62e37e 100644 --- a/testing/coreruleset/go.sum +++ b/testing/coreruleset/go.sum @@ -86,6 +86,7 @@ github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -93,6 +94,8 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw= +github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk= github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=