argusqa-os 9.6.6 → 9.7.3

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.
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Root Cause Linking — recent git changes → suspect files per finding.
3
+ *
4
+ * Pure git-diff heuristic: no external API, no AI call. Reads the last N
5
+ * commits from the local repo (`git log --name-only`), maps changed files to
6
+ * route paths with the same slug heuristic as pr-diff-analyzer, and annotates
7
+ * each NEW finding (isNew: true) with the files/commits most likely to have
8
+ * caused it:
9
+ *
10
+ * finding.rootCause = {
11
+ * files: ['src/pages/checkout/Form.jsx', ...], // ≤ MAX_FILES
12
+ * commits: [{ hash: 'abc1234', subject: '...' }], // ≤ MAX_COMMITS
13
+ * global: false, // true when only infra-level files matched
14
+ * }
15
+ *
16
+ * Only new findings are annotated — a finding that predates the recent commits
17
+ * cannot have been caused by them. Source repo defaults to ARGUS_SOURCE_DIR or
18
+ * the current working directory. Disable with ARGUS_ROOT_CAUSE=0.
19
+ */
20
+
21
+ import { execSync } from 'child_process';
22
+ import { INFRA_PATTERNS } from './pr-diff-analyzer.js';
23
+ import { childLogger } from './logger.js';
24
+
25
+ const logger = childLogger('root-cause-linker');
26
+
27
+ /** Commits inspected by default. */
28
+ export const DEFAULT_COMMITS = 10;
29
+ /** Max suspect files attached to one finding. */
30
+ const MAX_FILES = 5;
31
+ /** Max commits attached to one finding. */
32
+ const MAX_COMMITS = 3;
33
+
34
+ /**
35
+ * Read the last N commits with their changed files from a local git repo.
36
+ * Returns [] when the directory is not a repo, git is unavailable, or the
37
+ * command fails — root cause linking is always best-effort.
38
+ *
39
+ * @param {string} [repoDir] - Defaults to ARGUS_SOURCE_DIR, then cwd
40
+ * @param {object} [opts]
41
+ * @param {number} [opts.commits]
42
+ * @returns {Array<{ hash: string, subject: string, files: string[] }>}
43
+ */
44
+ export function getRecentChanges(repoDir = process.env.ARGUS_SOURCE_DIR || process.cwd(), { commits = DEFAULT_COMMITS } = {}) {
45
+ let out;
46
+ try {
47
+ out = execSync(`git log --name-only --pretty=format:%h%x09%s -n ${Math.max(1, commits | 0)}`, {
48
+ cwd: repoDir,
49
+ stdio: ['pipe', 'pipe', 'pipe'],
50
+ timeout: 5000,
51
+ }).toString();
52
+ } catch {
53
+ return [];
54
+ }
55
+
56
+ const changes = [];
57
+ let current = null;
58
+ for (const line of out.split('\n')) {
59
+ const trimmed = line.trimEnd();
60
+ if (!trimmed) continue;
61
+ if (trimmed.includes('\t')) {
62
+ const [hash, ...subjectParts] = trimmed.split('\t');
63
+ current = { hash, subject: subjectParts.join('\t'), files: [] };
64
+ changes.push(current);
65
+ } else if (current) {
66
+ // Git emits paths with forward slashes on every platform
67
+ current.files.push(trimmed);
68
+ }
69
+ }
70
+ return changes;
71
+ }
72
+
73
+ /**
74
+ * Match changed file paths against one route path using the slug heuristic
75
+ * from pr-diff-analyzer: a file matches when any of its path/name slugs equals
76
+ * a route path segment. Infra-level files (configs, root layouts, global CSS)
77
+ * match every route and are reported via `global`.
78
+ *
79
+ * @param {string[]} files
80
+ * @param {string} routePath - e.g. "/checkout/review"
81
+ * @returns {{ files: string[], global: boolean }}
82
+ */
83
+ export function matchFilesToRoutePath(files, routePath) {
84
+ const segments = String(routePath ?? '')
85
+ .toLowerCase()
86
+ .split('/')
87
+ .map(s => s.replace(/[^a-z0-9]/g, ''))
88
+ .filter(s => s.length > 1);
89
+
90
+ const matched = [];
91
+ let global = false;
92
+
93
+ for (const file of (files ?? [])) {
94
+ if (INFRA_PATTERNS.some(re => re.test(file))) {
95
+ global = true;
96
+ continue;
97
+ }
98
+ if (segments.length === 0) continue; // home route "/" — only infra files apply
99
+ const slugs = file
100
+ .toLowerCase()
101
+ .replace(/\.[^./\\]+$/, '')
102
+ .split(/[/\\._-]+/)
103
+ .filter(s => s.length > 1);
104
+ if (segments.some(seg => slugs.includes(seg))) matched.push(file);
105
+ }
106
+
107
+ return { files: matched, global };
108
+ }
109
+
110
+ /**
111
+ * Annotate NEW findings in the report with `rootCause` (mutates in place).
112
+ *
113
+ * For each route, slug-matches every recent commit's files against the route's
114
+ * URL pathname. Direct file matches win; when only infra-level files changed,
115
+ * the annotation carries `global: true` with those files as suspects.
116
+ *
117
+ * @param {object} report - { routes: [{ url, errors }] }; findings need isNew
118
+ * @param {Array} changes - From getRecentChanges()
119
+ * @returns {{ linkedCount: number }}
120
+ */
121
+ export function linkRootCauses(report, changes) {
122
+ let linkedCount = 0;
123
+ if (!Array.isArray(changes) || changes.length === 0) return { linkedCount };
124
+
125
+ for (const routeResult of (report.routes ?? [])) {
126
+ let routePath;
127
+ try {
128
+ routePath = new URL(routeResult.url).pathname;
129
+ } catch {
130
+ continue;
131
+ }
132
+
133
+ // Collect suspect files + commits for this route across the recent commits
134
+ const directFiles = [];
135
+ const infraFiles = [];
136
+ const directCommits = [];
137
+ const infraCommits = [];
138
+ for (const commit of changes) {
139
+ const { files, global } = matchFilesToRoutePath(commit.files, routePath);
140
+ if (files.length > 0) {
141
+ directFiles.push(...files);
142
+ directCommits.push({ hash: commit.hash, subject: commit.subject });
143
+ } else if (global) {
144
+ infraFiles.push(...commit.files.filter(f => INFRA_PATTERNS.some(re => re.test(f))));
145
+ infraCommits.push({ hash: commit.hash, subject: commit.subject });
146
+ }
147
+ }
148
+
149
+ const useDirect = directFiles.length > 0;
150
+ const suspectFiles = [...new Set(useDirect ? directFiles : infraFiles)].slice(0, MAX_FILES);
151
+ const suspectCommits = (useDirect ? directCommits : infraCommits).slice(0, MAX_COMMITS);
152
+ if (suspectFiles.length === 0) continue;
153
+
154
+ for (const finding of (routeResult.errors ?? [])) {
155
+ if (!finding.isNew) continue; // pre-existing findings predate these commits
156
+ finding.rootCause = {
157
+ files: suspectFiles,
158
+ commits: suspectCommits,
159
+ global: !useDirect,
160
+ };
161
+ linkedCount++;
162
+ }
163
+ }
164
+
165
+ if (linkedCount > 0) {
166
+ logger.info(`[ARGUS] Root cause linking: ${linkedCount} new finding(s) linked to recent commits`);
167
+ }
168
+ return { linkedCount };
169
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Argus Screen Recorder
3
+ *
4
+ * Records an audit session as a series of JPEG frames by periodically
5
+ * calling browser.screenshot(). Frames are saved to reports/recordings/<id>/.
6
+ *
7
+ * For genuine CDP screencast (Page.startScreencast) instead of polling,
8
+ * install the optional `ws` package:
9
+ * npm install ws
10
+ * then use CdpScreenRecorder (exported below) which connects directly to
11
+ * Chrome's WebSocket debugger URL.
12
+ *
13
+ * Usage (polling recorder — no extra deps):
14
+ * import { PollingRecorder } from './screen-recorder.js';
15
+ * const rec = new PollingRecorder(browser, { intervalMs: 500 });
16
+ * rec.start();
17
+ * // ... run audit ...
18
+ * const outputDir = await rec.stop('./reports/recordings/my-run');
19
+ *
20
+ * Usage (CDP screencast — requires ws):
21
+ * import { CdpScreenRecorder } from './screen-recorder.js';
22
+ * const rec = await CdpScreenRecorder.create(9222);
23
+ * await rec.start();
24
+ * // ...
25
+ * const outputDir = await rec.stop('./reports/recordings/my-run');
26
+ */
27
+
28
+ import { spawn } from 'child_process';
29
+ import fs from 'fs';
30
+ import path from 'path';
31
+ import http from 'http';
32
+
33
+ // ── Polling Recorder (no extra dependencies) ──────────────────────────────────
34
+
35
+ export class PollingRecorder {
36
+ /**
37
+ * @param {import('../adapters/browser.js').CdpBrowserAdapter} browser
38
+ * @param {{ intervalMs?: number, quality?: number }} [options]
39
+ */
40
+ constructor(browser, options = {}) {
41
+ this._browser = browser;
42
+ this._intervalMs = options.intervalMs ?? 500;
43
+ this._quality = options.quality ?? 70;
44
+ this._frames = [];
45
+ this._timer = null;
46
+ this._startedAt = null;
47
+ }
48
+
49
+ /** Begin periodic screenshot capture. */
50
+ start() {
51
+ if (this._timer) return;
52
+ this._startedAt = Date.now();
53
+ this._frames = [];
54
+ this._timer = setInterval(async () => {
55
+ try {
56
+ const raw = await this._browser.screenshot({ quality: this._quality });
57
+ // screenshot() returns an MCP result — extract base64 data if wrapped
58
+ const b64 = typeof raw === 'object' ? (raw.result ?? raw.data ?? raw) : raw;
59
+ if (b64) this._frames.push({ ts: Date.now(), data: b64 });
60
+ } catch { /* ignore individual capture errors */ }
61
+ }, this._intervalMs);
62
+ }
63
+
64
+ /**
65
+ * Stop recording and save frames to outputDir.
66
+ *
67
+ * @param {string} outputDir - Directory to write frame-NNN.jpg files into
68
+ * @returns {Promise<{ outputDir: string, frameCount: number, durationMs: number }>}
69
+ */
70
+ async stop(outputDir) {
71
+ if (this._timer) {
72
+ clearInterval(this._timer);
73
+ this._timer = null;
74
+ }
75
+
76
+ const durationMs = Date.now() - (this._startedAt ?? Date.now());
77
+ const frames = this._frames.slice();
78
+ this._frames = [];
79
+
80
+ const resolved = path.resolve(outputDir);
81
+ fs.mkdirSync(resolved, { recursive: true });
82
+
83
+ for (let i = 0; i < frames.length; i++) {
84
+ const name = `frame-${String(i).padStart(4, '0')}.jpg`;
85
+ const filePath = path.join(resolved, name);
86
+ const b64 = frames[i].data;
87
+ // b64 may be a data URL prefix — strip it
88
+ const raw = typeof b64 === 'string'
89
+ ? b64.replace(/^data:image\/\w+;base64,/, '')
90
+ : b64;
91
+ try {
92
+ fs.writeFileSync(filePath, Buffer.from(raw, 'base64'));
93
+ } catch { /* skip unwritable frames */ }
94
+ }
95
+
96
+ // Write metadata + ffmpeg hint
97
+ const meta = {
98
+ frameCount: frames.length,
99
+ durationMs,
100
+ intervalMs: this._intervalMs,
101
+ capturedAt: new Date(this._startedAt).toISOString(),
102
+ ffmpegHint: `ffmpeg -framerate ${Math.round(1000 / this._intervalMs)} -i frame-%04d.jpg -c:v libx264 -pix_fmt yuv420p recording.mp4`,
103
+ };
104
+ fs.writeFileSync(path.join(resolved, 'meta.json'), JSON.stringify(meta, null, 2));
105
+
106
+ // Run ffmpeg automatically if available
107
+ await _tryFfmpeg(resolved, meta.intervalMs).catch(() => {});
108
+
109
+ return { outputDir: resolved, frameCount: frames.length, durationMs };
110
+ }
111
+ }
112
+
113
+ // ── CDP Screencast Recorder (requires ws package) ─────────────────────────────
114
+
115
+ export class CdpScreenRecorder {
116
+ constructor(ws, sessionId) {
117
+ this._ws = ws;
118
+ this._sessionId = sessionId;
119
+ this._frames = [];
120
+ this._startedAt = null;
121
+ this._msgId = 1;
122
+ }
123
+
124
+ /**
125
+ * Create a CdpScreenRecorder connected to the first Chrome tab.
126
+ * Requires the `ws` package: npm install ws
127
+ *
128
+ * @param {number} [port=9222]
129
+ * @returns {Promise<CdpScreenRecorder>}
130
+ */
131
+ static async create(port = 9222) {
132
+ let WebSocket;
133
+ try {
134
+ WebSocket = (await import('ws')).default;
135
+ } catch {
136
+ throw new Error(
137
+ 'CDP screencast requires the ws package:\n' +
138
+ ' npm install ws\n' +
139
+ 'Or use PollingRecorder instead (no extra deps).'
140
+ );
141
+ }
142
+
143
+ const debuggerUrl = await _fetchDebuggerUrl(port);
144
+ const ws = new WebSocket(debuggerUrl);
145
+
146
+ await new Promise((resolve, reject) => {
147
+ ws.once('open', resolve);
148
+ ws.once('error', reject);
149
+ });
150
+
151
+ const rec = new CdpScreenRecorder(ws, null);
152
+ ws.on('message', data => {
153
+ try {
154
+ const msg = JSON.parse(data.toString());
155
+ if (msg.method === 'Page.screencastFrame') {
156
+ rec._frames.push({ ts: Date.now(), data: msg.params.data });
157
+ // Acknowledge the frame to keep Chrome sending
158
+ rec._send('Page.screencastFrameAck', { sessionId: msg.params.metadata.sessionId });
159
+ }
160
+ } catch { /* ignore parse errors */ }
161
+ });
162
+
163
+ return rec;
164
+ }
165
+
166
+ _send(method, params = {}) {
167
+ this._ws.send(JSON.stringify({ id: this._msgId++, method, params }));
168
+ }
169
+
170
+ /** Start CDP screencast. */
171
+ async start() {
172
+ this._startedAt = Date.now();
173
+ this._frames = [];
174
+ this._send('Page.startScreencast', {
175
+ format: 'jpeg',
176
+ quality: 70,
177
+ maxWidth: 1280,
178
+ maxHeight: 900,
179
+ everyNthFrame: 1,
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Stop screencast and save frames to outputDir.
185
+ *
186
+ * @param {string} outputDir
187
+ * @returns {Promise<{ outputDir: string, frameCount: number, durationMs: number }>}
188
+ */
189
+ async stop(outputDir) {
190
+ this._send('Page.stopScreencast');
191
+ await new Promise(r => setTimeout(r, 200)); // drain any buffered frames
192
+ this._ws.close();
193
+
194
+ const durationMs = Date.now() - (this._startedAt ?? Date.now());
195
+ const frames = this._frames.slice();
196
+ const resolved = path.resolve(outputDir);
197
+
198
+ fs.mkdirSync(resolved, { recursive: true });
199
+
200
+ for (let i = 0; i < frames.length; i++) {
201
+ const name = `frame-${String(i).padStart(4, '0')}.jpg`;
202
+ fs.writeFileSync(path.join(resolved, name), Buffer.from(frames[i].data, 'base64'));
203
+ }
204
+
205
+ const meta = {
206
+ frameCount: frames.length,
207
+ durationMs,
208
+ capturedAt: new Date(this._startedAt).toISOString(),
209
+ ffmpegHint: `ffmpeg -framerate 2 -i frame-%04d.jpg -c:v libx264 -pix_fmt yuv420p recording.mp4`,
210
+ };
211
+ fs.writeFileSync(path.join(resolved, 'meta.json'), JSON.stringify(meta, null, 2));
212
+
213
+ await _tryFfmpeg(resolved, 500).catch(() => {});
214
+
215
+ return { outputDir: resolved, frameCount: frames.length, durationMs };
216
+ }
217
+ }
218
+
219
+ // ── Helpers ───────────────────────────────────────────────────────────────────
220
+
221
+ function _fetchDebuggerUrl(port) {
222
+ return new Promise((resolve, reject) => {
223
+ http.get(`http://localhost:${port}/json/list`, res => {
224
+ let body = '';
225
+ res.on('data', d => { body += d; });
226
+ res.on('end', () => {
227
+ try {
228
+ const pages = JSON.parse(body);
229
+ const target = pages.find(p => p.type === 'page') ?? pages[0];
230
+ if (!target?.webSocketDebuggerUrl) reject(new Error('No debuggable Chrome page found'));
231
+ else resolve(target.webSocketDebuggerUrl);
232
+ } catch (e) { reject(e); }
233
+ });
234
+ }).on('error', reject);
235
+ });
236
+ }
237
+
238
+ function _tryFfmpeg(outputDir, intervalMs) {
239
+ return new Promise((resolve, reject) => {
240
+ const fps = Math.max(1, Math.round(1000 / intervalMs));
241
+ const proc = spawn('ffmpeg', [
242
+ '-y', '-framerate', String(fps),
243
+ '-i', 'frame-%04d.jpg',
244
+ '-c:v', 'libx264', '-pix_fmt', 'yuv420p',
245
+ 'recording.mp4',
246
+ ], { cwd: outputDir, stdio: 'ignore' });
247
+ proc.on('close', code => (code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}`))));
248
+ proc.on('error', reject);
249
+ });
250
+ }
@@ -18,6 +18,7 @@
18
18
  * • HTTP resource on HTTPS page (D6.9) — skips loopback; only fires on real HTTPS origins
19
19
  */
20
20
 
21
+ import { execFile } from 'child_process';
21
22
  import { thresholds } from '../config/targets.js';
22
23
  import { childLogger } from './logger.js';
23
24
 
@@ -111,7 +112,27 @@ export const SECURITY_ANALYSIS_SCRIPT = `async () => {
111
112
  }
112
113
  } catch (e) {}
113
114
 
114
- return JSON.stringify({ storageTokenKeys: storageTokenKeys, evalUsage: evalUsage, jsCookies: jsCookies, hasCSP: hasCSP, hasXFrame: hasXFrame, unsandboxedIframes: unsandboxedIframes, unsafeBlankLinks: unsafeBlankLinks });
115
+ // 7. SRI check external scripts and stylesheets without integrity attribute
116
+ var sriViolations = [];
117
+ try {
118
+ var pageOrigin = location.origin;
119
+ var extScripts = Array.prototype.slice.call(document.querySelectorAll('script[src]:not([integrity])'));
120
+ for (var sri_i = 0; sri_i < extScripts.length && sri_i < 20; sri_i++) {
121
+ var scriptSrc = extScripts[sri_i].src || '';
122
+ if (scriptSrc && !scriptSrc.startsWith(pageOrigin) && !scriptSrc.startsWith('/') && !scriptSrc.startsWith('blob:') && !scriptSrc.startsWith('data:')) {
123
+ sriViolations.push({ tag: 'script', src: scriptSrc.slice(0, 200) });
124
+ }
125
+ }
126
+ var extLinks = Array.prototype.slice.call(document.querySelectorAll('link[rel="stylesheet"][href]:not([integrity])'));
127
+ for (var sri_j = 0; sri_j < extLinks.length && sri_j < 20; sri_j++) {
128
+ var linkHref = extLinks[sri_j].href || '';
129
+ if (linkHref && !linkHref.startsWith(pageOrigin) && !linkHref.startsWith('/') && !linkHref.startsWith('blob:') && !linkHref.startsWith('data:')) {
130
+ sriViolations.push({ tag: 'link', src: linkHref.slice(0, 200) });
131
+ }
132
+ }
133
+ } catch (e) {}
134
+
135
+ return JSON.stringify({ storageTokenKeys: storageTokenKeys, evalUsage: evalUsage, jsCookies: jsCookies, hasCSP: hasCSP, hasXFrame: hasXFrame, unsandboxedIframes: unsandboxedIframes, unsafeBlankLinks: unsafeBlankLinks, sriViolations: sriViolations });
115
136
  }`;
116
137
 
117
138
  /**
@@ -219,6 +240,20 @@ export function parseSecurityAnalysisResult(rawResult, url) {
219
240
  });
220
241
  }
221
242
 
243
+ // SRI violations — external scripts/stylesheets without integrity attribute
244
+ if (Array.isArray(data.sriViolations) && data.sriViolations.length > 0) {
245
+ for (const v of data.sriViolations) {
246
+ bugs.push({
247
+ type: 'security_missing_sri',
248
+ tag: v.tag,
249
+ src: v.src,
250
+ message: `External <${v.tag}> without integrity attribute: "${String(v.src).slice(0, 200)}" — add integrity="sha384-..." to prevent supply-chain attacks`,
251
+ severity: 'warning',
252
+ url,
253
+ });
254
+ }
255
+ }
256
+
222
257
  return bugs;
223
258
  }
224
259
 
@@ -300,3 +335,99 @@ export function analyzeSecurityNetwork(networkReqs, url) {
300
335
  }
301
336
  return bugs;
302
337
  }
338
+
339
+ /**
340
+ * Detect source map files being served in production.
341
+ * Source maps expose original unminified source code to anyone with DevTools open.
342
+ *
343
+ * @param {object[]} networkReqs - Network request entries ({ url })
344
+ * @param {string} url - Page URL for context
345
+ * @returns {object[]}
346
+ */
347
+ export function checkSourceMapExposure(networkReqs, url) {
348
+ const bugs = [];
349
+ for (const req of (Array.isArray(networkReqs) ? networkReqs : [])) {
350
+ const reqUrl = req.url ?? req.requestUrl ?? '';
351
+ if (!reqUrl) continue;
352
+ if (/\.(js|css)\.map(\?|$)/i.test(reqUrl) || /\/[^/]+\.map(\?|$)/.test(reqUrl)) {
353
+ bugs.push({
354
+ type: 'security_sourcemap_exposed',
355
+ requestUrl: reqUrl,
356
+ message: `Source map publicly accessible: "${reqUrl.slice(0, 200)}" — remove or restrict .map files in production to protect original source code`,
357
+ severity: 'warning',
358
+ url,
359
+ });
360
+ }
361
+ }
362
+ return bugs;
363
+ }
364
+
365
+ /**
366
+ * Detect open redirect parameters in network request URLs.
367
+ * Open redirects allow attackers to craft phishing URLs that appear to come from
368
+ * the legitimate domain.
369
+ *
370
+ * @param {object[]} networkReqs - Network request entries ({ url })
371
+ * @param {string} url - Page URL for context
372
+ * @returns {object[]}
373
+ */
374
+ export function checkOpenRedirects(networkReqs, url) {
375
+ // 'to', 'target', 'url' excluded — too common in non-redirect contexts (CDN proxies, nav params).
376
+ const redirectParams = /[?&](redirect|return|next|dest|destination|goto|redir|forward)=/i;
377
+ const bugs = [];
378
+ for (const req of (Array.isArray(networkReqs) ? networkReqs : [])) {
379
+ const reqUrl = req.url ?? req.requestUrl ?? '';
380
+ if (!reqUrl || !redirectParams.test(reqUrl)) continue;
381
+ bugs.push({
382
+ type: 'security_open_redirect',
383
+ requestUrl: reqUrl,
384
+ message: `Potential open redirect parameter in URL: "${reqUrl.slice(0, 200)}" — validate redirect targets server-side against an allowlist`,
385
+ severity: 'warning',
386
+ url,
387
+ });
388
+ }
389
+ return bugs;
390
+ }
391
+
392
+ /**
393
+ * Run `npm audit --json` in the given project directory and convert CVEs to findings.
394
+ * Skips silently if projectDir is falsy, npm is not available, or the project has
395
+ * no package.json (not a Node project).
396
+ *
397
+ * @param {string|null} projectDir - Absolute path to the project root
398
+ * @returns {Promise<object[]>}
399
+ */
400
+ export async function auditNpmDependencies(projectDir) {
401
+ if (!projectDir) return [];
402
+
403
+ return new Promise(resolve => {
404
+ // shell: true resolves npm.cmd on Windows; harmless on macOS/Linux.
405
+ execFile('npm', ['audit', '--json'], { cwd: projectDir, maxBuffer: 4 * 1024 * 1024, shell: true }, (err, stdout) => {
406
+ // npm audit exits non-zero when vulnerabilities exist — we still want stdout.
407
+ if (!stdout) return resolve([]);
408
+ let report;
409
+ try { report = JSON.parse(stdout); } catch { return resolve([]); }
410
+
411
+ const bugs = [];
412
+ const vulns = report?.vulnerabilities ?? report?.advisories ?? {};
413
+
414
+ for (const [name, info] of Object.entries(vulns)) {
415
+ const sev = String(info.severity ?? 'moderate').toLowerCase();
416
+ const via = Array.isArray(info.via)
417
+ ? info.via.filter(v => typeof v === 'string').join(', ')
418
+ : '';
419
+ bugs.push({
420
+ type: 'security_npm_vulnerability',
421
+ package: name,
422
+ severity: sev === 'critical' || sev === 'high' ? 'critical' : 'warning',
423
+ message: `npm vulnerability in "${name}"${via ? ` via ${via}` : ''} (${sev}) — run \`npm audit fix\` to resolve`,
424
+ via,
425
+ });
426
+ }
427
+
428
+ // Deduplicate by package name (advisories-style reports can have duplicates).
429
+ const seen = new Set();
430
+ resolve(bugs.filter(b => { if (seen.has(b.package)) return false; seen.add(b.package); return true; }));
431
+ });
432
+ });
433
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ARGUS Theme Analyzer (Sprint 1 — A7: Theme & Dark Mode)
2
+ * ARGUS Theme Analyzer (A7: Theme & Dark Mode)
3
3
  *
4
4
  * Detects dark mode support gaps and theme consistency issues by:
5
5
  * 1. Scanning all stylesheets for @media (prefers-color-scheme: dark) rules
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ARGUS Visual Regression Analyzer (Sprint 3 — A8)
2
+ * ARGUS Visual Regression Analyzer (A8)
3
3
  *
4
4
  * Per-route visual regression detection via screenshot baseline comparison.
5
5
  * Takes a PNG screenshot, compares it pixel-by-pixel against a stored baseline,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ARGUS Web Vitals Analyzer (Sprint 9 — Advanced Performance Metrics)
2
+ * ARGUS Web Vitals Analyzer (Advanced Performance Metrics)
3
3
  *
4
4
  * Captures Core Web Vitals and performance metrics directly via the browser
5
5
  * Performance API. Unlike Lighthouse, this works in headless Chrome — metrics