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,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
|
+
}
|