argusqa-os 9.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +879 -0
- package/package.json +69 -0
- package/src/adapters/browser.js +82 -0
- package/src/argus.js +8 -0
- package/src/batch-runner.js +8 -0
- package/src/cli/init.js +314 -0
- package/src/config/schema.js +108 -0
- package/src/config/targets.js +309 -0
- package/src/domain/finding.js +25 -0
- package/src/mcp-server.js +156 -0
- package/src/orchestration/crawl-and-report.js +16 -0
- package/src/orchestration/dispatcher.js +263 -0
- package/src/orchestration/env-comparison.js +498 -0
- package/src/orchestration/orchestrator.js +1128 -0
- package/src/orchestration/report-processor.js +134 -0
- package/src/orchestration/slack-notifier.js +337 -0
- package/src/orchestration/watch-mode.js +316 -0
- package/src/registry.js +18 -0
- package/src/server/index.js +94 -0
- package/src/server/interaction-handler.js +126 -0
- package/src/server/slash-command-handler.js +185 -0
- package/src/utils/api-frequency.js +128 -0
- package/src/utils/baseline-manager.js +255 -0
- package/src/utils/codebase-analyzer.js +299 -0
- package/src/utils/content-analyzer.js +155 -0
- package/src/utils/contract-validator.js +178 -0
- package/src/utils/css-analyzer.js +407 -0
- package/src/utils/diff.js +189 -0
- package/src/utils/flakiness-detector.js +82 -0
- package/src/utils/flow-runner.js +572 -0
- package/src/utils/github-reporter.js +310 -0
- package/src/utils/hover-analyzer.js +214 -0
- package/src/utils/html-reporter.js +301 -0
- package/src/utils/issues-analyzer.js +171 -0
- package/src/utils/keyboard-analyzer.js +141 -0
- package/src/utils/lighthouse-checker.js +120 -0
- package/src/utils/logger.js +39 -0
- package/src/utils/login-orchestrator.js +99 -0
- package/src/utils/mcp-client.js +264 -0
- package/src/utils/mcp-parsers.js +57 -0
- package/src/utils/memory-analyzer.js +270 -0
- package/src/utils/network-timing-analyzer.js +76 -0
- package/src/utils/parallel-crawler.js +28 -0
- package/src/utils/responsive-analyzer.js +253 -0
- package/src/utils/retry.js +36 -0
- package/src/utils/route-discoverer.js +306 -0
- package/src/utils/security-analyzer.js +302 -0
- package/src/utils/seo-analyzer.js +164 -0
- package/src/utils/session-manager.js +12 -0
- package/src/utils/session-persistence.js +214 -0
- package/src/utils/severity-overrides.js +91 -0
- package/src/utils/slack-guard.js +18 -0
- package/src/utils/slug.js +8 -0
- package/src/utils/snapshot-analyzer.js +330 -0
- package/src/utils/telemetry.js +190 -0
|
@@ -0,0 +1,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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').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
|
+
}
|