Security / PHP Security (semgrep) (push) Failing after 2m44s
Lint / Deploy (push) Successful in 8s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Medium:
- create_ticket_api.php: environment tags were parsed with explode('][') which
left brackets on the first/last tag so the whitelist never matched, dropping
the env tag from the dedup hash — a [production] and [staging] issue with
otherwise-identical components could collide onto one ticket. Use a
bracket-aware regex.
- CommentModel::getThreadedCommentsPaged only fetched DIRECT children of root
comments, so when pagination is active, nested replies at depth 2-3 vanished
from the thread. Expand replies level-by-level (bounded to depth 3).
- StatsModel::getTicketsByAssignee ignored the visibility filter the rest of the
stats apply, so a non-admin's "by assignee" widget counted (leaked) confidential
tickets. Thread the same filter through.
- watch_ticket.php GET path returned watch state / watcher names / count for any
ticket with no access check (the POST path checks it) — added canUserAccessTicket.
- dashboard.js kanban: every card rendered as P4 because the [class*="lt-p"]
selector never matched the lt-badge-p1 class and the fallback didn't strip "P".
Extract the digit directly.
Low:
- audit_log.php CSV: "Log ID" column was always blank ($log['log_id'] vs the real
audit_id column). Use audit_id.
- check_duplicates.php: the graceful-degradation try/catch only covered the throw
path; guard the false-return (non-exception mysqli) path too.
- notifications.php: owner-who-is-also-@mentioned got two notifications for one
comment; drop the duplicate comment row when a mention covers the same comment.
- dashboard.js hover preview rendered "PP1" (doubled prefix); strip the leading P.
- markdown.js: code/inline-code restore used string replace, so $&, $$, $`, $' in
user code were treated as replacement patterns; use a function replacer. Also
removed an unused loop var.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
4.4 KiB
PHP
139 lines
4.4 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Audit Log API Endpoint
|
|
* Handles fetching filtered audit logs and CSV export
|
|
* Admin-only access
|
|
*/
|
|
|
|
require_once __DIR__ . '/bootstrap.php';
|
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
|
|
|
// Check admin status - audit log viewing is admin-only
|
|
if (!$isAdmin) {
|
|
http_response_code(403);
|
|
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
|
exit;
|
|
}
|
|
|
|
$auditLogModel = new AuditLogModel($conn);
|
|
|
|
// GET - Fetch filtered audit logs or export to CSV
|
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|
// Check for CSV export request
|
|
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
|
|
// Build filters
|
|
$filters = [];
|
|
if (isset($_GET['action_type'])) {
|
|
$filters['action_type'] = $_GET['action_type'];
|
|
}
|
|
if (isset($_GET['entity_type'])) {
|
|
$filters['entity_type'] = $_GET['entity_type'];
|
|
}
|
|
if (isset($_GET['user_id'])) {
|
|
$filters['user_id'] = $_GET['user_id'];
|
|
}
|
|
if (isset($_GET['entity_id'])) {
|
|
$filters['entity_id'] = $_GET['entity_id'];
|
|
}
|
|
if (isset($_GET['date_from'])) {
|
|
$filters['date_from'] = $_GET['date_from'];
|
|
}
|
|
if (isset($_GET['date_to'])) {
|
|
$filters['date_to'] = $_GET['date_to'];
|
|
}
|
|
if (isset($_GET['ip_address'])) {
|
|
$filters['ip_address'] = $_GET['ip_address'];
|
|
}
|
|
|
|
// Get all matching logs for export. The forExport flag raises the cap
|
|
// (model clamps to its export limit) so the CSV isn't silently truncated
|
|
// to the 1000-row UI page limit.
|
|
$result = $auditLogModel->getFilteredLogs($filters, PHP_INT_MAX, 0, true);
|
|
$logs = $result['logs'];
|
|
|
|
// Set CSV headers
|
|
header('Content-Type: text/csv');
|
|
header('Content-Disposition: attachment; filename="audit_log_' . date('Y-m-d_His') . '.csv"');
|
|
|
|
// Output CSV
|
|
$output = fopen('php://output', 'w');
|
|
|
|
// Write CSV header
|
|
fputcsv($output, ['Log ID', 'Timestamp', 'User', 'Action', 'Entity Type', 'Entity ID', 'IP Address', 'Details']);
|
|
|
|
// Write data rows
|
|
foreach ($logs as $log) {
|
|
$details = '';
|
|
if (is_array($log['details'])) {
|
|
$details = json_encode($log['details']);
|
|
}
|
|
|
|
fputcsv($output, [
|
|
$log['audit_id'] ?? ($log['log_id'] ?? ''),
|
|
$log['created_at'],
|
|
$log['display_name'] ?? $log['username'] ?? 'N/A',
|
|
$log['action_type'],
|
|
$log['entity_type'],
|
|
$log['entity_id'] ?? 'N/A',
|
|
$log['ip_address'] ?? 'N/A',
|
|
$details
|
|
]);
|
|
}
|
|
|
|
fclose($output);
|
|
exit;
|
|
}
|
|
|
|
// Normal JSON response for filtered logs
|
|
try {
|
|
// Get pagination parameters
|
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
|
$limit = min(500, max(1, (int)($_GET['limit'] ?? 50)));
|
|
$offset = ($page - 1) * $limit;
|
|
|
|
// Build filters
|
|
$filters = [];
|
|
if (isset($_GET['action_type'])) {
|
|
$filters['action_type'] = $_GET['action_type'];
|
|
}
|
|
if (isset($_GET['entity_type'])) {
|
|
$filters['entity_type'] = $_GET['entity_type'];
|
|
}
|
|
if (isset($_GET['user_id'])) {
|
|
$filters['user_id'] = $_GET['user_id'];
|
|
}
|
|
if (isset($_GET['entity_id'])) {
|
|
$filters['entity_id'] = $_GET['entity_id'];
|
|
}
|
|
if (isset($_GET['date_from'])) {
|
|
$filters['date_from'] = $_GET['date_from'];
|
|
}
|
|
if (isset($_GET['date_to'])) {
|
|
$filters['date_to'] = $_GET['date_to'];
|
|
}
|
|
if (isset($_GET['ip_address'])) {
|
|
$filters['ip_address'] = $_GET['ip_address'];
|
|
}
|
|
|
|
// Get filtered logs
|
|
$result = $auditLogModel->getFilteredLogs($filters, $limit, $offset);
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'logs' => $result['logs'],
|
|
'total' => $result['total'],
|
|
'pages' => $result['pages'],
|
|
'current_page' => $page
|
|
]);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'error' => 'Failed to fetch audit logs']);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// Method not allowed
|
|
http_response_code(405);
|
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|