diff --git a/apps/kolab_guam/src/rules/kolab_guam_rule_audit.erl b/apps/kolab_guam/src/rules/kolab_guam_rule_audit.erl new file mode 100644 index 0000000..f1186e9 --- /dev/null +++ b/apps/kolab_guam/src/rules/kolab_guam_rule_audit.erl @@ -0,0 +1,94 @@ +%% Copyright 2021 Apheleia IT AG (http://www.apheleia.ch) +%% +%% Aaron Seigo (Kolab Systems) +%% Christian Mollekopf (Apheleia IT) +%% +%% This program is free software: you can redistribute it and/or modify +%% it under the terms of the GNU General Public License as published by +%% the Free Software Foundation, either version 3 of the License, or +%% (at your option) any later version. +%% +%% This program is distributed in the hope that it will be useful, +%% but WITHOUT ANY WARRANTY; without even the implied warranty of +%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +%% GNU General Public License for more details. +%% +%% You should have received a copy of the GNU General Public License +%% along with this program. If not, see . + +-module(kolab_guam_rule_audit). +-export([new/1, applies/4, imap_data/3, apply_to_client_message/4, apply_to_server_message/3]). +-behavior(kolab_guam_rule). + +-include("kolab_guam_rule_audit.hrl"). + +new(_Config) -> #state { }. + +applies(Socket, _Buffer, { _Tag, _Command, _Data }, State) -> + {ok, {Ip, _Port}} = inet:peername(Socket), + % This command is always immediately active as we expect the LOGIN command at the beginning + { true, State#state{ ip = Ip }}. + + +% Unused +imap_data(_ResponseToken, _Response, State) -> State. + + +apply_to_client_message(_ImapSession, Buffer, undefined, State) -> + { Buffer, State }; + +% We just buffer the entire command once activated +apply_to_client_message(_ImapSession, Buffer, { _Tag, _Command, _Data }, #state{ buffer = LeftOvers, active = true } = State) -> + { Buffer, State#state{ buffer = <> }}; + +% Monitor for the trigger command, otherwise do nothing +apply_to_client_message(ImapSession, Buffer, { Tag, Command, Data }, State) -> + case is_triggering_command(Command, Data, State) of + true -> apply_to_client_message(ImapSession, Buffer, { Tag, Command, Data }, State#state{ active = true, tag = Tag, command = Command }); + _ -> { Buffer, State } + end. + + +apply_to_server_message(_ImapSession, Buffer, #state{ active = true, tag = Tag, ip = Ip, buffer = FullBuffer, command = Command } = State) -> + % Only look into tagged responses + NewState = case binary:part(Buffer, 0, byte_size(Tag)) =:= Tag of + true -> + Username = extract_username(FullBuffer, Command), + case eimap_utils:check_response_for_failure(Buffer, Tag) of + ok -> + lager:info("LOGIN ATTEMPT: ~p from ~p, OK", [Username, inet:ntoa(Ip)]), + State#state{ active = false, username = Username }; + { no, Reason } -> + lager:info("LOGIN ATTEMPT: ~p from ~p, NO: ~p", [Username, inet:ntoa(Ip), Reason]), + State#state{ active = false, username = Username }; + { bad, Reason } -> + lager:info("LOGIN ATTEMPT: ~p from ~p, BAD: ~p", [Username, inet:ntoa(Ip), Reason]), + State#state{ active = false, username = Username } + end; + _ -> State + end, + { Buffer, NewState }; + +% Do nothing if not active +apply_to_server_message(_ImapSession, Buffer, State) -> + { Buffer, State }. + +%%PRIVATE +is_triggering_command(Command, Data, #state{ trigger_commands = TriggerCommands }) -> + %% if the command is in the list of trigger commands and the ending is not "" (which means "send me + %% the root and separator" according to RFC 3501), then it is treated as a triggering event + lists:any(fun(T) -> (Command =:= T) andalso (binary:longest_common_suffix([Data, <<"\"\"">>]) =/= 2) end, + TriggerCommands). + + +extract_username(FullBuffer, Command) -> + case Command of + Command when Command =:= <<"AUTHENTICATE">>; Command =:= <<"authenticate">> -> + Lines = binary:split(FullBuffer, <<"\r\n">>, [ global ]), + % We can only handle the LOGIN method + lager:info("Lines ~p", [Lines]), + base64:decode(lists:nth(2, Lines)); + <<"LOGIN">> -> + List = binary:split(FullBuffer, <<" ">>, [ global ]), + lists:nth(3, List) + end. diff --git a/apps/kolab_guam/src/rules/kolab_guam_rule_audit.hrl b/apps/kolab_guam/src/rules/kolab_guam_rule_audit.hrl new file mode 100644 index 0000000..5b06816 --- /dev/null +++ b/apps/kolab_guam/src/rules/kolab_guam_rule_audit.hrl @@ -0,0 +1,3 @@ +-record(state, { tag = <<>>, active = false, buffer = <<>>, ip = "", username = <<>>, command = <<>>, + trigger_commands = [<<"LOGIN">>, <<"login">>, <<"AUTHENTICATE">>, <<"authenticate">>]}). + diff --git a/apps/kolab_guam/test/kolab_guam_rule_audit_SUITE.erl b/apps/kolab_guam/test/kolab_guam_rule_audit_SUITE.erl new file mode 100644 index 0000000..229d89e --- /dev/null +++ b/apps/kolab_guam/test/kolab_guam_rule_audit_SUITE.erl @@ -0,0 +1,111 @@ +%% Copyright 2015 Kolab Systems AG (http://www.kolabsys.com) +%% +%% Aaron Seigo (Kolab Systems) +%% +%% This program is free software: you can redistribute it and/or modify +%% it under the terms of the GNU General Public License as published by +%% the Free Software Foundation, either version 3 of the License, or +%% (at your option) any later version. +%% +%% This program is distributed in the hope that it will be useful, +%% but WITHOUT ANY WARRANTY; without even the implied warranty of +%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +%% GNU General Public License for more details. +%% +%% You should have received a copy of the GNU General Public License +%% along with this program. If not, see . + +-module(kolab_guam_rule_audit_SUITE). + +% easier than exporting by name +-compile(export_all). + +% required for common_test to work +-include_lib("common_test/include/ct.hrl"). +-include("../src/rules/kolab_guam_rule_audit.hrl"). + +%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% common test callbacks %% +%%%%%%%%%%%%%%%%%%%%%%%%%%% + +% Specify a list of all unit test functions +all() -> [ + kolab_guam_rule_audit_test + ]. + +% required, but can just return Config. this is a suite level setup function. +init_per_suite(Config) -> + Config. + +% required, but can just return Config. this is a suite level tear down function. +end_per_suite(Config) -> + Config. + +% optional, can do function level setup for all functions, +% or for individual functions by matching on TestCase. +init_per_testcase(_TestCase, Config) -> + Config. + +% optional, can do function level tear down for all functions, +% or for individual functions by matching on TestCase. +end_per_testcase(_TestCase, Config) -> + Config. + +kolab_guam_rule_audit_test(_TestConfig) -> + %% Data to be fed into the test, one tuple per iteration + %% Tuple format: { client_data, server_data, LoginAttemptSucceeded } + Data = [ + { + [<<"a001 LOGIN test1@kolab.org SESAME\r\n">>], + <<"a001 OK LOGIN completed">>, + true + }, + { + [<<"a001 LOGIN test1@kolab.org SESAME\r\n">>], + <<"a001 NO LOGIN completed">>, + false + }, + { + [<<"a001 LOGIN test1@kolab.org SESAME\r\n">>], + <<"a001 BAD LOGIN completed">>, + false + }, + { + [<<"a001 AUTHENTICATE LOGIN\r\n">>, + <<"dGVzdDFAa29sYWIub3Jn\r\n">>, + <<"V2VsY29tZTJLb2xhYlN5c3RlbXM=\r\n">>], + <<"a001 BAD LOGIN completed">>, + false + }, + { + [<<"a001 AUTHENTICATE LOGIN\r\n">>, + <<"dGVzdDFAa29sYWIub3Jn\r\n">>, + <<"V2VsY29tZTJLb2xhYlN5c3RlbXM=\r\n">>], + <<"a001 OK LOGIN completed">>, + true + } + ], + + lager:start(), + lager:set_loglevel(lager_console_backend, debug), + + %% setup boilerplate + Config = {}, + State = kolab_guam_rule_audit:new(Config), + ServerConfig = kolab_guam_sup:default_imap_server_config(), + { ok, ImapSession } = eimap:start_link(ServerConfig), + + %% run the dataset through the rule + lists:foreach( + fun({ ClientDataList, ServerData, LoginAttemptSucceeded }) -> + ReadyState = lists:foldl(fun(ClientData, State) -> + Split = eimap_utils:split_command_into_components(ClientData), + { _, ReadyState } = kolab_guam_rule_audit:apply_to_client_message(ImapSession, ClientData, Split, State), + ReadyState + end, State, ClientDataList), + { _Filtered, NewState } = kolab_guam_rule_audit:apply_to_server_message(ImapSession, ServerData, ReadyState), + lager:info("Result ~p", [NewState]), + #state{ username = <<"test1@kolab.org">> } = NewState + % true = false + end, + Data). diff --git a/apps/kolab_guam/test/kolab_guam_rules_SUITE.erl b/apps/kolab_guam/test/kolab_guam_rules_SUITE.erl index 738b5d3..a9a660e 100644 --- a/apps/kolab_guam/test/kolab_guam_rules_SUITE.erl +++ b/apps/kolab_guam/test/kolab_guam_rules_SUITE.erl @@ -1,213 +1,217 @@ %% Copyright 2015 Kolab Systems AG (http://www.kolabsys.com) %% %% Aaron Seigo (Kolab Systems) %% %% This program is free software: you can redistribute it and/or modify %% it under the terms of the GNU General Public License as published by %% the Free Software Foundation, either version 3 of the License, or %% (at your option) any later version. %% %% This program is distributed in the hope that it will be useful, %% but WITHOUT ANY WARRANTY; without even the implied warranty of %% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the %% GNU General Public License for more details. %% %% You should have received a copy of the GNU General Public License %% along with this program. If not, see . -module(kolab_guam_rules_SUITE). % easier than exporting by name -compile(export_all). % required for common_test to work -include_lib("common_test/include/ct.hrl"). -include("../src/rules/kolab_guam_rule_filter_groupware.hrl"). %%%%%%%%%%%%%%%%%%%%%%%%%%% %% common test callbacks %% %%%%%%%%%%%%%%%%%%%%%%%%%%% % Specify a list of all unit test functions all() -> [ kolab_guam_rule_filter_groupware_responsefiltering_test, kolab_guam_rule_filter_groupware_responsefiltering_multipacket_test ]. % required, but can just return Config. this is a suite level setup function. init_per_suite(Config) -> Config. % required, but can just return Config. this is a suite level tear down function. end_per_suite(Config) -> Config. % optional, can do function level setup for all functions, % or for individual functions by matching on TestCase. init_per_testcase(_TestCase, Config) -> Config. % optional, can do function level tear down for all functions, % or for individual functions by matching on TestCase. end_per_testcase(_TestCase, Config) -> Config. % c("apps/kolab_guam/test/kolab_guam_sup_tests.erl"). eunit:test(kolab_guam_sup_tests). kolab_guam_rule_filter_groupware_responsefiltering_test(_TestConfig) -> %% Data to be fed into the test, one tuple per iteration %% Tuple format: { [] = folder_blacklist, input_data, correct_output } Data = [ { [ {<<"Calendar">>, <<"Calendar/">>}, {<<"Calendar/Personal Calendar">>, <<"Calendar/Personal Calendar/">>}, {<<"Configuration">>, <<"Configuration/">>}, {<<"Contacts">>, <<"Contacts/">>}, {<<"Contacts/Personal Contacts">>, <<"Contacts/Personal Contacts/">>}, {<<"Files">>, <<"Files/">>}, {<<"Journal">>, <<"Journal/">>}, {<<"Notes">>, <<"Notes/">>}, {<<"Tasks">>, <<"Tasks/">>} ], <<"* LIST (\\Noinferiors \\Subscribed) \"/\" INBOX\r\n* LIST (\\Subscribed) \"/\" Archive\r\n* LIST (\\Subscribed \\HasChildren) \"/\" Calendar\r\n* LIST (\\Subscribed) \"/\" \"Calendar/Personal Calendar\"\r\n* LIST (\\Subscribed) \"/\" Configuration\r\n* LIST (\\Subscribed \\HasChildren) \"/\" Contacts\r\n* LIST (\\Subscribed) \"/\" \"Contacts/Personal Contacts\"\r\n* LIST (\\Subscribed) \"/\" Drafts\r\n* LIST (\\Subscribed) \"/\" Files\r\n* LIST (\\Subscribed) \"/\" Journal\r\n* LIST (\\Subscribed) \"/\" Notes\r\n* LIST (\\Subscribed) \"/\" Sent\r\n* LIST (\\Subscribed) \"/\" Spam\r\n* LIST (\\Subscribed) \"/\" Tasks\r\n* LIST (\\Subscribed) \"/\" Trash\r\n7 OK Completed (0.000 secs 15 calls)\r\n">>, <<"* LIST (\\Noinferiors \\Subscribed) \"/\" INBOX\r\n* LIST (\\Subscribed) \"/\" Archive\r\n* LIST (\\Subscribed) \"/\" Drafts\r\n* LIST (\\Subscribed) \"/\" Sent\r\n* LIST (\\Subscribed) \"/\" Spam\r\n* LIST (\\Subscribed) \"/\" Trash\r\n7 OK Completed (0.000 secs 15 calls)\r\n">> } ], %% setup boilerplate - Config = {}, %%TODO? + Config = {}, State = kolab_guam_rule_filter_groupware:new(Config), ServerConfig = kolab_guam_sup:default_imap_server_config(), { ok, ImapSession } = eimap:start_link(ServerConfig), %% create the rule, ready for testing ClientData = <<"7 list (subscribed) \"\" \"*\" return (special-use)">>, Split = eimap_utils:split_command_into_components(ClientData), { _, ReadyState } = kolab_guam_rule_filter_groupware:apply_to_client_message(ImapSession, ClientData, Split, State), %% run the dataset through the rule lists:foreach(fun({ Blacklist, Input, Filtered }) -> BlacklistState = ReadyState#state{ blacklist = Blacklist }, { Filtered, _NewState } = kolab_guam_rule_filter_groupware:apply_to_server_message(ImapSession, Input, BlacklistState) end, Data). kolab_guam_rule_filter_groupware_responsefiltering_multipacket_test(_TestConfig) -> %% Data to be fed into the test, one tuple per iteration %% Tuple format: { [] = folder_blacklist, [] = input_data_packets, correct_output } Data = [ { [ {<<"Calendar">>, <<"Calendar/">>}, {<<"Calendar/Personal Calendar">>, <<"Calendar/Personal Calendar/">>}, {<<"Configuration">>, <<"Configuration/">>}, {<<"Contacts">>, <<"Contacts/">>}, {<<"Contacts/Personal Contacts">>, <<"Contacts/Personal Contacts/">>}, {<<"Files">>, <<"Files/">>}, {<<"Journal">>, <<"Journal/">>}, {<<"Notes">>, <<"Notes/">>}, {<<"Tasks">>, <<"Tasks/">>} ], [ <<"* LIST (\\Noinferiors \\Subscribed) \"/\" INBOX\r\n* LIST (\\Subscribed) \"/\" Archive\r\n* LIST (\\Subscribed \\HasChildren) \"/\" Calendar\r\n* LIST (\\Subscribed) \"/\" \"Calendar/Personal Calendar\"\r\n* LIST (\\Subscribed) \"/\" Configuration\r\n* LIST (\\Subscribed \\HasChildren) \"/\" Contacts\r\n* LIST (\\Subscribed) \"/\" \"Contacts/Personal Contacts\"\r\n* LIST (\\Subscribed) \"/\" Drafts\r\n* LIST (\\Subscribed) \"/\" Files\r\n* LIST (\\Subscribed) \"/\" Journal\r\n* LIST (\\Subscribed)">>, <<" \"/\" Notes\r\n* LIST (\\Subscribed) \"/\" Sent\r\n* LIST (\\Subscribed) \"/\" Spam\r\n* LIST (\\Subscribed) \"/\" Tasks\r\n* LIST (\\Subscribed) \"/\" Trash\r\n7 OK Completed (0.000 secs 15 calls)\r\n">> ], <<"* LIST (\\Noinferiors \\Subscribed) \"/\" INBOX\r\n* LIST (\\Subscribed) \"/\" Archive\r\n* LIST (\\Subscribed) \"/\" Drafts\r\n* LIST (\\Subscribed) \"/\" Sent\r\n* LIST (\\Subscribed) \"/\" Spam\r\n* LIST (\\Subscribed) \"/\" Trash\r\n7 OK Completed (0.000 secs 15 calls)\r\n">> }, %Filter complete packet { [ {<<"Calendar">>, <<"Calendar/">>} ], [ <<"* LIST (\\Subscribed \\HasChildren) \"/\" Calendar\r\n">>, <<"7 OK Completed (0.000 secs 15 calls)\r\n">> ], <<"7 OK Completed (0.000 secs 15 calls)\r\n">> }, %Test folder names with square brackes { [ {<<"[Calendar]">>,<<"[Calendar]/">>} ], [ <<"* LSUB () \".\" INBOX\r\n* LSUB () \".\" \"[Stuff].Sent Mail.Sent\"\r\n* LSUB () \".\" [Calendar]\r\nwgku OK Lsub completed (0.001 + 0.000 secs).\r\n">> ], <<"* LSUB () \".\" INBOX\r\n* LSUB () \".\" \"[Stuff].Sent Mail.Sent\"\r\nwgku OK Lsub completed (0.001 + 0.000 secs).\r\n">> }, %Split CR and LF { [ {<<"Calendar">>, <<"Calendar/">>} ], [ <<"* LIST (\\Noinferiors \\Subscribed) \"/\" INBOX\r">>, <<"\n* LIST (\\Subscribed \\HasChildren) \"/\" Calendar\r">>, <<"\n7 OK Completed (0.000 secs 15 calls)\r\n">> ], <<"* LIST (\\Noinferiors \\Subscribed) \"/\" INBOX\r\n7 OK Completed (0.000 secs 15 calls)\r\n">> }, %Numeric folders { [ ], [ <<"* LIST () \"/\" 2017\r\n">>, <<"7 OK Completed (0.000 secs 15 calls)\r\n">> ], <<"* LIST () \"/\" 2017\r\n7 OK Completed (0.000 secs 15 calls)\r\n">> }, %LIST-EXTENDED + LIST-STATUS response (as used by evolution) { [ {<<"Calendar">>, <<"Calendar/">>}, {<<"Calendar/Calendar2/">>, <<"Calendar/Calendar2/">>} ], [ <<"* LIST (\\Noinferiors \\Subscribed) \"/\" INBOX\r\n">>, <<"* STATUS \"INBOX\" (MESSAGES 17 UNSEEN 16)\r\n">>, <<"* LIST (\\Subscribed \\HasChildren) \"/\" Calendar (CHILDINFO (\"SUBSCRIBED\"))\r\n">>, <<"* STATUS \"Calendar\" (MESSAGES 17 UNSEEN 16)\r\n">>, <<"* LIST (\\Subscribed \\HasNoChildren) \"/\" Calendar/Calendar2\r\n">>, <<"* STATUS \"Calendar/Calendar2\" (MESSAGES 17 UNSEEN 16)\r\n">>, <<"7 OK Completed (0.000 secs 15 calls)\r\n">> ], <<"* LIST (\\Noinferiors \\Subscribed) \"/\" INBOX\r\n* STATUS \"INBOX\" (MESSAGES 17 UNSEEN 16)\r\n7 OK Completed (0.000 secs 15 calls)\r\n">> }, %String literals - %Filtering on string literals is broken, it will attempt to filter on {8}Calendar + %FIXME Filtering on string literals is broken, it will attempt to filter on {8}Calendar + %It doesn't currently matter though, because there's no literals in folder names (not normally anyways) { [ %{<<"Calendar">>, <<"Calendar/">>} ], [ %<<"* LIST (\Subscribed) \"/\" {8}Calendar\r\n">>, <<"* LIST (\Subscribed) \"/\" {6}\r\n">>, <<"Folder\r\n">> ], <<"* LIST (\Subscribed) \"/\" {6}\r\nFolder\r\n">> } ], %% setup boilerplate - Config = {}, %%TODO? + Config = {}, State = kolab_guam_rule_filter_groupware:new(Config), ServerConfig = kolab_guam_sup:default_imap_server_config(), { ok, ImapSession } = eimap:start_link(ServerConfig), %% create the rule, ready for testing ClientData = <<"7 list (subscribed) \"\" \"*\" return (special-use)">>, Split = eimap_utils:split_command_into_components(ClientData), { _, ReadyState } = kolab_guam_rule_filter_groupware:apply_to_client_message(ImapSession, ClientData, Split, State), %% run the dataset through the rule lists:foreach(fun({ Blacklist, Input, Filtered }) -> BlacklistState = ReadyState#state{ blacklist = Blacklist }, Filtered = filter_groupware_packets(ImapSession, BlacklistState, Input, <<>>) end, Data). filter_groupware_packets(_ImapSession, _ReadyState, [], Buffer) -> Buffer; filter_groupware_packets(ImapSession, ReadyState, [Input|More], Buffer) -> { Processed, State } = kolab_guam_rule_filter_groupware:apply_to_server_message(ImapSession, Input, ReadyState), filter_groupware_packets(ImapSession, State, More, <>). + + + diff --git a/test.spec.in b/test.spec.in index 705e689..f4a8e82 100644 --- a/test.spec.in +++ b/test.spec.in @@ -1,4 +1,4 @@ {cover, "cover.spec"}. {logdir, "@PATH@/test_logs"}. {alias, kolab_guam, "@PATH@/apps/kolab_guam"}. -{suites, kolab_guam, [kolab_guam_sup_SUITE, kolab_guam_rules_SUITE]}. +{suites, kolab_guam, [kolab_guam_sup_SUITE, kolab_guam_rules_SUITE, kolab_guam_rule_audit_SUITE]}.