Proteção XSS, assinatura de webhook, limite de execuções e data no histórico

- Utilitário centralizado Utils.escapeHtml() substituindo duplicações locais
- Escaping completo em todos os componentes (agents, tasks, schedules, pipelines, webhooks, terminal, history, tags)
- Verificação HMAC-SHA256 para webhooks usando raw body
- Limite de 5000 registros no store de execuções (maxSize)
- Data de execução visível no histórico com ícone de calendário
- Remoção de mutex desnecessário no flush síncrono do db.js
- Novos stores preparatórios (secrets, notifications, agentVersions)
This commit is contained in:
Frederico Castro
2026-02-26 18:26:27 -03:00
parent 93d9027e2c
commit d7d2421fc2
14 changed files with 135 additions and 126 deletions

View File

@@ -3437,50 +3437,36 @@ tbody tr:hover td {
white-space: nowrap; white-space: nowrap;
} }
.history-card-status {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.history-card-date {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
}
.history-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.history-card-task { .history-card-task {
font-size: 13px; font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
flex: 1;
min-width: 0;
} }
.history-card-info {
display: flex;
align-items: center;
gap: 16px;
font-size: 12px;
color: var(--text-muted);
}
.history-card-date,
.history-card-duration { .history-card-duration {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 5px;
font-size: 12px;
color: var(--text-muted);
flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
} }
.history-card-date i,
.history-card-duration i { .history-card-duration i {
width: 12px; width: 13px;
height: 12px; height: 13px;
flex-shrink: 0; flex-shrink: 0;
opacity: 0.6;
} }
.history-card-actions { .history-card-actions {
@@ -3986,12 +3972,6 @@ tbody tr:hover td {
height: 12px; height: 12px;
} }
.history-card-duration-group {
display: flex;
align-items: center;
gap: 12px;
}
.pipeline-step-meta-group { .pipeline-step-meta-group {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1118,6 +1118,7 @@
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script> <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
<script src="js/api.js"></script> <script src="js/api.js"></script>
<script src="js/utils.js"></script>
<script src="js/components/toast.js"></script> <script src="js/components/toast.js"></script>
<script src="js/components/modal.js"></script> <script src="js/components/modal.js"></script>
<script src="js/components/terminal.js"></script> <script src="js/components/terminal.js"></script>

View File

@@ -253,7 +253,7 @@ const App = {
<div class="approval-icon"><i data-lucide="shield-alert"></i></div> <div class="approval-icon"><i data-lucide="shield-alert"></i></div>
<div class="approval-text"> <div class="approval-text">
<strong>Aprovação necessária</strong> <strong>Aprovação necessária</strong>
<span>Passo ${stepIndex + 1} (${agentName || 'agente'}) aguardando autorização</span> <span>Passo ${stepIndex + 1} (${Utils.escapeHtml(agentName) || 'agente'}) aguardando autorização</span>
</div> </div>
<div class="approval-actions"> <div class="approval-actions">
<button class="btn btn--primary btn--sm" id="approval-approve-btn" type="button">Aprovar</button> <button class="btn btn--primary btn--sm" id="approval-approve-btn" type="button">Aprovar</button>
@@ -661,8 +661,8 @@ const App = {
hidden.value = JSON.stringify(tags); hidden.value = JSON.stringify(tags);
chips.innerHTML = tags.map((t) => ` chips.innerHTML = tags.map((t) => `
<span class="tag-chip"> <span class="tag-chip">
${t} ${Utils.escapeHtml(t)}
<button type="button" class="tag-remove" data-tag="${t}" aria-label="Remover tag ${t}">×</button> <button type="button" class="tag-remove" data-tag="${Utils.escapeHtml(t)}" aria-label="Remover tag ${Utils.escapeHtml(t)}">×</button>
</span> </span>
`).join(''); `).join('');
}; };

View File

@@ -76,7 +76,7 @@ const AgentsUI = {
const model = (agent.config && agent.config.model) || agent.model || 'claude-sonnet-4-6'; const model = (agent.config && agent.config.model) || agent.model || 'claude-sonnet-4-6';
const updatedAt = AgentsUI.formatDate(agent.updated_at || agent.updatedAt || agent.created_at || agent.createdAt); const updatedAt = AgentsUI.formatDate(agent.updated_at || agent.updatedAt || agent.created_at || agent.createdAt);
const tags = Array.isArray(agent.tags) && agent.tags.length > 0 const tags = Array.isArray(agent.tags) && agent.tags.length > 0
? `<div class="agent-tags">${agent.tags.map((t) => `<span class="tag-chip tag-chip--sm">${t}</span>`).join('')}</div>` ? `<div class="agent-tags">${agent.tags.map((t) => `<span class="tag-chip tag-chip--sm">${Utils.escapeHtml(t)}</span>`).join('')}</div>`
: ''; : '';
return ` return `
@@ -87,12 +87,12 @@ const AgentsUI = {
<span>${initials}</span> <span>${initials}</span>
</div> </div>
<div class="agent-info"> <div class="agent-info">
<h3 class="agent-name">${name}</h3> <h3 class="agent-name">${Utils.escapeHtml(name)}</h3>
<span class="badge ${statusClass}">${statusLabel}</span> <span class="badge ${statusClass}">${statusLabel}</span>
</div> </div>
</div> </div>
${agent.description ? `<p class="agent-description">${agent.description}</p>` : ''} ${agent.description ? `<p class="agent-description">${Utils.escapeHtml(agent.description)}</p>` : ''}
${tags} ${tags}
<div class="agent-meta"> <div class="agent-meta">
@@ -279,7 +279,7 @@ const AgentsUI = {
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' + selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
allAgents allAgents
.filter((a) => a.status === 'active') .filter((a) => a.status === 'active')
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`) .map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`)
.join(''); .join('');
selectEl.value = agentId; selectEl.value = agentId;
@@ -311,7 +311,7 @@ const AgentsUI = {
savedTaskSelect.innerHTML = '<option value="">Digitar manualmente...</option>' + savedTaskSelect.innerHTML = '<option value="">Digitar manualmente...</option>' +
tasks.map((t) => { tasks.map((t) => {
const label = t.category ? `[${t.category.toUpperCase()}] ${t.name}` : t.name; const label = t.category ? `[${t.category.toUpperCase()}] ${t.name}` : t.name;
return `<option value="${t.id}">${label}</option>`; return `<option value="${t.id}">${Utils.escapeHtml(label)}</option>`;
}).join(''); }).join('');
AgentsUI._savedTasksCache = tasks; AgentsUI._savedTasksCache = tasks;
} catch { } catch {

View File

@@ -78,8 +78,8 @@ const DashboardUI = {
list.innerHTML = executions.map((exec) => { list.innerHTML = executions.map((exec) => {
const statusClass = DashboardUI._statusBadgeClass(exec.status); const statusClass = DashboardUI._statusBadgeClass(exec.status);
const statusLabel = DashboardUI._statusLabel(exec.status); const statusLabel = DashboardUI._statusLabel(exec.status);
const name = exec.agentName || exec.pipelineName || exec.agentId || 'Execução'; const name = Utils.escapeHtml(exec.agentName || exec.pipelineName || exec.agentId || 'Execução');
const taskText = exec.task || exec.input || ''; const taskText = Utils.escapeHtml(exec.task || exec.input || '');
const typeBadge = exec.type === 'pipeline' const typeBadge = exec.type === 'pipeline'
? '<span class="badge badge--purple" style="font-size:0.6rem;padding:1px 5px;">Pipeline</span> ' ? '<span class="badge badge--purple" style="font-size:0.6rem;padding:1px 5px;">Pipeline</span> '
: ''; : '';

View File

@@ -70,22 +70,21 @@ const HistoryUI = {
<div class="history-card-header"> <div class="history-card-header">
<div class="history-card-identity"> <div class="history-card-identity">
${typeBadge} ${typeBadge}
<span class="history-card-name">${HistoryUI._escapeHtml(name)}</span> <span class="history-card-name">${Utils.escapeHtml(name)}</span>
</div>
<div class="history-card-status">
${statusBadge} ${statusBadge}
<span class="history-card-date">${date}</span>
</div> </div>
</div> </div>
<div class="history-card-meta"> <div class="history-card-task">${Utils.escapeHtml(task)}</div>
<span class="history-card-task">${HistoryUI._escapeHtml(task)}</span> <div class="history-card-info">
<span class="history-card-duration-group"> <span class="history-card-date">
<i data-lucide="calendar" aria-hidden="true"></i>
${date}
</span>
<span class="history-card-duration"> <span class="history-card-duration">
<i data-lucide="clock" aria-hidden="true"></i> <i data-lucide="clock" aria-hidden="true"></i>
${duration} ${duration}
</span> </span>
${costHtml} ${costHtml}
</span>
</div> </div>
<div class="history-card-actions"> <div class="history-card-actions">
<button class="btn btn-ghost btn-sm" data-action="view-execution" data-id="${exec.id}" type="button"> <button class="btn btn-ghost btn-sm" data-action="view-execution" data-id="${exec.id}" type="button">
@@ -195,18 +194,18 @@ const HistoryUI = {
const endDate = exec.endedAt ? HistoryUI._formatDate(exec.endedAt) : '—'; const endDate = exec.endedAt ? HistoryUI._formatDate(exec.endedAt) : '—';
const resultBlock = exec.result const resultBlock = exec.result
? `<div class="execution-result" role="region" aria-label="Resultado da execução">${HistoryUI._escapeHtml(exec.result)}</div>` ? `<div class="execution-result" role="region" aria-label="Resultado da execução">${Utils.escapeHtml(exec.result)}</div>`
: ''; : '';
const errorBlock = exec.error const errorBlock = exec.error
? `<div class="execution-result execution-result--error" role="alert">${HistoryUI._escapeHtml(exec.error)}</div>` ? `<div class="execution-result execution-result--error" role="alert">${Utils.escapeHtml(exec.error)}</div>`
: ''; : '';
return ` return `
<div class="execution-detail-meta"> <div class="execution-detail-meta">
<div class="execution-detail-row"> <div class="execution-detail-row">
<span class="execution-detail-label">Agente</span> <span class="execution-detail-label">Agente</span>
<span class="execution-detail-value">${HistoryUI._escapeHtml(exec.agentName || exec.agentId || '—')}</span> <span class="execution-detail-value">${Utils.escapeHtml(exec.agentName || exec.agentId || '—')}</span>
</div> </div>
<div class="execution-detail-row"> <div class="execution-detail-row">
<span class="execution-detail-label">Status</span> <span class="execution-detail-label">Status</span>
@@ -243,7 +242,7 @@ const HistoryUI = {
${exec.task ? ` ${exec.task ? `
<div class="execution-detail-section"> <div class="execution-detail-section">
<h3 class="execution-detail-section-title">Tarefa</h3> <h3 class="execution-detail-section-title">Tarefa</h3>
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.task)}</p> <p class="execution-detail-task">${Utils.escapeHtml(exec.task)}</p>
</div>` : ''} </div>` : ''}
${resultBlock ? ` ${resultBlock ? `
<div class="execution-detail-section"> <div class="execution-detail-section">
@@ -279,7 +278,7 @@ const HistoryUI = {
<div class="pipeline-step-detail"> <div class="pipeline-step-detail">
<div class="pipeline-step-header"> <div class="pipeline-step-header">
<div class="pipeline-step-identity"> <div class="pipeline-step-identity">
<span class="pipeline-step-agent">${HistoryUI._escapeHtml(step.agentName || step.agentId || 'Agente')}</span> <span class="pipeline-step-agent">${Utils.escapeHtml(step.agentName || step.agentId || 'Agente')}</span>
${HistoryUI._statusBadge(step.status)} ${HistoryUI._statusBadge(step.status)}
</div> </div>
<span class="pipeline-step-meta-group"> <span class="pipeline-step-meta-group">
@@ -297,13 +296,13 @@ const HistoryUI = {
Prompt utilizado Prompt utilizado
</button> </button>
<div class="pipeline-step-prompt-body" hidden> <div class="pipeline-step-prompt-body" hidden>
<div class="execution-result execution-result--prompt">${HistoryUI._escapeHtml(step.prompt)}</div> <div class="execution-result execution-result--prompt">${Utils.escapeHtml(step.prompt)}</div>
</div> </div>
</div>` : ''} </div>` : ''}
${step.result ? ` ${step.result ? `
<div class="pipeline-step-result"> <div class="pipeline-step-result">
<span class="pipeline-step-result-label">Resultado</span> <span class="pipeline-step-result-label">Resultado</span>
<div class="execution-result">${HistoryUI._escapeHtml(step.result)}</div> <div class="execution-result">${Utils.escapeHtml(step.result)}</div>
</div>` : ''} </div>` : ''}
${step.status === 'error' ? ` ${step.status === 'error' ? `
<div class="execution-result execution-result--error">Passo falhou.</div>` : ''} <div class="execution-result execution-result--error">Passo falhou.</div>` : ''}
@@ -316,7 +315,7 @@ const HistoryUI = {
<div class="execution-detail-meta"> <div class="execution-detail-meta">
<div class="execution-detail-row"> <div class="execution-detail-row">
<span class="execution-detail-label">Pipeline</span> <span class="execution-detail-label">Pipeline</span>
<span class="execution-detail-value">${HistoryUI._escapeHtml(exec.pipelineName || exec.pipelineId || '—')}</span> <span class="execution-detail-value">${Utils.escapeHtml(exec.pipelineName || exec.pipelineId || '—')}</span>
</div> </div>
<div class="execution-detail-row"> <div class="execution-detail-row">
<span class="execution-detail-label">Status</span> <span class="execution-detail-label">Status</span>
@@ -343,7 +342,7 @@ const HistoryUI = {
${exec.input ? ` ${exec.input ? `
<div class="execution-detail-section"> <div class="execution-detail-section">
<h3 class="execution-detail-section-title">Input Inicial</h3> <h3 class="execution-detail-section-title">Input Inicial</h3>
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.input)}</p> <p class="execution-detail-task">${Utils.escapeHtml(exec.input)}</p>
</div>` : ''} </div>` : ''}
${steps.length > 0 ? ` ${steps.length > 0 ? `
<div class="execution-detail-section"> <div class="execution-detail-section">
@@ -355,7 +354,7 @@ const HistoryUI = {
${exec.error ? ` ${exec.error ? `
<div class="execution-detail-section"> <div class="execution-detail-section">
<h3 class="execution-detail-section-title">Erro</h3> <h3 class="execution-detail-section-title">Erro</h3>
<div class="execution-result execution-result--error">${HistoryUI._escapeHtml(exec.error)}</div> <div class="execution-result execution-result--error">${Utils.escapeHtml(exec.error)}</div>
</div>` : ''} </div>` : ''}
`; `;
}, },
@@ -435,15 +434,6 @@ const HistoryUI = {
}); });
}, },
_escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
}; };
window.HistoryUI = HistoryUI; window.HistoryUI = HistoryUI;

View File

@@ -83,7 +83,7 @@ const PipelinesUI = {
const stepCount = steps.length; const stepCount = steps.length;
const flowHtml = steps.map((step, index) => { const flowHtml = steps.map((step, index) => {
const agentName = step.agentName || step.agentId || 'Agente'; const agentName = Utils.escapeHtml(step.agentName || step.agentId || 'Agente');
const isLast = index === steps.length - 1; const isLast = index === steps.length - 1;
const approvalIcon = step.requiresApproval && index > 0 const approvalIcon = step.requiresApproval && index > 0
? '<i data-lucide="shield-check" style="width:10px;height:10px;color:var(--warning)"></i> ' ? '<i data-lucide="shield-check" style="width:10px;height:10px;color:var(--warning)"></i> '
@@ -102,12 +102,12 @@ const PipelinesUI = {
<div class="agent-card-body"> <div class="agent-card-body">
<div class="agent-card-top"> <div class="agent-card-top">
<div class="agent-info"> <div class="agent-info">
<h3 class="agent-name">${pipeline.name || 'Sem nome'}</h3> <h3 class="agent-name">${Utils.escapeHtml(pipeline.name || 'Sem nome')}</h3>
<span class="badge badge-active">${stepCount} ${stepCount === 1 ? 'passo' : 'passos'}</span> <span class="badge badge-active">${stepCount} ${stepCount === 1 ? 'passo' : 'passos'}</span>
</div> </div>
</div> </div>
${pipeline.description ? `<p class="agent-description">${pipeline.description}</p>` : ''} ${pipeline.description ? `<p class="agent-description">${Utils.escapeHtml(pipeline.description)}</p>` : ''}
<div class="pipeline-flow"> <div class="pipeline-flow">
${flowHtml || '<span class="agent-description">Nenhum passo configurado</span>'} ${flowHtml || '<span class="agent-description">Nenhum passo configurado</span>'}
@@ -192,7 +192,7 @@ const PipelinesUI = {
} }
const agentOptions = PipelinesUI.agents const agentOptions = PipelinesUI.agents
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`) .map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`)
.join(''); .join('');
container.innerHTML = PipelinesUI._steps.map((step, index) => { container.innerHTML = PipelinesUI._steps.map((step, index) => {
@@ -225,7 +225,7 @@ const PipelinesUI = {
placeholder="{{input}} será substituído pelo output anterior" placeholder="{{input}} será substituído pelo output anterior"
data-step-field="inputTemplate" data-step-field="inputTemplate"
data-step-index="${index}" data-step-index="${index}"
>${step.inputTemplate || ''}</textarea> >${Utils.escapeHtml(step.inputTemplate || '')}</textarea>
${approvalHtml} ${approvalHtml}
</div> </div>
<div class="pipeline-step-actions"> <div class="pipeline-step-actions">

View File

@@ -44,8 +44,8 @@ const SchedulesUI = {
return ` return `
<tr> <tr>
<td>${schedule.agentName || '—'}</td> <td>${Utils.escapeHtml(schedule.agentName || '—')}</td>
<td class="schedule-task-cell" title="${schedule.taskDescription || ''}">${schedule.taskDescription || '—'}</td> <td class="schedule-task-cell" title="${Utils.escapeHtml(schedule.taskDescription || '')}">${Utils.escapeHtml(schedule.taskDescription || '—')}</td>
<td> <td>
<code class="font-mono">${cronExpr}</code> <code class="font-mono">${cronExpr}</code>
</td> </td>
@@ -106,7 +106,7 @@ const SchedulesUI = {
select.innerHTML = '<option value="">Selecionar agente...</option>' + select.innerHTML = '<option value="">Selecionar agente...</option>' +
agents agents
.filter((a) => a.status === 'active') .filter((a) => a.status === 'active')
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`) .map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`)
.join(''); .join('');
} }
@@ -233,12 +233,12 @@ const SchedulesUI = {
const duration = SchedulesUI._formatDuration(exec.startedAt, exec.endedAt); const duration = SchedulesUI._formatDuration(exec.startedAt, exec.endedAt);
const cost = exec.costUsd || exec.totalCostUsd || 0; const cost = exec.costUsd || exec.totalCostUsd || 0;
const costStr = cost > 0 ? `$${cost.toFixed(4)}` : '—'; const costStr = cost > 0 ? `$${cost.toFixed(4)}` : '—';
const taskStr = SchedulesUI._escapeHtml(SchedulesUI._truncate(exec.task || '', 60)); const taskStr = Utils.escapeHtml(Utils.truncate(exec.task || '', 60));
return ` return `
<tr> <tr>
<td>${SchedulesUI._escapeHtml(exec.agentName || '—')}</td> <td>${Utils.escapeHtml(exec.agentName || '—')}</td>
<td title="${SchedulesUI._escapeHtml(exec.task || '')}">${taskStr}</td> <td title="${Utils.escapeHtml(exec.task || '')}">${taskStr}</td>
<td>${status}</td> <td>${status}</td>
<td>${date}</td> <td>${date}</td>
<td>${duration}</td> <td>${duration}</td>
@@ -283,16 +283,6 @@ const SchedulesUI = {
return `${minutes}m ${seconds}s`; return `${minutes}m ${seconds}s`;
}, },
_escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
},
_truncate(str, max) {
if (!str || str.length <= max) return str;
return str.slice(0, max) + '…';
},
cronToHuman(expression) { cronToHuman(expression) {
if (!expression) return '—'; if (!expression) return '—';

View File

@@ -65,10 +65,10 @@ const TasksUI = {
return ` return `
<div class="task-card" data-task-id="${task.id}"> <div class="task-card" data-task-id="${task.id}">
<div class="task-card-header"> <div class="task-card-header">
<h4 class="task-card-name">${task.name}</h4> <h4 class="task-card-name">${Utils.escapeHtml(task.name)}</h4>
<span class="badge ${categoryClass}">${categoryLabel}</span> <span class="badge ${categoryClass}">${Utils.escapeHtml(categoryLabel)}</span>
</div> </div>
${task.description ? `<p class="task-card-description">${task.description}</p>` : ''} ${task.description ? `<p class="task-card-description">${Utils.escapeHtml(task.description)}</p>` : ''}
<div class="task-card-footer"> <div class="task-card-footer">
<span class="task-card-date"> <span class="task-card-date">
<i data-lucide="calendar"></i> <i data-lucide="calendar"></i>
@@ -117,7 +117,7 @@ const TasksUI = {
<div class="task-card task-card--form" id="task-inline-form"> <div class="task-card task-card--form" id="task-inline-form">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="task-inline-name">${title}</label> <label class="form-label" for="task-inline-name">${title}</label>
<input type="text" id="task-inline-name" class="input" placeholder="Ex: Code Review de PR" required autocomplete="off" value="${task.name || ''}"> <input type="text" id="task-inline-name" class="input" placeholder="Ex: Code Review de PR" required autocomplete="off" value="${Utils.escapeHtml(task.name || '')}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="task-inline-category">Categoria</label> <label class="form-label" for="task-inline-category">Categoria</label>
@@ -133,7 +133,7 @@ const TasksUI = {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="task-inline-description">Descrição</label> <label class="form-label" for="task-inline-description">Descrição</label>
<textarea id="task-inline-description" class="textarea" rows="2" placeholder="Descreva o objetivo desta tarefa...">${task.description || ''}</textarea> <textarea id="task-inline-description" class="textarea" rows="2" placeholder="Descreva o objetivo desta tarefa...">${Utils.escapeHtml(task.description || '')}</textarea>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button class="btn btn--primary" id="btn-save-inline-task" type="button">${btnLabel}</button> <button class="btn btn--primary" id="btn-save-inline-task" type="button">${btnLabel}</button>
@@ -228,7 +228,7 @@ const TasksUI = {
const selectEl = document.getElementById('execute-agent-select'); const selectEl = document.getElementById('execute-agent-select');
if (selectEl) { if (selectEl) {
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' + selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
activeAgents.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`).join(''); activeAgents.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`).join('');
selectEl.value = ''; selectEl.value = '';
} }

View File

@@ -102,7 +102,7 @@ const Terminal = {
const html = lines.map((line) => { const html = lines.map((line) => {
const typeClass = line.type && line.type !== 'default' ? ' ' + line.type : ''; const typeClass = line.type && line.type !== 'default' ? ' ' + line.type : '';
const escaped = Terminal._escapeHtml(line.content); const escaped = Utils.escapeHtml(line.content);
const formatted = escaped.replace(/\n/g, '<br>'); const formatted = escaped.replace(/\n/g, '<br>');
return `<div class="terminal-line${typeClass}"> return `<div class="terminal-line${typeClass}">
@@ -120,14 +120,6 @@ const Terminal = {
if (Terminal.autoScroll) Terminal.scrollToBottom(); if (Terminal.autoScroll) Terminal.scrollToBottom();
}, },
_escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
},
}; };
window.Terminal = Terminal; window.Terminal = Terminal;

View File

@@ -71,7 +71,7 @@ const WebhooksUI = {
<article class="webhook-card"> <article class="webhook-card">
<div class="webhook-card-header"> <div class="webhook-card-header">
<div class="webhook-card-identity"> <div class="webhook-card-identity">
<span class="webhook-card-name">${WebhooksUI._escapeHtml(webhook.name)}</span> <span class="webhook-card-name">${Utils.escapeHtml(webhook.name)}</span>
${typeBadge} ${typeBadge}
${statusBadge} ${statusBadge}
</div> </div>
@@ -87,7 +87,7 @@ const WebhooksUI = {
<div class="webhook-card-body"> <div class="webhook-card-body">
<div class="webhook-card-target"> <div class="webhook-card-target">
<span class="webhook-card-label">Destino</span> <span class="webhook-card-label">Destino</span>
<span class="webhook-card-value">${WebhooksUI._escapeHtml(targetName)}</span> <span class="webhook-card-value">${Utils.escapeHtml(targetName)}</span>
</div> </div>
<div class="webhook-card-url"> <div class="webhook-card-url">
<span class="webhook-card-label">URL</span> <span class="webhook-card-label">URL</span>
@@ -150,10 +150,10 @@ const WebhooksUI = {
if (targetType === 'agent') { if (targetType === 'agent') {
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' + selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
WebhooksUI.agents.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`).join(''); WebhooksUI.agents.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`).join('');
} else { } else {
selectEl.innerHTML = '<option value="">Selecionar pipeline...</option>' + selectEl.innerHTML = '<option value="">Selecionar pipeline...</option>' +
WebhooksUI.pipelines.map((p) => `<option value="${p.id}">${p.name}</option>`).join(''); WebhooksUI.pipelines.map((p) => `<option value="${p.id}">${Utils.escapeHtml(p.name)}</option>`).join('');
} }
}, },
@@ -243,15 +243,6 @@ const WebhooksUI = {
} }
}, },
_escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
}; };
window.WebhooksUI = WebhooksUI; window.WebhooksUI = WebhooksUI;

32
public/js/utils.js Normal file
View File

@@ -0,0 +1,32 @@
const Utils = {
escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
},
formatDuration(ms) {
if (!ms || ms < 0) return '—';
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60000);
const s = Math.floor((ms % 60000) / 1000);
return `${m}m ${s}s`;
},
formatCost(usd) {
if (!usd || usd === 0) return '$0.0000';
return `$${Number(usd).toFixed(4)}`;
},
truncate(str, max = 80) {
if (!str) return '';
return str.length > max ? str.slice(0, max) + '…' : str;
},
};
window.Utils = Utils;

View File

@@ -4,6 +4,7 @@ import { WebSocketServer } from 'ws';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
import apiRouter, { setWsBroadcast, setWsBroadcastTo, hookRouter } from './src/routes/api.js'; import apiRouter, { setWsBroadcast, setWsBroadcastTo, hookRouter } from './src/routes/api.js';
import * as manager from './src/agents/manager.js'; import * as manager from './src/agents/manager.js';
import { setGlobalBroadcast } from './src/agents/manager.js'; import { setGlobalBroadcast } from './src/agents/manager.js';
@@ -14,6 +15,24 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const AUTH_TOKEN = process.env.AUTH_TOKEN || ''; const AUTH_TOKEN = process.env.AUTH_TOKEN || '';
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || ''; const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || '';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
function verifyWebhookSignature(req, res, next) {
if (!WEBHOOK_SECRET) return next();
const sig = req.headers['x-hub-signature-256'];
if (!sig) return res.status(401).json({ error: 'Assinatura ausente' });
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
hmac.update(req.rawBody || '');
const expected = 'sha256=' + hmac.digest('hex');
try {
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).json({ error: 'Assinatura inválida' });
}
} catch {
return res.status(401).json({ error: 'Assinatura inválida' });
}
next();
}
const app = express(); const app = express();
const httpServer = createServer(app); const httpServer = createServer(app);
@@ -39,8 +58,10 @@ if (AUTH_TOKEN) {
}); });
} }
app.use(express.json()); app.use(express.json({
app.use('/hook', hookRouter); verify: (req, res, buf) => { req.rawBody = buf; },
}));
app.use('/hook', verifyWebhookSignature, hookRouter);
app.use(express.static(join(__dirname, 'public'))); app.use(express.static(join(__dirname, 'public')));
app.use('/api', apiRouter); app.use('/api', apiRouter);

View File

@@ -41,6 +41,7 @@ function createStore(filePath) {
let mem = null; let mem = null;
let dirty = false; let dirty = false;
let timer = null; let timer = null;
let maxSize = Infinity;
function boot() { function boot() {
if (mem !== null) return; if (mem !== null) return;
@@ -81,6 +82,9 @@ function createStore(filePath) {
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}; };
mem.push(item); mem.push(item);
if (maxSize !== Infinity && mem.length > maxSize) {
mem.splice(0, mem.length - maxSize);
}
touch(); touch();
return clone(item); return clone(item);
}, },
@@ -118,6 +122,10 @@ function createStore(filePath) {
dirty = false; dirty = false;
} }
}, },
setMaxSize(n) {
maxSize = n;
},
}; };
allStores.push(store); allStores.push(store);
@@ -185,5 +193,9 @@ export const tasksStore = createStore(`${DATA_DIR}/tasks.json`);
export const pipelinesStore = createStore(`${DATA_DIR}/pipelines.json`); export const pipelinesStore = createStore(`${DATA_DIR}/pipelines.json`);
export const schedulesStore = createStore(`${DATA_DIR}/schedules.json`); export const schedulesStore = createStore(`${DATA_DIR}/schedules.json`);
export const executionsStore = createStore(`${DATA_DIR}/executions.json`); export const executionsStore = createStore(`${DATA_DIR}/executions.json`);
executionsStore.setMaxSize(5000);
export const webhooksStore = createStore(`${DATA_DIR}/webhooks.json`); export const webhooksStore = createStore(`${DATA_DIR}/webhooks.json`);
export const settingsStore = createSettingsStore(`${DATA_DIR}/settings.json`); export const settingsStore = createSettingsStore(`${DATA_DIR}/settings.json`);
export const secretsStore = createStore(`${DATA_DIR}/secrets.json`);
export const notificationsStore = createStore(`${DATA_DIR}/notifications.json`);
export const agentVersionsStore = createStore(`${DATA_DIR}/agent_versions.json`);