Skip to content
Merged
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
209 changes: 209 additions & 0 deletions lib/reporting/mfa_report.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
# frozen_string_literal: true

require 'csv'
begin
require 'reporting/cloudwatch_client'
require 'reporting/cloudwatch_query_quoting'
require 'reporting/command_line_options'
rescue LoadError => e
warn 'could not load paths, try running with "bundle exec rails runner"'
raise e
end

module Reporting
class MfaReport
include Reporting::CloudwatchQueryQuoting

attr_reader :issuers, :time_range
EVENT = 'Multi-Factor Authentication'

module Methods
def self.all_methods
TwoFactorAuthenticatable::AuthMethod.constants.map do |c|
next if c == :PHISHING_RESISTANT_METHODS || c == :REMEMBER_DEVICE

if c == :PERSONAL_KEY
# The AuthMethod constant defines this as `personal_key`
# but in the events it is `personal-key`
'personal-key'
else
TwoFactorAuthenticatable::AuthMethod.const_get(c)
end
end.compact
end
end

# @param [Array<String>] issuers
# @param [Range<Time>] time_range
def initialize(
issuers:,
time_range:,
verbose: false,
progress: false,
slice: 1.day,
threads: 10
)
@issuers = issuers
@time_range = time_range
@verbose = verbose
@progress = progress
@slice = slice
@threads = threads
end

def verbose?
@verbose
end

def progress?
@progress
end

def as_tables
[
overview_table,
multi_factor_auth_table,
]
end

def as_emailable_reports
[
Reporting::EmailableReport.new(
title: 'Overview',
table: overview_table,
),
Reporting::EmailableReport.new(
title: 'Multi Factor Authentication Metrics',
table: multi_factor_auth_table,
),
]
end

def to_csvs
as_tables.map do |table|
CSV.generate do |csv|
table.each do |row|
csv << row
end
end
end
end

# @return Array<Hash>
def data
@data ||= begin
fetch_results
end
end

def fetch_results
cloudwatch_client.fetch(query:, from: time_range.begin, to: time_range.end)
end

def query
params = {
issuers: quote(issuers),
event: quote(EVENT),
}

format(<<~QUERY, params)
fields properties.event_properties.multi_factor_auth_method = 'backup_code' as backup_code,
properties.event_properties.multi_factor_auth_method = 'voice' as voice,
properties.event_properties.multi_factor_auth_method = 'webauthn' as webauthn,
properties.event_properties.multi_factor_auth_method = 'webauthn_platform' as webauthn_platform,
properties.event_properties.multi_factor_auth_method = 'personal-key' as personal_key,
properties.event_properties.multi_factor_auth_method = 'totp' as totp,
properties.event_properties.multi_factor_auth_method = 'piv_cac' as piv_cac,
properties.event_properties.multi_factor_auth_method = 'sms' as sms
| filter properties.service_provider IN %{issuers}
| filter name = %{event}
AND NOT properties.event_properties.confirmation_for_add_phone
AND properties.event_properties.context != 'reauthentication'
| filter properties.event_properties.success = '1'
| stats sum(backup_code) as `backup_code_total`,
sum(voice) as `voice_total`,
sum(webauthn) as `webauthn_total`,
sum(webauthn_platform) as `webauthn_platform_total`,
sum(personal_key) as `personal_key_total`,
sum(totp) as `totp_total`,
sum(piv_cac) as `piv_cac_total`,
sum(sms) as `sms_total`
QUERY
end

def cloudwatch_client
@cloudwatch_client ||= Reporting::CloudwatchClient.new(
num_threads: @threads,
ensure_complete_logs: true,
slice_interval: @slice,
progress: progress?,
logger: verbose? ? Logger.new(STDERR) : nil,
)
end

def overview_table
[
['Report Timeframe', "#{time_range.begin} to #{time_range.end}"],
# This needs to be Date.today so it works when run on the command line
['Report Generated', Date.today.to_s], # rubocop:disable Rails/Date
['Issuer', issuers.join(', ')],
]
end

def totals(key)
data.inject(0) { |sum, slice| slice[key].to_i + sum }
end

def multi_factor_auth_table
[
['Multi Factor Authentication (MFA) method', 'Number of successful sign-ins'],
[
'SMS',
totals('sms_total'),
],
[
'Voice',
totals('voice_total'),
],
[
'Security key',
totals('webauthn_total'),
],
[
'Face or touch unlock',
totals('webauthn_platform_total'),
],
[
'PIV/CAC',
totals('piv_cac_total'),
],
[
'Authentication app',
totals('totp_total'),
],
[
'Backup codes',
totals('backup_code_total'),
],
[
'Personal key',
totals('personal_key_total'),
],
[
'Total number of phishing resistant methods',
totals('webauthn_total') + totals('webauthn_platform_total') + totals('piv_cac_total'),
],
]
end
end
end

# rubocop:disable Rails/Output
if __FILE__ == $PROGRAM_NAME
options = Reporting::CommandLineOptions.new.parse!(ARGV)

Reporting::MfaReport.new(**options).to_csvs.each do |csv|
puts csv
end
end
# rubocop:enable Rails/Output
168 changes: 168 additions & 0 deletions spec/lib/reporting/mfa_report_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
require 'rails_helper'
require 'reporting/mfa_report'

RSpec.describe Reporting::MfaReport do
let(:issuer) { 'my:example:issuer' }
let(:time_range) { Date.new(2022, 1, 1).all_day }

subject(:report) { Reporting::MfaReport.new(issuers: [issuer], time_range:) }

before do
cloudwatch_client = double(
'Reporting::CloudwatchClient',
fetch: [
{
'personal_key_total' => '2',
'sms_total' => '5',
'totp_total' => '4',
'webauthn_platform_total' => '3',
'webauthn_total' => '3',
'backup_code_total' => '1',
'voice_total' => '4',
'piv_cac_total' => '3',
},
{
'personal_key_total' => '1',
'sms_total' => '4',
'totp_total' => '3',
'webauthn_platform_total' => '2',
'webauthn_total' => '2',
'backup_code_total' => '0',
'voice_total' => '3',
'piv_cac_total' => '2',
},
],
)

allow(report).to receive(:cloudwatch_client).and_return(cloudwatch_client)
end

describe '#as_tables' do
it 'generates the tabular csv data' do
expect(report.as_tables).to eq expected_tables
end
end

describe '#as_emailable_reports' do
it 'adds a "first row" hash with a title for tables_report mailer' do
reports = report.as_emailable_reports
aggregate_failures do
reports.each do |report|
expect(report.title).to be_present
end
end
end
end

describe '#to_csvs' do
it 'generates a csv' do
csv_string_list = report.to_csvs
expect(csv_string_list.count).to be 2

csvs = csv_string_list.map { |csv| CSV.parse(csv) }

aggregate_failures do
csvs.map(&:to_a).zip(expected_tables(strings: true)).each do |actual, expected|
expect(actual).to eq(expected)
end
end
end
end

describe '#cloudwatch_client' do
let(:opts) { {} }
let(:subject) { described_class.new(issuers: [issuer], time_range:, **opts) }
let(:default_args) do
{
num_threads: 10,
ensure_complete_logs: true,
slice_interval: 1.day,
progress: false,
logger: nil,
}
end

describe 'when all args are default' do
it 'creates a client with the default options' do
expect(Reporting::CloudwatchClient).to receive(:new).with(default_args)

subject.cloudwatch_client
end
end

describe 'when verbose is passed in' do
let(:opts) { { verbose: true } }
let(:logger) { double Logger }

before do
expect(Logger).to receive(:new).with(STDERR).and_return logger
default_args[:logger] = logger
end

it 'creates a client with the expected logger' do
expect(Reporting::CloudwatchClient).to receive(:new).with(default_args)

subject.cloudwatch_client
end
end

describe 'when progress is passed in as true' do
let(:opts) { { progress: true } }
before { default_args[:progress] = true }

it 'creates a client with progress as true' do
expect(Reporting::CloudwatchClient).to receive(:new).with(default_args)

subject.cloudwatch_client
end
end

describe 'when threads is passed in' do
let(:opts) { { threads: 17 } }
before { default_args[:num_threads] = 17 }

it 'creates a client with the expected thread count' do
expect(Reporting::CloudwatchClient).to receive(:new).with(default_args)

subject.cloudwatch_client
end
end

describe 'when slice is passed in' do
let(:opts) { { slice: 2.weeks } }
before { default_args[:slice_interval] = 2.weeks }

it 'creates a client with expected time slice' do
expect(Reporting::CloudwatchClient).to receive(:new).with(default_args)

subject.cloudwatch_client
end
end
end

def expected_tables(strings: false)
[
[
['Report Timeframe', "#{time_range.begin} to #{time_range.end}"],
['Report Generated', Date.today.to_s], # rubocop:disable Rails/Date
['Issuer', issuer],
],
[
['Multi Factor Authentication (MFA) method', 'Number of successful sign-ins'],
['SMS', string_or_num(strings, 9)],
['Voice', string_or_num(strings, 7)],
['Security key', string_or_num(strings, 5)],
['Face or touch unlock', string_or_num(strings, 5)],
['PIV/CAC', string_or_num(strings, 5)],
['Authentication app', string_or_num(strings, 7)],
['Backup codes', string_or_num(strings, 1)],
['Personal key', string_or_num(strings, 3)],
['Total number of phishing resistant methods', string_or_num(strings, 15)],
],
]
end

def string_or_num(strings, value)
strings ? value.to_s : value
end
end