Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New metric "Matched Rules" #272

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion example/envoy/envoy-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ stats_config:
# Envoy extracts the first matching group as a value.
# See https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/metrics/v3/stats.proto#config-metrics-v3-statsconfig.
- tag_name: phase
regex: "(_phase=([a-z_]+))"
regex: "(_phase=([a-z_]+)(?:_[a-z]+=|$))"
- tag_name: rule_id
regex: "(_ruleid=([0-9]+))"
- tag_name: transaction_id
regex: "(_transactionid=([a-zA-Z]+))"
- tag_name: identifier
regex: "(_identifier=([0-9a-z.:]+))"
- tag_name: owner
Expand Down Expand Up @@ -80,6 +82,10 @@ static_resources:
"owner": "coraza",
"identifier": "global"
},
"metric_flags": {
"transaction_id": true,
"export_matched_rules": true
},
"per_authority_directives":{
"foo.example.com":"rs2",
"bar.example.com":"rs2"
Expand Down
7 changes: 7 additions & 0 deletions wasmplugin/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
type pluginConfiguration struct {
directivesMap DirectivesMap
metricLabels map[string]string
metricFlags map[string]bool
defaultDirectives string
perAuthorityDirectives map[string]string
}
Expand Down Expand Up @@ -56,6 +57,12 @@ func parsePluginConfiguration(data []byte, infoLogger func(string)) (pluginConfi
return true
})

config.metricFlags = make(map[string]bool)
jsonData.Get("metric_flags").ForEach(func(key, value gjson.Result) bool {
config.metricFlags[key.String()] = value.Bool()
return true
})

defaultDirectives := jsonData.Get("default_directives")
if defaultDirectives.Exists() {
defaultDirectivesName := defaultDirectives.String()
Expand Down
25 changes: 25 additions & 0 deletions wasmplugin/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,28 @@ func (m *wafMetrics) CountTXInterruption(phase string, ruleID int, metricLabelsK
fqn := sb.String()
m.incrementCounter(fqn)
}

func (m *wafMetrics) CountTXMatchedRules(phase string, ruleID int, transactionID string, metricLabelsKV []string, flagTransactionID bool) {
// Using the same logic as Count TXInterruption, but with this metric we want to:
// - record the number of times a rule was triggered in a specific phase of the specified transaction
// - record the phase where the rule was triggered
// - record the rule ID
// - record the transaction ID of matched rule. This is a unique identifier for the transaction.
// - record the labels that were used to identify the rule.
// This is metric is processed as:
// waf_filter_tx_matchedrules{phase="http_request_body",rule_id="100",transaction_id="SJNBEaBHutzVixMcVRi",identifier="global"}.
var sb strings.Builder

if flagTransactionID {
sb.WriteString(fmt.Sprintf("waf_filter.tx.matchedrules_ruleid=%d_transactionid=%s_phase=%s", ruleID, transactionID, phase))
} else {
sb.WriteString(fmt.Sprintf("waf_filter.tx.matchedrules_ruleid=%d_phase=%s", ruleID, phase))
}

for i := 0; i < len(metricLabelsKV); i += 2 {
sb.WriteString(fmt.Sprintf("_%s=%s", metricLabelsKV[i], metricLabelsKV[i+1]))
}

fqn := sb.String()
m.incrementCounter(fqn)
}
75 changes: 67 additions & 8 deletions wasmplugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@ type corazaPlugin struct {
// Embed the default plugin context here,
// so that we don't need to reimplement all the methods.
types.DefaultPluginContext
perAuthorityWAFs wafMap
metricLabelsKV []string
metrics *wafMetrics
perAuthorityWAFs wafMap
metricLabelsKV []string
transactionID bool
exportMatchedRules bool
metrics *wafMetrics
}

func (ctx *corazaPlugin) OnPluginStart(pluginConfigurationSize int) types.OnPluginStartStatus {
Expand Down Expand Up @@ -170,17 +172,28 @@ func (ctx *corazaPlugin) OnPluginStart(pluginConfigurationSize int) types.OnPlug
for k, v := range config.metricLabels {
ctx.metricLabelsKV = append(ctx.metricLabelsKV, k, v)
}

for k, v := range config.metricFlags {
if k == "transaction_id" && v {
ctx.transactionID = true
}
if k == "export_matched_rules" && v {
ctx.exportMatchedRules = true
}
}
ctx.metrics = NewWAFMetrics()

return types.OnPluginStartStatusOK
}

func (ctx *corazaPlugin) NewHttpContext(contextID uint32) types.HttpContext {
return &httpContext{
contextID: contextID,
metrics: ctx.metrics,
metricLabelsKV: ctx.metricLabelsKV,
perAuthorityWAFs: ctx.perAuthorityWAFs,
contextID: contextID,
metrics: ctx.metrics,
metricLabelsKV: ctx.metricLabelsKV,
transactionID: ctx.transactionID,
exportMatchedRules: ctx.exportMatchedRules,
perAuthorityWAFs: ctx.perAuthorityWAFs,
}
}

Expand All @@ -201,7 +214,7 @@ func (p interruptionPhase) String() string {
case interruptionPhaseHttpResponseBody:
return "http_response_body"
default:
return "no interruption yet"
return "no_interruption_yet"
}
}

Expand All @@ -228,6 +241,8 @@ type httpContext struct {
interruptedAt interruptionPhase
logger debuglog.Logger
metricLabelsKV []string
transactionID bool
exportMatchedRules bool
}

func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
Expand Down Expand Up @@ -335,7 +350,12 @@ func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) t
}

interruption := tx.ProcessRequestHeaders()
matchedRules := tx.MatchedRules()

if interruption != nil {
if ctx.exportMatchedRules {
ctx.ExportRuleID(interruptionPhaseHttpRequestHeaders, matchedRules)
}
return ctx.handleInterruption(interruptionPhaseHttpRequestHeaders, interruption)
}

Expand Down Expand Up @@ -372,12 +392,16 @@ func (ctx *httpContext) OnHttpRequestBody(bodySize int, endOfStream bool) types.
// ProcessRequestBody is still performed for phase 2 rules, checking already populated variables
ctx.processedRequestBody = true
interruption, err := tx.ProcessRequestBody()
matchedRules := tx.MatchedRules()
if err != nil {
ctx.logger.Error().Err(err).Msg("Failed to process request body")
return types.ActionContinue
}

if interruption != nil {
if ctx.exportMatchedRules {
ctx.ExportRuleID(interruptionPhaseHttpRequestBody, matchedRules)
}
return ctx.handleInterruption(interruptionPhaseHttpRequestBody, interruption)
}

Expand All @@ -402,11 +426,15 @@ func (ctx *httpContext) OnHttpRequestBody(bodySize int, endOfStream bool) types.
ctx.logger.Warn().Int("read_chunk_size", readchunkSize).Int("chunk_size", chunkSize).Msg("Request chunk size read is different from the computed one")
}
interruption, writtenBytes, err := tx.WriteRequestBody(bodyChunk)
matchedRules := tx.MatchedRules()
if err != nil {
ctx.logger.Error().Err(err).Msg("Failed to write request body")
return types.ActionContinue
}
if interruption != nil {
if ctx.exportMatchedRules {
ctx.ExportRuleID(interruptionPhaseHttpRequestBody, matchedRules)
}
return ctx.handleInterruption(interruptionPhaseHttpRequestBody, interruption)
}

Expand All @@ -426,13 +454,17 @@ func (ctx *httpContext) OnHttpRequestBody(bodySize int, endOfStream bool) types.
ctx.processedRequestBody = true
ctx.bodyReadIndex = 0 // cleaning for further usage
interruption, err := tx.ProcessRequestBody()
matchedRules := tx.MatchedRules()
if err != nil {
ctx.logger.Error().
Err(err).
Msg("Failed to process request body")
return types.ActionContinue
}
if interruption != nil {
if ctx.exportMatchedRules {
ctx.ExportRuleID(interruptionPhaseHttpRequestBody, matchedRules)
}
return ctx.handleInterruption(interruptionPhaseHttpRequestBody, interruption)
}

Expand Down Expand Up @@ -472,12 +504,16 @@ func (ctx *httpContext) OnHttpResponseHeaders(numHeaders int, endOfStream bool)
if !ctx.processedRequestBody {
ctx.processedRequestBody = true
interruption, err := tx.ProcessRequestBody()
matchedRules := tx.MatchedRules()
if err != nil {
ctx.logger.Error().
Err(err).Msg("Failed to process request body")
return types.ActionContinue
}
if interruption != nil {
if ctx.exportMatchedRules {
ctx.ExportRuleID(interruptionPhaseHttpResponseHeaders, matchedRules)
}
return ctx.handleInterruption(interruptionPhaseHttpResponseHeaders, interruption)
}
}
Expand Down Expand Up @@ -514,7 +550,11 @@ func (ctx *httpContext) OnHttpResponseHeaders(numHeaders int, endOfStream bool)
}

interruption := tx.ProcessResponseHeaders(code, ctx.httpProtocol)
matchedRules := tx.MatchedRules()
if interruption != nil {
if ctx.exportMatchedRules {
ctx.ExportRuleID(interruptionPhaseHttpResponseHeaders, matchedRules)
}
return ctx.handleInterruption(interruptionPhaseHttpResponseHeaders, interruption)
}

Expand Down Expand Up @@ -557,6 +597,7 @@ func (ctx *httpContext) OnHttpResponseBody(bodySize int, endOfStream bool) types
// ProcessResponseBody is performed for phase 4 rules, checking already populated variables
if !ctx.processedResponseBody {
interruption, err := tx.ProcessResponseBody()
matchedRules := tx.MatchedRules()
if err != nil {
ctx.logger.Error().Err(err).Msg("Failed to process response body")
return types.ActionContinue
Expand All @@ -566,6 +607,9 @@ func (ctx *httpContext) OnHttpResponseBody(bodySize int, endOfStream bool) types
// Proxy-wasm can not anymore deny the response. The best interruption is emptying the body
// Coraza Multiphase evaluation will help here avoiding late interruptions
ctx.bodyReadIndex = bodySize // hacky: bodyReadIndex stores the body size that has to be replaced
if ctx.exportMatchedRules {
ctx.ExportRuleID(interruptionPhaseHttpResponseBody, matchedRules)
}
return ctx.handleInterruption(interruptionPhaseHttpResponseBody, interruption)
}
}
Expand All @@ -590,6 +634,7 @@ func (ctx *httpContext) OnHttpResponseBody(bodySize int, endOfStream bool) types
ctx.logger.Warn().Int("read_chunk_size", readchunkSize).Int("chunk_size", chunkSize).Msg("Response chunk size read is different from the computed one")
}
interruption, writtenBytes, err := tx.WriteResponseBody(bodyChunk)
matchedRules := tx.MatchedRules()
if err != nil {
ctx.logger.Error().Err(err).Msg("Failed to write response body")
return types.ActionContinue
Expand All @@ -598,6 +643,9 @@ func (ctx *httpContext) OnHttpResponseBody(bodySize int, endOfStream bool) types
// it is internally needed to replace the full body if the transaction is interrupted
ctx.bodyReadIndex += readchunkSize
if interruption != nil {
if ctx.exportMatchedRules {
ctx.ExportRuleID(interruptionPhaseHttpResponseBody, matchedRules)
}
return ctx.handleInterruption(interruptionPhaseHttpResponseBody, interruption)
}
// If not the whole chunk has been written, it implicitly means that we reached the waf response body limit,
Expand All @@ -615,13 +663,17 @@ func (ctx *httpContext) OnHttpResponseBody(bodySize int, endOfStream bool) types
// The error will also be logged by Coraza.
ctx.processedResponseBody = true
interruption, err := tx.ProcessResponseBody()
matchedRules := tx.MatchedRules()
if err != nil {
ctx.logger.Error().
Err(err).
Msg("Failed to process response body")
return types.ActionContinue
}
if interruption != nil {
if ctx.exportMatchedRules {
ctx.ExportRuleID(interruptionPhaseHttpResponseBody, matchedRules)
}
return ctx.handleInterruption(interruptionPhaseHttpResponseBody, interruption)
}
return types.ActionContinue
Expand Down Expand Up @@ -695,6 +747,13 @@ func (ctx *httpContext) handleInterruption(phase interruptionPhase, interruption
return types.ActionPause
}

func (ctx *httpContext) ExportRuleID(phase interruptionPhase, matchedRule []ctypes.MatchedRule) {
// Exporting all triggered rules for the current transaction
for _, rule := range matchedRule {
ctx.metrics.CountTXMatchedRules(phase.String(), rule.Rule().ID(), rule.TransactionID(), ctx.metricLabelsKV, ctx.transactionID)
}
}

func logError(error ctypes.MatchedRule) {
msg := error.ErrorLog()
switch error.Rule().Severity() {
Expand Down