Commit 56cea261 authored by Lucas Charles's avatar Lucas Charles

Add WAF anomaly aggregation queries

Adds Elasticsearch query aggregating WAF anomalies by querying against
deployment environment name and time window.

See https://gitlab.com/gitlab-org/gitlab/issues/14707 for additional
details
parent 763b34f7
...@@ -18,16 +18,19 @@ module Security ...@@ -18,16 +18,19 @@ module Security
# Use multi-search with single query as we'll be adding nginx later # Use multi-search with single query as we'll be adding nginx later
# with https://gitlab.com/gitlab-org/gitlab/issues/14707 # with https://gitlab.com/gitlab-org/gitlab/issues/14707
aggregate_results = elasticsearch_client.msearch(body: body) aggregate_results = elasticsearch_client.msearch(body: body)
nginx_results = aggregate_results['responses'].first nginx_results, modsec_results = aggregate_results['responses']
nginx_total_requests = nginx_results.dig('hits', 'total').to_f nginx_total_requests = nginx_results.dig('hits', 'total').to_f
modsec_total_requests = modsec_results.dig('hits', 'total').to_f
anomalous_traffic_count = nginx_total_requests.zero? ? 0 : (modsec_total_requests / nginx_total_requests).round(2)
{ {
total_traffic: nginx_total_requests, total_traffic: nginx_total_requests,
anomalous_traffic: 0.0, anomalous_traffic: anomalous_traffic_count,
history: { history: {
nominal: histogram_from(nginx_results), nominal: histogram_from(nginx_results),
anomalous: [] anomalous: histogram_from(modsec_results)
}, },
interval: @interval, interval: @interval,
from: @from, from: @from,
...@@ -37,7 +40,7 @@ module Security ...@@ -37,7 +40,7 @@ module Security
end end
def elasticsearch_client def elasticsearch_client
@client ||= @environment.deployment_platform.cluster.application_elastic_stack&.elasticsearch_client @client ||= @environment.deployment_platform&.cluster&.application_elastic_stack&.elasticsearch_client
end end
private private
...@@ -49,6 +52,12 @@ module Security ...@@ -49,6 +52,12 @@ module Security
query: nginx_requests_query, query: nginx_requests_query,
aggs: aggregations(@interval), aggs: aggregations(@interval),
size: 0 # no docs needed, only counts size: 0 # no docs needed, only counts
},
{ index: indices },
{
query: modsec_requests_query,
aggs: aggregations(@interval),
size: 0 # no docs needed, only counts
} }
] ]
end end
...@@ -110,6 +119,42 @@ module Security ...@@ -110,6 +119,42 @@ module Security
} }
end end
def modsec_requests_query
{
bool: {
must: [
{
range: {
'@timestamp' => {
gte: @from,
lte: @to
}
}
},
{
prefix: {
'transaction.unique_id': application_server_name
}
},
{
match_phrase: {
'kubernetes.container.name' => {
query: ::Clusters::Applications::Ingress::MODSECURITY_LOG_CONTAINER_NAME
}
}
},
{
match_phrase: {
'kubernetes.namespace' => {
query: Gitlab::Kubernetes::Helm::NAMESPACE
}
}
}
]
}
}
end
def aggregations(interval) def aggregations(interval)
{ {
counts: { counts: {
...@@ -130,6 +175,11 @@ module Security ...@@ -130,6 +175,11 @@ module Security
buckets.map { |bucket| [bucket['key_as_string'], bucket['doc_count']] } buckets.map { |bucket| [bucket['key_as_string'], bucket['doc_count']] }
end end
# Derive server_name to filter modsec audit log by environment
def application_server_name
"#{@environment.project.full_path_slug}.#{@environment.deployment_platform.cluster.base_domain}"
end
# Derive proxy upstream name to filter nginx log by environment # Derive proxy upstream name to filter nginx log by environment
# See https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/log-format/ # See https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/log-format/
def environment_proxy_upstream_name_tokens def environment_proxy_upstream_name_tokens
......
...@@ -10,40 +10,66 @@ describe Security::WafAnomalySummaryService do ...@@ -10,40 +10,66 @@ describe Security::WafAnomalySummaryService do
let(:es_client) { double(Elasticsearch::Client) } let(:es_client) { double(Elasticsearch::Client) }
let(:empty_response) do
{
'took' => 40,
'timed_out' => false,
'_shards' => { 'total' => 11, 'successful' => 11, 'skipped' => 0, 'failed' => 0 },
'hits' => { 'total' => 0, 'max_score' => 0.0, 'hits' => [] },
'aggregations' => {
'counts' => {
'buckets' => []
}
},
'status' => 200
}
end
let(:nginx_response) do let(:nginx_response) do
empty_response.deep_merge( empty_response.deep_merge(
"hits" => { "total" => 3 }, 'hits' => { 'total' => 3 },
"aggregations" => { 'aggregations' => {
"counts" => { 'counts' => {
"buckets" => [ 'buckets' => [
{ "key_as_string" => "2020-02-14T23:00:00.000Z", "key" => 1575500400000, "doc_count" => 1 }, { 'key_as_string' => '2020-02-14T23:00:00.000Z', 'key' => 1575500400000, 'doc_count' => 1 },
{ "key_as_string" => "2020-02-15T00:00:00.000Z", "key" => 1575504000000, "doc_count" => 0 }, { 'key_as_string' => '2020-02-15T00:00:00.000Z', 'key' => 1575504000000, 'doc_count' => 0 },
{ "key_as_string" => "2020-02-15T01:00:00.000Z", "key" => 1575507600000, "doc_count" => 0 }, { 'key_as_string' => '2020-02-15T01:00:00.000Z', 'key' => 1575507600000, 'doc_count' => 0 },
{ "key_as_string" => "2020-02-15T08:00:00.000Z", "key" => 1575532800000, "doc_count" => 2 } { 'key_as_string' => '2020-02-15T08:00:00.000Z', 'key' => 1575532800000, 'doc_count' => 2 }
] ]
} }
} }
) )
end end
let(:empty_response) do let(:modsec_response) do
{ empty_response.deep_merge(
"took" => 40, 'hits' => { 'total' => 1 },
"timed_out" => false, 'aggregations' => {
"_shards" => { "total" => 11, "successful" => 11, "skipped" => 0, "failed" => 0 }, 'counts' => {
"hits" => { "total" => 0, "max_score" => 0.0, "hits" => [] }, 'buckets' => [
"aggregations" => { { 'key_as_string' => '2019-12-04T23:00:00.000Z', 'key' => 1575500400000, 'doc_count' => 0 },
"counts" => { { 'key_as_string' => '2019-12-05T00:00:00.000Z', 'key' => 1575504000000, 'doc_count' => 0 },
"buckets" => [] { 'key_as_string' => '2019-12-05T01:00:00.000Z', 'key' => 1575507600000, 'doc_count' => 0 },
{ 'key_as_string' => '2019-12-05T08:00:00.000Z', 'key' => 1575532800000, 'doc_count' => 1 }
]
} }
}, }
"status" => 200 )
}
end end
subject { described_class.new(environment: environment) } subject { described_class.new(environment: environment) }
describe '#execute' do describe '#execute' do
context 'without cluster' do
before do
allow(environment).to receive(:deployment_platform) { nil }
end
it 'returns no results' do
expect(subject.execute).to be_nil
end
end
context 'without elastic_stack' do context 'without elastic_stack' do
it 'returns no results' do it 'returns no results' do
expect(subject.execute).to be_nil expect(subject.execute).to be_nil
...@@ -53,7 +79,7 @@ describe Security::WafAnomalySummaryService do ...@@ -53,7 +79,7 @@ describe Security::WafAnomalySummaryService do
context 'with default histogram' do context 'with default histogram' do
before do before do
allow(es_client).to receive(:msearch) do allow(es_client).to receive(:msearch) do
{ "responses" => [nginx_results, modsec_results] } { 'responses' => [nginx_results, modsec_results] }
end end
allow(environment.deployment_platform.cluster).to receive_message_chain( allow(environment.deployment_platform.cluster).to receive_message_chain(
...@@ -65,7 +91,7 @@ describe Security::WafAnomalySummaryService do ...@@ -65,7 +91,7 @@ describe Security::WafAnomalySummaryService do
let(:nginx_results) { empty_response } let(:nginx_results) { empty_response }
let(:modsec_results) { empty_response } let(:modsec_results) { empty_response }
it 'returns results' do it 'returns results', :aggregate_failures do
results = subject.execute results = subject.execute
expect(results.fetch(:status)).to eq :success expect(results.fetch(:status)).to eq :success
...@@ -79,7 +105,7 @@ describe Security::WafAnomalySummaryService do ...@@ -79,7 +105,7 @@ describe Security::WafAnomalySummaryService do
let(:nginx_results) { nginx_response } let(:nginx_results) { nginx_response }
let(:modsec_results) { empty_response } let(:modsec_results) { empty_response }
it 'returns results' do it 'returns results', :aggregate_failures do
results = subject.execute results = subject.execute
expect(results.fetch(:status)).to eq :success expect(results.fetch(:status)).to eq :success
...@@ -88,6 +114,20 @@ describe Security::WafAnomalySummaryService do ...@@ -88,6 +114,20 @@ describe Security::WafAnomalySummaryService do
expect(results.fetch(:anomalous_traffic)).to eq 0.0 expect(results.fetch(:anomalous_traffic)).to eq 0.0
end end
end end
context 'with violations' do
let(:nginx_results) { nginx_response }
let(:modsec_results) { modsec_response }
it 'returns results', :aggregate_failures do
results = subject.execute
expect(results.fetch(:status)).to eq :success
expect(results.fetch(:interval)).to eq 'day'
expect(results.fetch(:total_traffic)).to eq 3
expect(results.fetch(:anomalous_traffic)).to eq 0.33
end
end
end end
context 'with time window' do context 'with time window' do
...@@ -122,7 +162,7 @@ describe Security::WafAnomalySummaryService do ...@@ -122,7 +162,7 @@ describe Security::WafAnomalySummaryService do
) )
) )
) )
).and_return({ 'responses' => [{}] }) ).and_return({ 'responses' => [{}, {}] })
subject.execute subject.execute
end end
...@@ -151,7 +191,7 @@ describe Security::WafAnomalySummaryService do ...@@ -151,7 +191,7 @@ describe Security::WafAnomalySummaryService do
) )
) )
) )
).and_return({ 'responses' => [{}] }) ).and_return({ 'responses' => [{}, {}] })
subject.execute subject.execute
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment