Skip to content

Commit b8f1bdd

Browse files
Added XML parsing to extract deeper context from violations, bringing the logic in line with agent v2
1 parent 7a2adfb commit b8f1bdd

File tree

7 files changed

+961
-342
lines changed

7 files changed

+961
-342
lines changed
Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,51 @@
1-
# SecurityViolations Processor
1+
# Security Violations Processor
22

3-
Internal component of the NGINX Agent that processes security violation 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.
3+
OpenTelemetry Collector processor that transforms NGINX App Protect security violation syslog messages into structured protobuf events.
44

5-
Part of the NGINX Agent's log collection pipeline.
5+
## What It Does
6+
7+
Processes NGINX App Protect WAF syslog messages and transforms them into `SecurityViolationEvent` protobuf messages:
8+
9+
1. Parses RFC3164 syslog messages (best-effort mode)
10+
2. Extracts CSV formatted data from NAP `secops_dashboard` log profile
11+
3. Parses XML violation details with context extraction (parameter, header, cookie, uri, request)
12+
4. Extracts attack signature details
13+
5. Outputs structured protobuf events for downstream consumption
14+
15+
## Implementation
16+
17+
| File | Purpose |
18+
|------|---------|
19+
| [`processor.go`](processor.go) | Main processor implementation, RFC3164 parsing, orchestration |
20+
| [`csv_parser.go`](csv_parser.go) | CSV parsing and field mapping |
21+
| [`violations_parser.go`](violations_parser.go) | XML parsing, context extraction, signature parsing |
22+
| [`xml_structs.go`](xml_structs.go) | XML structure definitions (BADMSG, violation contexts) |
23+
| [`helpers.go`](helpers.go) | Utility functions |
24+
25+
See individual files for implementation details. Protobuf schema defined in [`api/grpc/events/v1/security_violation.proto`](../../../api/grpc/events/v1/security_violation.proto).
26+
27+
## Requirements
28+
29+
- **Input**: NAP syslog messages with `secops_dashboard` log profile (33 CSV fields)
30+
- **Output**: `SecurityViolationEvent` protobuf messages
31+
32+
## Testing
33+
34+
```bash
35+
# Run all tests
36+
go test ./internal/collector/securityviolationsprocessor -v
37+
38+
# Check coverage
39+
go test ./internal/collector/securityviolationsprocessor -coverprofile=coverage.out
40+
go tool cover -html=coverage.out
41+
```
42+
43+
Test coverage: CSV parsing, XML parsing (5 violation contexts), encoding edge cases, error handling.
44+
45+
## Error Handling
46+
47+
Implements graceful degradation:
48+
- Malformed XML: Logs warning, continues processing
49+
- Base64 decode errors: Falls back to raw data
50+
- Missing fields: Uses empty strings
51+
- Context inference: Derives from violation names when not explicit
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// Copyright (c) F5, Inc.
2+
//
3+
// This source code is licensed under the Apache License, Version 2.0 license found in the
4+
// LICENSE file in the root directory of this source tree.
5+
6+
package securityviolationsprocessor
7+
8+
import (
9+
"encoding/csv"
10+
"strconv"
11+
"strings"
12+
13+
events "github.com/nginx/agent/v3/api/grpc/events/v1"
14+
)
15+
16+
// Mapping of CSV field positions to their corresponding keys
17+
var fieldOrder = []string{
18+
"blocking_exception_reason",
19+
"dest_port",
20+
"ip_client",
21+
"is_truncated_bool",
22+
"method",
23+
"policy_name",
24+
"protocol",
25+
"request_status",
26+
"response_code",
27+
"severity",
28+
"sig_cves",
29+
"sig_set_names",
30+
"src_port",
31+
"sub_violations",
32+
"support_id",
33+
"threat_campaign_names",
34+
"violation_rating",
35+
"vs_name",
36+
"x_forwarded_for_header_value",
37+
"outcome",
38+
"outcome_reason",
39+
"violations",
40+
"violation_details",
41+
"bot_signature_name",
42+
"bot_category",
43+
"bot_anomalies",
44+
"enforced_bot_anomalies",
45+
"client_class",
46+
"client_application",
47+
"client_application_version",
48+
"transport_protocol",
49+
"uri",
50+
"request",
51+
}
52+
53+
// parseCSVLog parses comma-separated syslog messages where fields are in a
54+
// 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).
55+
// versions when key-value logging isn't enabled.
56+
//
57+
//nolint:lll //long test string kept for log profile readability
58+
func parseCSVLog(message string) map[string]string {
59+
fieldValueMap := make(map[string]string, 33)
60+
61+
// Remove the "ASM:" prefix if present so we only process the values
62+
message = strings.TrimPrefix(message, "ASM:")
63+
64+
reader := csv.NewReader(strings.NewReader(message))
65+
reader.LazyQuotes = true
66+
fields, err := reader.Read()
67+
if err != nil {
68+
// fallback: return empty map if parsing fails
69+
return fieldValueMap
70+
}
71+
72+
for i, field := range fields {
73+
if i >= len(fieldOrder) {
74+
break
75+
}
76+
fieldValueMap[fieldOrder[i]] = strings.TrimSpace(field)
77+
}
78+
79+
// combine multiple values separated by '::'
80+
if combined, ok := fieldValueMap["sig_cves"]; ok {
81+
parts := strings.SplitN(combined, "::", maxSplitParts)
82+
fieldValueMap["sig_ids"] = parts[0]
83+
if len(parts) > 1 {
84+
fieldValueMap["sig_names"] = parts[1]
85+
}
86+
}
87+
88+
if combined, ok := fieldValueMap["sig_set_names"]; ok {
89+
parts := strings.SplitN(combined, "::", maxSplitParts)
90+
fieldValueMap["sig_set_names"] = parts[0]
91+
if len(parts) > 1 {
92+
fieldValueMap["sig_cves"] = parts[1]
93+
}
94+
}
95+
96+
return fieldValueMap
97+
}
98+
99+
// parseOutcome converts string outcome to RequestOutcome enum
100+
func parseOutcome(outcome string) events.RequestOutcome {
101+
switch strings.ToLower(strings.TrimSpace(outcome)) {
102+
case "passed":
103+
return events.RequestOutcome_REQUEST_OUTCOME_PASSED
104+
case "rejected":
105+
return events.RequestOutcome_REQUEST_OUTCOME_REJECTED
106+
default:
107+
return events.RequestOutcome_REQUEST_OUTCOME_UNKNOWN
108+
}
109+
}
110+
111+
// parseIsTruncated converts string to boolean
112+
func parseIsTruncated(value string) bool {
113+
switch strings.ToLower(strings.TrimSpace(value)) {
114+
case "true":
115+
return true
116+
default:
117+
return false
118+
}
119+
}
120+
121+
// parseSeverity converts string severity to Severity enum
122+
func parseSeverity(severity string) events.Severity {
123+
switch strings.ToLower(strings.TrimSpace(severity)) {
124+
case "informational":
125+
return events.Severity_SEVERITY_INFORMATIONAL
126+
case "low":
127+
return events.Severity_SEVERITY_LOW
128+
case "medium":
129+
return events.Severity_SEVERITY_MEDIUM
130+
case "high":
131+
return events.Severity_SEVERITY_HIGH
132+
case "critical":
133+
return events.Severity_SEVERITY_CRITICAL
134+
default:
135+
return events.Severity_SEVERITY_UNKNOWN
136+
}
137+
}
138+
139+
// parseRequestOutcomeReason converts string outcome reason to RequestOutcomeReason enum
140+
func parseRequestOutcomeReason(reason string) events.RequestOutcomeReason {
141+
switch strings.ToUpper(strings.TrimSpace(reason)) {
142+
case "SECURITY_WAF_OK":
143+
return events.RequestOutcomeReason_SECURITY_WAF_OK
144+
case "SECURITY_WAF_VIOLATION":
145+
return events.RequestOutcomeReason_SECURITY_WAF_VIOLATION
146+
case "SECURITY_WAF_FLAGGED":
147+
return events.RequestOutcomeReason_SECURITY_WAF_FLAGGED
148+
case "SECURITY_WAF_VIOLATION_TRANSPARENT":
149+
return events.RequestOutcomeReason_SECURITY_WAF_VIOLATION_TRANSPARENT
150+
default:
151+
return events.RequestOutcomeReason_SECURITY_WAF_UNKNOWN
152+
}
153+
}
154+
155+
// parseRequestStatus converts string request status to RequestStatus enum
156+
func parseRequestStatus(status string) events.RequestStatus {
157+
switch strings.ToLower(strings.TrimSpace(status)) {
158+
case "blocked":
159+
return events.RequestStatus_REQUEST_STATUS_BLOCKED
160+
case "alerted":
161+
return events.RequestStatus_REQUEST_STATUS_ALERTED
162+
case "passed":
163+
return events.RequestStatus_REQUEST_STATUS_PASSED
164+
default:
165+
return events.RequestStatus_REQUEST_STATUS_UNKNOWN
166+
}
167+
}
168+
169+
// parseUint32 converts string to uint32
170+
func parseUint32(value string) uint32 {
171+
if val, err := strconv.ParseUint(strings.TrimSpace(value), 10, 32); err == nil {
172+
return uint32(val)
173+
}
174+
return 0
175+
}
176+
177+
func mapKVToSecurityViolationEvent(log *events.SecurityViolationEvent,
178+
kvMap map[string]string,
179+
) {
180+
log.PolicyName = kvMap["policy_name"]
181+
log.SupportId = kvMap["support_id"]
182+
log.RequestOutcome = parseOutcome(kvMap["outcome"])
183+
log.RequestOutcomeReason = parseRequestOutcomeReason(kvMap["outcome_reason"])
184+
log.BlockingExceptionReason = kvMap["blocking_exception_reason"]
185+
log.Method = kvMap["method"]
186+
log.Protocol = kvMap["protocol"]
187+
log.XffHeaderValue = kvMap["x_forwarded_for_header_value"]
188+
log.Uri = kvMap["uri"]
189+
log.Request = kvMap["request"]
190+
log.IsTruncated = parseIsTruncated(kvMap["is_truncated_bool"])
191+
log.RequestStatus = parseRequestStatus(kvMap["request_status"])
192+
log.ResponseCode = parseUint32(kvMap["response_code"])
193+
log.ServerAddr = kvMap["server_addr"]
194+
log.VsName = kvMap["vs_name"]
195+
log.RemoteAddr = kvMap["ip_client"]
196+
log.DestinationPort = parseUint32(kvMap["dest_port"])
197+
log.ServerPort = parseUint32(kvMap["src_port"])
198+
log.Violations = kvMap["violations"]
199+
log.SubViolations = kvMap["sub_violations"]
200+
log.ViolationRating = parseUint32(kvMap["violation_rating"])
201+
log.SigSetNames = kvMap["sig_set_names"]
202+
log.SigCves = kvMap["sig_cves"]
203+
log.ClientClass = kvMap["client_class"]
204+
log.ClientApplication = kvMap["client_application"]
205+
log.ClientApplicationVersion = kvMap["client_application_version"]
206+
log.Severity = parseSeverity(kvMap["severity"])
207+
log.ThreatCampaignNames = kvMap["threat_campaign_names"]
208+
log.BotAnomalies = kvMap["bot_anomalies"]
209+
log.BotCategory = kvMap["bot_category"]
210+
log.EnforcedBotAnomalies = kvMap["enforced_bot_anomalies"]
211+
log.BotSignatureName = kvMap["bot_signature_name"]
212+
log.DisplayName = kvMap["display_name"]
213+
214+
if log.GetRemoteAddr() == "" {
215+
log.RemoteAddr = kvMap["remote_addr"]
216+
}
217+
if log.GetDestinationPort() == 0 {
218+
log.DestinationPort = parseUint32(kvMap["remote_port"])
219+
}
220+
}

0 commit comments

Comments
 (0)