diff --git a/README.md b/README.md index a93e5df0..edf81dbe 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,36 @@ modsecurity_use_error_log Turns on or off ModSecurity error log functionality. +# Variables + +This module exposes the following variables that can be used in `log_format` or anywhere else nginx variables are valid. + +modsecurity_intervention +------------------------- +**value:** *`1` if ModSecurity triggered a disruptive intervention +(deny, redirect, etc.) on the request, `0` otherwise* + +modsecurity_triggered_rules +---------------------------- +**value:** *comma-separated list of matched rule IDs (e.g. `941100,949110`), +or `-` when no rule matched* + +```nginx +log_format modsec '$remote_addr [$time_local] "$request" $status ' + 'intervention=$modsecurity_intervention ' + 'rules=$modsecurity_triggered_rules'; + +server { + listen 8080; + modsecurity on; + modsecurity_rules_file /etc/modsecurity.d/modsecurity.conf; + access_log logs/modsec-access.log modsec; + location / { + ... + } +} +``` + # Contributing As an open source project we invite (and encourage) anyone from the community to contribute to our project. This may take the form of: new diff --git a/src/ngx_http_modsecurity_module.c b/src/ngx_http_modsecurity_module.c index d3d9624d..bb557eb2 100644 --- a/src/ngx_http_modsecurity_module.c +++ b/src/ngx_http_modsecurity_module.c @@ -30,6 +30,11 @@ #endif static ngx_int_t ngx_http_modsecurity_init(ngx_conf_t *cf); +static ngx_int_t ngx_http_modsecurity_add_variables(ngx_conf_t *cf); +static ngx_int_t ngx_http_modsecurity_intervention_variable(ngx_http_request_t *r, + ngx_http_variable_value_t *v, uintptr_t data); +static ngx_int_t ngx_http_modsecurity_triggered_rules_variable( + ngx_http_request_t *r, ngx_http_variable_value_t *v, uintptr_t data); static void *ngx_http_modsecurity_create_main_conf(ngx_conf_t *cf); static char *ngx_http_modsecurity_init_main_conf(ngx_conf_t *cf, void *conf); static void *ngx_http_modsecurity_create_conf(ngx_conf_t *cf); @@ -38,6 +43,18 @@ static void ngx_http_modsecurity_cleanup_instance(void *data); static void ngx_http_modsecurity_cleanup_rules(void *data); +static ngx_http_variable_t ngx_http_modsecurity_vars[] = { + + { ngx_string("modsecurity_intervention"), NULL, + ngx_http_modsecurity_intervention_variable, 0, 0, 0 }, + + { ngx_string("modsecurity_triggered_rules"), NULL, + ngx_http_modsecurity_triggered_rules_variable, 0, 0, 0 }, + + ngx_http_null_variable +}; + + /* * PCRE malloc/free workaround, based on * https://github.com/openresty/lua-nginx-module/blob/master/src/ngx_http_lua_pcrefix.c @@ -161,6 +178,8 @@ ngx_http_modsecurity_process_intervention (Transaction *transaction, ngx_http_re return 0; } + ctx->intervention_triggered = 1; + mcf = ngx_http_get_module_loc_conf(r, ngx_http_modsecurity_module); if (mcf == NULL) { return NGX_HTTP_INTERNAL_SERVER_ERROR; @@ -534,7 +553,7 @@ static ngx_command_t ngx_http_modsecurity_commands[] = { static ngx_http_module_t ngx_http_modsecurity_ctx = { - NULL, /* preconfiguration */ + ngx_http_modsecurity_add_variables, /* preconfiguration */ ngx_http_modsecurity_init, /* postconfiguration */ ngx_http_modsecurity_create_main_conf, /* create main configuration */ @@ -564,6 +583,111 @@ ngx_module_t ngx_http_modsecurity_module = { }; +static ngx_int_t +ngx_http_modsecurity_intervention_variable(ngx_http_request_t *r, + ngx_http_variable_value_t *v, uintptr_t data) +{ + ngx_http_modsecurity_ctx_t *ctx; + static u_char zero = '0'; + static u_char one = '1'; + + ctx = ngx_http_modsecurity_get_module_ctx(r); + if (ctx == NULL) { + v->not_found = 1; + return NGX_OK; + } + + v->data = ctx->intervention_triggered ? &one : &zero; + v->len = 1; + v->valid = 1; + v->no_cacheable = 0; + v->not_found = 0; + + return NGX_OK; +} + + +static ngx_int_t +ngx_http_modsecurity_triggered_rules_variable(ngx_http_request_t *r, + ngx_http_variable_value_t *v, uintptr_t data) +{ + ngx_http_modsecurity_ctx_t *ctx; + size_t size; + size_t written; + size_t i; + size_t cap; + int64_t *ids; + u_char *buf, *p, *end; + + ctx = ngx_http_modsecurity_get_module_ctx(r); + if (ctx == NULL || ctx->modsec_transaction == NULL) { + v->not_found = 1; + return NGX_OK; + } + + size = msc_get_rules_messages_size(ctx->modsec_transaction); + if (size == 0) { + v->not_found = 1; + return NGX_OK; + } + + ids = ngx_pnalloc(r->pool, size * sizeof(int64_t)); + if (ids == NULL) { + return NGX_ERROR; + } + + written = msc_get_rules_messages_rule_ids(ctx->modsec_transaction, + ids, size); + if (written == 0) { + v->not_found = 1; + return NGX_OK; + } + + /* NGX_INT64_LEN digits per id + one comma separator per id */ + cap = written * (NGX_INT64_LEN + 1); + buf = ngx_pnalloc(r->pool, cap); + if (buf == NULL) { + return NGX_ERROR; + } + + p = buf; + end = buf + cap; + for (i = 0; i < written; i++) { + if (i > 0) { + *p++ = ','; + } + p = ngx_snprintf(p, end - p, "%L", ids[i]); + } + + v->data = buf; + v->len = p - buf; + v->valid = 1; + v->no_cacheable = 0; + v->not_found = 0; + + return NGX_OK; +} + + +static ngx_int_t +ngx_http_modsecurity_add_variables(ngx_conf_t *cf) +{ + ngx_http_variable_t *var, *v; + + for (v = ngx_http_modsecurity_vars; v->name.len; v++) { + var = ngx_http_add_variable(cf, &v->name, v->flags); + if (var == NULL) { + return NGX_ERROR; + } + + var->get_handler = v->get_handler; + var->data = v->data; + } + + return NGX_OK; +} + + static ngx_int_t ngx_http_modsecurity_init(ngx_conf_t *cf) { diff --git a/tests/modsecurity-log-vars.t b/tests/modsecurity-log-vars.t new file mode 100644 index 00000000..0974ac01 --- /dev/null +++ b/tests/modsecurity-log-vars.t @@ -0,0 +1,172 @@ +#!/usr/bin/perl + +# Tests for $modsecurity_intervention and $modsecurity_triggered_rules. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http/); + +$t->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + log_format modsec '$request_uri|i=$modsecurity_intervention|r=$modsecurity_triggered_rules'; + access_log %%TESTDIR%%/access.log modsec; + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /pass { + modsecurity on; + modsecurity_rules ' + SecRuleEngine On + SecRule ARGS "@streq never" "id:100,phase:2,log,pass" + '; + } + + location /match-logonly { + modsecurity on; + modsecurity_rules ' + SecRuleEngine On + SecRule ARGS "@streq hit" "id:200,phase:2,log,pass" + '; + } + + location /multi { + modsecurity on; + modsecurity_rules ' + SecRuleEngine On + SecRule ARGS "@streq hit" "id:301,phase:2,log,pass" + SecRule ARGS "@streq hit" "id:302,phase:2,log,pass" + SecRule ARGS "@streq hit" "id:303,phase:2,log,pass" + '; + } + + location /mixed-logging { + modsecurity on; + modsecurity_rules ' + SecRuleEngine On + SecRule ARGS "@streq hit" "id:701,phase:2,nolog,pass" + SecRule ARGS "@streq hit" "id:702,phase:2,noauditlog,pass" + SecRule ARGS "@streq hit" "id:703,phase:2,log,pass" + '; + } + + location /allow { + modsecurity on; + modsecurity_rules ' + SecRuleEngine On + SecRule ARGS "@streq skip" "id:800,phase:1,log,allow" + SecRule ARGS "@streq skip" "id:801,phase:1,log,deny,status:403" + '; + } + + location /block { + modsecurity on; + modsecurity_rules ' + SecRuleEngine On + SecRule ARGS "@streq go" "id:400,phase:1,log,deny,status:403" + '; + } + + location /redirect { + modsecurity on; + modsecurity_rules ' + SecRuleEngine On + SecRule ARGS "@streq go" "id:500,phase:1,log,status:302,redirect:http://example.com/" + '; + } + + location /block-phase3 { + modsecurity on; + modsecurity_rules ' + SecRuleEngine On + SecRule ARGS "@streq go" "id:600,phase:3,log,deny,status:403" + '; + } + } +} +EOF + +$t->write_file("/block-phase3", "body"); +$t->run(); +$t->plan(16); + +############################################################################### + +# No rule matches: intervention=0, no rule list (nginx prints '-' for missing var). +http_get('/pass?arg=x'); +like(log_line($t, '/pass'), qr/\|i=0\|/, 'pass: intervention=0'); +like(log_line($t, '/pass'), qr/\|r=-$/, 'pass: no triggered rules'); + +# Rule matches but non-disruptive: intervention=0, rule id listed. +http_get('/match-logonly?arg=hit'); +like(log_line($t, '/match-logonly'), qr/\|i=0\|/, 'log-only: intervention=0'); +like(log_line($t, '/match-logonly'), qr/\|r=200$/, 'log-only: rule id captured'); + +# Multiple rules all matching: intervention=0, every id listed. +http_get('/multi?arg=hit'); +like(log_line($t, '/multi'), qr/\|i=0\|/, 'multi: intervention=0'); +like(log_line($t, '/multi'), qr/\|r=301,302,303$/, 'multi: all rule ids listed in order'); + +# Three rules with different logging actions (nolog / noauditlog / log). +http_get('/mixed-logging?arg=hit'); +like(log_line($t, '/mixed-logging'), qr/\|i=0\|/, 'mixed-logging: intervention=0'); +like(log_line($t, '/mixed-logging'), qr/\|r=703$/, 'mixed-logging: only the log-action rule is captured (nolog/noauditlog both clear m_saveMessage)'); + +# allow action: short-circuits rule evaluation but is NOT treated as an intervention. +http_get('/allow?arg=skip'); +like(log_line($t, '/allow'), qr/\|i=0\|/, 'allow: intervention=0'); +like(log_line($t, '/allow'), qr/\|r=800$/, 'allow: only the allow rule captured; subsequent deny short-circuited'); + +# Deny intervention (phase 1): intervention=1, rule id listed. +http_get('/block?arg=go'); +like(log_line($t, '/block'), qr/\|i=1\|/, 'block: intervention=1'); +like(log_line($t, '/block'), qr/\|r=400$/, 'block: rule id captured'); + +# Redirect intervention: intervention=1, rule id listed. +http_get('/redirect?arg=go'); +like(log_line($t, '/redirect'), qr/\|i=1\|/, 'redirect: intervention=1'); +like(log_line($t, '/redirect'), qr/\|r=500$/, 'redirect: rule id captured'); + +# Intervention fired from a post-access phase. +http_get('/block-phase3?arg=go'); +like(log_line($t, '/block-phase3'), qr/\|i=1\|/, 'phase3 block: intervention=1'); +like(log_line($t, '/block-phase3'), qr/\|r=600$/, 'phase3 block: rule id captured'); + +############################################################################### + +sub log_line { + my ($t, $uri_prefix) = @_; + my $path = $t->testdir() . '/access.log'; + open my $fh, '<', $path or return "open: $!"; + my @matches = grep { /^\Q$uri_prefix\E/ } <$fh>; + close $fh; + return $matches[-1] // ''; +} + +###############################################################################