Commit 9bd0ce47 authored by Lucas Charles's avatar Lucas Charles Committed by Heinrich Lee Yu

Add WAF anomalies controller

Adds controller for serving anomaly request summaries for WAF. Serves
requests with polling interval of 5000ms and reactive caching

Part of the work to complete https://gitlab.com/gitlab-org/gitlab/issues/14707
parent cc9ebcfb
......@@ -4,6 +4,7 @@ module Clusters
module Applications
class Ingress < ApplicationRecord
VERSION = '1.22.1'
MODSECURITY_LOG_CONTAINER_NAME = 'modsecurity-log'
self.table_name = 'clusters_applications_ingress'
......@@ -85,7 +86,7 @@ module Clusters
},
"extraContainers" => [
{
"name" => "modsecurity-log",
"name" => MODSECURITY_LOG_CONTAINER_NAME,
"image" => "busybox",
"args" => [
"/bin/sh",
......
---
title: Add WAF Anomaly Summary service
merge_request: 22736
author:
type: added
# frozen_string_literal: true
module Projects
module Security
class WafAnomaliesController < Projects::ApplicationController
POLLING_INTERVAL = 5_000
before_action :authorize_read_waf_anomalies!
before_action :set_polling_interval
def summary
return not_found unless anomaly_summary_service.elasticsearch_client
result = anomaly_summary_service.execute
respond_to do |format|
format.json do
status = result[:status] == :success ? :ok : :bad_request
render status: status, json: result
end
end
end
private
def anomaly_summary_service
@anomaly_summary_service ||= ::Security::WafAnomalySummaryService.new(
environment: environment,
**query_params.to_h.symbolize_keys
)
end
def query_params
params.permit(:interval, :from, :to)
end
def set_polling_interval
Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
end
def environment
@environment ||= project.environments.find(params.delete("environment_id"))
end
def authorize_read_waf_anomalies!
render_403 unless can?(current_user, :read_threat_monitoring, project)
end
end
end
end
# frozen_string_literal: true
module Security
# Service for fetching summary statistics from ElasticSearch.
# Queries ES and retrieves both total nginx requests & modsec violations
#
class WafAnomalySummaryService < ::BaseService
def initialize(environment:, interval: 'day', from: 30.days.ago.iso8601, to: Time.zone.now.iso8601)
@environment = environment
@interval = interval
@from = from
@to = to
end
def execute
return if elasticsearch_client.nil?
{
total_traffic: 0,
anomalous_traffic: 0.0,
history: {
nominal: [],
anomalous: []
},
interval: @interval,
from: @from,
to: @to,
status: :success
}
end
def elasticsearch_client
@client ||= @environment.deployment_platform.cluster.application_elastic_stack&.elasticsearch_client
end
end
end
......@@ -6,7 +6,7 @@
#js-threat-monitoring-app{ data: { documentation_path: help_page_path('user/clusters/applications', anchor: 'web-application-firewall-modsecurity'),
chart_empty_state_svg_path: image_path('illustrations/chart-empty-state.svg'),
empty_state_svg_path: image_path('illustrations/security-dashboard-empty-state.svg'),
waf_statistics_endpoint: 'dummy',
waf_statistics_endpoint: summary_project_security_waf_anomalies_path(@project, format: :json),
environments_endpoint: project_environments_path(@project),
default_environment_id: default_environment_id,
user_callouts_path: user_callouts_path,
......
......@@ -78,6 +78,12 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :audit_events, only: [:index]
namespace :security do
resources :waf_anomalies, only: [] do
get :summary, on: :collection
end
end
namespace :analytics do
resources :code_reviews, only: [:index]
end
......
# frozen_string_literal: true
require 'spec_helper'
describe Projects::Security::WafAnomaliesController do
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public, :repository, group: group) }
let_it_be(:environment) { create(:environment, :with_review_app, project: project) }
let_it_be(:action_params) { { project_id: project, namespace_id: project.namespace, environment_id: environment } }
let(:es_client) { nil }
describe 'GET #summary' do
subject { get :summary, params: action_params, format: :json }
before do
stub_licensed_features(threat_monitoring: true)
sign_in(user)
allow_next_instance_of(::Security::WafAnomalySummaryService) do |instance|
allow(instance).to receive(:elasticsearch_client).at_most(:twice) { es_client }
end
end
context 'with authorized user' do
before do
group.add_developer(user)
end
context 'with elastic_stack' do
let(:es_client) { double(Elasticsearch::Client) }
before do
allow(es_client).to receive(:msearch) { { "responses" => [{}, {}] } }
end
it 'returns anomaly summary' do
subject
expect(response).to have_gitlab_http_status(200)
expect(json_response['total_traffic']).to eq(0)
expect(json_response['anomalous_traffic']).to eq(0)
expect(response).to match_response_schema('vulnerabilities/summary', dir: 'ee')
end
end
context 'without elastic_stack' do
it 'returns not found' do
subject
expect(response).to have_gitlab_http_status(404)
end
end
it 'sets a polling interval header' do
subject
expect(response.headers['Poll-Interval']).to eq('5000')
end
end
context 'with unauthorized user' do
it 'returns unauthorized' do
subject
expect(response).to have_gitlab_http_status(403)
end
end
end
end
{
"type": "object",
"required" : [
"total_traffic",
"anomalous_traffic",
"history",
"interval",
"from",
"to",
"status"
],
"properties" : {
"total_traffic": { "type": "integer" },
"anomalous_traffic": { "type": "integer" },
"history": {
"nominal": { "type": ["array"] },
"anomalous": { "type": ["array"] }
},
"interval": { "type": "string" },
"from": { "type": "date" },
"to": { "type": "date" },
"status": { "type": "string", "enum": ["success", "failure"] }
},
"additionalProperties": false
}
# frozen_string_literal: true
require 'spec_helper'
describe Security::WafAnomalySummaryService do
let(:environment) { create(:environment, :with_review_app) }
let!(:cluster) do
create(:cluster, :provided_by_gcp, environment_scope: '*', projects: [environment.project])
end
let(:es_client) { double(Elasticsearch::Client) }
let(:empty_response) do
{
"took" => 40,
"timed_out" => false,
"_shards" => { "total" => 1, "successful" => 1, "skipped" => 0, "failed" => 0 },
"hits" => { "total" => 0, "max_score" => 0.0, "hits" => [] },
"aggregations" => {
"counts" => {
"buckets" => []
}
},
"status" => 200
}
end
subject { described_class.new(environment: environment) }
describe '#execute' do
context 'without elastic_stack' do
it 'returns no results' do
expect(subject.execute).to be_nil
end
end
context 'with default histogram' do
before do
allow(es_client).to receive(:msearch) do
{ "responses" => [nginx_results, modsec_results] }
end
allow(environment.deployment_platform.cluster).to receive_message_chain(
:application_elastic_stack, :elasticsearch_client
) { es_client }
end
context 'no requests' do
let(:nginx_results) { empty_response }
let(:modsec_results) { empty_response }
it 'returns results' 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 0
expect(results.fetch(:anomalous_traffic)).to eq 0.0
end
end
end
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