diff --git a/go.mod b/go.mod
index 4bc0ca265..018671b59 100644
--- a/go.mod
+++ b/go.mod
@@ -16,6 +16,7 @@ require (
github.com/google/uuid v1.6.0
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2
+ github.com/leodido/go-syslog/v4 v4.2.0
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c
github.com/nginxinc/nginx-plus-go-client/v2 v2.0.1
github.com/nginxinc/nginx-prometheus-exporter v1.3.0
@@ -182,7 +183,6 @@ require (
github.com/knadh/koanf/providers/confmap v1.0.0 // indirect
github.com/knadh/koanf/v2 v2.2.2 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
- github.com/leodido/go-syslog/v4 v4.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b // indirect
github.com/lightstep/go-expohisto v1.0.0 // indirect
diff --git a/internal/collector/factories.go b/internal/collector/factories.go
index 6443dba7c..d0da34b02 100644
--- a/internal/collector/factories.go
+++ b/internal/collector/factories.go
@@ -10,6 +10,7 @@ import (
"github.com/nginx/agent/v3/internal/collector/logsgzipprocessor"
nginxreceiver "github.com/nginx/agent/v3/internal/collector/nginxossreceiver"
"github.com/nginx/agent/v3/internal/collector/nginxplusreceiver"
+ "github.com/nginx/agent/v3/internal/collector/securityviolationsprocessor"
"github.com/open-telemetry/opentelemetry-collector-contrib/exporter/prometheusexporter"
"github.com/open-telemetry/opentelemetry-collector-contrib/extension/headerssetterextension"
@@ -104,6 +105,7 @@ func createProcessorFactories() map[component.Type]processor.Factory {
memorylimiterprocessor.NewFactory(),
redactionprocessor.NewFactory(),
resourceprocessor.NewFactory(),
+ securityviolationsprocessor.NewFactory(),
transformprocessor.NewFactory(),
logsgzipprocessor.NewFactory(),
}
diff --git a/internal/collector/factories_test.go b/internal/collector/factories_test.go
index 34dcdcc1f..4726e5269 100644
--- a/internal/collector/factories_test.go
+++ b/internal/collector/factories_test.go
@@ -19,7 +19,7 @@ func TestOTelComponentFactoriesDefault(t *testing.T) {
assert.NotNil(t, factories, "factories should not be nil")
assert.Len(t, factories.Receivers, 6)
- assert.Len(t, factories.Processors, 9)
+ assert.Len(t, factories.Processors, 10)
assert.Len(t, factories.Exporters, 4)
assert.Len(t, factories.Extensions, 3)
assert.Empty(t, factories.Connectors)
diff --git a/internal/collector/otel_collector_plugin_test.go b/internal/collector/otel_collector_plugin_test.go
index ac25ce775..5c45ae926 100644
--- a/internal/collector/otel_collector_plugin_test.go
+++ b/internal/collector/otel_collector_plugin_test.go
@@ -416,6 +416,7 @@ func TestCollector_ProcessResourceUpdateTopicFails(t *testing.T) {
conf.Collector.Processors.Attribute = nil
conf.Collector.Processors.Resource = nil
conf.Collector.Processors.LogsGzip = nil
+ conf.Collector.Processors.Syslog = nil
conf.Collector.Exporters.OtlpExporters = nil
conf.Collector.Exporters.PrometheusExporter = &config.PrometheusExporter{
Server: &config.ServerConfig{
@@ -740,6 +741,7 @@ func TestCollector_updateNginxAppProtectTcplogReceivers(t *testing.T) {
conf.Collector.Processors.Attribute = nil
conf.Collector.Processors.Resource = nil
conf.Collector.Processors.LogsGzip = nil
+ conf.Collector.Processors.Syslog = nil
collector, err := NewCollector(conf)
require.NoError(t, err)
@@ -922,6 +924,9 @@ func TestCollector_findAvailableSyslogServers(t *testing.T) {
conf.Collector.Processors.Attribute = nil
conf.Collector.Processors.Resource = nil
conf.Collector.Processors.LogsGzip = nil
+ conf.Collector.Processors.Syslog = nil
+ collector, err := NewCollector(conf)
+ require.NoError(t, err)
tests := []struct {
name string
@@ -976,7 +981,6 @@ func TestCollector_findAvailableSyslogServers(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(tt *testing.T) {
- collector, err := NewCollector(conf)
require.NoError(t, err)
collector.previousNAPSysLogServer = test.previousNAPSysLogServer
diff --git a/internal/collector/otelcol.tmpl b/internal/collector/otelcol.tmpl
index 49e46cf23..4fe5854c5 100644
--- a/internal/collector/otelcol.tmpl
+++ b/internal/collector/otelcol.tmpl
@@ -156,6 +156,11 @@ processors:
send_batch_max_size: {{ $batch.SendBatchMaxSize }}
{{- end }}
{{- end }}
+{{- if ne .Processors.Syslog nil }}
+{{- range $key, $value := .Processors.Syslog }}
+ syslog/{{$key}}: {}
+{{- end }}
+{{- end }}
{{- if ne .Processors.LogsGzip nil }}
{{ range $key, $value := .Processors.LogsGzip }}
logsgzip/{{$key}}: {}
diff --git a/internal/collector/securityviolationsprocessor/README.md b/internal/collector/securityviolationsprocessor/README.md
new file mode 100644
index 000000000..678d02a79
--- /dev/null
+++ b/internal/collector/securityviolationsprocessor/README.md
@@ -0,0 +1,5 @@
+# Syslog Processor
+
+Internal component of the NGINX Agent that processes syslog messages. Parses RFC3164 formatted syslog entries from log records and extracts structured attributes. Successfully parsed messages have their body replaced with the clean message content.
+
+Part of the NGINX Agent's log collection pipeline.
\ No newline at end of file
diff --git a/internal/collector/securityviolationsprocessor/factory.go b/internal/collector/securityviolationsprocessor/factory.go
new file mode 100644
index 000000000..802f9f2b5
--- /dev/null
+++ b/internal/collector/securityviolationsprocessor/factory.go
@@ -0,0 +1,41 @@
+// Copyright (c) F5, Inc.
+//
+// This source code is licensed under the Apache License, Version 2.0 license found in the
+// LICENSE file in the root directory of this source tree.
+
+package securityviolationsprocessor
+
+import (
+ "context"
+
+ "go.opentelemetry.io/collector/component"
+ "go.opentelemetry.io/collector/consumer"
+ "go.opentelemetry.io/collector/processor"
+)
+
+const typeStr = "syslog"
+
+// NewFactory creates a factory for the syslog processor.
+//
+//nolint:ireturn // factory methods return interfaces by design
+func NewFactory() processor.Factory {
+ return processor.NewFactory(
+ component.MustNewType(typeStr),
+ func() component.Config { return &struct{}{} },
+ processor.WithLogs(createSecurityViolationsProcessor, component.StabilityLevelAlpha),
+ )
+}
+
+// createSecurityViolationsProcessor instantiates the logs processor.
+//
+//nolint:ireturn // required to comply with component factory interface
+func createSecurityViolationsProcessor(
+ _ context.Context,
+ settings processor.Settings,
+ _ component.Config,
+ next consumer.Logs,
+) (processor.Logs, error) {
+ settings.Logger.Info("Creating security violations processor")
+
+ return newSecurityViolationsProcessor(next, settings), nil
+}
diff --git a/internal/collector/securityviolationsprocessor/model.go b/internal/collector/securityviolationsprocessor/model.go
new file mode 100644
index 000000000..3ce92c8b8
--- /dev/null
+++ b/internal/collector/securityviolationsprocessor/model.go
@@ -0,0 +1,70 @@
+// Copyright (c) F5, Inc.
+//
+// This source code is licensed under the Apache License, Version 2.0 license found in the
+// LICENSE file in the root directory of this source tree.
+
+package securityviolationsprocessor
+
+// SecurityViolationEvent represents the structured NGINX App Protect security violation data
+type SecurityViolationEvent struct {
+ PolicyName string `json:"policy_name"`
+ SupportID string `json:"support_id"`
+ Outcome string `json:"outcome"`
+ OutcomeReason string `json:"outcome_reason"`
+ BlockingExceptionReason string `json:"blocking_exception_reason"`
+ Method string `json:"method"`
+ Protocol string `json:"protocol"`
+ XForwardedForHeaderValue string `json:"xff_header_value"`
+ URI string `json:"uri"`
+ Request string `json:"request"`
+ IsTruncated string `json:"is_truncated"`
+ RequestStatus string `json:"request_status"`
+ ResponseCode string `json:"response_code"`
+ ServerAddr string `json:"server_addr"`
+ VSName string `json:"vs_name"`
+ RemoteAddr string `json:"remote_addr"`
+ RemotePort string `json:"destination_port"`
+ ServerPort string `json:"server_port"`
+ Violations string `json:"violations"`
+ SubViolations string `json:"sub_violations"`
+ ViolationRating string `json:"violation_rating"`
+ SigSetNames string `json:"sig_set_names"`
+ SigCVEs string `json:"sig_cves"`
+ ClientClass string `json:"client_class"`
+ ClientApplication string `json:"client_application"`
+ ClientApplicationVersion string `json:"client_application_version"`
+ Severity string `json:"severity"`
+ ThreatCampaignNames string `json:"threat_campaign_names"`
+ BotAnomalies string `json:"bot_anomalies"`
+ BotCategory string `json:"bot_category"`
+ EnforcedBotAnomalies string `json:"enforced_bot_anomalies"`
+ BotSignatureName string `json:"bot_signature_name"`
+ SystemID string `json:"system_id"`
+ InstanceTags string `json:"instance_tags"`
+ InstanceGroup string `json:"instance_group"`
+ ParentHostname string `json:"parent_hostname"`
+ DisplayName string `json:"display_name"`
+ ViolationsData []ViolationData `json:"violations_data"`
+}
+
+type ViolationData struct {
+ Name string `json:"violation_data_name"`
+ Context string `json:"violation_data_context"`
+ ContextData ContextData `json:"violation_data_context_data"`
+ Signatures []SignatureData `json:"violation_data_signatures"`
+}
+
+// SignatureData represents signature data contained within each violation
+type SignatureData struct {
+ ID string `json:"sig_data_id"`
+ BlockingMask string `json:"sig_data_blocking_mask"`
+ Buffer string `json:"sig_data_buffer"`
+ Offset string `json:"sig_data_offset"`
+ Length string `json:"sig_data_length"`
+}
+
+// ContextData represents the context data of the violation
+type ContextData struct {
+ Name string `json:"context_data_name"`
+ Value string `json:"context_data_value"`
+}
diff --git a/internal/collector/securityviolationsprocessor/processor.go b/internal/collector/securityviolationsprocessor/processor.go
new file mode 100644
index 000000000..126acb436
--- /dev/null
+++ b/internal/collector/securityviolationsprocessor/processor.go
@@ -0,0 +1,472 @@
+// Copyright (c) F5, Inc.
+//
+// This source code is licensed under the Apache License, Version 2.0 license found in the
+// LICENSE file in the root directory of this source tree.
+
+package securityviolationsprocessor
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net"
+ "regexp"
+ "strings"
+ "time"
+
+ syslog "github.com/leodido/go-syslog/v4"
+ "github.com/leodido/go-syslog/v4/rfc3164"
+ "go.opentelemetry.io/collector/component"
+ "go.opentelemetry.io/collector/consumer"
+ "go.opentelemetry.io/collector/pdata/pcommon"
+ "go.opentelemetry.io/collector/pdata/plog"
+ "go.opentelemetry.io/collector/processor"
+ "go.uber.org/multierr"
+ "go.uber.org/zap"
+)
+
+const (
+ notAvailable = "N/A"
+ maxSplitParts = 2
+)
+
+// securityViolationsProcessor parses syslog-formatted log records and annotates
+// them with structured SecurityEvent attributes.
+type securityViolationsProcessor struct {
+ nextConsumer consumer.Logs
+ parser syslog.Machine
+ settings processor.Settings
+}
+
+func newSecurityViolationsProcessor(next consumer.Logs, settings processor.Settings) *securityViolationsProcessor {
+ return &securityViolationsProcessor{
+ nextConsumer: next,
+ parser: rfc3164.NewParser(rfc3164.WithBestEffort()),
+ settings: settings,
+ }
+}
+
+func (p *securityViolationsProcessor) Start(ctx context.Context, _ component.Host) error {
+ p.settings.Logger.Info("Starting syslog processor")
+ return nil
+}
+
+func (p *securityViolationsProcessor) Shutdown(ctx context.Context) error {
+ p.settings.Logger.Info("Shutting down syslog processor")
+ return nil
+}
+
+func (p *securityViolationsProcessor) Capabilities() consumer.Capabilities {
+ return consumer.Capabilities{MutatesData: true}
+}
+
+func (p *securityViolationsProcessor) ConsumeLogs(ctx context.Context, logs plog.Logs) error {
+ var errs error
+
+ resourceLogs := logs.ResourceLogs()
+ for _, scopeLog := range resourceLogs.All() {
+ for _, logRecord := range scopeLog.ScopeLogs().All() {
+ if err := p.processLogRecords(logRecord.LogRecords()); err != nil {
+ errs = multierr.Append(errs, err)
+ }
+ }
+ }
+
+ if errs != nil {
+ return fmt.Errorf("failed processing log records: %w", errs)
+ }
+
+ return p.nextConsumer.ConsumeLogs(ctx, logs)
+}
+
+func (p *securityViolationsProcessor) processLogRecords(logRecordSlice plog.LogRecordSlice) error {
+ // Drop anything that isn't a string-bodied log before processing.
+ var skipped, errCount int
+ var logType pcommon.ValueType
+ var errs error
+ logRecordSlice.RemoveIf(func(lr plog.LogRecord) bool {
+ logType = lr.Body().Type()
+ if logType == pcommon.ValueTypeStr {
+ return false
+ }
+
+ skipped++
+
+ return true
+ })
+ if skipped > 0 {
+ p.settings.Logger.Debug("Skipping log record with unsupported body type", zap.Any("type", logType))
+ }
+ errCount = 0
+ for _, logRecord := range logRecordSlice.All() {
+ if err := p.processLogRecord(logRecord); err != nil {
+ errs = multierr.Append(errs, err)
+ errCount++
+ }
+ }
+ if errCount > 0 {
+ p.settings.Logger.Debug("Some log records failed to process", zap.Int("count", errCount))
+ return errs
+ }
+
+ return nil
+}
+
+func (p *securityViolationsProcessor) processLogRecord(lr plog.LogRecord) error {
+ // Read the string body once.
+ bodyStr := lr.Body().Str()
+
+ msg, err := p.parser.Parse([]byte(bodyStr))
+ if err != nil {
+ return err
+ }
+
+ m, ok := msg.(*rfc3164.SyslogMessage)
+ if !ok || !m.Valid() {
+ return errors.New("invalid syslog message")
+ }
+
+ p.setSyslogAttributes(lr, m)
+
+ if m.Message != nil {
+ return p.processAppProtectMessage(lr, *m.Message, m.Hostname)
+ }
+
+ return nil
+}
+
+func (p *securityViolationsProcessor) setSyslogAttributes(lr plog.LogRecord, m *rfc3164.SyslogMessage) {
+ attrs := lr.Attributes()
+ if m.Timestamp != nil {
+ attrs.PutStr("syslog.timestamp", m.Timestamp.Format(time.RFC3339))
+ }
+ if m.ProcID != nil {
+ attrs.PutStr("syslog.procid", *m.ProcID)
+ }
+ if sev := m.SeverityLevel(); sev != nil {
+ attrs.PutStr("syslog.severity", *sev)
+ }
+ if fac := m.FacilityLevel(); fac != nil {
+ attrs.PutStr("syslog.facility", *fac)
+ }
+}
+
+func (p *securityViolationsProcessor) processAppProtectMessage(lr plog.LogRecord,
+ message string,
+ hostname *string,
+) error {
+ appProtectLog := p.parseAppProtectLog(message, hostname)
+
+ jsonData, marshalErr := json.Marshal(appProtectLog)
+ if marshalErr != nil {
+ return marshalErr
+ }
+
+ lr.Body().SetStr(string(jsonData))
+ attrs := lr.Attributes()
+ attrs.PutStr("app_protect.policy_name", appProtectLog.PolicyName)
+ attrs.PutStr("app_protect.support_id", appProtectLog.SupportID)
+ attrs.PutStr("app_protect.outcome", appProtectLog.Outcome)
+ attrs.PutStr("app_protect.remote_addr", appProtectLog.RemoteAddr)
+
+ return nil
+}
+
+func (p *securityViolationsProcessor) parseAppProtectLog(message string, hostname *string) *SecurityViolationEvent {
+ log := &SecurityViolationEvent{}
+
+ p.assignHostnames(log, hostname)
+
+ kvMap := p.parseCSVLog(message)
+
+ p.mapKVToSecurityViolationEvent(log, kvMap)
+
+ if log.ServerAddr == "" && hostname != nil {
+ if ip := extractIPFromHostname(*hostname); ip != "" {
+ log.ServerAddr = ip
+ }
+ }
+
+ // Parse violations data from available fields
+ log.ViolationsData = p.parseViolationsData(kvMap)
+
+ return log
+}
+
+func (p *securityViolationsProcessor) assignHostnames(log *SecurityViolationEvent, hostname *string) {
+ if hostname == nil {
+ return
+ }
+ log.SystemID = *hostname
+ log.ParentHostname = *hostname
+
+ if log.ServerAddr == "" {
+ if ip := extractIPFromHostname(*hostname); ip != "" {
+ log.ServerAddr = ip
+ }
+ }
+}
+
+// parseCSVLog parses comma-separated syslog messages where fields are in a
+// order : blocking_exception_reason,dest_port,ip_client,is_truncated_bool,method,policy_name,protocol,request_status,response_code,severity,sig_cves,sig_set_names,src_port,sub_violations,support_id,threat_campaign_names,violation_rating,vs_name,x_forwarded_for_header_value,outcome,outcome_reason,violations,violation_details,bot_signature_name,bot_category,bot_anomalies,enforced_bot_anomalies,client_class,client_application,client_application_version,transport_protocol,uri,request (secops_dashboard-log profile format).
+// versions when key-value logging isn't enabled.
+//
+//nolint:lll //long test string kept for log profile readability
+func (p *securityViolationsProcessor) parseCSVLog(message string) map[string]string {
+ fieldValueMap := make(map[string]string)
+
+ // Remove the "ASM:" prefix if present so we only process the values
+ if idx := strings.Index(message, ":"); idx >= 0 {
+ message = message[idx+1:]
+ }
+
+ fields := strings.Split(message, ",")
+
+ // Mapping of CSV field positions to their corresponding keys
+ fieldOrder := []string{
+ "blocking_exception_reason",
+ "dest_port",
+ "ip_client",
+ "is_truncated_bool",
+ "method",
+ "policy_name",
+ "protocol",
+ "request_status",
+ "response_code",
+ "severity",
+ "sig_cves",
+ "sig_set_names",
+ "src_port",
+ "sub_violations",
+ "support_id",
+ "threat_campaign_names",
+ "violation_rating",
+ "vs_name",
+ "x_forwarded_for_header_value",
+ "outcome",
+ "outcome_reason",
+ "violations",
+ "violation_details",
+ "bot_signature_name",
+ "bot_category",
+ "bot_anomalies",
+ "enforced_bot_anomalies",
+ "client_class",
+ "client_application",
+ "client_application_version",
+ "transport_protocol",
+ "uri",
+ "request",
+ }
+
+ for i, field := range fields {
+ if i >= len(fieldOrder) {
+ break
+ }
+ fieldValueMap[fieldOrder[i]] = strings.TrimSpace(field)
+ }
+
+ // combine multiple values separated by '::'
+ if combined, ok := fieldValueMap["sig_cves"]; ok {
+ parts := strings.SplitN(combined, "::", maxSplitParts)
+ fieldValueMap["sig_ids"] = parts[0]
+ if len(parts) > 1 {
+ fieldValueMap["sig_names"] = parts[1]
+ }
+ }
+
+ if combined, ok := fieldValueMap["sig_set_names"]; ok {
+ parts := strings.SplitN(combined, "::", maxSplitParts)
+ fieldValueMap["sig_set_names"] = parts[0]
+ if len(parts) > 1 {
+ fieldValueMap["sig_cves"] = parts[1]
+ }
+ }
+
+ return fieldValueMap
+}
+
+func (p *securityViolationsProcessor) mapKVToSecurityViolationEvent(log *SecurityViolationEvent,
+ kvMap map[string]string,
+) {
+ log.PolicyName = kvMap["policy_name"]
+ log.SupportID = kvMap["support_id"]
+ log.Outcome = kvMap["outcome"]
+ log.OutcomeReason = kvMap["outcome_reason"]
+ log.BlockingExceptionReason = kvMap["blocking_exception_reason"]
+ log.Method = kvMap["method"]
+ log.Protocol = kvMap["protocol"]
+ log.XForwardedForHeaderValue = kvMap["x_forwarded_for_header_value"]
+ log.URI = kvMap["uri"]
+ log.Request = kvMap["request"]
+ log.IsTruncated = kvMap["is_truncated_bool"]
+ log.RequestStatus = kvMap["request_status"]
+ log.ResponseCode = kvMap["response_code"]
+ log.ServerAddr = kvMap["server_addr"]
+ log.VSName = kvMap["vs_name"]
+ log.RemoteAddr = kvMap["ip_client"]
+ log.RemotePort = kvMap["dest_port"]
+ log.ServerPort = kvMap["src_port"]
+ log.Violations = kvMap["violations"]
+ log.SubViolations = kvMap["sub_violations"]
+ log.ViolationRating = kvMap["violation_rating"]
+ log.SigSetNames = kvMap["sig_set_names"]
+ log.SigCVEs = kvMap["sig_cves"]
+ log.ClientClass = kvMap["client_class"]
+ log.ClientApplication = kvMap["client_application"]
+ log.ClientApplicationVersion = kvMap["client_application_version"]
+ log.Severity = kvMap["severity"]
+ log.ThreatCampaignNames = kvMap["threat_campaign_names"]
+ log.BotAnomalies = kvMap["bot_anomalies"]
+ log.BotCategory = kvMap["bot_category"]
+ log.EnforcedBotAnomalies = kvMap["enforced_bot_anomalies"]
+ log.BotSignatureName = kvMap["bot_signature_name"]
+ log.InstanceTags = kvMap["instance_tags"]
+ log.InstanceGroup = kvMap["instance_group"]
+ log.DisplayName = kvMap["display_name"]
+
+ if log.RemoteAddr == "" {
+ log.RemoteAddr = kvMap["remote_addr"]
+ }
+ if log.RemotePort == "" {
+ log.RemotePort = kvMap["remote_port"]
+ }
+}
+
+// parseViolationsData extracts violation data from the syslog key-value map
+func (p *securityViolationsProcessor) parseViolationsData(kvMap map[string]string) []ViolationData {
+ var violationsData []ViolationData
+
+ // Extract violation name from violation_details XML - this is the only source
+ violationName := ""
+ if violationDetails := kvMap["violation_details"]; violationDetails != "" {
+ violNameRegex := regexp.MustCompile(`([^<]+)`)
+ if matches := violNameRegex.FindStringSubmatch(violationDetails); len(matches) > 1 {
+ violationName = matches[1]
+ }
+ }
+
+ // Create violation data if we have violation information
+ if violationName != "" || kvMap["violations"] != "" {
+ signatures := p.extractSignatureData(kvMap)
+ if signatures == nil {
+ signatures = []SignatureData{}
+ }
+
+ violationData := ViolationData{
+ Name: violationName,
+ Context: p.extractViolationContext(kvMap),
+ ContextData: p.extractContextData(kvMap),
+ Signatures: signatures,
+ }
+ violationsData = append(violationsData, violationData)
+ }
+
+ return violationsData
+}
+
+// extractViolationContext extracts the violation context from syslog data
+func (p *securityViolationsProcessor) extractViolationContext(kvMap map[string]string) string {
+ if uri := kvMap["uri"]; uri != "" {
+ return uri
+ }
+ if method := kvMap["method"]; method != "" {
+ return method
+ }
+
+ return ""
+}
+
+// extractContextData extracts context data from syslog
+func (p *securityViolationsProcessor) extractContextData(kvMap map[string]string) ContextData {
+ contextData := ContextData{}
+
+ if paramName := kvMap["parameter_name"]; paramName != "" {
+ contextData.Name = paramName
+ contextData.Value = kvMap["parameter_value"]
+ } else if uri := kvMap["uri"]; uri != "" {
+ // Use URI as context if no specific parameter data
+ contextData.Name = "uri"
+ contextData.Value = uri
+ } else if request := kvMap["request"]; request != "" {
+ // Use request as context if no URI
+ contextData.Name = "request"
+ contextData.Value = request
+ }
+
+ return contextData
+}
+
+// extractSignatureData extracts signature data from syslog
+func (p *securityViolationsProcessor) extractSignatureData(kvMap map[string]string) []SignatureData {
+ sigIDs := kvMap["sig_ids"]
+ sigNames := kvMap["sig_names"]
+ blockingMask := kvMap["blocking_mask"]
+ sigOffset := kvMap["sig_offset"]
+ sigLength := kvMap["sig_length"]
+
+ if sigIDs == "" || sigIDs == notAvailable {
+ return []SignatureData{}
+ }
+
+ ids := splitAndTrim(sigIDs)
+ names := splitAndTrim(sigNames)
+
+ return buildSignatures(ids, names, blockingMask, sigOffset, sigLength)
+}
+
+func splitAndTrim(value string) []string {
+ if strings.TrimSpace(value) == "" || value == notAvailable {
+ return nil
+ }
+
+ parts := strings.Split(value, ",")
+
+ var trimmedParts []string
+ for _, part := range parts {
+ trimmed := strings.TrimSpace(part)
+ if trimmed != "" {
+ trimmedParts = append(trimmedParts, trimmed)
+ }
+ }
+
+ return trimmedParts
+}
+
+func buildSignatures(ids, names []string, mask, offset, length string) []SignatureData {
+ signatures := make([]SignatureData, 0, len(ids))
+ for i, id := range ids {
+ if id == "" || id == notAvailable {
+ continue
+ }
+ signature := SignatureData{
+ ID: id,
+ BlockingMask: mask,
+ Offset: offset,
+ Length: length,
+ }
+ if i < len(names) {
+ signature.Buffer = names[i]
+ }
+ signatures = append(signatures, signature)
+ }
+
+ return signatures
+}
+
+func extractIPFromHostname(hostname string) string {
+ if ip := net.ParseIP(hostname); ip != nil {
+ return ip.String()
+ }
+
+ re := regexp.MustCompile(`^ip-([0-9-]+)`)
+ if matches := re.FindStringSubmatch(hostname); len(matches) > 1 {
+ candidate := strings.ReplaceAll(matches[1], "-", ".")
+ if net.ParseIP(candidate) != nil {
+ return candidate
+ }
+ }
+
+ return ""
+}
diff --git a/internal/collector/securityviolationsprocessor/processor_bench_test.go b/internal/collector/securityviolationsprocessor/processor_bench_test.go
new file mode 100644
index 000000000..fc6460953
--- /dev/null
+++ b/internal/collector/securityviolationsprocessor/processor_bench_test.go
@@ -0,0 +1,78 @@
+// Copyright (c) F5, Inc.
+//
+// This source code is licensed under the Apache License, Version 2.0 license found in the
+// LICENSE file in the root directory of this source tree.
+
+package securityviolationsprocessor
+
+import (
+ "context"
+ "testing"
+
+ "go.opentelemetry.io/collector/consumer/consumertest"
+ "go.opentelemetry.io/collector/pdata/plog"
+ "go.opentelemetry.io/collector/processor/processortest"
+)
+
+//nolint:lll // long test string kept for readability
+const (
+ sampleAppProtectSyslog = `<130>Aug 22 03:28:35 ip-172-16-0-213 ASM:N/A,80,127.0.0.1,false,GET,nms_app_protect_default_policy,HTTP,blocked,0,N/A,N/A::N/A,{High Accuracy Signatures;Cross Site Scripting Signatures}::{High Accuracy Signatures; Cross Site Scripting Signatures},56064,N/A,5377540117854870581,N/A,5,1-localhost:1-/,N/A,REJECTED,SECURITY_WAF_VIOLATION,Illegal meta character in URL::Attack signature detected::Violation Rating Threat detected::Bot Client Detected,414000000200c00-3a03030c30000072-8000000000000000-0475f0ffcbbd0fea-befbf35cb000007e-f400000000000000-00-0-0-00-0-0-042VIOL_ATTACK_SIGNATUREurl2000000993Lzw+PHNjcmlwdD4=372000000933Lzw+PHNjcmlwdD4=4726VIOL_URL_METACHARLzw+PHNjcmlwdD4=60*026VIOL_URL_METACHARLzw+PHNjcmlwdD4=62*0122VIOL_BOT_CLIENT93VIOL_RATING_THREAT,curl,HTTP Library,N/A,N/A,Untrusted Bot,N/A,N/A,HTTP/1.1,/<>