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,164 @@
1
+ /**
2
+ * ARGUS SEO Analyzer (v3 Phase A3)
3
+ *
4
+ * Injected via evaluate_script to inspect the live DOM for SEO signals:
5
+ * - <meta name="description"> presence
6
+ * - Open Graph tags (og:title, og:description, og:image)
7
+ * - Number of <h1> tags
8
+ * - <title> length / genericness
9
+ * - <link rel="canonical"> presence
10
+ * - <meta name="viewport"> presence
11
+ *
12
+ * Returns a JSON string that parseSeoAnalysisResult() converts to bug entries.
13
+ */
14
+
15
+ /**
16
+ * JavaScript arrow function injected into the page via mcp.evaluate_script.
17
+ * Runs entirely in the page's browser context — no Node.js APIs available.
18
+ */
19
+ export const SEO_ANALYSIS_SCRIPT = `() => {
20
+ function sel(s) { return !!document.querySelector(s); }
21
+ return JSON.stringify({
22
+ hasDescription: sel('meta[name="description"]'),
23
+ hasOgTitle: sel('meta[property="og:title"]'),
24
+ hasOgDescription: sel('meta[property="og:description"]'),
25
+ hasOgImage: sel('meta[property="og:image"]'),
26
+ ogImageUrl: (document.querySelector('meta[property="og:image"]') || {}).getAttribute?.('content') || null,
27
+ h1Count: document.querySelectorAll('h1').length,
28
+ titleText: document.title || '',
29
+ titleLength: (document.title || '').trim().length,
30
+ hasCanonical: sel('link[rel="canonical"]'),
31
+ hasViewport: sel('meta[name="viewport"]'),
32
+ });
33
+ }`;
34
+
35
+ /**
36
+ * Convert the raw evaluate_script result from SEO_ANALYSIS_SCRIPT into
37
+ * structured bug entries for the Argus report.
38
+ *
39
+ * @param {object|string|null} rawResult - Parsed object, JSON string, or null
40
+ * @param {string} url - Page URL for error context
41
+ * @returns {object[]} Bug entries
42
+ */
43
+ export function parseSeoAnalysisResult(rawResult, url) {
44
+ if (rawResult == null) return [];
45
+
46
+ // Unwrap MCP { result: '...' } response shape before parsing — the same
47
+ // pattern used by security-analyzer and content-analyzer. Without this, when the MCP
48
+ // client returns an object wrapper, JSON.stringify(rawResult) serialises the envelope
49
+ // instead of the inner payload and all SEO fields are undefined → false positives.
50
+ let inner = rawResult;
51
+ if (typeof rawResult === 'object' && rawResult !== null && !Array.isArray(rawResult)) {
52
+ inner = rawResult.result !== undefined ? rawResult.result : rawResult;
53
+ }
54
+
55
+ let data;
56
+ try {
57
+ const str = typeof inner === 'string' ? inner : JSON.stringify(inner);
58
+ data = JSON.parse(str);
59
+ } catch {
60
+ return [];
61
+ }
62
+
63
+ if (!data || typeof data !== 'object') return [];
64
+
65
+ const bugs = [];
66
+
67
+ if (!data.hasDescription) {
68
+ bugs.push({
69
+ type: 'seo_missing_description',
70
+ message: 'Missing <meta name="description"> — page has no search snippet',
71
+ severity: 'warning',
72
+ url,
73
+ });
74
+ }
75
+
76
+ if (!data.hasOgTitle) {
77
+ bugs.push({
78
+ type: 'seo_missing_og',
79
+ property: 'og:title',
80
+ message: 'Missing <meta property="og:title"> — social sharing title not set',
81
+ severity: 'warning',
82
+ url,
83
+ });
84
+ }
85
+
86
+ if (!data.hasOgDescription) {
87
+ bugs.push({
88
+ type: 'seo_missing_og',
89
+ property: 'og:description',
90
+ message: 'Missing <meta property="og:description"> — social sharing description not set',
91
+ severity: 'warning',
92
+ url,
93
+ });
94
+ }
95
+
96
+ if (!data.hasOgImage) {
97
+ bugs.push({
98
+ type: 'seo_missing_og',
99
+ property: 'og:image',
100
+ message: 'Missing <meta property="og:image"> — social sharing image not set',
101
+ severity: 'warning',
102
+ url,
103
+ });
104
+ } else if (data.ogImageUrl && !data.ogImageUrl.startsWith('http://') && !data.ogImageUrl.startsWith('https://')) {
105
+ const isProtocolRelative = data.ogImageUrl.startsWith('//');
106
+ bugs.push({
107
+ type: 'seo_og_image_relative_url',
108
+ property: 'og:image',
109
+ message: isProtocolRelative
110
+ ? `og:image URL is protocol-relative ("${data.ogImageUrl}") — Open Graph requires an absolute URL with scheme (https://)`
111
+ : `og:image URL is relative ("${data.ogImageUrl}") — Open Graph requires an absolute URL`,
112
+ severity: 'warning',
113
+ url,
114
+ });
115
+ }
116
+
117
+ if (data.h1Count > 1) {
118
+ bugs.push({
119
+ type: 'seo_multiple_h1',
120
+ h1Count: data.h1Count,
121
+ message: `Multiple <h1> tags detected (${data.h1Count}) — page should have exactly one`,
122
+ severity: 'warning',
123
+ url,
124
+ });
125
+ } else if (data.h1Count === 0) {
126
+ bugs.push({
127
+ type: 'seo_missing_h1',
128
+ message: 'No <h1> tag on page — missing primary heading',
129
+ severity: 'warning',
130
+ url,
131
+ });
132
+ }
133
+
134
+ if (data.titleLength < 10) {
135
+ bugs.push({
136
+ type: 'seo_generic_title',
137
+ titleText: data.titleText,
138
+ titleLength: data.titleLength,
139
+ message: `Page title too short (${data.titleLength} chars: "${data.titleText}") — aim for 10–60 chars`,
140
+ severity: 'warning',
141
+ url,
142
+ });
143
+ }
144
+
145
+ if (!data.hasCanonical) {
146
+ bugs.push({
147
+ type: 'seo_missing_canonical',
148
+ message: 'Missing <link rel="canonical"> — duplicate content risk',
149
+ severity: 'warning',
150
+ url,
151
+ });
152
+ }
153
+
154
+ if (!data.hasViewport) {
155
+ bugs.push({
156
+ type: 'seo_missing_viewport',
157
+ message: 'Missing <meta name="viewport"> — mobile rendering undefined',
158
+ severity: 'warning',
159
+ url,
160
+ });
161
+ }
162
+
163
+ return bugs;
164
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Session Manager — backward-compat re-export barrel (v9.1.7).
3
+ *
4
+ * All callers continue to import from this file unchanged.
5
+ * Implementations live in the two focused modules below.
6
+ *
7
+ * session-persistence.js — saveSession, restoreSession, hasSession, clearSession
8
+ * login-orchestrator.js — runLoginFlow, refreshSession
9
+ */
10
+
11
+ export * from './session-persistence.js';
12
+ export * from './login-orchestrator.js';
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Session Persistence — save / restore / query browser session state.
3
+ *
4
+ * Handles cookies (JS-accessible only), localStorage, and sessionStorage.
5
+ * Uses an atomic tmp→rename write so a mid-write crash leaves the previous
6
+ * session file intact rather than a truncated JSON blob.
7
+ *
8
+ * Session file format:
9
+ * {
10
+ * savedAt: ISO timestamp,
11
+ * originUrl: origin the session was captured from,
12
+ * cookies: document.cookie string (JS-visible cookies only),
13
+ * localStorage: { key → value },
14
+ * sessionStorage: { key → value }
15
+ * }
16
+ */
17
+
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import { unwrapEval } from './mcp-client.js';
21
+ import { childLogger } from './logger.js';
22
+
23
+ const logger = childLogger('session-persistence');
24
+
25
+ // ── Capture Script ──────────────────────────────────────────────────────────────
26
+
27
+ const SESSION_CAPTURE_SCRIPT = `() => {
28
+ var ls = {};
29
+ for (var i = 0; i < localStorage.length; i++) {
30
+ var k = localStorage.key(i);
31
+ if (k !== null) ls[k] = localStorage.getItem(k);
32
+ }
33
+ var ss = {};
34
+ for (var j = 0; j < sessionStorage.length; j++) {
35
+ var sk = sessionStorage.key(j);
36
+ if (sk !== null) ss[sk] = sessionStorage.getItem(sk);
37
+ }
38
+ return JSON.stringify({
39
+ cookies: document.cookie,
40
+ localStorage: ls,
41
+ sessionStorage: ss,
42
+ origin: window.location.origin
43
+ });
44
+ }`;
45
+
46
+ // ── Restore Script Builder ──────────────────────────────────────────────────────
47
+
48
+ function buildRestoreScript(state) {
49
+ const lines = [];
50
+
51
+ if (state.cookies) {
52
+ for (const part of state.cookies.split(';')) {
53
+ const pair = part.trim();
54
+ if (pair) {
55
+ lines.push(`document.cookie=${JSON.stringify(pair + '; path=/')};`);
56
+ }
57
+ }
58
+ }
59
+
60
+ for (const [k, v] of Object.entries(state.localStorage ?? {})) {
61
+ lines.push(`localStorage.setItem(${JSON.stringify(k)},${JSON.stringify(String(v ?? ''))});`);
62
+ }
63
+
64
+ for (const [k, v] of Object.entries(state.sessionStorage ?? {})) {
65
+ lines.push(`sessionStorage.setItem(${JSON.stringify(k)},${JSON.stringify(String(v ?? ''))});`);
66
+ }
67
+
68
+ return `() => { ${lines.join(' ')} return true; }`;
69
+ }
70
+
71
+ // ── Session Save ────────────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Capture session state from the current page and write to a JSON file.
75
+ * Must be called while the browser is on the authenticated origin.
76
+ *
77
+ * @param {object} browser - CdpBrowserAdapter
78
+ * @param {string} sessionFile - Path to write the session JSON
79
+ * @returns {Promise<object>} The session state object
80
+ */
81
+ export async function saveSession(browser, sessionFile) {
82
+ let raw;
83
+ try {
84
+ raw = await browser.evaluate(SESSION_CAPTURE_SCRIPT);
85
+ } catch (err) {
86
+ throw new Error(`[ARGUS] saveSession: evaluate_script failed — Chrome may not be running: ${err.message}`);
87
+ }
88
+ const val = unwrapEval(raw);
89
+
90
+ let parsed;
91
+ try {
92
+ parsed = typeof val === 'string' ? JSON.parse(val) : val;
93
+ if (!parsed || typeof parsed !== 'object') throw new Error('unexpected shape');
94
+ } catch {
95
+ throw new Error(`[ARGUS] saveSession: evaluate_script returned non-JSON — Chrome may not be running. Raw: ${String(val).slice(0, 120)}`);
96
+ }
97
+
98
+ const state = {
99
+ savedAt: new Date().toISOString(),
100
+ originUrl: String(parsed.origin ?? ''),
101
+ cookies: String(parsed.cookies ?? ''),
102
+ localStorage: parsed.localStorage !== null && typeof parsed.localStorage === 'object' ? parsed.localStorage : {},
103
+ sessionStorage: parsed.sessionStorage !== null && typeof parsed.sessionStorage === 'object' ? parsed.sessionStorage : {},
104
+ };
105
+
106
+ const dir = path.dirname(sessionFile);
107
+ if (dir) fs.mkdirSync(dir, { recursive: true });
108
+
109
+ const tmpFile = `${sessionFile}.tmp`;
110
+ fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), 'utf8');
111
+ fs.renameSync(tmpFile, sessionFile);
112
+
113
+ const lsCount = Object.keys(state.localStorage).length;
114
+ const ssCount = Object.keys(state.sessionStorage).length;
115
+ const hasCookie = state.cookies.length > 0;
116
+ logger.info(
117
+ `[ARGUS] Session saved → ${sessionFile}` +
118
+ ` (${lsCount} localStorage, ${ssCount} sessionStorage, cookies: ${hasCookie ? 'yes' : 'none'})`
119
+ );
120
+
121
+ return state;
122
+ }
123
+
124
+ // ── Session Restore ─────────────────────────────────────────────────────────────
125
+
126
+ /**
127
+ * Restore a saved session into the browser.
128
+ *
129
+ * Navigates to baseUrl so cookies land on the correct domain, injects saved
130
+ * state, then returns. Caller should navigate to the target route afterward.
131
+ *
132
+ * @param {object} browser - CdpBrowserAdapter
133
+ * @param {string} baseUrl - Must match the origin the session was captured from
134
+ * @param {string} sessionFile - Path to the session JSON file
135
+ * @returns {Promise<boolean>} true if session was restored, false if no session file
136
+ */
137
+ export async function restoreSession(browser, baseUrl, sessionFile) {
138
+ if (!fs.existsSync(sessionFile)) {
139
+ logger.warn(`[ARGUS] No session file at ${sessionFile} — skipping restore`);
140
+ return false;
141
+ }
142
+
143
+ let state;
144
+ try {
145
+ state = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
146
+ } catch (err) {
147
+ logger.warn(`[ARGUS] Failed to parse session file ${sessionFile}: ${err.message}`);
148
+ return false;
149
+ }
150
+
151
+ if (state.originUrl) {
152
+ try {
153
+ const savedOrigin = new URL(state.originUrl).origin;
154
+ const currentOrigin = new URL(baseUrl).origin;
155
+ if (savedOrigin !== currentOrigin) {
156
+ logger.warn(
157
+ `[ARGUS] Session origin mismatch: saved="${savedOrigin}" current="${currentOrigin}" — ` +
158
+ `session will not apply; re-run login to capture a fresh session`
159
+ );
160
+ return false;
161
+ }
162
+ } catch { /* URL parse failure — proceed and let Chrome handle it */ }
163
+ }
164
+
165
+ await browser.navigate(baseUrl);
166
+ await new Promise(r => setTimeout(r, 400));
167
+
168
+ const restoreScript = buildRestoreScript(state);
169
+ await browser.evaluate(restoreScript);
170
+
171
+ logger.info(`[ARGUS] Session restored from ${sessionFile} (saved at ${state.savedAt})`);
172
+ return true;
173
+ }
174
+
175
+ // ── Session Utilities ───────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Check whether a valid, non-expired session file exists.
179
+ *
180
+ * @param {string} sessionFile
181
+ * @param {number} [maxAgeMs=3600000] - Max age in ms before requiring re-login (default: 1 h)
182
+ * @returns {boolean}
183
+ */
184
+ export function hasSession(sessionFile, maxAgeMs = 60 * 60 * 1000) {
185
+ if (!fs.existsSync(sessionFile)) return false;
186
+ try {
187
+ const state = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
188
+ const age = Date.now() - new Date(state.savedAt).getTime();
189
+ return age < maxAgeMs;
190
+ } catch {
191
+ return false;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Delete the session file, forcing re-login on the next run.
197
+ *
198
+ * @param {string} sessionFile
199
+ */
200
+ export function clearSession(sessionFile) {
201
+ let cleared = false;
202
+ if (fs.existsSync(sessionFile)) {
203
+ fs.unlinkSync(sessionFile);
204
+ cleared = true;
205
+ }
206
+ const tmpFile = `${sessionFile}.tmp`;
207
+ if (fs.existsSync(tmpFile)) {
208
+ try {
209
+ fs.unlinkSync(tmpFile);
210
+ logger.debug(`[ARGUS] Removed stale session temp file: ${tmpFile}`);
211
+ } catch {}
212
+ }
213
+ if (cleared) logger.info(`[ARGUS] Session cleared: ${sessionFile}`);
214
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Argus D7.5 — Severity policy overrides.
3
+ * Post-processes all findings in a report before Slack routing and baseline
4
+ * comparison, letting teams adjust or silence detection types without touching
5
+ * analyzer code.
6
+ *
7
+ * Configure in src/config/targets.js:
8
+ * export const severityOverrides = {
9
+ * seo_missing_description: 'info', // downgrade to info
10
+ * cache_headers_missing: 'suppress', // remove entirely from report
11
+ * };
12
+ *
13
+ * Supported target values: 'critical' | 'warning' | 'info' | 'suppress'
14
+ */
15
+
16
+ import { childLogger } from './logger.js';
17
+
18
+ const logger = childLogger('severity-overrides');
19
+
20
+ const VALID_SEVERITIES = new Set(['critical', 'warning', 'info']);
21
+
22
+ /**
23
+ * Apply severity overrides to every finding in the report (mutates in-place).
24
+ *
25
+ * For each finding whose `type` key appears in severityOverrides:
26
+ * - If the override value is 'suppress' → finding is removed from its array
27
+ * - If the override is a valid severity string → finding.severity is replaced
28
+ * - If the override value is unrecognized → finding is left unchanged
29
+ *
30
+ * After this call, report.routes[].errors and report.flows[].findings reflect
31
+ * the overridden state. The caller is responsible for rebuilding report.summary.
32
+ *
33
+ * @param {object} report - Report object (mutated in-place)
34
+ * @param {object} severityOverrides - Map of finding type → target severity / 'suppress'
35
+ * @returns {{ overriddenCount: number, suppressedCount: number }}
36
+ */
37
+ export function applyOverrides(report, severityOverrides) {
38
+ if (!severityOverrides || Object.keys(severityOverrides).length === 0) {
39
+ return { overriddenCount: 0, suppressedCount: 0 };
40
+ }
41
+
42
+ let overriddenCount = 0;
43
+ let suppressedCount = 0;
44
+
45
+ function processFindings(findings) {
46
+ // Guard against null/undefined — routeResult.errors may be absent if a route
47
+ // had no findings array populated; iterating undefined throws a TypeError.
48
+ if (!Array.isArray(findings)) return [];
49
+ const kept = [];
50
+ for (const finding of findings) {
51
+ const override = Object.prototype.hasOwnProperty.call(severityOverrides, finding.type)
52
+ ? severityOverrides[finding.type]
53
+ : undefined;
54
+ if (override === undefined) {
55
+ kept.push(finding);
56
+ continue;
57
+ }
58
+ if (override === 'suppress') {
59
+ suppressedCount++;
60
+ continue;
61
+ }
62
+ if (VALID_SEVERITIES.has(override)) {
63
+ if (finding.severity !== override) {
64
+ finding.severity = override;
65
+ overriddenCount++;
66
+ }
67
+ kept.push(finding);
68
+ continue;
69
+ }
70
+ // Log unknown override values — a typo in severityOverrides config silently
71
+ // does nothing; warn so developers can spot misconfiguration immediately.
72
+ logger.warn(`[ARGUS] severity-overrides: unrecognized value "${override}" for type "${finding.type}" — expected critical|warning|info|suppress`);
73
+ kept.push(finding);
74
+ }
75
+ return kept;
76
+ }
77
+
78
+ // report.routes must be guarded — report.flows uses ?? [] safely but routes did not;
79
+ // if routes is undefined the for-of throws a TypeError before any findings are processed.
80
+ for (const routeResult of (report.routes ?? [])) {
81
+ routeResult.errors = processFindings(routeResult.errors);
82
+ }
83
+ for (const flowResult of (report.flows ?? [])) {
84
+ flowResult.findings = processFindings(flowResult.findings);
85
+ }
86
+ if (report.codebase) {
87
+ report.codebase = processFindings(report.codebase);
88
+ }
89
+
90
+ return { overriddenCount, suppressedCount };
91
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Argus D7.7 — Slack-optional mode guard.
3
+ *
4
+ * Returns true only when a Slack Bot Token is present in the environment.
5
+ * Used by crawl-and-report.js to decide whether to dispatch to Slack or fall
6
+ * back to generating a local HTML report and opening it in the browser.
7
+ *
8
+ * Configure in .env:
9
+ * SLACK_BOT_TOKEN=xoxb-... ← Slack active
10
+ * (absent) ← HTML-only mode
11
+ */
12
+
13
+ /**
14
+ * @returns {boolean} true when SLACK_BOT_TOKEN is set and non-empty
15
+ */
16
+ export function isSlackConfigured() {
17
+ return !!(process.env.SLACK_BOT_TOKEN ?? '').trim();
18
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Shared slugify helper — converts a human-readable string to a URL/filename-safe slug.
3
+ * Used for screenshot filenames and report paths.
4
+ */
5
+ export function slugify(str) {
6
+ if (str == null) return 'unnamed';
7
+ return (String(str).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) || 'unnamed';
8
+ }