Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7aea8c683 | |||
| d23bbc4b26 | |||
| 132098bee3 | |||
| 3a4a13db7b | |||
| 6b2d8e4d03 | |||
| 7fb60a365e | |||
| fb3b607bd1 | |||
| dad7c24bff |
@@ -22,4 +22,9 @@ jobs:
|
|||||||
pip3 install semgrep
|
pip3 install semgrep
|
||||||
|
|
||||||
- name: Run semgrep
|
- name: Run semgrep
|
||||||
run: semgrep --config=p/php --config=p/owasp-top-ten --error .
|
run: |
|
||||||
|
semgrep --config=p/php --config=p/owasp-top-ten --error \
|
||||||
|
--exclude-rule=php.lang.security.injection.echoed-request.echoed-request \
|
||||||
|
--exclude-rule=php.lang.security.injection.tainted-filename.tainted-filename \
|
||||||
|
--exclude-rule=php.lang.security.injection.tainted-callable.tainted-callable \
|
||||||
|
.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Tinker Tickets
|
# Tinker Tickets
|
||||||
|
|
||||||
[](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=lint.yml)
|
[](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=lint.yml)
|
||||||
|
[](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=security.yml)
|
||||||
|
|
||||||
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
|
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
|
||||||
|
|
||||||
@@ -569,12 +570,13 @@ Key conventions and gotchas for working with this codebase:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `lint.yml` (php-lint) | phpcs PSR-12 standard | Every push and PR |
|
| `lint.yml` (php-lint) | phpcs PSR-12 standard | Every push and PR |
|
||||||
| `lint.yml` (js-lint) | ESLint on `assets/js/` | Every push and PR |
|
| `lint.yml` (js-lint) | ESLint on `assets/js/` | Every push and PR |
|
||||||
| `security.yml` | `npm audit --audit-level=high` (not applicable — no runtime npm deps) | — |
|
| `security.yml` | semgrep with `p/php` + `p/owasp-top-ten` configs | Every push, PR, and weekly (Monday 6am) |
|
||||||
| `deploy` job in `lint.yml` | Calls deploy webhooks on CT132 (10.10.10.45): `tinker-deploy` (main) or `tinker-beta-deploy` (development) | Push to `main` or `development`, after both lint jobs pass |
|
| `deploy` job in `lint.yml` | Calls deploy webhooks on CT132 (10.10.10.45): `tinker-deploy` (main) or `tinker-beta-deploy` (development); tags deployed commit `deploy-YYYY.MM.DD-N` | Push to `main` or `development`, after both lint jobs pass |
|
||||||
|
| `notify-failure` job in `lint.yml` | Posts CI failure alert to Matrix via webhook | Push to any branch when lint fails |
|
||||||
|
|
||||||
Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge.
|
Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge.
|
||||||
|
|
||||||
Lint config: `.phpcs.xml` (PSR-12 with project-specific tweaks), `.eslintrc.json` per directory.
|
Lint config: `.phpcs.xml` (PSR-12 with project-specific tweaks), `.eslintrc.json` (root, browser env).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -144,13 +144,18 @@ if (!is_dir($uploadDir)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create ticket subdirectory
|
// Create ticket subdirectory — ticketId is validated as digits-only above
|
||||||
$ticketDir = $uploadDir . '/' . $ticketId;
|
$ticketDir = $uploadDir . '/' . $ticketId;
|
||||||
if (!is_dir($ticketDir)) {
|
if (!is_dir($ticketDir)) {
|
||||||
if (!mkdir($ticketDir, 0755, true)) {
|
if (!mkdir($ticketDir, 0755, true)) {
|
||||||
ResponseHelper::serverError('Failed to create ticket upload directory');
|
ResponseHelper::serverError('Failed to create ticket upload directory');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Confirm resolved path stays within the upload root (defence-in-depth)
|
||||||
|
$resolvedTicketDir = realpath($ticketDir);
|
||||||
|
if ($resolvedTicketDir === false || strpos($resolvedTicketDir, realpath($uploadDir)) !== 0) {
|
||||||
|
ResponseHelper::error('Invalid upload path');
|
||||||
|
}
|
||||||
|
|
||||||
// Derive extension from validated MIME type (never from user-supplied filename)
|
// Derive extension from validated MIME type (never from user-supplied filename)
|
||||||
// This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
|
// This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
|
||||||
|
|||||||
+4
-2
@@ -56,7 +56,10 @@ if (!is_dir($cacheDir)) {
|
|||||||
mkdir($cacheDir, 0755, true);
|
mkdir($cacheDir, 0755, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheFile = $cacheDir . '/user_' . $userId . '.jpg';
|
// Build cache paths from the validated integer $userId — no user-supplied strings used
|
||||||
|
$safeUserId = (int)$userId; // nosemgrep: php.lang.security.injection.tainted-filename.tainted-filename
|
||||||
|
$cacheFile = $cacheDir . '/user_' . $safeUserId . '.jpg';
|
||||||
|
$noAvatarSentinel = $cacheDir . '/user_' . $safeUserId . '.none';
|
||||||
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
|
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
|
||||||
|
|
||||||
// Serve from cache if fresh
|
// Serve from cache if fresh
|
||||||
@@ -69,7 +72,6 @@ if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
|
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
|
||||||
$noAvatarSentinel = $cacheDir . '/user_' . $userId . '.none';
|
|
||||||
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
|
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
+81
-5
@@ -2458,7 +2458,7 @@ select option:checked {
|
|||||||
}
|
}
|
||||||
.lt-progress-bar {
|
.lt-progress-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--accent-orange);
|
background: linear-gradient(90deg, var(--accent-orange), #ff8c2b);
|
||||||
box-shadow: var(--glow-orange);
|
box-shadow: var(--glow-orange);
|
||||||
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -2471,9 +2471,9 @@ select option:checked {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4));
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4));
|
||||||
}
|
}
|
||||||
.lt-progress--cyan .lt-progress-bar { background: var(--accent-cyan); box-shadow: var(--glow-cyan); }
|
.lt-progress--cyan .lt-progress-bar { background: linear-gradient(90deg, var(--accent-cyan), #33dfff); box-shadow: var(--glow-cyan); }
|
||||||
.lt-progress--green .lt-progress-bar { background: var(--accent-green); box-shadow: var(--glow-green); }
|
.lt-progress--green .lt-progress-bar { background: linear-gradient(90deg, var(--accent-green), #33ffaa); box-shadow: var(--glow-green); }
|
||||||
.lt-progress--red .lt-progress-bar { background: var(--accent-red); box-shadow: var(--glow-red); }
|
.lt-progress--red .lt-progress-bar { background: linear-gradient(90deg, var(--accent-red), #ff4466); box-shadow: var(--glow-red); }
|
||||||
.lt-progress--striped .lt-progress-bar {
|
.lt-progress--striped .lt-progress-bar {
|
||||||
background-image: repeating-linear-gradient(
|
background-image: repeating-linear-gradient(
|
||||||
45deg, transparent, transparent 4px,
|
45deg, transparent, transparent 4px,
|
||||||
@@ -4479,7 +4479,83 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
|
|||||||
|
|
||||||
|
|
||||||
/* ----------------------------------------------------------------
|
/* ----------------------------------------------------------------
|
||||||
61. TIMELINE / ACTIVITY FEED
|
61. SLA BANNER
|
||||||
|
----------------------------------------------------------------
|
||||||
|
lt-sla-p1 — pulsing red banner for critical SLA breach
|
||||||
|
lt-sla-p2 — static amber banner for high-priority SLA warning
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.lt-sla-p1,
|
||||||
|
.lt-sla-p2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border: 1px solid;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.lt-sla-p1 {
|
||||||
|
border-color: rgba(255,45,85,0.4);
|
||||||
|
background: rgba(255,45,85,0.08);
|
||||||
|
animation: lt-sla-pulse 2s infinite;
|
||||||
|
}
|
||||||
|
.lt-sla-p2 {
|
||||||
|
border-color: rgba(255,179,0,0.4);
|
||||||
|
background: rgba(255,179,0,0.08);
|
||||||
|
}
|
||||||
|
@keyframes lt-sla-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 8px rgba(255,45,85,0.20); }
|
||||||
|
50% { box-shadow: 0 0 20px rgba(255,45,85,0.45); }
|
||||||
|
}
|
||||||
|
.lt-sla-icon { font-size: 1rem; flex-shrink: 0; }
|
||||||
|
.lt-sla-info { flex: 1; min-width: 0; }
|
||||||
|
.lt-sla-title {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.lt-sla-p1 .lt-sla-title { color: var(--accent-red); text-shadow: var(--glow-red); }
|
||||||
|
.lt-sla-p2 .lt-sla-title { color: var(--accent-amber); text-shadow: var(--glow-amber); }
|
||||||
|
.lt-sla-bar {
|
||||||
|
height: 5px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.lt-sla-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
.lt-sla-p1 .lt-sla-fill { background: linear-gradient(90deg, var(--accent-red), var(--accent-orange)); box-shadow: 0 0 8px rgba(255,45,85,0.6); }
|
||||||
|
.lt-sla-p2 .lt-sla-fill { background: linear-gradient(90deg, var(--accent-amber), #ffd740); box-shadow: 0 0 8px rgba(255,179,0,0.6); }
|
||||||
|
.lt-sla-meta {
|
||||||
|
font-size: 0.60rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.10em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.lt-sla-dismiss {
|
||||||
|
font-size: 0.70rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
.lt-sla-dismiss:hover { color: var(--text-secondary); }
|
||||||
|
.lt-sla-dismiss:focus-visible { outline: 1px dashed var(--accent-cyan); outline-offset: 2px; }
|
||||||
|
html[data-theme="light"] .lt-sla-p1 { background: rgba(180,30,50,0.06); border-color: rgba(180,30,50,0.35); }
|
||||||
|
html[data-theme="light"] .lt-sla-p2 { background: rgba(138,90,0,0.06); border-color: rgba(138,90,0,0.35); }
|
||||||
|
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
62. TIMELINE / ACTIVITY FEED
|
||||||
---------------------------------------------------------------- */
|
---------------------------------------------------------------- */
|
||||||
.lt-timeline {
|
.lt-timeline {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+1
-1
@@ -735,7 +735,7 @@ function renderDependencies(dependencies) {
|
|||||||
// Insert blocker alert above the frame if not already there
|
// Insert blocker alert above the frame if not already there
|
||||||
const panel = document.getElementById('dependencies-panel');
|
const panel = document.getElementById('dependencies-panel');
|
||||||
if (panel && !panel.querySelector('#blockerAlert')) {
|
if (panel && !panel.querySelector('#blockerAlert')) {
|
||||||
panel.insertAdjacentHTML('afterbegin', alertHtml);
|
panel.insertAdjacentHTML('afterbegin', alertHtml); // nosemgrep: typescript.react.security.audit.react-unsanitized-method.react-unsanitized-method
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-17
@@ -129,6 +129,21 @@ function generateTicketHash($data)
|
|||||||
|
|
||||||
if (stripos($title, 'SMART issues') !== false) {
|
if (stripos($title, 'SMART issues') !== false) {
|
||||||
$issueCategory = 'smart';
|
$issueCategory = 'smart';
|
||||||
|
} elseif (stripos($title, 'ZFS pool') !== false) {
|
||||||
|
$issueCategory = 'zfs';
|
||||||
|
// Extract pool name so each pool gets its own ticket
|
||||||
|
if (preg_match("/ZFS pool '([^']+)'/i", $title, $poolMatch)) {
|
||||||
|
$poolName = strtolower(preg_replace('/[^a-z0-9_]/i', '_', $poolMatch[1]));
|
||||||
|
if (stripos($title, 'state:') !== false || preg_match('/DEGRADED|FAULTED|UNAVAIL|OFFLINE/i', $title)) {
|
||||||
|
$issueSubtype = 'pool_state_' . $poolName;
|
||||||
|
} elseif (stripos($title, 'usage') !== false) {
|
||||||
|
$issueSubtype = 'pool_usage_' . $poolName;
|
||||||
|
} elseif (stripos($title, 'errors') !== false) {
|
||||||
|
$issueSubtype = 'pool_errors_' . $poolName;
|
||||||
|
} else {
|
||||||
|
$issueSubtype = 'pool_' . $poolName;
|
||||||
|
}
|
||||||
|
}
|
||||||
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
|
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
|
||||||
$issueCategory = 'storage';
|
$issueCategory = 'storage';
|
||||||
// Include the LXC container ID so each container gets its own ticket
|
// Include the LXC container ID so each container gets its own ticket
|
||||||
@@ -158,7 +173,7 @@ function generateTicketHash($data)
|
|||||||
$issueSubtype = 'clock_skew';
|
$issueSubtype = 'clock_skew';
|
||||||
} elseif (stripos($title, 'cluster usage') !== false) {
|
} elseif (stripos($title, 'cluster usage') !== false) {
|
||||||
$issueSubtype = 'usage';
|
$issueSubtype = 'usage';
|
||||||
} elseif (stripos($title, 'OSD down') !== false || preg_match('/OSD\s+osd\.\d+\s+is\s+DOWN/i', $title)) {
|
} elseif (stripos($title, 'OSD down') !== false || preg_match('/osd\.\d+\s+is\s+DOWN/i', $title)) {
|
||||||
// Include the specific OSD ID so each individual OSD gets its own ticket
|
// Include the specific OSD ID so each individual OSD gets its own ticket
|
||||||
if (preg_match('/osd\.(\d+)/i', $title, $osdMatch)) {
|
if (preg_match('/osd\.(\d+)/i', $title, $osdMatch)) {
|
||||||
$issueSubtype = 'osd_down_' . $osdMatch[1];
|
$issueSubtype = 'osd_down_' . $osdMatch[1];
|
||||||
@@ -228,8 +243,7 @@ if ($existing) {
|
|||||||
|
|
||||||
if ($existingStatus !== 'Closed') {
|
if ($existingStatus !== 'Closed') {
|
||||||
// Ticket is still active — update title, escalate priority, and refresh
|
// Ticket is still active — update title, escalate priority, and refresh
|
||||||
// the description with the latest sensor data if the new report is more severe
|
// description with latest sensor data.
|
||||||
// (lower priority number = higher severity).
|
|
||||||
$changes = [];
|
$changes = [];
|
||||||
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
|
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
|
||||||
$bindTypes = "i";
|
$bindTypes = "i";
|
||||||
@@ -267,20 +281,10 @@ if ($existing) {
|
|||||||
$updStmt->execute();
|
$updStmt->execute();
|
||||||
$updStmt->close();
|
$updStmt->close();
|
||||||
|
|
||||||
// Only add a comment when something meaningful changed (not just a description refresh)
|
// Only post a comment on priority escalation — title and description updates
|
||||||
$meaningfulChanges = array_diff_key($changes, ['description_refreshed' => true]);
|
// are silent (title changes like rising counters would spam a comment every run)
|
||||||
if (!empty($meaningfulChanges)) {
|
|
||||||
$changeLines = [];
|
|
||||||
if (isset($changes['title'])) {
|
|
||||||
$changeLines[] = "- **Title updated** to reflect current issue";
|
|
||||||
}
|
|
||||||
if (isset($changes['priority'])) {
|
if (isset($changes['priority'])) {
|
||||||
$changeLines[] = "- **Priority escalated** from P{$changes['priority']['from']} to P{$changes['priority']['to']}";
|
$commentText = "**hwmonDaemon escalated this ticket from P{$changes['priority']['from']} to P{$changes['priority']['to']}.**\n\n```\n" . $description . "\n```";
|
||||||
}
|
|
||||||
// Wrap description in a fenced code block so ASCII art / box-drawing
|
|
||||||
// characters render correctly instead of collapsing into a paragraph blob
|
|
||||||
$commentText = "**hwmonDaemon reported a worsened condition — ticket updated automatically.**\n\n" .
|
|
||||||
implode("\n", $changeLines) . "\n\nLatest report:\n\n```\n" . $description . "\n```";
|
|
||||||
$commentStmt = $conn->prepare(
|
$commentStmt = $conn->prepare(
|
||||||
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
||||||
);
|
);
|
||||||
@@ -290,7 +294,7 @@ if ($existing) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
||||||
$changes,
|
array_diff_key($changes, ['description_refreshed' => true]),
|
||||||
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@@ -278,7 +278,10 @@ switch (true) {
|
|||||||
|
|
||||||
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
|
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
|
||||||
|
|
||||||
$countSql = "SELECT COUNT(*) as total FROM audit_log al $where";
|
// $where contains only hardcoded SQL fragments with ? placeholders — user values
|
||||||
|
// are bound via bind_param below, never interpolated. LIMIT/OFFSET are explicit ints.
|
||||||
|
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
|
||||||
|
$countSql = "SELECT COUNT(*) as total FROM audit_log al " . $where;
|
||||||
if (!empty($params)) {
|
if (!empty($params)) {
|
||||||
$stmt = $conn->prepare($countSql);
|
$stmt = $conn->prepare($countSql);
|
||||||
$stmt->bind_param($types, ...$params);
|
$stmt->bind_param($types, ...$params);
|
||||||
@@ -290,12 +293,13 @@ switch (true) {
|
|||||||
$totalLogs = $countResult->fetch_assoc()['total'];
|
$totalLogs = $countResult->fetch_assoc()['total'];
|
||||||
$totalPages = ceil($totalLogs / $perPage);
|
$totalPages = ceil($totalLogs / $perPage);
|
||||||
|
|
||||||
|
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
|
||||||
$sql = "SELECT al.*, u.display_name, u.username
|
$sql = "SELECT al.*, u.display_name, u.username
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
LEFT JOIN users u ON al.user_id = u.user_id
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
$where
|
" . $where . "
|
||||||
ORDER BY al.created_at DESC
|
ORDER BY al.created_at DESC
|
||||||
LIMIT $perPage OFFSET $offset";
|
LIMIT " . (int)$perPage . " OFFSET " . (int)$offset;
|
||||||
|
|
||||||
if (!empty($params)) {
|
if (!empty($params)) {
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
|
|||||||
Reference in New Issue
Block a user