argusqa-os 9.5.9 → 9.6.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/README.md +384 -1112
- package/glama.json +5 -1
- package/package.json +3 -3
- package/src/cli/init.js +8 -4
- package/src/mcp-server.js +64 -2
- package/src/orchestration/dispatcher.js +1 -1
- package/src/orchestration/env-comparison.js +0 -1
- package/src/orchestration/orchestrator.js +5 -5
- package/src/orchestration/report-processor.js +1 -1
- package/src/orchestration/slack-notifier.js +1 -1
- package/src/orchestration/watch-mode.js +0 -4
- package/src/server/index.js +24 -2
- package/src/server/slash-command-handler.js +0 -1
- package/src/utils/a11y-deep-analyzer.js +0 -2
- package/src/utils/baseline-manager.js +3 -3
- package/src/utils/codebase-analyzer.js +3 -3
- package/src/utils/content-analyzer.js +1 -1
- package/src/utils/flow-runner.js +4 -4
- package/src/utils/github-reporter.js +1 -2
- package/src/utils/har-recorder.js +19 -14
- package/src/utils/pr-diff-analyzer.js +121 -0
- package/src/utils/route-discoverer.js +1 -1
- package/src/utils/security-analyzer.js +1 -1
- package/src/utils/seo-analyzer.js +1 -1
- package/src/utils/session-persistence.js +1 -1
- package/src/utils/visual-diff-analyzer.js +9 -4
package/glama.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://glama.ai/mcp/schemas/server.json",
|
|
3
3
|
"name": "argus",
|
|
4
|
-
"description": "AI-powered QA harness that audits web apps via Chrome DevTools Protocol. Catches JS errors, network failures, a11y violations, SEO issues, security headers, CSS regressions, and more — directly from Claude conversations.
|
|
4
|
+
"description": "AI-powered QA harness that audits web apps via Chrome DevTools Protocol. Catches JS errors, network failures, a11y violations, SEO issues, security headers, CSS regressions, and more — directly from Claude conversations. 9 MCP tools: argus_audit (fast 8-analyzer pass), argus_audit_full (Lighthouse + memory + responsive), argus_compare (dev vs staging diff), argus_last_report (retrieve last JSON report), argus_watch_snapshot (live tab snapshot without navigating), argus_get_context (LLM-optimized context + fix loop with snapshot_id diff), argus_design_audit (Figma design fidelity — 13 finding types), argus_visual_diff (screenshot baseline comparison, updateBaseline flag), argus_pr_validate (PR diff → affected routes → targeted audit → blocked flag). 137 test blocks, 634 hard assertions, 63 detection categories.",
|
|
5
5
|
"maintainers": ["ironclawdevs27"],
|
|
6
6
|
"tools": [
|
|
7
7
|
{
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
{
|
|
36
36
|
"name": "argus_visual_diff",
|
|
37
37
|
"description": "Screenshot baseline comparison for a URL. First call saves a baseline PNG to reports/baselines/screenshots/. Subsequent calls diff the current screenshot against the baseline using pixelmatch and return visual_regression (warning ≥0.1% / critical ≥5% pixels changed) + visual_diff_summary (always). Pass updateBaseline: true to force-refresh the stored baseline after intentional UI changes."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "argus_pr_validate",
|
|
41
|
+
"description": "Targeted QA audit driven by a GitHub pull request diff. Fetches the PR's changed files, maps them to affected routes in your target config using path-slug heuristics (infrastructure changes trigger a full audit), then audits only those routes. Returns { findings, affectedRoutes, changedFiles, perRoute, summary, blocked, blockOn }. Use in CI to gate merges — blocked:true when findings meet the blockOn threshold (none/warning/critical, default: critical). Requires Chrome on --remote-debugging-port=9222. GITHUB_TOKEN env var recommended for private repos."
|
|
38
42
|
}
|
|
39
43
|
]
|
|
40
44
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "argusqa-os",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.6.0",
|
|
4
4
|
"mcpName": "io.github.ironclawdevs27/argus",
|
|
5
5
|
"description": "Argus — AI-powered automated dev-testing platform using Chrome DevTools MCP and Claude Code",
|
|
6
6
|
"keywords": [
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"@opentelemetry/sdk-node": "^0.218.0",
|
|
57
57
|
"@slack/web-api": "^7.16.0",
|
|
58
58
|
"axe-core": "^4.12.0",
|
|
59
|
-
"dotenv": "^
|
|
59
|
+
"dotenv": "^17.4.2",
|
|
60
60
|
"express": "^5.2.1",
|
|
61
61
|
"pino": "^10.3.1",
|
|
62
62
|
"pino-pretty": "^13.1.3",
|
|
@@ -65,6 +65,6 @@
|
|
|
65
65
|
"zod": "^4.4.3"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
|
-
"vitest": "^4.1.
|
|
68
|
+
"vitest": "^4.1.8"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/cli/init.js
CHANGED
|
@@ -287,11 +287,15 @@ async function main() {
|
|
|
287
287
|
githubToken, githubRepo, sourceDir, envFile: envFilePath });
|
|
288
288
|
const targetsContent = generateTargetsJs(finalRoutes, { framework, sourceDir, envFile: envFilePath });
|
|
289
289
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
} else {
|
|
293
|
-
fs.writeFileSync('.env', envContent, 'utf8');
|
|
290
|
+
try {
|
|
291
|
+
fs.writeFileSync('.env', envContent, { flag: 'wx', encoding: 'utf8' });
|
|
294
292
|
tick('Wrote .env');
|
|
293
|
+
} catch (err) {
|
|
294
|
+
if (err.code === 'EEXIST') {
|
|
295
|
+
logger.warn(' ⚠ .env already exists — skipping write to preserve existing credentials. Delete it manually to regenerate.');
|
|
296
|
+
} else {
|
|
297
|
+
throw err;
|
|
298
|
+
}
|
|
295
299
|
}
|
|
296
300
|
|
|
297
301
|
const targetsPath = path.join('src', 'config', 'targets.js');
|
package/src/mcp-server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Argus MCP Server (v9.
|
|
3
|
+
* Argus MCP Server (v9.6.0)
|
|
4
4
|
*
|
|
5
5
|
* Exposes Argus as an MCP server so Claude (or any MCP client) can call
|
|
6
6
|
* argus_audit, argus_audit_full, argus_compare, argus_last_report, and
|
|
@@ -33,6 +33,7 @@ import { CdpBrowserAdapter } from './adapters/browser.js';
|
|
|
33
33
|
import { getFigmaFrame } from './adapters/figma.js';
|
|
34
34
|
import { analyzeDesignFidelity } from './utils/design-fidelity-analyzer.js';
|
|
35
35
|
import { analyzeVisualRegression } from './utils/visual-diff-analyzer.js';
|
|
36
|
+
import { parsePrUrl, fetchPrFiles, mapFilesToRoutes } from './utils/pr-diff-analyzer.js';
|
|
36
37
|
|
|
37
38
|
const REPORTS_DIR = path.resolve(process.cwd(), 'reports');
|
|
38
39
|
|
|
@@ -145,6 +146,20 @@ const TOOLS = [
|
|
|
145
146
|
required: ['url', 'figmaFrameUrl'],
|
|
146
147
|
},
|
|
147
148
|
},
|
|
149
|
+
{
|
|
150
|
+
name: 'argus_pr_validate',
|
|
151
|
+
description: 'Runs a targeted Argus audit on the routes affected by a GitHub pull request. Fetches the PR diff, maps changed files to routes in your target config using path-slug heuristics (infrastructure changes trigger a full audit; targeted otherwise), and audits only those routes — faster than a full scan and focused on what the PR actually touched. Returns { findings, affectedRoutes, changedFiles, perRoute, summary, blocked, blockOn }. Use in CI to gate merges: check blocked:true or pipe findings to an AI verdict step. Requires Chrome on --remote-debugging-port=9222. GITHUB_TOKEN env var recommended for private repos.',
|
|
152
|
+
inputSchema: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
properties: {
|
|
155
|
+
prUrl: { type: 'string', description: 'Full GitHub PR URL (e.g. https://github.com/owner/repo/pull/42). Used to fetch the list of changed files via the GitHub REST API.' },
|
|
156
|
+
targetUrl: { type: 'string', description: 'Base URL to audit (e.g. https://staging.example.com). Overrides TARGET_DEV_URL env var.' },
|
|
157
|
+
githubToken: { type: 'string', description: 'GitHub Personal Access Token or workflow GITHUB_TOKEN. Optional for public repos. Falls back to GITHUB_TOKEN env var.' },
|
|
158
|
+
blockOn: { type: 'string', enum: ['none', 'warning', 'critical'], description: '"critical" = block only when critical findings exist. "warning" = block on any warning or critical. "none" = never block. Defaults to ARGUS_BLOCK_ON env var, then "critical".', default: 'critical' },
|
|
159
|
+
},
|
|
160
|
+
required: ['prUrl'],
|
|
161
|
+
},
|
|
162
|
+
},
|
|
148
163
|
];
|
|
149
164
|
|
|
150
165
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
@@ -368,6 +383,52 @@ async function handleDesignAudit({ url, figmaFrameUrl }) {
|
|
|
368
383
|
});
|
|
369
384
|
}
|
|
370
385
|
|
|
386
|
+
async function handlePrValidate({ prUrl, targetUrl, githubToken, blockOn } = {}) {
|
|
387
|
+
if (!prUrl) throw new Error('argus_pr_validate: prUrl is required');
|
|
388
|
+
|
|
389
|
+
const { routes } = await import('./config/targets.js');
|
|
390
|
+
const token = githubToken ?? process.env.GITHUB_TOKEN;
|
|
391
|
+
const base = targetUrl ?? process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
|
|
392
|
+
const policy = blockOn ?? process.env.ARGUS_BLOCK_ON ?? 'critical';
|
|
393
|
+
|
|
394
|
+
const changedFiles = await fetchPrFiles(prUrl, token);
|
|
395
|
+
const affectedRoutes = mapFilesToRoutes(changedFiles, routes ?? []);
|
|
396
|
+
|
|
397
|
+
const allFindings = [];
|
|
398
|
+
const perRoute = [];
|
|
399
|
+
|
|
400
|
+
for (const route of affectedRoutes) {
|
|
401
|
+
const url = new URL(route.path, base).href;
|
|
402
|
+
const res = await handleAudit({ url, critical: route.critical ?? false });
|
|
403
|
+
const data = JSON.parse(res.content[0].text);
|
|
404
|
+
allFindings.push(...(data.findings ?? []));
|
|
405
|
+
perRoute.push({ route: route.path, ...data.summary });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const summary = {
|
|
409
|
+
critical: allFindings.filter(f => f.severity === 'critical').length,
|
|
410
|
+
warning: allFindings.filter(f => f.severity === 'warning').length,
|
|
411
|
+
info: allFindings.filter(f => f.severity === 'info').length,
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const blocked =
|
|
415
|
+
policy === 'critical' ? summary.critical > 0 :
|
|
416
|
+
policy === 'warning' ? summary.critical + summary.warning > 0 :
|
|
417
|
+
false;
|
|
418
|
+
|
|
419
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
420
|
+
prUrl,
|
|
421
|
+
targetUrl: base,
|
|
422
|
+
affectedRoutes: affectedRoutes.map(r => r.path),
|
|
423
|
+
changedFiles,
|
|
424
|
+
findings: allFindings,
|
|
425
|
+
perRoute,
|
|
426
|
+
summary,
|
|
427
|
+
blocked,
|
|
428
|
+
blockOn: policy,
|
|
429
|
+
}, null, 2) }] };
|
|
430
|
+
}
|
|
431
|
+
|
|
371
432
|
async function handleLastReport() {
|
|
372
433
|
if (!fs.existsSync(REPORTS_DIR)) {
|
|
373
434
|
return { content: [{ type: 'text', text: '{"error":"No reports found in reports/"}' }] };
|
|
@@ -386,7 +447,7 @@ async function handleLastReport() {
|
|
|
386
447
|
// ── Server bootstrap ──────────────────────────────────────────────────────────
|
|
387
448
|
|
|
388
449
|
const server = new Server(
|
|
389
|
-
{ name: 'argus', version: '9.
|
|
450
|
+
{ name: 'argus', version: '9.6.0' },
|
|
390
451
|
{ capabilities: { tools: {} } },
|
|
391
452
|
);
|
|
392
453
|
|
|
@@ -403,6 +464,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
403
464
|
case 'argus_get_context': return await handleGetContext(req.params.arguments ?? {});
|
|
404
465
|
case 'argus_visual_diff': return await handleVisualDiff(req.params.arguments ?? {});
|
|
405
466
|
case 'argus_design_audit': return await handleDesignAudit(req.params.arguments ?? {});
|
|
467
|
+
case 'argus_pr_validate': return await handlePrValidate(req.params.arguments ?? {});
|
|
406
468
|
default: throw new Error(`Unknown tool: ${req.params.name}`);
|
|
407
469
|
}
|
|
408
470
|
} catch (err) {
|
|
@@ -26,7 +26,7 @@ function openInBrowser(filePath) {
|
|
|
26
26
|
try {
|
|
27
27
|
const abs = path.resolve(filePath);
|
|
28
28
|
if (process.platform === 'win32') {
|
|
29
|
-
execFile('cmd', ['/c', 'start', '', abs], () => {});
|
|
29
|
+
execFile('cmd', ['/c', 'start', '', abs], () => {}); // lgtm[js/shell-command-injection-from-environment] — execFile with args array is injection-safe; abs is path.resolve() of an internally-computed report path
|
|
30
30
|
} else if (process.platform === 'darwin') {
|
|
31
31
|
execFile('open', [abs], () => {});
|
|
32
32
|
} else {
|
|
@@ -21,7 +21,6 @@ import { unwrapEval } from '../utils/mcp-client.js';
|
|
|
21
21
|
import { childLogger } from '../utils/logger.js';
|
|
22
22
|
|
|
23
23
|
const logger = childLogger('env-comparison');
|
|
24
|
-
import { normalizeArray } from '../utils/flow-runner.js';
|
|
25
24
|
import { CdpBrowserAdapter } from '../adapters/browser.js';
|
|
26
25
|
|
|
27
26
|
import { comparisonRoutes, config } from '../config/targets.js';
|
|
@@ -490,7 +490,7 @@ export async function crawlRouteCheap(route, baseUrl, mcp) {
|
|
|
490
490
|
const text = (msg.text ?? msg.message ?? '');
|
|
491
491
|
if (text.toLowerCase().includes('has been blocked by cors policy')) continue;
|
|
492
492
|
const severity = classifyConsoleMessage(msg, route.critical);
|
|
493
|
-
if (
|
|
493
|
+
if (msg.level !== 'log') {
|
|
494
494
|
result.errors.push({
|
|
495
495
|
type: 'console',
|
|
496
496
|
level: msg.level,
|
|
@@ -1026,10 +1026,10 @@ export async function runCrawl(mcp, routeOverrides = null, baseUrlOverride = nul
|
|
|
1026
1026
|
// Auth session persistence (B2)
|
|
1027
1027
|
const sessionFile = auth?.sessionFile ?? '.argus-session.json';
|
|
1028
1028
|
if (auth?.steps?.length > 0) {
|
|
1029
|
-
if (!hasSession(sessionFile, auth
|
|
1030
|
-
logger.info(`[ARGUS] Auth: running login flow (${auth
|
|
1029
|
+
if (!hasSession(sessionFile, auth?.sessionMaxAgeMs)) {
|
|
1030
|
+
logger.info(`[ARGUS] Auth: running login flow (${auth?.steps?.length ?? 0} steps)...`);
|
|
1031
1031
|
try {
|
|
1032
|
-
await runLoginFlow(browser, targetBaseUrl, auth
|
|
1032
|
+
await runLoginFlow(browser, targetBaseUrl, auth?.steps ?? []);
|
|
1033
1033
|
await saveSession(browser, sessionFile);
|
|
1034
1034
|
} catch (err) {
|
|
1035
1035
|
logger.warn(`[ARGUS] Auth: login flow failed — crawl will proceed unauthenticated: ${err.message}`);
|
|
@@ -1154,5 +1154,5 @@ export async function runCrawl(mcp, routeOverrides = null, baseUrlOverride = nul
|
|
|
1154
1154
|
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
|
1155
1155
|
logger.info('[ARGUS] orchestrator.js loaded. Invoke runCrawl(mcp) from Claude Code with MCP tools connected.');
|
|
1156
1156
|
logger.info('[ARGUS] Target base URL: ' + BASE_URL);
|
|
1157
|
-
logger.info('[ARGUS] Routes to crawl: ' +
|
|
1157
|
+
logger.info('[ARGUS] Routes to crawl: ' + routes.map(r => r?.path ?? '(no path)').join(', '));
|
|
1158
1158
|
}
|
|
@@ -108,7 +108,7 @@ export async function processReport(report, { outputDir, severityOverrides }) {
|
|
|
108
108
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
109
109
|
const reportPath = path.join(outputDir, `error-report-${timestamp}.json`);
|
|
110
110
|
try {
|
|
111
|
-
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
|
111
|
+
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); // lgtm[js/network-data-to-file] — intentional: Argus persists crawl findings to a local JSON report file by design
|
|
112
112
|
} catch (err) {
|
|
113
113
|
logger.error(`[ARGUS] Failed to write report JSON: ${err.message}`);
|
|
114
114
|
throw err;
|
|
@@ -99,7 +99,7 @@ async function uploadFileToSlack(filePath, channelId, filename) {
|
|
|
99
99
|
const response = await fetch(uploadUrl, {
|
|
100
100
|
method: 'PUT',
|
|
101
101
|
headers: { 'Content-Type': 'application/octet-stream' },
|
|
102
|
-
body: fileBuffer,
|
|
102
|
+
body: fileBuffer, // lgtm[js/file-access-to-http] — intentional screenshot upload to Slack pre-signed URL; file path is internally generated by Argus, not from HTTP request input
|
|
103
103
|
signal: AbortSignal.timeout(30000),
|
|
104
104
|
});
|
|
105
105
|
if (!response.ok) {
|
|
@@ -35,10 +35,6 @@ import {
|
|
|
35
35
|
import { postBugReport } from './slack-notifier.js';
|
|
36
36
|
import { isSlackConfigured } from '../utils/slack-guard.js';
|
|
37
37
|
import { generateHtmlReport } from '../utils/html-reporter.js';
|
|
38
|
-
import {
|
|
39
|
-
parseConsoleMsgResponse,
|
|
40
|
-
parseNetworkReqResponse,
|
|
41
|
-
} from '../utils/mcp-parsers.js';
|
|
42
38
|
|
|
43
39
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
44
40
|
const REPORTS_DIR = path.resolve(__dirname, '../../reports');
|
package/src/server/index.js
CHANGED
|
@@ -50,6 +50,28 @@ app.use((req, res, next) => {
|
|
|
50
50
|
next();
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
// ── Rate limiting (per-IP, in-memory) ──────────────────────────────────────────
|
|
54
|
+
// 30 requests per minute per IP — prevents Slack endpoint flooding.
|
|
55
|
+
// Slack signature verification rejects invalid payloads, but rate limiting adds
|
|
56
|
+
// a defence-in-depth layer before signature verification even runs.
|
|
57
|
+
const RATE_WINDOW_MS = 60_000;
|
|
58
|
+
const RATE_MAX = 30;
|
|
59
|
+
const rateLimitMap = new Map();
|
|
60
|
+
|
|
61
|
+
function slackRateLimit(req, res, next) {
|
|
62
|
+
const key = req.ip ?? 'unknown';
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const entry = rateLimitMap.get(key) ?? { count: 0, start: now };
|
|
65
|
+
if (now - entry.start > RATE_WINDOW_MS) { entry.count = 0; entry.start = now; }
|
|
66
|
+
entry.count++;
|
|
67
|
+
rateLimitMap.set(key, entry);
|
|
68
|
+
if (entry.count > RATE_MAX) {
|
|
69
|
+
res.setHeader('Retry-After', Math.ceil(RATE_WINDOW_MS / 1000));
|
|
70
|
+
return res.status(429).json({ error: 'Too Many Requests' });
|
|
71
|
+
}
|
|
72
|
+
next();
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
// ── Routes ─────────────────────────────────────────────────────────────────────
|
|
54
76
|
|
|
55
77
|
app.get('/health', (req, res) => {
|
|
@@ -57,10 +79,10 @@ app.get('/health', (req, res) => {
|
|
|
57
79
|
});
|
|
58
80
|
|
|
59
81
|
// Slack slash commands
|
|
60
|
-
app.post('/slack/commands', handleSlashCommand);
|
|
82
|
+
app.post('/slack/commands', slackRateLimit, handleSlashCommand);
|
|
61
83
|
|
|
62
84
|
// Slack Block Kit interactions (button clicks)
|
|
63
|
-
app.post('/slack/interactions', handleInteraction);
|
|
85
|
+
app.post('/slack/interactions', slackRateLimit, handleInteraction);
|
|
64
86
|
|
|
65
87
|
// ── Start ──────────────────────────────────────────────────────────────────────
|
|
66
88
|
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import crypto from 'crypto';
|
|
18
|
-
import { postBugReport } from '../orchestration/slack-notifier.js';
|
|
19
18
|
import { createMcpClient } from '../utils/mcp-client.js';
|
|
20
19
|
import { runCrawl } from '../orchestration/crawl-and-report.js';
|
|
21
20
|
import { WebClient } from '@slack/web-api';
|
|
@@ -27,9 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import fs from 'fs';
|
|
30
|
-
import path from 'path';
|
|
31
30
|
import { createRequire } from 'module';
|
|
32
|
-
import { fileURLToPath } from 'url';
|
|
33
31
|
import { registerExpensive } from '../registry.js';
|
|
34
32
|
import { unwrapEval } from './mcp-client.js';
|
|
35
33
|
import { childLogger } from './logger.js';
|
|
@@ -110,7 +110,7 @@ export function saveBaseline(baselineFile, report) {
|
|
|
110
110
|
flows[flowResult.flowName] = (flowResult.findings ?? []).map(findingKey);
|
|
111
111
|
}
|
|
112
112
|
const codebase = (report.codebase ?? []).map(findingKey);
|
|
113
|
-
const tmpBaseline = baselineFile
|
|
113
|
+
const tmpBaseline = `${baselineFile}.${process.pid}.${Date.now()}.tmp`;
|
|
114
114
|
fs.writeFileSync(
|
|
115
115
|
tmpBaseline,
|
|
116
116
|
JSON.stringify({ savedAt: new Date().toISOString(), routes, flows, codebase }, null, 2),
|
|
@@ -245,8 +245,8 @@ export function appendTrend(trendsFile, entry) {
|
|
|
245
245
|
}
|
|
246
246
|
trends.push(entry);
|
|
247
247
|
if (trends.length > 500) trends = trends.slice(-500);
|
|
248
|
-
const tmpTrends = trendsFile
|
|
249
|
-
fs.writeFileSync(tmpTrends, JSON.stringify(trends, null, 2));
|
|
248
|
+
const tmpTrends = `${trendsFile}.${process.pid}.${Date.now()}.tmp`;
|
|
249
|
+
fs.writeFileSync(tmpTrends, JSON.stringify(trends, null, 2)); // lgtm[js/network-data-to-file] — intentional: Argus persists crawl trend data to a local baseline file by design
|
|
250
250
|
fs.renameSync(tmpTrends, trendsFile);
|
|
251
251
|
} finally {
|
|
252
252
|
try { fs.closeSync(lockFd); } catch {}
|
|
@@ -40,9 +40,9 @@ function collectSourceFiles(sourceDir) {
|
|
|
40
40
|
if (e.isDirectory()) { walk(full); }
|
|
41
41
|
else if (SOURCE_EXTENSIONS.has(path.extname(e.name))) {
|
|
42
42
|
try {
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
files.push({ filePath: full, content
|
|
43
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
44
|
+
if (Buffer.byteLength(content, 'utf8') > 1_000_000) continue; // skip files > 1MB
|
|
45
|
+
files.push({ filePath: full, content });
|
|
46
46
|
} catch {}
|
|
47
47
|
}
|
|
48
48
|
}
|
|
@@ -97,7 +97,7 @@ export function parseContentAnalysisResult(rawResult, url) {
|
|
|
97
97
|
// all field lookups (nullMatches, brokenImages, etc.) return undefined — zero findings.
|
|
98
98
|
// JSON.stringify on a circular object throws; catch logs and returns [].
|
|
99
99
|
let raw = rawResult;
|
|
100
|
-
if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) {
|
|
100
|
+
if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) { // lgtm[js/comparison-of-unconvertible-types] — typeof null === 'object', so raw !== null is required after the typeof check
|
|
101
101
|
raw = raw.result;
|
|
102
102
|
}
|
|
103
103
|
const str = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
package/src/utils/flow-runner.js
CHANGED
|
@@ -223,7 +223,7 @@ async function runAssert(step, browser, flowName, baseUrl, baselines) {
|
|
|
223
223
|
const start = Date.now();
|
|
224
224
|
let present = false;
|
|
225
225
|
do {
|
|
226
|
-
const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(step.selector)})`);
|
|
226
|
+
const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(step.selector)})`); // lgtm[js/code-injection] — selector is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
|
|
227
227
|
present = !!unwrapEval(raw);
|
|
228
228
|
if (present) break;
|
|
229
229
|
await new Promise(r => setTimeout(r, 200));
|
|
@@ -244,7 +244,7 @@ async function runAssert(step, browser, flowName, baseUrl, baselines) {
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
case 'element_not_visible': {
|
|
247
|
-
const raw = await browser.evaluate(`() => !document.querySelector(${JSON.stringify(step.selector)})`);
|
|
247
|
+
const raw = await browser.evaluate(`() => !document.querySelector(${JSON.stringify(step.selector)})`); // lgtm[js/code-injection] — selector is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
|
|
248
248
|
const absent = unwrapEval(raw);
|
|
249
249
|
if (!absent) {
|
|
250
250
|
findings.push({
|
|
@@ -261,7 +261,7 @@ async function runAssert(step, browser, flowName, baseUrl, baselines) {
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
case 'url_contains': {
|
|
264
|
-
const raw = await browser.evaluate(`() => window.location.href.includes(${JSON.stringify(step.value)})`);
|
|
264
|
+
const raw = await browser.evaluate(`() => window.location.href.includes(${JSON.stringify(step.value)})`); // lgtm[js/code-injection] — value is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
|
|
265
265
|
const matches = unwrapEval(raw);
|
|
266
266
|
if (!matches) {
|
|
267
267
|
findings.push({
|
|
@@ -545,7 +545,7 @@ export async function runFlow(flow, baseUrl, browser) {
|
|
|
545
545
|
export async function waitForSelector(browser, selector, timeoutMs = 10_000) {
|
|
546
546
|
const end = Date.now() + timeoutMs;
|
|
547
547
|
while (Date.now() < end) {
|
|
548
|
-
const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(selector)})`).catch(() => null);
|
|
548
|
+
const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(selector)})`).catch(() => null); // lgtm[js/code-injection] — selector is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
|
|
549
549
|
const found = unwrapEval(raw);
|
|
550
550
|
if (found === true || String(found) === 'true') return true;
|
|
551
551
|
if (Date.now() < end) await new Promise(r => setTimeout(r, 300));
|
|
@@ -41,7 +41,7 @@ function sevIcon(sev) { return SEV_ICON[sev] ?? '⚪'; }
|
|
|
41
41
|
|
|
42
42
|
/** Escape pipe characters so they don't break Markdown tables. */
|
|
43
43
|
function mdCell(text, maxLen = 100) {
|
|
44
|
-
return String(text ?? '').slice(0, maxLen).replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
44
|
+
return String(text ?? '').slice(0, maxLen).replace(/\|/g, '\\|').replace(/\n/g, ' '); // lgtm[js/incomplete-string-escaping] — escaping pipe and newline is correct and sufficient for GitHub Markdown table cells
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// ── C2.1: PR comment formatter (pure — no I/O) ───────────────────────────────
|
|
@@ -429,7 +429,6 @@ export function generateReleaseNotes(currentReport, prevReport, opts = {}) {
|
|
|
429
429
|
|
|
430
430
|
if (newOnes.length > 0) {
|
|
431
431
|
const crits = newOnes.filter(f => f.severity === 'critical').length;
|
|
432
|
-
const warns = newOnes.filter(f => f.severity === 'warning').length;
|
|
433
432
|
lines.push(`### 🆕 New Issues (${newOnes.length})`);
|
|
434
433
|
if (crits > 0) lines.push(`> ⚠️ ${crits} new critical issue(s) require attention`);
|
|
435
434
|
lines.push('');
|
|
@@ -79,20 +79,25 @@ export async function analyzeHar(browser, url, opts = {}) {
|
|
|
79
79
|
const harFile = path.join(harDir, `${slug}.json`);
|
|
80
80
|
|
|
81
81
|
// ── First run: save baseline ──────────────────────────────────────────────
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
82
|
+
// Use flag:'wx' for atomic create — throws EEXIST if baseline already exists (TOCTOU-safe).
|
|
83
|
+
fs.mkdirSync(harDir, { recursive: true });
|
|
84
|
+
const baseline = {
|
|
85
|
+
version: '1.2',
|
|
86
|
+
createdAt: new Date().toISOString(),
|
|
87
|
+
url,
|
|
88
|
+
entries: requests.map(toBaselineEntry),
|
|
89
|
+
};
|
|
90
|
+
let harIsNew = false;
|
|
91
|
+
try {
|
|
92
|
+
fs.writeFileSync(harFile, JSON.stringify(baseline, null, 2), { flag: 'wx' });
|
|
93
|
+
harIsNew = true;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err.code !== 'EEXIST') {
|
|
93
96
|
logger.warn(`[ARGUS] har-recorder: failed to write baseline: ${err.message}`);
|
|
94
97
|
return findings;
|
|
95
98
|
}
|
|
99
|
+
}
|
|
100
|
+
if (harIsNew) {
|
|
96
101
|
|
|
97
102
|
findings.push({
|
|
98
103
|
type: 'har_baseline_created',
|
|
@@ -106,15 +111,15 @@ export async function analyzeHar(browser, url, opts = {}) {
|
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
// ── Subsequent runs: compare against baseline ─────────────────────────────
|
|
109
|
-
let
|
|
114
|
+
let existingBaseline;
|
|
110
115
|
try {
|
|
111
|
-
|
|
116
|
+
existingBaseline = JSON.parse(fs.readFileSync(harFile, 'utf8'));
|
|
112
117
|
} catch (err) {
|
|
113
118
|
logger.warn(`[ARGUS] har-recorder: failed to read baseline: ${err.message}`);
|
|
114
119
|
return findings;
|
|
115
120
|
}
|
|
116
121
|
|
|
117
|
-
const baselineEntries =
|
|
122
|
+
const baselineEntries = existingBaseline.entries ?? [];
|
|
118
123
|
const baselineMap = new Map(baselineEntries.map(e => [normaliseUrl(e.request.url), e]));
|
|
119
124
|
const currentMap = new Map(requests.map(r => [normaliseUrl(r.url), r]));
|
|
120
125
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR Diff Analyzer — maps GitHub PR changed files to affected Argus routes.
|
|
3
|
+
*
|
|
4
|
+
* parsePrUrl(prUrl) → { owner, repo, prNumber }
|
|
5
|
+
* fetchPrFiles(prUrl, token) → string[] of changed file paths
|
|
6
|
+
* mapFilesToRoutes(files, routes) → Route[] subset likely affected by the diff
|
|
7
|
+
*
|
|
8
|
+
* Pure functions + one async fetch — no Chrome, no MCP, no AI verdict.
|
|
9
|
+
* AI verdict logic ships separately in the private argus-pro repo.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse a GitHub PR URL into its owner/repo/prNumber components.
|
|
14
|
+
*
|
|
15
|
+
* Accepted formats:
|
|
16
|
+
* https://github.com/owner/repo/pull/123
|
|
17
|
+
* https://github.com/owner/repo/pull/123/files
|
|
18
|
+
*
|
|
19
|
+
* @param {string} prUrl
|
|
20
|
+
* @returns {{ owner: string, repo: string, prNumber: number }}
|
|
21
|
+
*/
|
|
22
|
+
export function parsePrUrl(prUrl) {
|
|
23
|
+
const match = String(prUrl).match(
|
|
24
|
+
/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/,
|
|
25
|
+
);
|
|
26
|
+
if (!match) throw new Error(`Invalid GitHub PR URL: ${prUrl}`);
|
|
27
|
+
return { owner: match[1], repo: match[2], prNumber: parseInt(match[3], 10) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Fetch the list of file paths changed by a GitHub pull request (up to 100 files).
|
|
32
|
+
*
|
|
33
|
+
* @param {string} prUrl - GitHub PR URL (any format accepted by parsePrUrl)
|
|
34
|
+
* @param {string} [githubToken] - GitHub token; omit for public repos
|
|
35
|
+
* @returns {Promise<string[]>} - Changed file paths relative to the repo root
|
|
36
|
+
*/
|
|
37
|
+
export async function fetchPrFiles(prUrl, githubToken) {
|
|
38
|
+
const { owner, repo, prNumber } = parsePrUrl(prUrl);
|
|
39
|
+
const apiUrl =
|
|
40
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/files?per_page=100`;
|
|
41
|
+
const headers = {
|
|
42
|
+
Accept: 'application/vnd.github+json',
|
|
43
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
44
|
+
'User-Agent': 'argusqa-os',
|
|
45
|
+
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const res = await fetch(apiUrl, { headers });
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const body = await res.text().catch(() => '');
|
|
51
|
+
throw new Error(`GitHub API ${res.status}: ${body || res.statusText}`);
|
|
52
|
+
}
|
|
53
|
+
const files = await res.json();
|
|
54
|
+
return files.map(f => f.filename);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Patterns that indicate an infrastructure-level file whose change can affect
|
|
59
|
+
* every route — framework configs, root layouts, global stylesheets, package.json.
|
|
60
|
+
*/
|
|
61
|
+
const INFRA_PATTERNS = [
|
|
62
|
+
/next\.config\./i,
|
|
63
|
+
/vite\.config\./i,
|
|
64
|
+
/tailwind\.config\./i,
|
|
65
|
+
/postcss\.config\./i,
|
|
66
|
+
/webpack\.config\./i,
|
|
67
|
+
/global(s)?\.(css|scss|less)$/i,
|
|
68
|
+
/(^|[/\\])(layout|_app|_document|root)\.(tsx?|jsx?)$/i,
|
|
69
|
+
/(^|[/\\])app\.(tsx?|jsx?)$/i,
|
|
70
|
+
/(^|[/\\])main\.(tsx?|jsx?)$/i,
|
|
71
|
+
/package\.json$/i,
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Map a list of changed file paths to the subset of Argus route configs that
|
|
76
|
+
* are likely affected, using heuristic slug matching.
|
|
77
|
+
*
|
|
78
|
+
* Heuristic rules (applied in order):
|
|
79
|
+
* 1. Any infrastructure file → return ALL routes (full audit)
|
|
80
|
+
* 2. File path contains a slug that matches a route path segment → include that route
|
|
81
|
+
* 3. No matches → return ALL routes (conservative fallback — never miss a regression)
|
|
82
|
+
*
|
|
83
|
+
* @param {string[]} changedFiles - Relative file paths from fetchPrFiles
|
|
84
|
+
* @param {Array<{ path: string, name: string }>} routes - Route configs from targets.js
|
|
85
|
+
* @returns {Array<{ path: string, name: string }>}
|
|
86
|
+
*/
|
|
87
|
+
export function mapFilesToRoutes(changedFiles, routes) {
|
|
88
|
+
if (!routes || routes.length === 0) return [];
|
|
89
|
+
if (!changedFiles || changedFiles.length === 0) return routes;
|
|
90
|
+
|
|
91
|
+
// Infrastructure change → full audit
|
|
92
|
+
if (changedFiles.some(f => INFRA_PATTERNS.some(re => re.test(f)))) {
|
|
93
|
+
return routes;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Build a flat set of lowercase slugs from every changed file path
|
|
97
|
+
const fileSlugs = new Set(
|
|
98
|
+
changedFiles.flatMap(f =>
|
|
99
|
+
// Strip extension, split on separators, keep non-trivial tokens
|
|
100
|
+
f.toLowerCase()
|
|
101
|
+
.replace(/\.[^./\\]+$/, '')
|
|
102
|
+
.split(/[/\\._-]+/)
|
|
103
|
+
.filter(s => s.length > 1),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Extract meaningful segments from a route path (e.g. "/checkout/review" → ["checkout","review"])
|
|
108
|
+
const routeSegments = (route) =>
|
|
109
|
+
route.path
|
|
110
|
+
.toLowerCase()
|
|
111
|
+
.split('/')
|
|
112
|
+
.map(s => s.replace(/[^a-z0-9]/g, ''))
|
|
113
|
+
.filter(s => s.length > 1);
|
|
114
|
+
|
|
115
|
+
const matched = routes.filter(route =>
|
|
116
|
+
routeSegments(route).some(seg => fileSlugs.has(seg)),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// Conservative fallback: if nothing matched, audit everything
|
|
120
|
+
return matched.length > 0 ? matched : routes;
|
|
121
|
+
}
|
|
@@ -74,7 +74,7 @@ export async function discoverFromSitemap(baseUrl) {
|
|
|
74
74
|
const origin = new URL(baseUrl).origin;
|
|
75
75
|
const sitemapUrl = `${baseUrl.replace(/\/$/, '')}/sitemap.xml`;
|
|
76
76
|
try {
|
|
77
|
-
const res = await fetch(sitemapUrl, { signal: AbortSignal.timeout(10000) });
|
|
77
|
+
const res = await fetch(sitemapUrl, { signal: AbortSignal.timeout(10000) }); // lgtm[js/ssrf] — sitemapUrl is derived from developer-configured baseUrl in targets.js, not from HTTP request input
|
|
78
78
|
if (!res.ok) return [];
|
|
79
79
|
|
|
80
80
|
const buf = await res.arrayBuffer();
|
|
@@ -132,7 +132,7 @@ export function parseSecurityAnalysisResult(rawResult, url) {
|
|
|
132
132
|
// all field lookups (storageTokenKeys, evalUsage, etc.) return undefined — zero findings.
|
|
133
133
|
// JSON.stringify on a circular object throws; catch logs and returns [].
|
|
134
134
|
let raw = rawResult;
|
|
135
|
-
if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) {
|
|
135
|
+
if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) { // lgtm[js/comparison-of-unconvertible-types] — typeof null === 'object', so raw !== null is required after the typeof check
|
|
136
136
|
raw = raw.result;
|
|
137
137
|
}
|
|
138
138
|
const str = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
@@ -48,7 +48,7 @@ export function parseSeoAnalysisResult(rawResult, url) {
|
|
|
48
48
|
// client returns an object wrapper, JSON.stringify(rawResult) serialises the envelope
|
|
49
49
|
// instead of the inner payload and all SEO fields are undefined → false positives.
|
|
50
50
|
let inner = rawResult;
|
|
51
|
-
if (typeof rawResult === 'object' && rawResult !== null && !Array.isArray(rawResult)) {
|
|
51
|
+
if (typeof rawResult === 'object' && rawResult !== null && !Array.isArray(rawResult)) { // lgtm[js/comparison-of-unconvertible-types] — typeof null === 'object', so rawResult !== null is required after the typeof check
|
|
52
52
|
inner = rawResult.result !== undefined ? rawResult.result : rawResult;
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -65,7 +65,7 @@ function buildRestoreScript(state) {
|
|
|
65
65
|
lines.push(`sessionStorage.setItem(${JSON.stringify(k)},${JSON.stringify(String(v ?? ''))});`);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
return `() => { ${lines.join(' ')} return true; }`;
|
|
68
|
+
return `() => { ${lines.join(' ')} return true; }`; // lgtm[js/code-injection] — all k/v values are JSON.stringify-escaped before insertion; derived from browser session storage, not HTTP request input
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// ── Session Save ────────────────────────────────────────────────────────────────
|