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.
Files changed (57) hide show
  1. package/.mcp.json +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +879 -0
  4. package/package.json +69 -0
  5. package/src/adapters/browser.js +82 -0
  6. package/src/argus.js +8 -0
  7. package/src/batch-runner.js +8 -0
  8. package/src/cli/init.js +314 -0
  9. package/src/config/schema.js +108 -0
  10. package/src/config/targets.js +309 -0
  11. package/src/domain/finding.js +25 -0
  12. package/src/mcp-server.js +156 -0
  13. package/src/orchestration/crawl-and-report.js +16 -0
  14. package/src/orchestration/dispatcher.js +263 -0
  15. package/src/orchestration/env-comparison.js +498 -0
  16. package/src/orchestration/orchestrator.js +1128 -0
  17. package/src/orchestration/report-processor.js +134 -0
  18. package/src/orchestration/slack-notifier.js +337 -0
  19. package/src/orchestration/watch-mode.js +316 -0
  20. package/src/registry.js +18 -0
  21. package/src/server/index.js +94 -0
  22. package/src/server/interaction-handler.js +126 -0
  23. package/src/server/slash-command-handler.js +185 -0
  24. package/src/utils/api-frequency.js +128 -0
  25. package/src/utils/baseline-manager.js +255 -0
  26. package/src/utils/codebase-analyzer.js +299 -0
  27. package/src/utils/content-analyzer.js +155 -0
  28. package/src/utils/contract-validator.js +178 -0
  29. package/src/utils/css-analyzer.js +407 -0
  30. package/src/utils/diff.js +189 -0
  31. package/src/utils/flakiness-detector.js +82 -0
  32. package/src/utils/flow-runner.js +572 -0
  33. package/src/utils/github-reporter.js +310 -0
  34. package/src/utils/hover-analyzer.js +214 -0
  35. package/src/utils/html-reporter.js +301 -0
  36. package/src/utils/issues-analyzer.js +171 -0
  37. package/src/utils/keyboard-analyzer.js +141 -0
  38. package/src/utils/lighthouse-checker.js +120 -0
  39. package/src/utils/logger.js +39 -0
  40. package/src/utils/login-orchestrator.js +99 -0
  41. package/src/utils/mcp-client.js +264 -0
  42. package/src/utils/mcp-parsers.js +57 -0
  43. package/src/utils/memory-analyzer.js +270 -0
  44. package/src/utils/network-timing-analyzer.js +76 -0
  45. package/src/utils/parallel-crawler.js +28 -0
  46. package/src/utils/responsive-analyzer.js +253 -0
  47. package/src/utils/retry.js +36 -0
  48. package/src/utils/route-discoverer.js +306 -0
  49. package/src/utils/security-analyzer.js +302 -0
  50. package/src/utils/seo-analyzer.js +164 -0
  51. package/src/utils/session-manager.js +12 -0
  52. package/src/utils/session-persistence.js +214 -0
  53. package/src/utils/severity-overrides.js +91 -0
  54. package/src/utils/slack-guard.js +18 -0
  55. package/src/utils/slug.js +8 -0
  56. package/src/utils/snapshot-analyzer.js +330 -0
  57. package/src/utils/telemetry.js +190 -0
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Argus Phase C3: Auto route discovery.
3
+ *
4
+ * C3.1 discoverFromSitemap(baseUrl) — fetch /sitemap.xml, parse <loc> paths
5
+ * C3.2 discoverFromNextJs(sourceDir) — scan pages/ + app/ directory structure
6
+ * C3.3 discoverFromReactRouter(sourceDir) — grep source for <Route path> patterns
7
+ * C3.4 mergeRoutes(manualRoutes, paths) — deduplicate, preserve manual config
8
+ * C3.5 discoverRoutes(baseUrl, ...) — orchestrate all sources
9
+ *
10
+ * Design decisions:
11
+ * - discoverFromSitemap uses native fetch (Node 18+) with a 10s timeout; returns []
12
+ * on any network or parse error so a missing sitemap never fails a crawl.
13
+ * - discoverFromNextJs handles both pages/ (Next 12) and app/ (Next 13+) layouts.
14
+ * Route groups like (auth) are stripped from app/ paths.
15
+ * - discoverFromReactRouter is intentionally conservative: only static paths
16
+ * (starting with /, no :param, no * wildcard) are extracted.
17
+ * - mergeRoutes is pure — manual route config (critical, waitFor) is always preserved.
18
+ */
19
+
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+ import { childLogger } from './logger.js';
23
+
24
+ const logger = childLogger('route-discoverer');
25
+
26
+ // File extensions to scan for page/route files
27
+ const PAGE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx']);
28
+
29
+ // Next.js pages/ entries to always skip
30
+ const NEXTJS_SKIP = new Set(['_app', '_document', '_error', 'api']);
31
+
32
+ // ── Helpers ───────────────────────────────────────────────────────────────────
33
+
34
+ /** Derive a human-readable label from a URL path. */
35
+ function nameFromPath(urlPath) {
36
+ if (urlPath === '/') return 'Home';
37
+ return urlPath
38
+ .split('/')
39
+ .filter(Boolean)
40
+ .map(seg => seg.charAt(0).toUpperCase() + seg.slice(1).replace(/-/g, ' '))
41
+ .join(' / ');
42
+ }
43
+
44
+ /** Recursively list all files under dir. Returns [] if dir doesn't exist. */
45
+ function walkDir(dir) {
46
+ const results = [];
47
+ if (!fs.existsSync(dir)) return results;
48
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
49
+ if (entry.isSymbolicLink()) continue; // avoid symlink cycles
50
+ const full = path.join(dir, entry.name);
51
+ if (entry.isDirectory()) {
52
+ results.push(...walkDir(full));
53
+ } else {
54
+ results.push(full);
55
+ }
56
+ }
57
+ return results;
58
+ }
59
+
60
+ // ── C3.1: Sitemap discovery ───────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Fetch /sitemap.xml from baseUrl and return same-origin URL paths.
64
+ * Follows a single level of sitemap index indirection.
65
+ * Returns [] on any network or parse error.
66
+ *
67
+ * @param {string} baseUrl
68
+ * @returns {Promise<string[]>}
69
+ */
70
+ const SITEMAP_MAX_BYTES = 5 * 1024 * 1024; // 5 MB cap — prevents OOM on giant sitemaps
71
+ const SITEMAP_MAX_URLS = 500; // cap URL list to avoid unbounded crawls
72
+
73
+ export async function discoverFromSitemap(baseUrl) {
74
+ const origin = new URL(baseUrl).origin;
75
+ const sitemapUrl = `${baseUrl.replace(/\/$/, '')}/sitemap.xml`;
76
+ try {
77
+ const res = await fetch(sitemapUrl, { signal: AbortSignal.timeout(10000) });
78
+ if (!res.ok) return [];
79
+
80
+ const buf = await res.arrayBuffer();
81
+ if (buf.byteLength > SITEMAP_MAX_BYTES) return []; // oversized — skip
82
+ const xml = new TextDecoder().decode(buf);
83
+
84
+ // Sitemap index: follow first child sitemap only (avoid unbounded fan-out)
85
+ // Match <loc> inside a <sitemap> element to avoid picking up a <url><loc> entry.
86
+ if (/<sitemapindex/i.test(xml)) {
87
+ const childMatch = xml.match(/<sitemap[^>]*>[\s\S]*?<loc>([\s\S]*?)<\/loc>/i);
88
+ if (!childMatch) return [];
89
+ const childUrl = childMatch[1].trim();
90
+ // SSRF: child sitemap must be same origin as baseUrl
91
+ try { if (new URL(childUrl).origin !== origin) return []; } catch { return []; }
92
+ const childRes = await fetch(childUrl, { signal: AbortSignal.timeout(10000) });
93
+ if (!childRes.ok) return [];
94
+ const childBuf = await childRes.arrayBuffer();
95
+ if (childBuf.byteLength > SITEMAP_MAX_BYTES) return [];
96
+ return parseLocElements(new TextDecoder().decode(childBuf), baseUrl);
97
+ }
98
+
99
+ return parseLocElements(xml, baseUrl);
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+
105
+ function parseLocElements(xml, baseUrl) {
106
+ const origin = new URL(baseUrl).origin;
107
+ const paths = new Set();
108
+ for (const m of xml.matchAll(/<loc>([\s\S]*?)<\/loc>/gi)) {
109
+ if (paths.size >= SITEMAP_MAX_URLS) break;
110
+ const raw = m[1].trim();
111
+ try {
112
+ const u = new URL(raw);
113
+ if (u.origin !== origin) continue;
114
+ paths.add(u.pathname || '/');
115
+ } catch { /* skip malformed loc entries */ }
116
+ }
117
+ return [...paths];
118
+ }
119
+
120
+ // ── C3.2: Next.js route discovery ─────────────────────────────────────────────
121
+
122
+ /**
123
+ * Scan a Next.js project root for routable pages.
124
+ *
125
+ * pages/ layout (Next 12):
126
+ * - pages/index.jsx → /
127
+ * - pages/blog/index.jsx → /blog
128
+ * - pages/about.tsx → /about
129
+ * - pages/[slug].tsx → skipped (dynamic — no concrete URL to crawl)
130
+ * - pages/_app.jsx → skipped
131
+ * - pages/api/* → skipped
132
+ *
133
+ * app/ layout (Next 13+):
134
+ * - app/page.tsx → /
135
+ * - app/about/page.tsx → /about
136
+ * - app/(auth)/login/page.tsx → /login (route group stripped)
137
+ * - app/api/route.ts → skipped (not a page.* file)
138
+ *
139
+ * @param {string} sourceDir project root (contains pages/ and/or app/)
140
+ * @returns {string[]}
141
+ */
142
+ export function discoverFromNextJs(sourceDir) {
143
+ const discovered = new Set();
144
+
145
+ // ── pages/ ────────────────────────────────────────────────────────────────
146
+ const pagesDir = path.join(sourceDir, 'pages');
147
+ if (fs.existsSync(pagesDir)) {
148
+ for (const file of walkDir(pagesDir)) {
149
+ const ext = path.extname(file);
150
+ if (!PAGE_EXTS.has(ext)) continue;
151
+
152
+ const rel = path.relative(pagesDir, file);
153
+ const parts = rel.split(path.sep);
154
+
155
+ // Skip underscore files and reserved directories anywhere in path
156
+ if (parts.some(p => p.startsWith('_') || NEXTJS_SKIP.has(p.replace(/\..+$/, '')))) continue;
157
+
158
+ // Strip extension from final segment, collapse trailing 'index' to parent
159
+ const urlParts = parts.map((p, i) => i === parts.length - 1 ? p.replace(ext, '') : p);
160
+ if (urlParts[urlParts.length - 1] === 'index') urlParts.pop();
161
+
162
+ // Skip dynamic segments like [slug] — no concrete URL to crawl
163
+ if (urlParts.some(p => p.includes('['))) continue;
164
+
165
+ discovered.add(urlParts.length === 0 ? '/' : '/' + urlParts.join('/'));
166
+ }
167
+ }
168
+
169
+ // ── app/ ─────────────────────────────────────────────────────────────────
170
+ const appDir = path.join(sourceDir, 'app');
171
+ if (fs.existsSync(appDir)) {
172
+ for (const file of walkDir(appDir)) {
173
+ // Only files named page.{ext} are routes in the app/ router
174
+ if (!/^page\.(js|jsx|ts|tsx)$/.test(path.basename(file))) continue;
175
+
176
+ const relDir = path.dirname(path.relative(appDir, file));
177
+ const parts = relDir === '.' ? [] : relDir.split(path.sep);
178
+
179
+ // Skip api/ and private _folders; strip route groups (parenthesized dirs)
180
+ if (parts.some(p => p === 'api' || p.startsWith('_'))) continue;
181
+ const filtered = parts.filter(p => !/^\(.*\)$/.test(p));
182
+
183
+ // Skip dynamic segments like [id] — no concrete URL to crawl
184
+ if (filtered.some(p => p.includes('['))) continue;
185
+
186
+ discovered.add(filtered.length === 0 ? '/' : '/' + filtered.join('/'));
187
+ }
188
+ }
189
+
190
+ return [...discovered];
191
+ }
192
+
193
+ // ── C3.3: React Router route discovery ───────────────────────────────────────
194
+
195
+ /**
196
+ * Grep JS/TS source files for React Router path declarations.
197
+ *
198
+ * Detects:
199
+ * <Route path="/foo" ... />
200
+ * { path: '/foo', element: ... } (createBrowserRouter / route objects)
201
+ *
202
+ * Only static paths are returned: must start with /, no :param segments, no * wildcards.
203
+ *
204
+ * @param {string} sourceDir
205
+ * @returns {string[]}
206
+ */
207
+ export function discoverFromReactRouter(sourceDir) {
208
+ if (!fs.existsSync(sourceDir)) return [];
209
+
210
+ const files = walkDir(sourceDir).filter(f => PAGE_EXTS.has(path.extname(f)));
211
+ const discovered = new Set();
212
+
213
+ const PATTERNS = [
214
+ // JSX: <Route path="/foo"
215
+ /<Route[^>]*\bpath\s*=\s*['"]([^'"]+)['"]/g,
216
+ // Object: path: '/foo'
217
+ /\bpath\s*:\s*['"]([^'"]+)['"]/g,
218
+ ];
219
+
220
+ for (const file of files) {
221
+ let src;
222
+ try { src = fs.readFileSync(file, 'utf8'); } catch { continue; }
223
+
224
+ for (const re of PATTERNS) {
225
+ re.lastIndex = 0;
226
+ for (const m of src.matchAll(re)) {
227
+ const p = m[1].trim();
228
+ // Only absolute, static paths
229
+ if (!p.startsWith('/') || p.includes(':') || p.includes('*')) continue;
230
+ if (p.includes('//') || /\.(js|ts|json|css)$/.test(p)) continue;
231
+ discovered.add(p);
232
+ }
233
+ }
234
+ }
235
+
236
+ return [...discovered];
237
+ }
238
+
239
+ // ── C3.4: Merge with manual routes ────────────────────────────────────────────
240
+
241
+ /**
242
+ * Merge discovered paths into the manual routes array.
243
+ * Manual routes always take precedence — their config (critical, waitFor, name) is
244
+ * preserved as-is. New paths get sensible defaults and a `discovered: true` flag.
245
+ *
246
+ * @param {Array<{path: string}>} manualRoutes
247
+ * @param {string[]} discoveredPaths
248
+ * @returns {Array}
249
+ */
250
+ export function mergeRoutes(manualRoutes, discoveredPaths) {
251
+ const known = new Set(manualRoutes.map(r => r.path));
252
+ const merged = [...manualRoutes];
253
+ for (const p of discoveredPaths) {
254
+ if (!known.has(p)) {
255
+ known.add(p);
256
+ merged.push({
257
+ path: p,
258
+ name: nameFromPath(p),
259
+ critical: false,
260
+ waitFor: null,
261
+ discovered: true,
262
+ });
263
+ }
264
+ }
265
+ return merged;
266
+ }
267
+
268
+ // ── C3.5: Orchestrator ────────────────────────────────────────────────────────
269
+
270
+ /**
271
+ * Run all enabled discovery methods and return a merged route array.
272
+ *
273
+ * @param {string} baseUrl
274
+ * @param {string|null} sourceDir
275
+ * @param {{ sitemap?: boolean, nextjs?: boolean, reactRouter?: boolean }} autoDiscover
276
+ * @param {Array} manualRoutes
277
+ * @returns {Promise<Array>}
278
+ */
279
+ export async function discoverRoutes(baseUrl, sourceDir, autoDiscover, manualRoutes) {
280
+ if (!autoDiscover) return manualRoutes;
281
+ const { sitemap = true, nextjs = true, reactRouter = false } = autoDiscover;
282
+ const allPaths = [];
283
+
284
+ if (sitemap) {
285
+ const paths = await discoverFromSitemap(baseUrl);
286
+ allPaths.push(...paths);
287
+ if (paths.length > 0) logger.info(`[ARGUS] C3: sitemap → ${paths.length} route(s)`);
288
+ }
289
+
290
+ if (nextjs && sourceDir) {
291
+ const paths = discoverFromNextJs(sourceDir);
292
+ allPaths.push(...paths);
293
+ if (paths.length > 0) logger.info(`[ARGUS] C3: Next.js → ${paths.length} route(s)`);
294
+ }
295
+
296
+ if (reactRouter && sourceDir) {
297
+ const paths = discoverFromReactRouter(sourceDir);
298
+ allPaths.push(...paths);
299
+ if (paths.length > 0) logger.info(`[ARGUS] C3: React Router → ${paths.length} route(s)`);
300
+ }
301
+
302
+ const merged = mergeRoutes(manualRoutes, allPaths);
303
+ const added = merged.length - manualRoutes.length;
304
+ if (added > 0) logger.info(`[ARGUS] C3: ${added} new route(s) added (total: ${merged.length})`);
305
+ return merged;
306
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * ARGUS Security Analyzer (v3 Phase A4)
3
+ *
4
+ * Three detection surfaces:
5
+ * 1. DOM / browser context — SECURITY_ANALYSIS_SCRIPT via evaluate_script
6
+ * • localStorage keys with token/auth names or JWT-shaped values
7
+ * • eval() in inline <script> tags
8
+ * • JS-accessible cookies (no HttpOnly flag)
9
+ * • Missing Content-Security-Policy and X-Frame-Options response headers
10
+ * (checked via a same-origin fetch HEAD request)
11
+ *
12
+ * 2. Console messages — analyzeSecurityConsole
13
+ * • Mixed content (D6.9): "blocked" in message → critical; passive (image/audio) → warning
14
+ * • Sensitive data patterns (email address, JWT, Bearer token, param=value)
15
+ *
16
+ * 3. Network request URLs — analyzeSecurityNetwork
17
+ * • Sensitive query parameters (?token=, ?key=, ?auth=, …)
18
+ * • HTTP resource on HTTPS page (D6.9) — skips loopback; only fires on real HTTPS origins
19
+ */
20
+
21
+ import { thresholds } from '../config/targets.js';
22
+ import { childLogger } from './logger.js';
23
+
24
+ const logger = childLogger('security-analyzer');
25
+
26
+ /**
27
+ * Async arrow function injected into the page via mcp.evaluate_script.
28
+ * Uses a fetch HEAD request to check response headers on the same origin.
29
+ * Returns a JSON string consumed by parseSecurityAnalysisResult().
30
+ * Timeout value is interpolated from thresholds.security.headTimeoutMs at module load.
31
+ */
32
+ export const SECURITY_ANALYSIS_SCRIPT = `async () => {
33
+ // 1. localStorage — token-shaped key names or JWT-shaped values
34
+ const storageTokenKeys = [];
35
+ try {
36
+ var kPat = /token|jwt|auth|secret|apikey|api_key|password|credential|session/i;
37
+ var jwtPat = /^ey[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]+/;
38
+ var keys = Object.keys(localStorage || {});
39
+ for (var i = 0; i < keys.length; i++) {
40
+ var k = keys[i];
41
+ var v = (localStorage.getItem(k) || '').slice(0, 500);
42
+ if (kPat.test(k) || jwtPat.test(v)) storageTokenKeys.push(k);
43
+ }
44
+ } catch (e) {}
45
+
46
+ // 2. eval() in inline <script> tags
47
+ var evalUsage = false;
48
+ try {
49
+ var scripts = Array.prototype.slice.call(document.querySelectorAll('script:not([src])'));
50
+ evalUsage = scripts.some(function(s) { return /\\beval\\s*\\(/.test(s.textContent || ''); });
51
+ } catch (e) {}
52
+
53
+ // 3. JS-accessible cookies (visible to JS = no HttpOnly flag)
54
+ // Limitation: document.cookie only exposes cookies WITHOUT HttpOnly. HttpOnly cookies
55
+ // (most sensitive session tokens) are completely invisible here. The Secure flag also
56
+ // cannot be detected via JS — Secure-only cookies still appear in document.cookie.
57
+ // For HttpOnly detection, the only path is response headers (Set-Cookie inspection),
58
+ // which requires network-layer interception outside this DOM script.
59
+ var jsCookies = [];
60
+ try {
61
+ jsCookies = document.cookie.split(';')
62
+ .map(function(c) { return c.trim(); })
63
+ .filter(function(c) { return c.length > 0; })
64
+ .map(function(c) { return c.split('=')[0].trim(); });
65
+ } catch (e) {}
66
+
67
+ // 4. Response headers — CSP + X-Frame-Options via fetch HEAD (same-origin)
68
+ // Timeout is configurable via ARGUS_SECURITY_TIMEOUT (ms); defaults to 3000.
69
+ // Hardcoded 3s caused false negatives on staging servers behind VPNs/proxies.
70
+ var hasCSP = null, hasXFrame = null;
71
+ try {
72
+ var ctrl = new AbortController();
73
+ var timeout = (typeof ARGUS_SECURITY_TIMEOUT !== 'undefined' ? ARGUS_SECURITY_TIMEOUT : ${thresholds.security.headTimeoutMs});
74
+ var tid = setTimeout(function() { ctrl.abort(); }, timeout);
75
+ try {
76
+ // clearTimeout must run even if fetch rejects — use inner try/finally.
77
+ var r = await fetch(location.href, { method: 'HEAD', cache: 'no-store', signal: ctrl.signal });
78
+ hasCSP = r.headers.has('Content-Security-Policy');
79
+ hasXFrame = r.headers.has('X-Frame-Options');
80
+ } finally {
81
+ clearTimeout(tid);
82
+ }
83
+ } catch (e) {}
84
+
85
+ // 5. iframe sandbox check
86
+ // Cross-origin iframes without the sandbox attribute can execute scripts,
87
+ // access top-level navigation, and exfiltrate cookies — significant security risk.
88
+ var unsandboxedIframes = [];
89
+ try {
90
+ var iframes = Array.prototype.slice.call(document.querySelectorAll('iframe[src]'));
91
+ for (var fi = 0; fi < iframes.length && fi < 20; fi++) {
92
+ var iframe = iframes[fi];
93
+ var iframeSrc = iframe.getAttribute('src') || '';
94
+ if (!iframe.hasAttribute('sandbox') && iframeSrc && !iframeSrc.startsWith('javascript:') && !iframeSrc.startsWith('about:') && !iframeSrc.startsWith('blob:')) {
95
+ unsandboxedIframes.push({ src: iframeSrc.slice(0, 100) });
96
+ }
97
+ }
98
+ } catch (e) {}
99
+
100
+ // 6. Links opening in new tabs without rel="noopener noreferrer"
101
+ // window.opener on the opened page allows it to navigate the opener — phishing vector.
102
+ var unsafeBlankLinks = [];
103
+ try {
104
+ var blankLinks = Array.prototype.slice.call(document.querySelectorAll('a[target="_blank"]'));
105
+ for (var li = 0; li < blankLinks.length && li < 30; li++) {
106
+ var link = blankLinks[li];
107
+ var rel = (link.getAttribute('rel') || '').toLowerCase();
108
+ if (!rel.includes('noopener') && !rel.includes('noreferrer')) {
109
+ unsafeBlankLinks.push({ href: (link.href || link.getAttribute('href') || '').slice(0, 100) });
110
+ }
111
+ }
112
+ } catch (e) {}
113
+
114
+ return JSON.stringify({ storageTokenKeys: storageTokenKeys, evalUsage: evalUsage, jsCookies: jsCookies, hasCSP: hasCSP, hasXFrame: hasXFrame, unsandboxedIframes: unsandboxedIframes, unsafeBlankLinks: unsafeBlankLinks });
115
+ }`;
116
+
117
+ /**
118
+ * Convert the raw evaluate_script result from SECURITY_ANALYSIS_SCRIPT into
119
+ * structured bug entries for the Argus report.
120
+ *
121
+ * @param {object|string|null} rawResult
122
+ * @param {string} url - Page URL for context
123
+ * @returns {object[]}
124
+ */
125
+ export function parseSecurityAnalysisResult(rawResult, url) {
126
+ if (rawResult == null) return [];
127
+
128
+ let data;
129
+ try {
130
+ // Unwrap MCP { result: '...' } wrapper before parsing. Without this,
131
+ // JSON.stringify({ result: '{"key":"val"}' }) → parse → { result: '...' } and
132
+ // all field lookups (storageTokenKeys, evalUsage, etc.) return undefined — zero findings.
133
+ // JSON.stringify on a circular object throws; catch logs and returns [].
134
+ let raw = rawResult;
135
+ if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) {
136
+ raw = raw.result;
137
+ }
138
+ const str = typeof raw === 'string' ? raw : JSON.stringify(raw);
139
+ data = JSON.parse(str);
140
+ } catch (e) {
141
+ logger.warn('[ARGUS] parseSecurityAnalysisResult: parse failed —', e.message);
142
+ return [];
143
+ }
144
+
145
+ if (!data || typeof data !== 'object') return [];
146
+
147
+ const bugs = [];
148
+
149
+ if (Array.isArray(data.storageTokenKeys) && data.storageTokenKeys.length > 0) {
150
+ bugs.push({
151
+ type: 'security_token_in_storage',
152
+ keys: data.storageTokenKeys,
153
+ message: `Auth token stored in localStorage (keys: ${data.storageTokenKeys.join(', ')}) — XSS-accessible`,
154
+ severity: 'critical',
155
+ url,
156
+ });
157
+ }
158
+
159
+ if (data.evalUsage) {
160
+ bugs.push({
161
+ type: 'security_eval_usage',
162
+ message: 'eval() usage detected in inline script — security and performance risk',
163
+ severity: 'warning',
164
+ url,
165
+ });
166
+ }
167
+
168
+ if (Array.isArray(data.jsCookies) && data.jsCookies.length > 0) {
169
+ bugs.push({
170
+ type: 'security_cookie_no_httponly',
171
+ cookies: data.jsCookies,
172
+ message: `${data.jsCookies.length} cookie(s) readable by JavaScript (no HttpOnly flag): ${data.jsCookies.join(', ')}`,
173
+ severity: 'warning',
174
+ url,
175
+ });
176
+ }
177
+
178
+ if (data.hasCSP === false) {
179
+ bugs.push({
180
+ type: 'security_missing_csp',
181
+ message: 'Missing Content-Security-Policy response header — XSS risk',
182
+ severity: 'warning',
183
+ url,
184
+ });
185
+ }
186
+
187
+ if (data.hasXFrame === false) {
188
+ bugs.push({
189
+ type: 'security_missing_xframe',
190
+ message: 'Missing X-Frame-Options response header — clickjacking risk',
191
+ severity: 'warning',
192
+ url,
193
+ });
194
+ }
195
+
196
+ // unsandboxed cross-origin iframes
197
+ if (Array.isArray(data.unsandboxedIframes) && data.unsandboxedIframes.length > 0) {
198
+ for (const frame of data.unsandboxedIframes) {
199
+ bugs.push({
200
+ type: 'security_iframe_no_sandbox',
201
+ src: frame.src,
202
+ message: `<iframe> with src="${String(frame.src).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').slice(0, 200)}" has no sandbox attribute — add sandbox="allow-scripts allow-same-origin" to restrict capabilities`,
203
+ severity: 'warning',
204
+ url,
205
+ });
206
+ }
207
+ }
208
+
209
+ // target="_blank" links without rel="noopener noreferrer"
210
+ // The opened page can access window.opener and redirect the parent tab — phishing vector.
211
+ if (Array.isArray(data.unsafeBlankLinks) && data.unsafeBlankLinks.length > 0) {
212
+ bugs.push({
213
+ type: 'security_unsafe_blank_link',
214
+ count: data.unsafeBlankLinks.length,
215
+ hrefs: data.unsafeBlankLinks.map(l => l.href),
216
+ message: `${data.unsafeBlankLinks.length} link(s) with target="_blank" missing rel="noopener noreferrer" — add rel="noopener noreferrer" to prevent opener hijacking`,
217
+ severity: 'warning',
218
+ url,
219
+ });
220
+ }
221
+
222
+ return bugs;
223
+ }
224
+
225
+ /**
226
+ * Scan console messages for mixed content warnings and sensitive data patterns.
227
+ * Targeted pattern avoids false positives on common error strings.
228
+ *
229
+ * @param {object[]} consoleMsgs - Raw console message objects ({ level, text })
230
+ * @param {string} url
231
+ * @returns {object[]}
232
+ */
233
+ export function analyzeSecurityConsole(consoleMsgs, url) {
234
+ const bugs = [];
235
+ // Targeted: require delimiter after keyword (password=, secret:) OR structural patterns
236
+ const sensitivePattern = /password[:=]|secret[:=]|api[_-]?key[:=]|credential[:=]|eyJ[A-Za-z0-9_-]{10,}|Bearer\s+\S{6,}|\b[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9.\-]{1,253}\.[A-Za-z]{2,63}\b/i;
237
+ const mixedContentPattern = /mixed content/i;
238
+
239
+ for (const msg of (Array.isArray(consoleMsgs) ? consoleMsgs : [])) {
240
+ const text = String(msg.text ?? msg.message ?? msg ?? '');
241
+ if (!text) continue;
242
+ if (mixedContentPattern.test(text)) {
243
+ // D6.9: "blocked" in the message → active content, browser refuses to load → critical.
244
+ // No "blocked" → passive content (image/audio/video), browser loads with a warning → warning.
245
+ const isBlocked = /\bblocked\b/i.test(text);
246
+ bugs.push({
247
+ type: 'security_mixed_content',
248
+ message: `Mixed content ${isBlocked ? 'blocked' : 'warning'}: ${text.slice(0, 200)}`,
249
+ severity: isBlocked ? 'critical' : 'warning',
250
+ url,
251
+ });
252
+ } else if (sensitivePattern.test(text)) {
253
+ bugs.push({
254
+ type: 'security_sensitive_console',
255
+ message: `Sensitive data in console output: ${text.slice(0, 200)}`,
256
+ severity: 'warning',
257
+ url,
258
+ });
259
+ }
260
+ }
261
+ return bugs;
262
+ }
263
+
264
+ /**
265
+ * Scan network request URLs for sensitive query parameters.
266
+ *
267
+ * @param {object[]} networkReqs - Network request entries ({ url })
268
+ * @param {string} url - Page URL for context
269
+ * @returns {object[]}
270
+ */
271
+ export function analyzeSecurityNetwork(networkReqs, url) {
272
+ const bugs = [];
273
+ const sensitiveParams = /[?&](token|key|auth|password|secret|apikey|api_key|credential|jwt)=/i;
274
+ // D6.9: flag HTTP resources on HTTPS pages; skip loopback addresses (not mixed content).
275
+ const pageIsHttps = (url ?? '').startsWith('https://');
276
+ const isLoopback = /^http:\/\/(localhost|127\.|0\.0\.0\.0)/i;
277
+
278
+ for (const req of (Array.isArray(networkReqs) ? networkReqs : [])) {
279
+ const reqUrl = req.url ?? req.requestUrl ?? '';
280
+ if (!reqUrl) continue;
281
+
282
+ if (pageIsHttps && reqUrl.startsWith('http://') && !isLoopback.test(reqUrl)) {
283
+ bugs.push({
284
+ type: 'security_mixed_content',
285
+ requestUrl: reqUrl,
286
+ message: `Mixed content: HTTP resource "${reqUrl.slice(0, 200)}" on HTTPS page — request may be blocked`,
287
+ severity: 'critical',
288
+ url,
289
+ });
290
+ }
291
+
292
+ if (!sensitiveParams.test(reqUrl)) continue;
293
+ bugs.push({
294
+ type: 'security_token_in_url',
295
+ requestUrl: reqUrl,
296
+ message: `Sensitive parameter in request URL: ${reqUrl.slice(0, 300)}`,
297
+ severity: 'critical',
298
+ url,
299
+ });
300
+ }
301
+ return bugs;
302
+ }