Commit a2580ca6 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch '14707-add-waf-anomaly-endpoint' into 'master'

Add WAF Anomalies controller and mock service

See merge request gitlab-org/gitlab!22736
parents cc9ebcfb 9bd0ce47
......@@ -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