argusqa-os 9.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +879 -0
- package/package.json +69 -0
- package/src/adapters/browser.js +82 -0
- package/src/argus.js +8 -0
- package/src/batch-runner.js +8 -0
- package/src/cli/init.js +314 -0
- package/src/config/schema.js +108 -0
- package/src/config/targets.js +309 -0
- package/src/domain/finding.js +25 -0
- package/src/mcp-server.js +156 -0
- package/src/orchestration/crawl-and-report.js +16 -0
- package/src/orchestration/dispatcher.js +263 -0
- package/src/orchestration/env-comparison.js +498 -0
- package/src/orchestration/orchestrator.js +1128 -0
- package/src/orchestration/report-processor.js +134 -0
- package/src/orchestration/slack-notifier.js +337 -0
- package/src/orchestration/watch-mode.js +316 -0
- package/src/registry.js +18 -0
- package/src/server/index.js +94 -0
- package/src/server/interaction-handler.js +126 -0
- package/src/server/slash-command-handler.js +185 -0
- package/src/utils/api-frequency.js +128 -0
- package/src/utils/baseline-manager.js +255 -0
- package/src/utils/codebase-analyzer.js +299 -0
- package/src/utils/content-analyzer.js +155 -0
- package/src/utils/contract-validator.js +178 -0
- package/src/utils/css-analyzer.js +407 -0
- package/src/utils/diff.js +189 -0
- package/src/utils/flakiness-detector.js +82 -0
- package/src/utils/flow-runner.js +572 -0
- package/src/utils/github-reporter.js +310 -0
- package/src/utils/hover-analyzer.js +214 -0
- package/src/utils/html-reporter.js +301 -0
- package/src/utils/issues-analyzer.js +171 -0
- package/src/utils/keyboard-analyzer.js +141 -0
- package/src/utils/lighthouse-checker.js +120 -0
- package/src/utils/logger.js +39 -0
- package/src/utils/login-orchestrator.js +99 -0
- package/src/utils/mcp-client.js +264 -0
- package/src/utils/mcp-parsers.js +57 -0
- package/src/utils/memory-analyzer.js +270 -0
- package/src/utils/network-timing-analyzer.js +76 -0
- package/src/utils/parallel-crawler.js +28 -0
- package/src/utils/responsive-analyzer.js +253 -0
- package/src/utils/retry.js +36 -0
- package/src/utils/route-discoverer.js +306 -0
- package/src/utils/security-analyzer.js +302 -0
- package/src/utils/seo-analyzer.js +164 -0
- package/src/utils/session-manager.js +12 -0
- package/src/utils/session-persistence.js +214 -0
- package/src/utils/severity-overrides.js +91 -0
- package/src/utils/slack-guard.js +18 -0
- package/src/utils/slug.js +8 -0
- package/src/utils/snapshot-analyzer.js +330 -0
- package/src/utils/telemetry.js +190 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argus Watch Mode — passive browser monitoring.
|
|
3
|
+
*
|
|
4
|
+
* Instead of navigating to URLs itself, Argus connects to whatever page is
|
|
5
|
+
* already open in Chrome and polls list_console_messages / list_network_requests
|
|
6
|
+
* at a configurable interval, reporting new errors in real time.
|
|
7
|
+
*
|
|
8
|
+
* Usage (production):
|
|
9
|
+
* npm run watch
|
|
10
|
+
* # or: node src/orchestration/watch-mode.js
|
|
11
|
+
*
|
|
12
|
+
* The WatchSession class is exported separately so the test harness can drive
|
|
13
|
+
* individual poll() calls without running the interval loop.
|
|
14
|
+
*
|
|
15
|
+
* Environment variables:
|
|
16
|
+
* ARGUS_WATCH_INTERVAL_MS — poll interval in ms (default: 3000)
|
|
17
|
+
* TARGET_DEV_URL — base URL to monitor (default: http://localhost:3000)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'fs';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import { fileURLToPath } from 'url';
|
|
23
|
+
import 'dotenv/config';
|
|
24
|
+
import { createMcpClient } from '../utils/mcp-client.js';
|
|
25
|
+
import { childLogger } from '../utils/logger.js';
|
|
26
|
+
|
|
27
|
+
const logger = childLogger('watch-mode');
|
|
28
|
+
import { CdpBrowserAdapter } from '../adapters/browser.js';
|
|
29
|
+
import {
|
|
30
|
+
analyzeSecurityConsole,
|
|
31
|
+
analyzeSecurityNetwork,
|
|
32
|
+
} from '../utils/security-analyzer.js';
|
|
33
|
+
import { postBugReport } from './slack-notifier.js';
|
|
34
|
+
import { isSlackConfigured } from '../utils/slack-guard.js';
|
|
35
|
+
import { generateHtmlReport } from '../utils/html-reporter.js';
|
|
36
|
+
import {
|
|
37
|
+
parseConsoleMsgResponse,
|
|
38
|
+
parseNetworkReqResponse,
|
|
39
|
+
} from '../utils/mcp-parsers.js';
|
|
40
|
+
|
|
41
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
const REPORTS_DIR = path.resolve(__dirname, '../../reports');
|
|
43
|
+
|
|
44
|
+
// ── Deduplication key generators ───────────────────────────────────────────────
|
|
45
|
+
// Two messages/requests are considered "the same" if their keys match. This
|
|
46
|
+
// prevents re-reporting errors that were already captured in a previous poll.
|
|
47
|
+
//
|
|
48
|
+
// Content-based keys (not ID-based) are intentional: msgid/reqid can reset
|
|
49
|
+
// after navigation, which would cause ID-based dedup to suppress new findings
|
|
50
|
+
// on a freshly loaded page if a prior page had the same IDs.
|
|
51
|
+
|
|
52
|
+
const consoleKey = (m) =>
|
|
53
|
+
`${(m.level ?? m.type ?? 'log').toLowerCase()}::${(m.text ?? m.message ?? '').slice(0, 200)}`;
|
|
54
|
+
|
|
55
|
+
const networkKey = (r) =>
|
|
56
|
+
`${r.method ?? 'GET'}::${r.url ?? ''}::${r.status ?? r.statusCode ?? 0}`;
|
|
57
|
+
|
|
58
|
+
// ── Classifiers ────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function classifyConsoleMsg(msg, url) {
|
|
61
|
+
const level = (msg.level ?? msg.type ?? '').toLowerCase();
|
|
62
|
+
if (level === 'error' || level === 'exception' || level === 'jsexception') {
|
|
63
|
+
return {
|
|
64
|
+
type: 'console',
|
|
65
|
+
severity: 'warning',
|
|
66
|
+
message: msg.text ?? msg.message ?? '(empty)',
|
|
67
|
+
url,
|
|
68
|
+
source: msg.source ?? null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (level === 'warning' || level === 'warn') {
|
|
72
|
+
return {
|
|
73
|
+
type: 'console_warning',
|
|
74
|
+
severity: 'info',
|
|
75
|
+
message: msg.text ?? msg.message ?? '(empty)',
|
|
76
|
+
url,
|
|
77
|
+
source: msg.source ?? null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function classifyNetworkReq(req, url) {
|
|
84
|
+
const status = req.status ?? req.statusCode ?? 0;
|
|
85
|
+
const reqUrl = req.url ?? '';
|
|
86
|
+
|
|
87
|
+
if (status === 401 || status === 403) {
|
|
88
|
+
return {
|
|
89
|
+
type: status === 401 ? 'network_auth_error' : 'network_forbidden',
|
|
90
|
+
severity: 'critical',
|
|
91
|
+
message: `HTTP ${status} — ${reqUrl}`,
|
|
92
|
+
url: reqUrl,
|
|
93
|
+
status,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (status === 404) {
|
|
97
|
+
return {
|
|
98
|
+
type: 'network_not_found',
|
|
99
|
+
severity: 'warning',
|
|
100
|
+
message: `HTTP 404 — ${reqUrl}`,
|
|
101
|
+
url: reqUrl,
|
|
102
|
+
status,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (status >= 500) {
|
|
106
|
+
return {
|
|
107
|
+
type: 'network_server_error',
|
|
108
|
+
severity: 'critical',
|
|
109
|
+
message: `HTTP ${status} — ${reqUrl}`,
|
|
110
|
+
url: reqUrl,
|
|
111
|
+
status,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// CORS / net::ERR_* failures surface as failed requests with no status
|
|
115
|
+
if (req.failed || req.error) {
|
|
116
|
+
const err = (req.error ?? req.errorText ?? '').toLowerCase();
|
|
117
|
+
if (err.includes('cors') || err.includes('blocked') || err.includes('cross-origin')) {
|
|
118
|
+
return {
|
|
119
|
+
type: 'cors_error',
|
|
120
|
+
severity: 'critical',
|
|
121
|
+
message: `CORS blocked — ${reqUrl}`,
|
|
122
|
+
url: reqUrl,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (reqUrl) {
|
|
126
|
+
return {
|
|
127
|
+
type: 'network_failed',
|
|
128
|
+
severity: 'warning',
|
|
129
|
+
message: `Request failed — ${reqUrl}${req.error ? ': ' + req.error : ''}`,
|
|
130
|
+
url: reqUrl,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── WatchSession ───────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* WatchSession tracks state between polls (seen console keys, seen network keys,
|
|
141
|
+
* accumulated findings). It does NOT own the mcp client — the caller manages
|
|
142
|
+
* the client lifecycle.
|
|
143
|
+
*
|
|
144
|
+
* Exported so the test harness can call poll() in isolation without running
|
|
145
|
+
* the interval-based runWatchMode() entry point.
|
|
146
|
+
*/
|
|
147
|
+
export class WatchSession {
|
|
148
|
+
constructor(browser, baseUrl) {
|
|
149
|
+
this._browser = browser;
|
|
150
|
+
this._baseUrl = baseUrl;
|
|
151
|
+
this._seenConsole = new Set();
|
|
152
|
+
this._seenNetwork = new Set();
|
|
153
|
+
this._allFindings = [];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Run one poll cycle.
|
|
158
|
+
*
|
|
159
|
+
* @returns {{ findings: object[], newConsole: object[], newNetwork: object[] }}
|
|
160
|
+
* findings — only the NEW findings detected this poll (not previously seen)
|
|
161
|
+
* newConsole — raw new console messages (for caller inspection)
|
|
162
|
+
* newNetwork — raw new network requests (for caller inspection)
|
|
163
|
+
*/
|
|
164
|
+
async poll() {
|
|
165
|
+
const findings = [];
|
|
166
|
+
|
|
167
|
+
// ── Console ──────────────────────────────────────────────────────────────
|
|
168
|
+
const allConsole = await this._browser.listConsole();
|
|
169
|
+
const newConsole = allConsole.filter(m => {
|
|
170
|
+
const k = consoleKey(m);
|
|
171
|
+
if (this._seenConsole.has(k)) return false;
|
|
172
|
+
this._seenConsole.add(k);
|
|
173
|
+
return true;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
for (const msg of newConsole) {
|
|
177
|
+
const f = classifyConsoleMsg(msg, this._baseUrl);
|
|
178
|
+
if (f) findings.push(f);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Network ──────────────────────────────────────────────────────────────
|
|
182
|
+
const allNetwork = await this._browser.listNetwork();
|
|
183
|
+
const newNetwork = allNetwork.filter(r => {
|
|
184
|
+
const k = networkKey(r);
|
|
185
|
+
if (this._seenNetwork.has(k)) return false;
|
|
186
|
+
this._seenNetwork.add(k);
|
|
187
|
+
return true;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
for (const req of newNetwork) {
|
|
191
|
+
const f = classifyNetworkReq(req, this._baseUrl);
|
|
192
|
+
if (f) findings.push(f);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Security surface (reuses existing analyzers) ──────────────────────────
|
|
196
|
+
findings.push(...analyzeSecurityConsole(newConsole, this._baseUrl));
|
|
197
|
+
findings.push(...analyzeSecurityNetwork(newNetwork, this._baseUrl));
|
|
198
|
+
|
|
199
|
+
this._allFindings.push(...findings);
|
|
200
|
+
return { findings, newConsole, newNetwork };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** All findings accumulated across every poll() call so far. */
|
|
204
|
+
getAllFindings() {
|
|
205
|
+
return [...this._allFindings];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Production entry point ─────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Start the watch loop. Polls on ARGUS_WATCH_INTERVAL_MS interval, prints new
|
|
213
|
+
* findings to the terminal, posts to Slack if configured, and on Ctrl+C writes
|
|
214
|
+
* a final HTML report.
|
|
215
|
+
*
|
|
216
|
+
* @param {string} [baseUrl] — URL to attribute findings to (does not navigate)
|
|
217
|
+
*/
|
|
218
|
+
export async function runWatchMode(baseUrl) {
|
|
219
|
+
const target = baseUrl ?? process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
|
|
220
|
+
const pollIntervalMs = parseInt(process.env.ARGUS_WATCH_INTERVAL_MS ?? '3000', 10);
|
|
221
|
+
|
|
222
|
+
const mcp = await createMcpClient();
|
|
223
|
+
const browser = new CdpBrowserAdapter(mcp);
|
|
224
|
+
const session = new WatchSession(browser, target);
|
|
225
|
+
|
|
226
|
+
logger.info('\n[ARGUS WATCH] ─────────────────────────────────────────────────');
|
|
227
|
+
logger.info(`[ARGUS WATCH] Passive monitoring — ${target}`);
|
|
228
|
+
logger.info(`[ARGUS WATCH] Polling every ${pollIntervalMs}ms. Press Ctrl+C to stop.`);
|
|
229
|
+
logger.info('[ARGUS WATCH] ─────────────────────────────────────────────────\n');
|
|
230
|
+
|
|
231
|
+
const badge = (severity) =>
|
|
232
|
+
severity === 'critical' ? '✗ CRIT' :
|
|
233
|
+
severity === 'warning' ? '! WARN' : 'i INFO';
|
|
234
|
+
|
|
235
|
+
const doPoll = async () => {
|
|
236
|
+
try {
|
|
237
|
+
const { findings } = await session.poll();
|
|
238
|
+
if (findings.length === 0) return;
|
|
239
|
+
|
|
240
|
+
logger.info(`\n[ARGUS WATCH] ${new Date().toLocaleTimeString()} — ${findings.length} new finding(s):`);
|
|
241
|
+
for (const f of findings) {
|
|
242
|
+
logger.info(` [${badge(f.severity)}] [${f.type}] ${f.message}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (isSlackConfigured()) {
|
|
246
|
+
const bySeverity = { critical: [], warning: [], info: [] };
|
|
247
|
+
for (const f of findings) {
|
|
248
|
+
(bySeverity[f.severity] ?? bySeverity.info).push(f);
|
|
249
|
+
}
|
|
250
|
+
for (const [sev, group] of Object.entries(bySeverity)) {
|
|
251
|
+
if (group.length === 0) continue;
|
|
252
|
+
await postBugReport({
|
|
253
|
+
severity: sev,
|
|
254
|
+
title: `[Watch] ${group.length} ${sev} finding(s) — ${target}`,
|
|
255
|
+
description: group.map(f => `• *[${f.type}]* ${f.message}`).join('\n'),
|
|
256
|
+
url: target,
|
|
257
|
+
screenshotPath: null,
|
|
258
|
+
details: { findings: group, source: 'watch-mode' },
|
|
259
|
+
}).catch(e => logger.warn('[ARGUS WATCH] Slack post failed:', e.message));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} catch (err) {
|
|
263
|
+
logger.warn('[ARGUS WATCH] Poll error:', err.message);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// First poll fires immediately, then on interval
|
|
268
|
+
await doPoll();
|
|
269
|
+
const interval = setInterval(doPoll, pollIntervalMs);
|
|
270
|
+
|
|
271
|
+
process.on('SIGINT', async () => {
|
|
272
|
+
clearInterval(interval);
|
|
273
|
+
const all = session.getAllFindings();
|
|
274
|
+
|
|
275
|
+
logger.info(`\n[ARGUS WATCH] Stopped. Total findings: ${all.length}`);
|
|
276
|
+
|
|
277
|
+
if (all.length > 0) {
|
|
278
|
+
try {
|
|
279
|
+
fs.mkdirSync(REPORTS_DIR, { recursive: true });
|
|
280
|
+
const reportJson = {
|
|
281
|
+
baseUrl: target,
|
|
282
|
+
generatedAt: new Date().toISOString(),
|
|
283
|
+
summary: {
|
|
284
|
+
total: all.length,
|
|
285
|
+
critical: all.filter(f => f.severity === 'critical').length,
|
|
286
|
+
warning: all.filter(f => f.severity === 'warning').length,
|
|
287
|
+
info: all.filter(f => f.severity === 'info').length,
|
|
288
|
+
},
|
|
289
|
+
routes: [{ route: 'watch', url: target, errors: all }],
|
|
290
|
+
flows: [],
|
|
291
|
+
};
|
|
292
|
+
const jsonPath = path.join(REPORTS_DIR, 'watch-report.json');
|
|
293
|
+
fs.writeFileSync(jsonPath, JSON.stringify(reportJson, null, 2), 'utf8');
|
|
294
|
+
const htmlPath = generateHtmlReport(jsonPath);
|
|
295
|
+
logger.info(`[ARGUS WATCH] HTML report written → ${htmlPath}`);
|
|
296
|
+
} catch (e) {
|
|
297
|
+
logger.warn('[ARGUS WATCH] HTML report failed:', e.message);
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
logger.info('[ARGUS WATCH] No issues detected during this session. ✓');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try { await browser.close(); } catch { /* ignore */ }
|
|
304
|
+
process.exit(0);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── CLI invocation ─────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
// Run when invoked directly: node src/orchestration/watch-mode.js [url]
|
|
311
|
+
if (process.argv[1]?.endsWith('watch-mode.js')) {
|
|
312
|
+
runWatchMode(process.argv[2]).catch(err => {
|
|
313
|
+
logger.error('[ARGUS WATCH] Fatal:', err.message);
|
|
314
|
+
process.exit(1);
|
|
315
|
+
});
|
|
316
|
+
}
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argus Analyzer Plugin Registry (v9.1.2)
|
|
3
|
+
*
|
|
4
|
+
* Analyzers self-register at module load time by calling registerCheap()
|
|
5
|
+
* or registerExpensive(). The orchestrator iterates getCheap() / getExpensive()
|
|
6
|
+
* instead of 14+ named function calls — adding a new detector = 1 file only.
|
|
7
|
+
*
|
|
8
|
+
* clearAll() is a test helper — do not call in production code.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const _cheap = [];
|
|
12
|
+
const _expensive = [];
|
|
13
|
+
|
|
14
|
+
export const registerCheap = (analyzer) => _cheap.push(analyzer);
|
|
15
|
+
export const registerExpensive = (analyzer) => _expensive.push(analyzer);
|
|
16
|
+
export const getCheap = () => [..._cheap];
|
|
17
|
+
export const getExpensive = () => [..._expensive];
|
|
18
|
+
export const clearAll = () => { _cheap.length = 0; _expensive.length = 0; };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARGUS Server
|
|
3
|
+
*
|
|
4
|
+
* Express server that receives:
|
|
5
|
+
* POST /slack/commands — slash command (/argus-retest <url>)
|
|
6
|
+
* POST /slack/interactions — Block Kit button interactions (Acknowledge, Retest)
|
|
7
|
+
* GET /health — health check
|
|
8
|
+
*
|
|
9
|
+
* Run: node src/server/index.js
|
|
10
|
+
*
|
|
11
|
+
* For production, expose this server via a public URL and configure it in
|
|
12
|
+
* your Slack App settings (Slash Commands + Interactivity & Shortcuts).
|
|
13
|
+
* For local development: cloudflared tunnel --url http://localhost:3001
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import express from 'express';
|
|
17
|
+
import 'dotenv/config';
|
|
18
|
+
|
|
19
|
+
import { handleSlashCommand } from './slash-command-handler.js';
|
|
20
|
+
import { handleInteraction } from './interaction-handler.js';
|
|
21
|
+
import { childLogger } from '../utils/logger.js';
|
|
22
|
+
|
|
23
|
+
const logger = childLogger('server');
|
|
24
|
+
|
|
25
|
+
const app = express();
|
|
26
|
+
const PORT = process.env.PORT ?? 3001;
|
|
27
|
+
|
|
28
|
+
// ── Raw body capture (needed for Slack signature verification) ─────────────────
|
|
29
|
+
// Uses Express body-parser verify callbacks so req.rawBody is populated without
|
|
30
|
+
// consuming the stream separately (separate stream consumer would leave body parsers
|
|
31
|
+
// with an already-exhausted stream and produce empty req.body on every request).
|
|
32
|
+
// 512 KB limit matches Slack's max payload size.
|
|
33
|
+
const BODY_LIMIT = '512kb';
|
|
34
|
+
|
|
35
|
+
function captureRawBody(req, _res, buf) {
|
|
36
|
+
req.rawBody = buf.toString('utf8');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Parse URL-encoded bodies (Slack slash commands + interactions)
|
|
40
|
+
app.use(express.urlencoded({ extended: true, limit: BODY_LIMIT, verify: captureRawBody }));
|
|
41
|
+
// Parse JSON bodies
|
|
42
|
+
app.use(express.json({ limit: BODY_LIMIT, verify: captureRawBody }));
|
|
43
|
+
|
|
44
|
+
// ── Request error handler ──────────────────────────────────────────────────────
|
|
45
|
+
app.use((req, res, next) => {
|
|
46
|
+
req.on('error', err => {
|
|
47
|
+
logger.error('[ARGUS] Request stream error:', err.message);
|
|
48
|
+
if (!res.headersSent) res.status(400).send('Bad request');
|
|
49
|
+
});
|
|
50
|
+
next();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── Routes ─────────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
app.get('/health', (req, res) => {
|
|
56
|
+
res.json({ status: 'ok', service: 'argus', ts: new Date().toISOString() });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Slack slash commands
|
|
60
|
+
app.post('/slack/commands', handleSlashCommand);
|
|
61
|
+
|
|
62
|
+
// Slack Block Kit interactions (button clicks)
|
|
63
|
+
app.post('/slack/interactions', handleInteraction);
|
|
64
|
+
|
|
65
|
+
// ── Start ──────────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
// Capture server instance so we can attach an error listener.
|
|
68
|
+
const server = app.listen(PORT, () => {
|
|
69
|
+
logger.info(`[ARGUS] Server running on port ${PORT}`);
|
|
70
|
+
logger.info(`[ARGUS] Slash commands: POST http://localhost:${PORT}/slack/commands`);
|
|
71
|
+
logger.info(`[ARGUS] Interactions: POST http://localhost:${PORT}/slack/interactions`);
|
|
72
|
+
logger.info(`[ARGUS] Health: GET http://localhost:${PORT}/health`);
|
|
73
|
+
logger.info('');
|
|
74
|
+
logger.info('[ARGUS] For local testing, expose with: cloudflared tunnel --url http://localhost:' + PORT);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// requestTimeout is assigned synchronously here — before the Node.js event loop
|
|
78
|
+
// processes any incoming connection — so every request inherits the 10 s limit.
|
|
79
|
+
// Must remain after app.listen() (the server object doesn't exist before that call)
|
|
80
|
+
// but must remain synchronous (not inside the listen callback) to close the startup race.
|
|
81
|
+
server.requestTimeout = 10_000;
|
|
82
|
+
|
|
83
|
+
// Without this, a port conflict emits an unhandled 'error' event and terminates
|
|
84
|
+
// the process with a cryptic EADDRINUSE message and no guidance.
|
|
85
|
+
server.on('error', err => {
|
|
86
|
+
if (err.code === 'EADDRINUSE') {
|
|
87
|
+
logger.error(`[ARGUS] Port ${PORT} is already in use — try PORT=3002 node src/server/index.js`);
|
|
88
|
+
} else {
|
|
89
|
+
logger.error('[ARGUS] Server error:', err.message);
|
|
90
|
+
}
|
|
91
|
+
process.exit(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export default app;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARGUS Interaction Handler
|
|
3
|
+
*
|
|
4
|
+
* Handles Slack Block Kit button interactions:
|
|
5
|
+
* - "Acknowledge" button → updates the original message with an acknowledged badge
|
|
6
|
+
* - "Retest" button → triggers a new test run and posts results as thread reply
|
|
7
|
+
*
|
|
8
|
+
* Configure in Slack App:
|
|
9
|
+
* Interactivity & Shortcuts → Request URL: https://your-server.com/slack/interactions
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { verifySlackSignature } from './slash-command-handler.js';
|
|
13
|
+
import { acknowledgeMessage, postRetestResult } from '../orchestration/slack-notifier.js';
|
|
14
|
+
import { createMcpClient } from '../utils/mcp-client.js';
|
|
15
|
+
import { runCrawl } from '../orchestration/crawl-and-report.js';
|
|
16
|
+
import { childLogger } from '../utils/logger.js';
|
|
17
|
+
|
|
18
|
+
const logger = childLogger('interaction-handler');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Handle POST /slack/interactions
|
|
22
|
+
* @param {object} req - Express request
|
|
23
|
+
* @param {object} res - Express response
|
|
24
|
+
*/
|
|
25
|
+
export async function handleInteraction(req, res) {
|
|
26
|
+
if (!verifySlackSignature(req)) {
|
|
27
|
+
return res.status(401).json({ error: 'Invalid signature' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let payload;
|
|
31
|
+
try {
|
|
32
|
+
// Slack sends interactions as URL-encoded JSON in the `payload` field
|
|
33
|
+
payload = JSON.parse(req.body.payload);
|
|
34
|
+
} catch {
|
|
35
|
+
return res.status(400).json({ error: 'Invalid payload' });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { type, actions, message, channel, user } = payload;
|
|
39
|
+
|
|
40
|
+
if (type !== 'block_actions' || !actions?.length) {
|
|
41
|
+
return res.status(200).send(); // Unrecognised interaction — ack and ignore
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const action = actions[0];
|
|
45
|
+
const actionId = action.action_id;
|
|
46
|
+
const messageTs = message?.ts;
|
|
47
|
+
const channelId = channel?.id;
|
|
48
|
+
const userName = user?.name ?? user?.username ?? 'unknown';
|
|
49
|
+
|
|
50
|
+
// Acknowledge the interaction immediately (Slack requires < 3s)
|
|
51
|
+
res.status(200).send();
|
|
52
|
+
|
|
53
|
+
// channelId is required for all follow-up Slack posts. Missing means the payload
|
|
54
|
+
// is from an unsupported interaction type — ack it (above) but skip async processing.
|
|
55
|
+
if (!channelId) {
|
|
56
|
+
logger.warn('[ARGUS] Interaction missing channel.id — cannot dispatch response');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Wrap post-response async work in try/catch. The response is already committed
|
|
61
|
+
// at this point, so any throws escape Express's error handler and become unhandled
|
|
62
|
+
// promise rejections — which crash the server in Node 15+.
|
|
63
|
+
try {
|
|
64
|
+
if (actionId === 'acknowledge') {
|
|
65
|
+
await acknowledgeMessage(messageTs, channelId, userName);
|
|
66
|
+
} else if (actionId === 'retest') {
|
|
67
|
+
await handleRetestAction({ action, messageTs, channelId, userName });
|
|
68
|
+
}
|
|
69
|
+
// 'view_page' is a URL button — Slack handles it client-side, no server action needed
|
|
70
|
+
} catch (err) {
|
|
71
|
+
logger.error('[ARGUS] Interaction post-response error:', err.message);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Handle the "Retest" button click.
|
|
77
|
+
* Triggers a new test run and posts result as thread reply.
|
|
78
|
+
*/
|
|
79
|
+
async function handleRetestAction({ action, messageTs, channelId, userName }) {
|
|
80
|
+
let parsedValue;
|
|
81
|
+
try {
|
|
82
|
+
parsedValue = JSON.parse(action.value ?? '{}');
|
|
83
|
+
} catch (e) {
|
|
84
|
+
// Log the raw value and error so we can diagnose malformed action payloads.
|
|
85
|
+
logger.warn('[ARGUS] Failed to parse action.value:', action.value, e.message);
|
|
86
|
+
parsedValue = {};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Validate targetUrl is a string starting with http — parsedValue.url could be
|
|
90
|
+
// a number or boolean from a crafted payload, which passes the truthy check but breaks
|
|
91
|
+
// downstream string operations and URL construction in runCrawl.
|
|
92
|
+
const targetUrl = parsedValue.url;
|
|
93
|
+
if (typeof targetUrl !== 'string') return;
|
|
94
|
+
let _parsed;
|
|
95
|
+
try { _parsed = new URL(targetUrl); } catch { return; }
|
|
96
|
+
if (!['http:', 'https:'].includes(_parsed.protocol)) return;
|
|
97
|
+
if (/^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.|::1)/i.test(_parsed.hostname)) return;
|
|
98
|
+
|
|
99
|
+
let mcp;
|
|
100
|
+
try {
|
|
101
|
+
mcp = await createMcpClient();
|
|
102
|
+
|
|
103
|
+
// Do NOT mutate process.env.TARGET_DEV_URL — concurrent retests share
|
|
104
|
+
// the same Node.js process env and would corrupt each other's URLs. Pass targetUrl directly.
|
|
105
|
+
const CRAWL_TIMEOUT_MS = parseInt(process.env.ARGUS_CRAWL_TIMEOUT_MS ?? '120000', 10);
|
|
106
|
+
const report = await Promise.race([
|
|
107
|
+
runCrawl(mcp, [{ path: '', name: 'Retest', critical: true, waitFor: null }], targetUrl),
|
|
108
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Crawl timed out after ${Math.round(CRAWL_TIMEOUT_MS / 1000)}s`)), CRAWL_TIMEOUT_MS)),
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const passed = report.summary.critical === 0;
|
|
112
|
+
const safeUserName = (userName ?? 'unknown').replace(/[*_`~<>&]/g, '');
|
|
113
|
+
const details =
|
|
114
|
+
`URL: ${targetUrl}\n` +
|
|
115
|
+
`Triggered by: @${safeUserName}\n` +
|
|
116
|
+
`Critical: ${report.summary.critical} | Warnings: ${report.summary.warning} | Info: ${report.summary.info}`;
|
|
117
|
+
|
|
118
|
+
await postRetestResult(messageTs, channelId, passed ? 'pass' : 'fail', details);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// Log full error server-side; redact from the thread reply.
|
|
121
|
+
logger.error('[ARGUS] Retest interaction failed:', err);
|
|
122
|
+
await postRetestResult(messageTs, channelId, 'fail', 'Error: check server logs for details');
|
|
123
|
+
} finally {
|
|
124
|
+
mcp?.close?.();
|
|
125
|
+
}
|
|
126
|
+
}
|