#!/usr/bin/perl -I/usr/local/teamfile/tfsplugin/lib/perl
#
# Operation log audit report builder.
#
use strict;
use utf8;
use Getopt::Long;
use File::Basename qw(dirname);
use File::Path qw(make_path);
use File::Spec;
use Time::Local;
use Time::HiRes qw(time);
use TF::OplogIndex;

require "/usr/local/teamfile/www/bin/mt-lib.pl";

my $AUDIT_ROOT = '/var/lib/teamfile/audit';
my $CONFIG_FILE = $AUDIT_ROOT . '/oplogauditor.conf';
my $LOCK_FILE = getLogPath() . '/oplogauditor.pid';
my $LOG_FILE  = getJobsWorkPath() . '/oplogauditor.log';

my $F_LOG = undef;
my $opt_h = undef;
my $opt_v = undef;
my $opt_f = undef;
my $opt_hour = undef;
my $opt_min = undef;
my $opt_target_date = undef;
my $opt_location = undef;
my $opt_days = undef;

my $DEFAULT_HOUR = 3;
my $DEFAULT_MIN = 45;
my $DEFAULT_DAYS = 7;
my $DETAIL_LIMIT = 20;
my $AUDIT_OWNER = 'teamfile';

my $DAILY_IP_WARN = 3;
my $PERIOD_IP_CAUTION = 3;
my $PERIOD_IP_WARN = 5;
my $IP_DRIFT_MIN_OPS = 5;
my $BULK_PUT_CAUTION = 200;
my $BULK_PUT_WARN = 1000;
my $BULK_DELETE_WARN = 100;
my $BULK_DELETE_CRITICAL = 300;
my $OFFHOURS_OPS_CAUTION = 50;
my $FAILED_STATUS_WARN = 10;
my $BULK_GET_WARN = 500;
my $BULK_GET_CRITICAL = 2000;
my $BULK_GET_BYTES_WARN = 50 * 1024 * 1024 * 1024;
my $BULK_GET_BYTES_CRITICAL = 200 * 1024 * 1024 * 1024;
my $BULK_GET_BASELINE_DAYS = 90;
my $BULK_GET_BASELINE_MIN_ACTIVE_DAYS = 5;
my $BULK_GET_BASELINE_FACTOR = 3;
my $BULK_GET_BASELINE_COUNT_FLOOR = 20;
my $BULK_GET_BASELINE_BYTES_FLOOR = 1024 * 1024 * 1024;
my $DENIED_WARN = 20;
my $DENIED_TARGET_WARN = 5;
my $WEBDAV_METHOD_WARN = 50;
my $MANAGEMENT_CHANGE_WARN = 20;

my %IP_DRIFT_EXCLUDED_USERS = (
	'._guest_' => 1,
	'._sysexec_' => 1,
);

my $ret = GetOptions(
	"h" => \$opt_h,
	"v" => \$opt_v,
	"f" => \$opt_f,
	"hour=i" => \$opt_hour,
	"min=i" => \$opt_min,
	"target-date=s" => \$opt_target_date,
	"location=s" => \$opt_location,
	"days=i" => \$opt_days,
);
if (!$ret || $opt_h) {
	usage();
	exit($ret ? 0 : 1);
}
chdir('/');

exit(main());

sub main {
	ensure_audit_config();
	my %conf = read_audit_config();
	my $hour = defined($opt_hour) ? $opt_hour : ($conf{hour} || $DEFAULT_HOUR);
	my $min = defined($opt_min) ? $opt_min : ($conf{min} || $DEFAULT_MIN);
	my $target_date = normalize_target_date($opt_target_date || $conf{target_date} || yesterday_ymd());
	my $days = defined($opt_days) ? $opt_days : ($conf{days} || $DEFAULT_DAYS);
	$days = $DEFAULT_DAYS if $days !~ /^\d+$/ || $days < 1;
	$BULK_GET_WARN = positive_setting($conf{bulk_get_warn}, $BULK_GET_WARN);
	$BULK_GET_CRITICAL = positive_setting($conf{bulk_get_critical}, $BULK_GET_CRITICAL);
	$BULK_GET_BYTES_WARN = positive_setting($conf{bulk_get_bytes_warn}, $BULK_GET_BYTES_WARN);
	$BULK_GET_BYTES_CRITICAL = positive_setting($conf{bulk_get_bytes_critical}, $BULK_GET_BYTES_CRITICAL);
	$BULK_GET_BASELINE_DAYS = positive_setting($conf{bulk_get_baseline_days}, $BULK_GET_BASELINE_DAYS);
	$BULK_GET_BASELINE_MIN_ACTIVE_DAYS = positive_setting($conf{bulk_get_baseline_min_active_days}, $BULK_GET_BASELINE_MIN_ACTIVE_DAYS);
	my $now = time;

	if (!$opt_f && !isItTimeToRunJob($now, $hour, $min)) {
		printerr("Skipped. schedule=${hour}:${min}\n");
		return 0;
	}

	my $cRetry = 0;
	while (!isRunning(getApachePidPath())) {
		sleep 1;
		if ($cRetry++ >= 30) {
			printerr("Apache had been stopped. we give up execution of operation log auditor.\n");
			return 1;
		}
	}

	if ((my $pid = lockScript($LOCK_FILE)) != 0) {
		printerr("This script had been already started by $pid.\n");
		return 1;
	}

	initLog();
	ensure_audit_dir($AUDIT_ROOT);

	my %gparams = readTfGlobalSettings();
	my @lcfiles = getLocationConflist();
	my $ok_count = 0;
	my $fail_count = 0;

	for my $lcfile (@lcfiles) {
		my %lcparams = parseLocationConf($lcfile, \%gparams);
		next if !%lcparams || !$lcparams{Location};
		next if $opt_location && $opt_location ne $lcparams{Location};

		my $ok = audit_location(\%lcparams, $target_date, $days);
		if ($ok) {
			$ok_count++;
		}
		else {
			$fail_count++;
		}
	}

	writeLog("Finished operation log auditor. ok=$ok_count failed=$fail_count target_date=$target_date days=$days");
	closeLog();
	unlockScript($LOCK_FILE);
	return $fail_count ? 1 : 0;
}

sub audit_location {
	my ($lcparams_ref, $target_date, $days) = @_;
	my %lcparams = %{$lcparams_ref || {}};
	my $location = $lcparams{Location} || '';
	my $started_at = time;

	if (!$location) {
		writeLog("Skipped location because location name is missing.");
		return 0;
	}

	writeLog("Start operation log audit. location=$location target_date=$target_date days=$days");
	my $data_ref = eval { collect_oplog_data($location, $target_date, $days) };
	if ($@) {
		my $err = $@;
		chomp($err);
		write_error($location, "Operation log audit failed. $err");
		return 0;
	}

	my %summary = %{$data_ref->{summary} || {}};
	my %details = %{$data_ref->{details} || {}};
	my @issues;

	if ($summary{daily_ip_drift_users} > 0) {
		push @issues, issue_item('warn', 'oplog_daily_ip_drift', $summary{daily_ip_drift_users} . ' users used ' . $DAILY_IP_WARN . '+ source IPs in one day.');
	}
	elsif ($summary{period_ip_drift_warn_users} > 0) {
		push @issues, issue_item('warn', 'oplog_period_ip_drift_warn', $summary{period_ip_drift_warn_users} . ' users used ' . $PERIOD_IP_WARN . '+ source IPs in the period.');
	}
	elsif ($summary{period_ip_drift_caution_users} > 0) {
		push @issues, issue_item('caution', 'oplog_period_ip_drift_caution', $summary{period_ip_drift_caution_users} . ' users used ' . $PERIOD_IP_CAUTION . '+ source IPs in the period.');
	}
	if ($summary{bulk_put_warn_groups} > 0) {
		push @issues, issue_item('warn', 'oplog_bulk_put', $summary{bulk_put_warn_groups} . ' group days had many PUT operations.');
	}
	elsif ($summary{bulk_put_caution_groups} > 0) {
		push @issues, issue_item('caution', 'oplog_bulk_put', $summary{bulk_put_caution_groups} . ' group days had elevated PUT operations.');
	}
	if ($summary{bulk_delete_critical_groups} > 0) {
		push @issues, issue_item('critical', 'oplog_bulk_delete_critical', $summary{bulk_delete_critical_groups} . ' group days had very many DELETE operations.');
	}
	elsif ($summary{bulk_delete_warn_groups} > 0) {
		push @issues, issue_item('warn', 'oplog_bulk_delete', $summary{bulk_delete_warn_groups} . ' group days had many DELETE operations.');
	}
	if ($summary{offhours_groups} > 0) {
		push @issues, issue_item('caution', 'oplog_offhours_activity', $summary{offhours_groups} . ' group days had off-hours write/delete activity.');
	}
	if ($summary{failed_status_groups} > 0) {
		push @issues, issue_item('warn', 'oplog_failed_status', $summary{failed_status_groups} . ' group days had many failed statuses.');
	}
	if ($summary{bulk_get_critical_users} > 0) {
		push @issues, issue_item('critical', 'oplog_bulk_get_critical', $summary{bulk_get_critical_users} . ' user days had very large downloads.');
	}
	elsif ($summary{bulk_get_warn_users} > 0) {
		push @issues, issue_item('warn', 'oplog_bulk_get', $summary{bulk_get_warn_users} . ' user days had elevated downloads.');
	}
	if ($summary{denied_users} > 0 || $summary{denied_ips} > 0) {
		push @issues, issue_item('warn', 'oplog_denied_probe', ($summary{denied_users} || 0) . ' users and ' . ($summary{denied_ips} || 0) . ' source IPs had repeated denied requests.');
	}
	if ($summary{webdav_method_users} > 0) {
		push @issues, issue_item('caution', 'oplog_webdav_method', $summary{webdav_method_users} . ' user days had concentrated WebDAV methods.');
	}
	if ($summary{management_change_users} > 0) {
		push @issues, issue_item('caution', 'oplog_management_change', $summary{management_change_users} . ' user days had many management changes.');
	}
	if ($summary{missing_index_days} > 0) {
		push @issues, issue_item('warn', 'oplog_missing_index', $summary{missing_index_days} . ' access log days were not indexed.');
	}
	if (!@issues) {
		push @issues, issue_item('ok', 'no_problem', 'No major findings.');
	}

	my $elapsed_ms = int((time - $started_at) * 1000);
	my $result = {
		version => 1,
		type => 'oplog-audit',
		location => $location,
		generated_at => iso8601_now(),
		target_date => $target_date,
		period_days => $days,
		elapsed_ms => $elapsed_ms,
		status => 'ok',
		rules => {
			daily_ip_warn => $DAILY_IP_WARN,
			period_ip_caution => $PERIOD_IP_CAUTION,
			period_ip_warn => $PERIOD_IP_WARN,
			ip_drift_min_ops => $IP_DRIFT_MIN_OPS,
			bulk_put_caution => $BULK_PUT_CAUTION,
			bulk_put_warn => $BULK_PUT_WARN,
			bulk_delete_warn => $BULK_DELETE_WARN,
			bulk_delete_critical => $BULK_DELETE_CRITICAL,
			offhours_ops_caution => $OFFHOURS_OPS_CAUTION,
			failed_status_warn => $FAILED_STATUS_WARN,
			bulk_get_warn => $BULK_GET_WARN,
			bulk_get_critical => $BULK_GET_CRITICAL,
			bulk_get_bytes_warn => $BULK_GET_BYTES_WARN,
			bulk_get_bytes_critical => $BULK_GET_BYTES_CRITICAL,
			bulk_get_baseline_days => $BULK_GET_BASELINE_DAYS,
			bulk_get_baseline_min_active_days => $BULK_GET_BASELINE_MIN_ACTIVE_DAYS,
			bulk_get_baseline_factor => $BULK_GET_BASELINE_FACTOR,
			bulk_get_baseline_count_floor => $BULK_GET_BASELINE_COUNT_FLOOR,
			bulk_get_baseline_bytes_floor => $BULK_GET_BASELINE_BYTES_FLOOR,
			denied_warn => $DENIED_WARN,
			denied_target_warn => $DENIED_TARGET_WARN,
			webdav_method_warn => $WEBDAV_METHOD_WARN,
			management_change_warn => $MANAGEMENT_CHANGE_WARN,
		},
		summary => \%summary,
		issues => \@issues,
		details => \%details,
	};

	write_result($location, $result);
	writeLog("Finished operation log audit. location=$location rows=" . ($summary{total_rows} || 0) . " elapsed_ms=$elapsed_ms");
	return 1;
}

sub collect_oplog_data {
	my ($location, $target_date, $days) = @_;
	my @dates = audit_dates($target_date, $days);
	my $index_root = TF::OplogIndex::index_root();

	my %period_user_ips;
	my %period_user_ops;
	my %daily_user_ips;
	my %daily_user_ops;
	my %group_day_method;
	my %group_day_status_failed;
	my %group_day_offhours;
	my %groups;
	my $total_rows = 0;
	my $indexed_days = 0;

	for my $ymd (@dates) {
		my $dir = File::Spec->catdir($index_root, $ymd, $location);
		next if !-d $dir;
		$indexed_days++;
		if (opendir(my $dh, $dir)) {
			for my $entry (readdir($dh)) {
				next if $entry !~ /\.tsv$/;
				my $path = File::Spec->catfile($dir, $entry);
				my @rows = TF::OplogIndex::read_group_file($path);
				for my $row_ref (@rows) {
					$total_rows++;
					my $user = $row_ref->{user} || '';
					my $ip = $row_ref->{ip} || '';
					my $method = $row_ref->{method} || '';
					my $status = $row_ref->{status} || 0;
					my $folder = $row_ref->{folder} || '';
					my $group = $folder;
					$group =~ s!/.*!!;
					$groups{$group} = 1 if $group ne '';

					if ($user ne '' && $ip ne '' && !$IP_DRIFT_EXCLUDED_USERS{$user}) {
						$period_user_ips{$user}{$ip} = 1;
						$period_user_ops{$user}++;
						$daily_user_ips{$ymd}{$user}{$ip} = 1;
						$daily_user_ops{$ymd}{$user}++;
					}

					$group_day_method{$ymd}{$group}{$method}++ if $group ne '' && $method ne '';
					$group_day_status_failed{$ymd}{$group}++ if $group ne '' && $status >= 400;
					$group_day_offhours{$ymd}{$group}++ if $group ne '' && ($method eq 'PUT' || $method eq 'DELETE') && is_offhours($row_ref->{time} || '');
				}
			}
			closedir($dh);
		}
	}

	my @period_ip_drift;
	for my $user (sort keys %period_user_ips) {
		my $ops = $period_user_ops{$user} || 0;
		next if $ops < $IP_DRIFT_MIN_OPS;
		my @ips = sort keys %{$period_user_ips{$user}};
		next if @ips < $PERIOD_IP_CAUTION;
		push @period_ip_drift, {
			user => $user,
			ip_count => scalar(@ips),
			operation_count => $ops,
			ips => join(', ', @ips),
			level => scalar(@ips) >= $PERIOD_IP_WARN ? 'warn' : 'caution',
		};
	}

	my @daily_ip_drift;
	for my $ymd (sort keys %daily_user_ips) {
		for my $user (sort keys %{$daily_user_ips{$ymd}}) {
			my $ops = $daily_user_ops{$ymd}{$user} || 0;
			next if $ops < $IP_DRIFT_MIN_OPS;
			my @ips = sort keys %{$daily_user_ips{$ymd}{$user}};
			next if @ips < $DAILY_IP_WARN;
			push @daily_ip_drift, {
				date => ymd_compact_to_dash($ymd),
				user => $user,
				ip_count => scalar(@ips),
				operation_count => $ops,
				ips => join(', ', @ips),
			};
		}
	}

	my (@bulk_put, @bulk_delete, @offhours, @failed_status);
	for my $ymd (sort keys %group_day_method) {
		for my $group (sort keys %{$group_day_method{$ymd}}) {
			my $put = $group_day_method{$ymd}{$group}{PUT} || 0;
			my $delete = $group_day_method{$ymd}{$group}{DELETE} || 0;
			if ($put >= $BULK_PUT_CAUTION) {
				push @bulk_put, {
					date => ymd_compact_to_dash($ymd),
					group => $group,
					count => $put,
					level => $put >= $BULK_PUT_WARN ? 'warn' : 'caution',
				};
			}
			if ($delete >= $BULK_DELETE_WARN) {
				push @bulk_delete, {
					date => ymd_compact_to_dash($ymd),
					group => $group,
					count => $delete,
					level => $delete >= $BULK_DELETE_CRITICAL ? 'critical' : 'warn',
				};
			}
		}
	}
	for my $ymd (sort keys %group_day_offhours) {
		for my $group (sort keys %{$group_day_offhours{$ymd}}) {
			my $count = $group_day_offhours{$ymd}{$group} || 0;
			next if $count < $OFFHOURS_OPS_CAUTION;
			push @offhours, {
				date => ymd_compact_to_dash($ymd),
				group => $group,
				count => $count,
			};
		}
	}
	for my $ymd (sort keys %group_day_status_failed) {
		for my $group (sort keys %{$group_day_status_failed{$ymd}}) {
			my $count = $group_day_status_failed{$ymd}{$group} || 0;
			next if $count < $FAILED_STATUS_WARN;
			push @failed_status, {
				date => ymd_compact_to_dash($ymd),
				group => $group,
				count => $count,
			};
		}
	}

	@period_ip_drift = sort { $b->{ip_count} <=> $a->{ip_count} || $b->{operation_count} <=> $a->{operation_count} } @period_ip_drift;
	@daily_ip_drift = sort { $b->{ip_count} <=> $a->{ip_count} || $b->{operation_count} <=> $a->{operation_count} } @daily_ip_drift;
	@bulk_put = sort { $b->{count} <=> $a->{count} } @bulk_put;
	@bulk_delete = sort { $b->{count} <=> $a->{count} } @bulk_delete;
	@offhours = sort { $b->{count} <=> $a->{count} } @offhours;
	@failed_status = sort { $b->{count} <=> $a->{count} } @failed_status;

	my $period_warn = scalar(grep { $_->{level} eq 'warn' } @period_ip_drift);
	my $period_caution = scalar(@period_ip_drift) - $period_warn;
	my $bulk_put_warn = scalar(grep { $_->{level} eq 'warn' } @bulk_put);
	my $bulk_put_caution = scalar(@bulk_put) - $bulk_put_warn;
	my $bulk_delete_critical = scalar(grep { $_->{level} eq 'critical' } @bulk_delete);
	my $bulk_delete_warn = scalar(@bulk_delete) - $bulk_delete_critical;

	my $raw_ref = collect_access_security($location, \@dates, $index_root);
	my %raw_summary = %{$raw_ref->{summary} || {}};
	my %raw_details = %{$raw_ref->{details} || {}};

	return {
		summary => {
			target_days => $days,
			indexed_days => $indexed_days,
			total_rows => $total_rows,
			group_count => scalar(keys %groups),
			period_ip_drift_warn_users => $period_warn,
			period_ip_drift_caution_users => $period_caution,
			daily_ip_drift_users => scalar(@daily_ip_drift),
			bulk_put_warn_groups => $bulk_put_warn,
			bulk_put_caution_groups => $bulk_put_caution,
			bulk_delete_critical_groups => $bulk_delete_critical,
			bulk_delete_warn_groups => $bulk_delete_warn,
			offhours_groups => scalar(@offhours),
			failed_status_groups => scalar(@failed_status),
			%raw_summary,
		},
		details => {
			period_ip_drift => limit_rows(\@period_ip_drift),
			daily_ip_drift => limit_rows(\@daily_ip_drift),
			bulk_put => limit_rows(\@bulk_put),
			bulk_delete => limit_rows(\@bulk_delete),
			offhours => limit_rows(\@offhours),
			failed_status => limit_rows(\@failed_status),
			%raw_details,
		},
	};
}

sub collect_access_security {
	my ($location, $dates_ref, $index_root) = @_;
	my (%downloads, %denied_users, %denied_ips, %webdav_methods, %management_changes);
	my @missing_index;
	my $log_root = dirname($index_root);
	my $location_prefix = '/' . TF::OplogIndex::normalize_location($location);
	$location_prefix =~ s{//+}{/}g;
	my $baseline_ref = collect_download_baseline($location_prefix, $dates_ref, $index_root);

	for my $ymd (@{$dates_ref || []}) {
		my $access_log = File::Spec->catfile($log_root, 'access_log.' . $ymd);
		next if !-f $access_log;
		my $complete = File::Spec->catfile($index_root, $ymd, '.complete');
		push @missing_index, { date => ymd_compact_to_dash($ymd) } if !-f $complete;

		open(my $fh, '<', $access_log) or next;
		while (my $line = <$fh>) {
			my $row = parse_security_access_line($line, $location_prefix);
			next if !$row;
			my $user = $row->{user} || '-';
			my $ip = $row->{ip} || '-';
			my $status = $row->{status} || 0;
			my $method = $row->{method} || '';

			if ($method eq 'GET' && $status >= 200 && $status < 400 && is_file_download_uri($row->{uri})) {
				$downloads{$ymd}{$user}{count}++;
				$downloads{$ymd}{$user}{bytes} += $row->{bytes} || 0;
			}
			if ($status == 401 || $status == 403 || $status == 404) {
				my $target = security_target_class($row->{uri});
				my $resource = $row->{uri};
				$resource =~ s/\?.*$//;
				$denied_users{$user}{count}++;
				$denied_users{$user}{ips}{$ip} = 1;
				$denied_users{$user}{classes}{$target} = 1;
				$denied_users{$user}{resources}{$resource} = 1
					if scalar(keys %{$denied_users{$user}{resources} || {}}) < $DENIED_TARGET_WARN;
				$denied_ips{$ip}{count}++;
				$denied_ips{$ip}{users}{$user} = 1;
				$denied_ips{$ip}{classes}{$target} = 1;
				$denied_ips{$ip}{resources}{$resource} = 1
					if scalar(keys %{$denied_ips{$ip}{resources} || {}}) < $DENIED_TARGET_WARN;
			}
			if ($method =~ /^(?:PROPFIND|PROPPATCH|MOVE|COPY|LOCK|UNLOCK|SEARCH|MKCOL)$/) {
				$webdav_methods{$ymd}{$user}{$method}++;
			}
			if ($row->{uri} =~ m{/\.management/} && $method =~ /^(?:PUT|DELETE|PROPPATCH|MOVE|COPY|MKCOL)$/) {
				$management_changes{$ymd}{$user}{$method}++;
			}
		}
		close($fh);
	}

	my @bulk_get;
	for my $ymd (sort keys %downloads) {
		for my $user (sort keys %{$downloads{$ymd}}) {
			my $count = $downloads{$ymd}{$user}{count} || 0;
			my $bytes = $downloads{$ymd}{$user}{bytes} || 0;
			my $baseline = $baseline_ref->{$user};
			my $absolute_warn = $count >= $BULK_GET_WARN || $bytes >= $BULK_GET_BYTES_WARN;
			my $baseline_warn = $baseline &&
				($count >= $baseline->{count_threshold} || $bytes >= $baseline->{bytes_threshold});
			next if !$absolute_warn && !$baseline_warn;
			my $critical = $count >= $BULK_GET_CRITICAL || $bytes >= $BULK_GET_BYTES_CRITICAL;
			push @bulk_get, {
				date => ymd_compact_to_dash($ymd), user => $user, count => $count, bytes => $bytes,
				level => $critical ? 'critical' : 'warn',
				reason => $critical ? 'absolute_critical' : ($absolute_warn ? 'absolute_warn' : 'user_baseline'),
				baseline_active_days => $baseline ? $baseline->{active_days} : 0,
				baseline_count_threshold => $baseline ? $baseline->{count_threshold} : 0,
				baseline_bytes_threshold => $baseline ? $baseline->{bytes_threshold} : 0,
			};
		}
	}
	my @denied_user_rows = denied_rows(\%denied_users, 'ips');
	my @denied_ip_rows = denied_rows(\%denied_ips, 'users');
	my @webdav_rows = method_rows(\%webdav_methods, $WEBDAV_METHOD_WARN);
	my @management_rows = method_rows(\%management_changes, $MANAGEMENT_CHANGE_WARN);
	@bulk_get = sort { $b->{bytes} <=> $a->{bytes} || $b->{count} <=> $a->{count} } @bulk_get;

	return {
			summary => {
			baseline_users => scalar(keys %{$baseline_ref}),
			bulk_get_critical_users => scalar(grep { $_->{level} eq 'critical' } @bulk_get),
			bulk_get_warn_users => scalar(grep { $_->{level} eq 'warn' } @bulk_get),
			denied_users => scalar(@denied_user_rows),
			denied_ips => scalar(@denied_ip_rows),
			webdav_method_users => scalar(@webdav_rows),
			management_change_users => scalar(@management_rows),
			missing_index_days => scalar(@missing_index),
		},
		details => {
			bulk_get => limit_rows(\@bulk_get),
			denied_users => limit_rows(\@denied_user_rows),
			denied_ips => limit_rows(\@denied_ip_rows),
			webdav_methods => limit_rows(\@webdav_rows),
			management_changes => limit_rows(\@management_rows),
			missing_indexes => limit_rows(\@missing_index),
		},
	};
}

sub collect_download_baseline {
	my ($location_prefix, $audit_dates_ref, $index_root) = @_;
	return {} if !@{$audit_dates_ref || []};
	my $first_epoch = ymd_compact_to_epoch_start($audit_dates_ref->[0]);
	my $log_root = dirname($index_root);
	my %daily;

	for (my $i = $BULK_GET_BASELINE_DAYS; $i >= 1; $i--) {
		my $ymd = epoch_to_ymd_compact($first_epoch - ($i * 24 * 3600));
		my $path = File::Spec->catfile($log_root, 'access_log.' . $ymd);
		next if !-f $path;
		open(my $fh, '<', $path) or next;
		while (my $line = <$fh>) {
			my $row = parse_security_access_line($line, $location_prefix);
			next if !$row || $row->{method} ne 'GET' || $row->{status} < 200 || $row->{status} >= 400;
			next if !is_file_download_uri($row->{uri});
			my $user = $row->{user} || '-';
			$daily{$user}{$ymd}{count}++;
			$daily{$user}{$ymd}{bytes} += $row->{bytes} || 0;
		}
		close($fh);
	}

	my %result;
	for my $user (keys %daily) {
		my @days = values %{$daily{$user}};
		next if @days < $BULK_GET_BASELINE_MIN_ACTIVE_DAYS;
		my @counts = sort { $a <=> $b } map { $_->{count} || 0 } @days;
		my @bytes = sort { $a <=> $b } map { $_->{bytes} || 0 } @days;
		my $count_threshold = int(percentile95(\@counts) * $BULK_GET_BASELINE_FACTOR + 0.999);
		my $bytes_threshold = int(percentile95(\@bytes) * $BULK_GET_BASELINE_FACTOR + 0.999);
		$count_threshold = $BULK_GET_BASELINE_COUNT_FLOOR if $count_threshold < $BULK_GET_BASELINE_COUNT_FLOOR;
		$bytes_threshold = $BULK_GET_BASELINE_BYTES_FLOOR if $bytes_threshold < $BULK_GET_BASELINE_BYTES_FLOOR;
		$result{$user} = {
			active_days => scalar(@days),
			count_threshold => $count_threshold,
			bytes_threshold => $bytes_threshold,
		};
	}
	return \%result;
}

sub percentile95 {
	my $values_ref = shift || [];
	return 0 if !@{$values_ref};
	my $index = int((scalar(@{$values_ref}) * 95 + 99) / 100) - 1;
	return $values_ref->[$index];
}

sub parse_security_access_line {
	my ($line, $location_prefix) = @_;
	return undef if $line !~ /^(\S+)\s+-\s+(\S+)\s+\[[^\]]+\]\s+"([A-Z]+)\s+(\S+)\s+HTTP\/[^\"]+"\s+(\d{3})\s+([\d-]+)/;
	my ($ip, $user, $method, $uri, $status, $bytes) = ($1, $2, $3, $4, $5, $6);
	return undef if index($uri, $location_prefix) != 0;
	return undef if length($uri) > length($location_prefix) && substr($uri, length($location_prefix), 1) ne '/';
	if ($method eq 'POST' && $uri =~ /(?:\?|&)WEBDAV_METHOD=([A-Z]+)/) {
		$method = $1;
	}
	return { ip => $ip, user => $user, method => $method, uri => $uri, status => int($status), bytes => $bytes eq '-' ? 0 : int($bytes) };
}

sub is_file_download_uri {
	my $uri = shift || '';
	return 0 if $uri =~ /(?:\?|&)WEBDAV_METHOD=/;
	my ($path) = split(/\?/, $uri, 2);
	return 0 if $path =~ m{/$};
	return 0 if $path =~ m{/\.cgi-bin/|/\.management(?:/|$)|/Group%20Folder/?$};
	return 0 if $path =~ m{/\.box-receive/[^/]+/submission-record_[^/]+\.txt$};
	return 1;
}

sub security_target_class {
	my $uri = shift || '';
	return 'management' if $uri =~ m{/\.management(?:/|$)};
	return 'group' if $uri =~ m{/Group%20Folder(?:/|$)};
	return 'cgi' if $uri =~ m{/\.cgi-bin(?:/|$)};
	return 'private';
}

sub denied_rows {
	my ($source_ref, $peer_key) = @_;
	my @rows;
	for my $key (sort keys %{$source_ref || {}}) {
		my $item = $source_ref->{$key};
		my @peers = sort keys %{$item->{$peer_key} || {}};
		my @classes = sort keys %{$item->{classes} || {}};
		my $target_count = scalar(keys %{$item->{resources} || {}});
		next if ($item->{count} || 0) < $DENIED_WARN || $target_count < $DENIED_TARGET_WARN;
		push @rows, { subject => $key, count => $item->{count} || 0, peer_count => scalar(@peers), target_count => $target_count, targets => join(', ', @classes) };
	}
	return sort { $b->{count} <=> $a->{count} } @rows;
}

sub method_rows {
	my ($source_ref, $threshold) = @_;
	my @rows;
	for my $ymd (sort keys %{$source_ref || {}}) {
		for my $user (sort keys %{$source_ref->{$ymd}}) {
			my $methods_ref = $source_ref->{$ymd}{$user} || {};
			my $count = 0; $count += $_ for values %{$methods_ref};
			next if $count < $threshold;
			my $methods = join(', ', map { $_ . ':' . $methods_ref->{$_} } sort keys %{$methods_ref});
			push @rows, { date => ymd_compact_to_dash($ymd), user => $user, count => $count, methods => $methods };
		}
	}
	return sort { $b->{count} <=> $a->{count} } @rows;
}

sub limit_rows {
	my $rows_ref = shift || [];
	return [@{$rows_ref}[0 .. ($#{$rows_ref} < $DETAIL_LIMIT - 1 ? $#{$rows_ref} : $DETAIL_LIMIT - 1)]] if @{$rows_ref};
	return [];
}

sub is_offhours {
	my $time = shift || '';
	return 0 if $time !~ /^\d{4}\/\d{2}\/\d{2}\s+(\d{2}):/;
	my $hour = $1;
	return ($hour < 8 || $hour >= 20) ? 1 : 0;
}

sub audit_dates {
	my ($target_date, $days) = @_;
	my $end = ymd_to_epoch_start($target_date);
	my @dates;
	for (my $i = $days - 1; $i >= 0; $i--) {
		push @dates, epoch_to_ymd_compact($end - ($i * 24 * 3600));
	}
	return @dates;
}

sub write_result {
	my ($location, $result_ref) = @_;
	my $dir = location_audit_dir($location);
	ensure_audit_dir($dir);

	my $tmp = File::Spec->catfile($dir, 'latest-oplog-audit.json.tmp.' . $$);
	my $out = File::Spec->catfile($dir, 'latest-oplog-audit.json');

	open(my $fh, '>', $tmp) or die "Failed to open $tmp: $!";
	binmode($fh, ':utf8');
	print $fh to_json($result_ref);
	close($fh);
	set_audit_path_attrs($tmp, 0);
	rename($tmp, $out) or die "Failed to rename $tmp to $out: $!";
	set_audit_path_attrs($out, 0);
	my $err = File::Spec->catfile($dir, 'latest-oplog-audit.err');
	unlink($err) if -f $err;
}

sub write_error {
	my ($location, $message) = @_;
	my $dir = location_audit_dir($location || 'unknown');
	ensure_audit_dir($dir);
	my $err = File::Spec->catfile($dir, 'latest-oplog-audit.err');
	if (open(my $fh, '>', $err)) {
		binmode($fh, ':utf8');
		print $fh iso8601_now() . ' ' . ($message || '') . "\n";
		close($fh);
		set_audit_path_attrs($err, 0);
	}
	writeLog("ERROR location=$location $message");
}

sub location_audit_dir {
	my $location = shift || 'unknown';
	$location =~ s/[^A-Za-z0-9_-]/_/g;
	return File::Spec->catdir($AUDIT_ROOT, $location);
}

sub issue_item {
	my ($level, $code, $message) = @_;
	return {
		level => $level || '',
		code => $code || '',
		message => $message || '',
	};
}

sub read_audit_config {
	my %conf;
	return %conf if !-f $CONFIG_FILE;
	if (open(my $fh, '<', $CONFIG_FILE)) {
		while (my $line = <$fh>) {
			chomp($line);
			next if $line =~ /^\s*#/ || $line =~ /^\s*$/;
			if ($line =~ /^\s*([A-Za-z0-9_-]+)\s*=\s*(.*?)\s*$/) {
				$conf{$1} = $2;
			}
		}
		close($fh);
	}
	return %conf;
}

sub positive_setting {
	my ($value, $default) = @_;
	return $default if !defined($value) || $value !~ /^\d+$/ || $value < 1;
	return int($value);
}

sub ensure_audit_config {
	ensure_audit_dir($AUDIT_ROOT);
	return if -f $CONFIG_FILE;
	if (open(my $fh, '>', $CONFIG_FILE)) {
		print $fh "# TeamFile operation log audit settings\n";
		print $fh "# hour/min define the daily audit execution time.\n";
		print $fh "hour=$DEFAULT_HOUR\n";
		print $fh "min=$DEFAULT_MIN\n";
		print $fh "# Number of days ending at target_date.\n";
		print $fh "days=$DEFAULT_DAYS\n";
		print $fh "# Per-user, per-day download thresholds. Byte values are raw bytes.\n";
		print $fh "bulk_get_warn=$BULK_GET_WARN\n";
		print $fh "bulk_get_critical=$BULK_GET_CRITICAL\n";
		print $fh "bulk_get_bytes_warn=$BULK_GET_BYTES_WARN\n";
		print $fh "bulk_get_bytes_critical=$BULK_GET_BYTES_CRITICAL\n";
		print $fh "# User baseline period and minimum number of active download days.\n";
		print $fh "bulk_get_baseline_days=$BULK_GET_BASELINE_DAYS\n";
		print $fh "bulk_get_baseline_min_active_days=$BULK_GET_BASELINE_MIN_ACTIVE_DAYS\n";
		print $fh "# Optional fixed target date for testing: YYYY-MM-DD\n";
		print $fh "# target_date=\n";
		close($fh);
		set_audit_path_attrs($CONFIG_FILE, 0);
	}
}

sub ensure_audit_dir {
	my $dir = shift || '';
	return if !$dir;
	make_path($dir) if !-d $dir;
	set_audit_path_attrs($dir, 1);
}

sub set_audit_path_attrs {
	my ($path, $is_dir) = @_;
	return if !$path;
	my ($uid, $gid) = (getpwnam($AUDIT_OWNER))[2, 3];
	chown($uid, $gid, $path) if defined($uid) && defined($gid);
	chmod($is_dir ? 0750 : 0640, $path);
}

sub normalize_target_date {
	my $date = shift || '';
	if ($date =~ /^(\d{4})-(\d{2})-(\d{2})$/) {
		return "$1-$2-$3";
	}
	if ($date =~ /^(\d{4})(\d{2})(\d{2})$/) {
		return "$1-$2-$3";
	}
	return yesterday_ymd();
}

sub yesterday_ymd {
	my ($sec, $min, $hour, $mday, $mon, $year) = localtime(time - 24 * 3600);
	return sprintf('%04d-%02d-%02d', $year + 1900, $mon + 1, $mday);
}

sub ymd_to_epoch_start {
	my $date = shift || yesterday_ymd();
	my ($y, $m, $d) = ($date =~ /^(\d{4})-(\d{2})-(\d{2})$/);
	return timelocal(0, 0, 0, $d, $m - 1, $y - 1900);
}

sub ymd_compact_to_epoch_start {
	my $date = shift || '';
	return ymd_to_epoch_start(ymd_compact_to_dash($date));
}

sub epoch_to_ymd_compact {
	my $epoch = shift || time;
	my ($sec, $min, $hour, $mday, $mon, $year) = localtime($epoch);
	return sprintf('%04d%02d%02d', $year + 1900, $mon + 1, $mday);
}

sub ymd_compact_to_dash {
	my $date = shift || '';
	return $date if $date !~ /^(\d{4})(\d{2})(\d{2})$/;
	return "$1-$2-$3";
}

sub iso8601_now {
	my ($sec, $min, $hour, $mday, $mon, $year) = localtime(time);
	return sprintf('%04d-%02d-%02dT%02d:%02d:%02d', $year + 1900, $mon + 1, $mday, $hour, $min, $sec);
}

sub to_json {
	my $v = shift;
	if (!defined($v)) { return 'null'; }
	if (ref($v) eq 'HASH') {
		return '{' . join(',', map { json_string($_) . ':' . to_json($v->{$_}) } sort keys %{$v}) . '}';
	}
	if (ref($v) eq 'ARRAY') {
		return '[' . join(',', map { to_json($_) } @{$v}) . ']';
	}
	if (!ref($v) && $v =~ /^-?\d+(?:\.\d+)?$/) { return $v; }
	return json_string($v);
}

sub json_string {
	my $s = shift;
	$s = '' if !defined($s);
	$s =~ s/\\/\\\\/g;
	$s =~ s/"/\\"/g;
	$s =~ s/\n/\\n/g;
	$s =~ s/\r/\\r/g;
	$s =~ s/\t/\\t/g;
	return '"' . $s . '"';
}

sub usage {
	print <<"_EOF_";
Usage: oplogauditor [-v] [-f] [--hour H --min M] [--target-date YYYY-MM-DD] [--days N] [--location NAME] | [-h]
       -v                  Show detail status
       -f                  Force execution regardless of configured time
       --hour H --min M    Scheduled execution time (default: 03:45)
       --target-date DATE  Audit target date. Default is yesterday.
       --days N            Audit days ending at target date. Default is 7.
       --location NAME     Audit only one location
       -h                  Show usage
_EOF_
}

sub printerr {
	my $msg = shift || '';
	print STDERR $msg if $opt_v && $msg;
}

sub initLog {
	make_path(getJobsWorkPath()) if !-d getJobsWorkPath();
	open $F_LOG, ">", $LOG_FILE;
}

sub writeLog {
	my $msg = shift || '';
	return if !$msg;
	my $ctime = localtime(time);
	print $F_LOG $ctime . ' : ' . $msg . "\n" if $F_LOG;
	printerr($msg . "\n");
}

sub closeLog {
	close $F_LOG if $F_LOG;
}
