'Not changed in 30 days', 60 => 'Not changed in 60 days', 90 => 'Not changed in 90 days', 180 => 'Not changed in 6 months', 365 => 'Not changed in a year']; $days = intval($_GET['days'] ?? CREDENTIAL_ROTATE_DAYS); if (!isset($day_options[$days])) { $days = CREDENTIAL_ROTATE_DAYS; } $client_id = reportClientId(); $credentials = []; $sql = mysqli_query($mysqli, "SELECT credential_id, credential_name, credential_description, credential_uri, credential_category, credential_password_changed_at, credential_accessed_at, (credential_otp_secret IS NOT NULL AND credential_otp_secret != '') AS has_otp, credential_client_id, client_name, client_abbreviation FROM credentials JOIN clients ON credential_client_id = client_id WHERE credential_archived_at IS NULL AND client_archived_at IS NULL" . reportClientSql('credential_client_id', $client_id) . " ORDER BY credential_password_changed_at IS NOT NULL, credential_password_changed_at ASC, client_name ASC, credential_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { $row['has_otp'] = intval($row['has_otp']) === 1; $row['age'] = credentialAge($row['credential_password_changed_at']); $row['age_days'] = $row['age'] ? $row['age']['days'] : null; $row['due'] = !$row['age'] || $row['age']['days'] >= $days; $row['overdue'] = !$row['age'] || $row['age']['state'] === 'overdue'; $credentials[] = $row; } $due = array_values(array_filter($credentials, function ($c) { return $c['due']; })); // Every password by age $age_buckets = ['Under 90 days' => [0, 90, 'success'], '90 days to 6 months' => [90, 182, 'warning'], '6 to 12 months' => [182, 365, 'orange'], '1 to 2 years' => [365, 730, 'danger'], 'Over 2 years' => [730, PHP_INT_MAX, 'danger']]; $age_counts = array_fill_keys(array_keys($age_buckets), 0) + ['Never recorded' => 0]; $by_client = []; foreach ($credentials as $row) { $c = intval($row['credential_client_id']); $by_client[$c] = $by_client[$c] ?? ['client_id' => $c, 'client' => $row['client_name'], 'abbr' => $row['client_abbreviation'], 'total' => 0, 'due' => 0, 'overdue' => 0, 'otp' => 0, 'oldest' => null]; $by_client[$c]['total']++; $by_client[$c]['due'] += $row['due'] ? 1 : 0; $by_client[$c]['overdue'] += $row['overdue'] ? 1 : 0; $by_client[$c]['otp'] += $row['has_otp'] ? 1 : 0; if ($row['age']) { $by_client[$c]['oldest'] = max($by_client[$c]['oldest'] ?? 0, $row['age']['days']); foreach ($age_buckets as $label => [$from, $to]) { if ($row['age']['days'] >= $from && $row['age']['days'] < $to) { $age_counts[$label]++; break; } } } else { $age_counts['Never recorded']++; } } foreach ($by_client as &$client) { $client['otp_share'] = reportShare($client['otp'], $client['total'], 0); } unset($client); $by_client = array_values(array_filter($by_client, function ($c) { return $c['due'] > 0; })); usort($by_client, function ($a, $b) { return $b['overdue'] <=> $a['overdue'] ?: $b['due'] <=> $a['due']; }); $client_columns = [ 'client' => ['label' => 'Client', 'html' => function ($row) { return reportClientCell($row['client_id'], $row['client'], $row['abbr'], 'credentials.php'); }], 'total' => ['label' => 'Credentials', 'type' => 'count'], 'due' => ['label' => 'Due', 'type' => 'count', 'html' => function ($row) { return '' . reportCount($row['due']) . ''; }], 'overdue' => ['label' => 'Over a year or never', 'type' => 'count', 'html' => function ($row) { return $row['overdue'] ? '' . reportCount($row['overdue']) . '' : '–'; }], 'oldest' => ['label' => 'Oldest change', 'type' => 'count', 'csv_label' => 'Oldest change (days ago)', 'html' => function ($row) { return $row['oldest'] === null ? '–' : '' . escapeHtml(timeAgo(date('Y-m-d', strtotime('-' . $row['oldest'] . ' days')))) . ''; }], 'otp_share' => ['label' => 'With 2FA', 'type' => 'percent', 'title' => 'Credentials holding a 2FA (TOTP) code', 'class' => 'text-body-secondary'], ]; $credential_columns = [ 'credential_name' => ['label' => 'Credential', 'class' => 'itflow-report-text', 'html' => function ($row) { return '