#!/usr/bin/perl # Human: Jim Dunphy # License (ISC): It's yours. Enjoy # 1/22/2025 use strict; use warnings; use Data::Dumper; use Time::Piece; use Getopt::Long; use Term::ANSIColor; use JSON::PP; our $VERSION = "1.0.5"; # Version tracking # Command line options my $log_dir = '/opt/zimbra/log'; my $log_file = "$log_dir/audit.log"; my $target_user = ''; my $list_users = 0; my $format = 'table'; my $help = 0; my $show_version = 0; my $process_all = 0; GetOptions( "file=s" => \$log_file, "dir=s" => \$log_dir, "user=s" => \$target_user, "list" => \$list_users, "format=s" => \$format, "help" => \$help, "version" => \$show_version, "all" => \$process_all ) or die "Error in command line arguments\n"; if ($show_version) { print "Zimbra Audit Log Analyzer version $VERSION\n"; exit; } if ($help) { print "Zimbra Audit Log Analyzer version $VERSION\n\n"; print "Usage: $0 [options]\n"; print "Options:\n"; print " --dir=DIR Specify log directory (default: /opt/zimbra/log)\n"; print " --file=FILE Specify single log file (default: DIR/audit.log)\n"; print " --all Process all audit.log* files in directory\n"; print " --user=EMAIL Show details for specific user\n"; print " --list List all users\n"; print " --format=STR Output format: table, csv, json (default: table)\n"; print " --help Show this help message\n"; print " --version Show version information\n"; exit; } # Data structures to store our analysis my %users; # Store user activity my %devices; # Store device information my %ip_tracking; # Track IP addresses my %auth_methods; # Track authentication methods # Get list of log files to process sub get_log_files { my $dir = shift; my @files; if ($process_all) { # Get all audit log files @files = glob("$dir/audit.log*"); print "Found ", scalar(@files), " audit log files to process.\n"; } else { @files = ($log_file); } return sort @files; } # Process a single log file sub process_log_file { my ($file) = @_; my $errors = 0; print "Processing $file...\n"; # Use zcat -f for both compressed and uncompressed files open(my $fh, '-|', "zcat -f $file 2>/dev/null") or do { warn "Cannot open $file: $!\n"; return 1; }; while (my $line = <$fh>) { chomp $line; # Skip system zimbra authentication lines next if $line =~ /account=zimbra;/; # Extract timestamp (handle case with rsyslog using imfile module also) my ($timestamp) = $line =~ /^(?:\S+\s+\d{1,2} \d{2}:\d{2}:\d{2} \S+ \S+:? )?(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+Z)?)/; next unless $timestamp; # Skip lines without timestamp # Process app-specific password authentications if ($line =~ /successfully logged in with app-specific password/) { my ($account) = $line =~ /account ([^ ]+) successfully/; my ($ip) = $line =~ /oip=([^;]+)/ ? $1 : $line =~ /ip=([^;]+)/ ? $1 : undef; # Prioritize oip next unless ($account && $ip); $users{$account}{app_specific}{last_seen} = $timestamp; $users{$account}{app_specific}{last_ip} = $ip; $users{$account}{ips}{$ip}{last_seen} = $timestamp; $users{$account}{ips}{$ip}{count}++; $auth_methods{$account}{app_specific}++; # This is the counter that needs to be updated } # Process ActiveSync authentications elsif ($line =~ /Microsoft-Server-ActiveSync/) { my ($user) = $line =~ /User=([^&]+)/; my ($device_id) = $line =~ /DeviceId=([^&]+)/; my ($device_type) = $line =~ /DeviceType=([^&]+)/; my ($ip) = $line =~ /oip=([^;]+)/ ? $1 : $line =~ /ip=([^;]+)/ ? $1 : undef; # Prioritize oip my ($account) = $line =~ /account=([^;]+)/; # Skip if we don't have all required fields next unless ($user && $device_id && $device_type && $ip && $account); # Create/update basic device info $users{$account}{active_sync}{last_seen} = $timestamp; $users{$account}{active_sync}{devices}{$device_id} = { device_type => $device_type, model => $device_type, last_ip => $ip, last_seen => $timestamp }; $devices{$device_id}{account} = $account; $devices{$device_id}{type} = $device_type; $devices{$device_id}{last_seen} = $timestamp; $users{$account}{ips}{$ip}{last_seen} = $timestamp; $users{$account}{ips}{$ip}{count}++; } # Process 2FA authentications elsif ($line =~ /two-factor auth successful/) { my ($account) = $line =~ /account=([^;]+)/ ? $1 : $line =~ /name=([^;]+)/ ? $1 : undef; next unless ($account && $account =~ /@/); $users{$account}{security}{auth_type} = "2FA"; $users{$account}{security}{last_2fa} = $timestamp; $auth_methods{$account}{two_factor_auth}++; } # Process trusted device authentications elsif ($line =~ /trusted device verified.*bypassing two-factor auth/) { my ($account) = $line =~ /account=([^;]+)/ ? $1 : $line =~ /name=([^;]+)/ ? $1 : undef; my ($ip) = $line =~ /oip=([^;]+)/ ? $1 : $line =~ /ip=([^;]+)/ ? $1 : undef; # Prioritize oip my ($ua) = $line =~ /ua=([^;]+)/; # Skip if we don't have required fields or if it's the zimbra account next unless ($account && $ip); next if $account eq 'zimbra'; next unless $account =~ /@/; # Skip invalid email addresses $users{$account}{security}{auth_type} = "Trusted Device"; $users{$account}{security}{last_trusted} = $timestamp; $auth_methods{$account}{trusted_device}++; $users{$account}{web_client}{last_seen} = $timestamp; $users{$account}{web_client}{last_ip} = $ip; $users{$account}{web_client}{user_agent} = $ua if $ua; $users{$account}{ips}{$ip}{last_seen} = $timestamp; $users{$account}{ips}{$ip}{count}++; } # Process web client authentications and batch requests elsif ($line =~ /ZimbraWebClient/ || $line =~ /BatchRequest/) { my ($account) = $line =~ /account=([^;]+)/ ? $1 : $line =~ /name=([^;]+)/ ? $1 : undef; my ($ua) = $line =~ /ua=([^;]+)/; # Use oip if available, otherwise fall back to ip # Can happen when zimbraMailTrustedIP isn't set for the proxy my ($ip) = $line =~ /oip=([^;]+)/ ? $1 : $line =~ /ip=([^;]+)/ ? $1 : undef; # Prioritize oip # Skip if we don't have required fields or if it's the zimbra account next unless ($account && $ip); next if $account eq 'zimbra'; next unless $account =~ /@/; # Skip invalid email addresses $users{$account}{web_client}{last_seen} = $timestamp; $users{$account}{web_client}{last_ip} = $ip; $users{$account}{web_client}{user_agent} = $ua if $ua; $users{$account}{ips}{$ip}{last_seen} = $timestamp; $users{$account}{ips}{$ip}{count}++; } # Process POP3/IMAP authentications elsif ($line =~ /protocol=(pop3|imap);/) { my ($account) = $line =~ /account=([^;]+)/; my ($ip) = $line =~ /oip=([^;]+)/ ? $1 : $line =~ /ip=([^;]+)/ ? $1 : undef; # Prioritize oip my ($protocol) = $line =~ /protocol=([^;]+)/; next unless ($account && $ip); next if $account eq 'zimbra'; next unless $account =~ /@/; # Skip invalid email addresses #%%% #print "ip [$ip] protocol [$protocol] time [$timestamp] [$line]\n"; exit; $users{$account}{"${protocol}_client"}{last_seen} = $timestamp; $users{$account}{"${protocol}_client"}{last_ip} = $ip; $users{$account}{ips}{$ip}{last_seen} = $timestamp; $users{$account}{ips}{$ip}{count}++; $auth_methods{$account}{$protocol}++; } } close($fh); return 0; } # Process log files my $total_errors = 0; foreach my $file (get_log_files($log_dir)) { $total_errors += process_log_file($file); } # Report any processing errors if ($total_errors > 0) { print "\nWarning: Encountered problems with $total_errors file(s)\n"; } # Default to --list if no specific action if (!$target_user && !$list_users) { $list_users = 1; } # List all users if requested if ($list_users) { my @json_data; my @table_rows; # Print Header jika formatnya CSV if ($format eq 'csv') { print "Email;Last Seen;Auth Methods;IP Addresses\n"; } foreach my $user (sort keys %users) { my @methods; push @methods, "ActiveSync" if exists $users{$user}{active_sync}; push @methods, "WebClient" if exists $users{$user}{web_client}; push @methods, "AppSpecific" if exists $users{$user}{app_specific}; push @methods, "POP3" if exists $users{$user}{pop3_client}; push @methods, "IMAP" if exists $users{$user}{imap_client}; if (exists $users{$user}{security}) { push @methods, "2FA" if exists $users{$user}{security}{last_2fa}; push @methods, "2FA (Trusted)" if exists $users{$user}{security}{last_trusted}; } my $last_seen = ""; foreach my $type (qw(active_sync web_client app_specific pop3_client imap_client)) { if (exists $users{$user}{$type} && exists $users{$user}{$type}{last_seen}) { $last_seen = $users{$user}{$type}{last_seen} if (!$last_seen || $users{$user}{$type}{last_seen} gt $last_seen); } } my @ips = sort keys %{$users{$user}{ips}}; # --- LOGIKA OUTPUT --- if ($format eq 'json') { push @json_data, { email => $user, last_seen => $last_seen, methods => \@methods, ips => \@ips }; } elsif ($format eq 'csv') { # Kolom dipisah semicolon (;), data internal (methods/ips) dipisah koma (,) # Kita bungkus dengan kutip dua (") untuk menjaga integritas data internal printf("%s;%s;\"%s\";\"%s\"\n", $user, $last_seen, join(', ', @methods), join(', ', @ips) ); } else { # Default: Format Table (Logic asli kamu) my $formatted_ips = ''; for (my $i = 0; $i < @ips; $i++) { $formatted_ips .= $ips[$i]; if ($i < $#ips) { $formatted_ips .= ", "; $formatted_ips .= "\n" if (($i + 1) % 4 == 0); } } push @table_rows, [$user, $last_seen, join(", ", @methods), $formatted_ips]; } } # Cetak hasil akhir if ($format eq 'json') { print JSON::PP->new->ascii->pretty->sort_by(sub { my %order = (email => 1, last_seen => 2, methods => 3, ips => 4); return ($order{$JSON::PP::a} // 99) <=> ($order{$JSON::PP::b} // 99); })->encode(\@json_data); } elsif ($format eq 'table') { # Pastikan @table_rows ada isinya sebelum diprint if (@table_rows) { print format_all_table(['Email', 'Last Seen', 'Auth Methods', 'IP Addresses'], \@table_rows); } else { print "No data found to display in table.\n"; } } exit; } # Show details for specific user if ($target_user) { if (!exists $users{$target_user}) { print "No data found for user: $target_user\n"; exit 1; } print colored(['bold'], "\nUser Details: $target_user\n\n"); # Show POP3 access if (exists $users{$target_user}{pop3_client}) { print colored(['bold'], "POP3 Client Access:\n"); my @rows; my $pop3_info = $users{$target_user}{pop3_client}; push @rows, [ $pop3_info->{last_seen} // 'N/A', $pop3_info->{last_ip} // 'N/A', $pop3_info->{orig_ip} // 'N/A' ]; print format_table(['Last Seen', 'Last IP', 'Original IP'], \@rows); print "\n"; } # Show IMAP access if (exists $users{$target_user}{imap_client}) { print colored(['bold'], "IMAP Client Access:\n"); my @rows; my $imap_info = $users{$target_user}{imap_client}; push @rows, [ $imap_info->{last_seen} // 'N/A', $imap_info->{last_ip} // 'N/A', $imap_info->{orig_ip} // 'N/A' ]; print format_table(['Last Seen', 'Last IP', 'Original IP'], \@rows); print "\n"; } # Show devices if (exists $users{$target_user}{active_sync}) { print colored(['bold'], "ActiveSync Devices:\n"); my @rows; foreach my $device (sort keys %{$users{$target_user}{active_sync}{devices}}) { my $dev_info = $users{$target_user}{active_sync}{devices}{$device}; push @rows, [ $device, $dev_info->{model}, $dev_info->{last_seen}, $dev_info->{last_ip} ]; } print format_table(['Device ID', 'Device Model', 'Last Seen', 'Last IP'], \@rows); print "\n"; } # Show web client access if (exists $users{$target_user}{web_client}) { print colored(['bold'], "Web Client Access:\n"); my @rows; my $web_info = $users{$target_user}{web_client}; push @rows, [ $web_info->{last_seen} // 'N/A', $web_info->{last_ip} // 'N/A', $web_info->{user_agent} // 'N/A' ]; print format_table(['Last Seen', 'Last IP', 'User Agent'], \@rows); print "\n"; } # Show security info if (exists $users{$target_user}{security} || exists $auth_methods{$target_user}) { print colored(['bold'], "Security Info:\n"); my @rows; if (exists $users{$target_user}{security}) { if (exists $users{$target_user}{security}{last_2fa}) { push @rows, ["2FA", $users{$target_user}{security}{last_2fa}, "Last successful 2FA login"]; } if (exists $users{$target_user}{security}{last_trusted}) { push @rows, ["Trusted Device", $users{$target_user}{security}{last_trusted}, "Last trusted device login"]; } } my $two_fa_count = $auth_methods{$target_user}{two_factor_auth} // 0; my $trusted_count = $auth_methods{$target_user}{trusted_device} // 0; push @rows, ["2FA Logins", $two_fa_count, "Total 2FA authentications"]; push @rows, ["Trusted Device Logins", $trusted_count, "Total trusted device authentications"]; print format_table(['Type', 'Value/Time', 'Notes'], \@rows) if @rows; print "\n"; } # Show IP history if (exists $users{$target_user}{ips}) { print colored(['bold'], "IP Address History:\n"); my @rows; foreach my $ip (sort keys %{$users{$target_user}{ips}}) { push @rows, [ $ip, $users{$target_user}{ips}{$ip}{last_seen} // 'N/A', $users{$target_user}{ips}{$ip}{count} // 0 ]; } print format_table(['IP Address', 'Last Seen', 'Access Count'], \@rows); print "\n"; } # Show authentication methods if (exists $auth_methods{$target_user}) { print colored(['bold'], "Authentication Summary:\n"); my @rows; push @rows, ["Two-Factor Auth", $auth_methods{$target_user}{two_factor_auth} // 0]; push @rows, ["Trusted Device", $auth_methods{$target_user}{trusted_device} // 0]; push @rows, ["App-Specific Password", $auth_methods{$target_user}{app_specific} // 0]; push @rows, ["POP3 Access", $auth_methods{$target_user}{pop3} // 0]; push @rows, ["IMAP Access", $auth_methods{$target_user}{imap} // 0]; print format_table(['Method', 'Count'], \@rows); print "\n"; } } # Table formatting function sub format_all_table { my ($headers, $rows) = @_; my @col_widths; # Calculate column widths for my $col (0..$#$headers) { # Special handling for IP address column if ($col == 3) { # IP Addresses column $col_widths[$col] = (15 * 4) + (2 * 3) + 2; # 4 IPs * 15 chars + 3 separators * 2 chars + padding } else { # Get substring up to the first newline, if present my $header_value = $headers->[$col] =~ /\n/ ? (split(/\n/, $headers->[$col]))[0] : $headers->[$col]; my $max_width = length($header_value); for my $row (@$rows) { my $len_value = $row->[$col] =~ /\n/ ? (split(/\n/, $row->[$col]))[0] : $row->[$col]; my $len = length($len_value); $max_width = $len if $len > $max_width; } $col_widths[$col] = $max_width + 2; # Add padding } } # Calculate IP column start position (sum of previous column widths plus separators) my $ip_column_start = 1; # Start after first | for my $i (0..2) { # Add widths of first three columns $ip_column_start += $col_widths[$i] + 1; # +1 for each separator } # Format header my $separator = '+' . join('+', map {'-' x $_} @col_widths) . '+'; my $output = $separator . "\n"; $output .= '|' . join('|', map {sprintf("%-*s", $col_widths[$_], " $headers->[$_]")} 0..$#$headers) . "|\n"; $output .= $separator . "\n"; # Format rows with proper multi-line handling for my $row (@$rows) { my @formatted_columns = (); # Handle first three columns normally for my $col (0..2) { my $cell_value = $row->[$col] // ''; push @formatted_columns, sprintf("%-*s", $col_widths[$col], " $cell_value"); } # Special handling for IP address column my $ip_value = $row->[3] // ''; my @ip_lines = split(/\n/, $ip_value); # Format first line of IPs push @formatted_columns, sprintf("%-*s", $col_widths[3], " $ip_lines[0]"); # Output the first line $output .= '|' . join('|', @formatted_columns) . "|\n"; # Output continuation lines for IPs if they exist for my $i (1..$#ip_lines) { $output .= '|' . (' ' x ($ip_column_start - 1)) . sprintf("%-*s", $col_widths[3], " $ip_lines[$i]") . "|\n"; } } $output .= $separator . "\n"; return $output; } # Table formatting function # Table formatting function sub format_table { my ($headers, $rows) = @_; my @col_widths; # Calculate column widths for my $col (0..$#$headers) { my $max_width = length($headers->[$col]); for my $row (@$rows) { my $len = length($row->[$col] // ''); $max_width = $len if $len > $max_width; } $col_widths[$col] = $max_width + 2; # Add padding } # Format header my $separator = '+' . join('+', map {'-' x $_} @col_widths) . '+'; my $output = $separator . "\n"; $output .= '|' . join('|', map {sprintf("%-*s", $col_widths[$_], " $headers->[$_]")} 0..$#$headers) . "|\n"; $output .= $separator . "\n"; # Format rows for my $row (@$rows) { $output .= '|' . join('|', map {sprintf("%-*s", $col_widths[$_], " " . ($row->[$_] // ''))} 0..$#$headers) . "|\n"; } $output .= $separator . "\n"; return $output; }