argusqa-os 9.6.5 → 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.
- package/README.md +24 -14
- package/glama.json +1 -1
- package/package.json +77 -71
- package/src/adapters/browser.js +2 -1
- package/src/cli/chrome-launcher.js +175 -0
- package/src/cli/doctor.js +133 -0
- package/src/cli/pr-validate.js +24 -7
- package/src/mcp-server.js +3 -3
- package/src/orchestration/orchestrator.js +9 -7
- package/src/orchestration/report-processor.js +33 -1
- package/src/utils/a11y-deep-analyzer.js +1 -1
- package/src/utils/design-fidelity-analyzer.js +1 -1
- package/src/utils/flow-runner.js +16 -2
- package/src/utils/font-analyzer.js +1 -1
- package/src/utils/form-analyzer.js +1 -1
- package/src/utils/har-recorder.js +1 -1
- package/src/utils/issues-analyzer.js +12 -19
- package/src/utils/motion-analyzer.js +1 -1
- package/src/utils/noise-filter.js +159 -0
- package/src/utils/pdf-exporter.js +146 -0
- package/src/utils/pr-diff-analyzer.js +2 -1
- package/src/utils/root-cause-linker.js +169 -0
- package/src/utils/screen-recorder.js +250 -0
- package/src/utils/security-analyzer.js +132 -1
- package/src/utils/theme-analyzer.js +1 -1
- package/src/utils/visual-diff-analyzer.js +1 -1
- package/src/utils/web-vitals-analyzer.js +1 -1
|
@@ -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
|
-
|
|
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 Web Vitals Analyzer (
|
|
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
|