create-claude-cabinet 0.29.5 → 0.29.8
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/lib/site-audit-setup.js +15 -0
- package/package.json +1 -1
- package/templates/site-audit-runtime/package.json +2 -1
- package/templates/site-audit-runtime/src/checks/axe-core.mjs +3 -2
- package/templates/site-audit-runtime/src/checks/dns.mjs +1 -0
- package/templates/site-audit-runtime/src/checks/lighthouse.mjs +4 -3
- package/templates/site-audit-runtime/src/checks/linkinator.mjs +3 -2
- package/templates/site-audit-runtime/src/checks/meta-og.mjs +1 -0
- package/templates/site-audit-runtime/src/checks/nuclei.mjs +1 -0
- package/templates/site-audit-runtime/src/checks/observatory.mjs +3 -2
- package/templates/site-audit-runtime/src/checks/pa11y.mjs +3 -2
- package/templates/site-audit-runtime/src/checks/security-headers.mjs +8 -7
- package/templates/site-audit-runtime/src/checks/ssl-cert.mjs +1 -0
- package/templates/site-audit-runtime/src/checks/structured-data.mjs +4 -0
- package/templates/site-audit-runtime/src/checks/testssl.mjs +47 -42
- package/templates/site-audit-runtime/src/checks/unlighthouse.mjs +5 -4
- package/templates/site-audit-runtime/src/checks/website-carbon.mjs +1 -0
- package/templates/site-audit-runtime/src/orchestrator.mjs +7 -5
- package/templates/site-audit-runtime/src/report.mjs +39 -8
- package/templates/site-audit-runtime/tests/checks-tier1.test.mjs +4 -3
- package/templates/site-audit-runtime/tests/checks-tier3.test.mjs +0 -22
- package/templates/site-audit-runtime/tests/fixtures/testssl.json +1 -1
- package/templates/site-audit-runtime/src/checks/blacklight.mjs +0 -86
- package/templates/site-audit-runtime/tests/fixtures/blacklight.json +0 -1
package/lib/site-audit-setup.js
CHANGED
|
@@ -91,6 +91,21 @@ function extractAndInstall(versionDir, tarballPath, results) {
|
|
|
91
91
|
execSync(`tar xf "${tarballPath}" -C "${versionDir}"`, { encoding: 'utf8' });
|
|
92
92
|
execSync('npm install --omit=dev --ignore-scripts --silent', { cwd: pkgDir, encoding: 'utf8' });
|
|
93
93
|
|
|
94
|
+
// Pa11y needs Puppeteer's Chrome; axe-core needs chromedriver.
|
|
95
|
+
// Both were skipped by --ignore-scripts. Install them now.
|
|
96
|
+
try {
|
|
97
|
+
execSync('npx puppeteer browsers install chrome', { cwd: pkgDir, encoding: 'utf8', timeout: 120_000 });
|
|
98
|
+
results.push(' Installed Puppeteer Chrome for Pa11y');
|
|
99
|
+
} catch {
|
|
100
|
+
results.push(' ⚠ Puppeteer Chrome install failed — Pa11y will be unavailable');
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
execSync('node node_modules/chromedriver/install.js', { cwd: pkgDir, encoding: 'utf8', timeout: 120_000 });
|
|
104
|
+
results.push(' Installed chromedriver for axe-core');
|
|
105
|
+
} catch {
|
|
106
|
+
results.push(' ⚠ chromedriver install failed — axe-core will be unavailable');
|
|
107
|
+
}
|
|
108
|
+
|
|
94
109
|
const binDir = path.join(versionDir, 'bin');
|
|
95
110
|
fs.mkdirSync(binDir, { recursive: true });
|
|
96
111
|
const binSrc = path.join(pkgDir, 'bin', 'cc-site-audit');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claude-cabinet/site-audit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Comprehensive deployed-site quality audit engine for Claude Cabinet. Runs checks across performance, accessibility, security, SEO, content, DNS, and privacy against a deployed URL; single-site and comparison modes; standalone HTML report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"lighthouse": "^12.0.0",
|
|
23
23
|
"linkinator": "^7.0.0",
|
|
24
24
|
"pa11y": "^9.0.0",
|
|
25
|
+
"ssl-checker": "^3.0.0",
|
|
25
26
|
"unlighthouse": "^0.16.0"
|
|
26
27
|
}
|
|
27
28
|
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
export const checkId = 'axe-core';
|
|
2
2
|
export const tool = 'axe-core (WCAG AA)';
|
|
3
|
+
export const whyItMatters = "Checks whether people with disabilities can use your site — screen readers, keyboard navigation, color contrast. Legal liability if not met.";
|
|
3
4
|
|
|
4
5
|
export async function detect(executor) {
|
|
5
|
-
const r = await executor.spawn('
|
|
6
|
+
const r = await executor.spawn('axe', ['--version'], { timeoutMs: 15_000 });
|
|
6
7
|
return r.code === 0;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export async function run(url, executor) {
|
|
10
|
-
return executor.spawn('
|
|
11
|
+
return executor.spawn('axe', [url, '--exit'], { timeoutMs: 60_000 });
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
const IMPACT_TO_SEVERITY = { critical: 'critical', serious: 'serious', moderate: 'moderate', minor: 'info' };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const checkId = 'dns';
|
|
2
2
|
export const tool = 'DNS & Protocol';
|
|
3
|
+
export const whyItMatters = "DNS security prevents attackers from redirecting your visitors to fake versions of your site. HTTP/2 makes pages load faster.";
|
|
3
4
|
|
|
4
5
|
export async function detect(executor) {
|
|
5
6
|
const r = await executor.spawn('dig', ['-v'], { timeoutMs: 5_000 });
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
export const checkId = 'lighthouse';
|
|
2
2
|
export const tool = 'Lighthouse';
|
|
3
|
+
export const whyItMatters = "Google's own audit tool — scores four dimensions separately: Performance (page speed), Accessibility (usability for all), Best Practices (security and code quality), and SEO (search visibility). The overall score averages all four.";
|
|
3
4
|
export const defaultTimeoutMs = 120_000;
|
|
4
5
|
|
|
5
6
|
export async function detect(executor) {
|
|
6
|
-
const r = await executor.spawn('
|
|
7
|
+
const r = await executor.spawn('lighthouse', ['--version'], { timeoutMs: 15_000 });
|
|
7
8
|
return r.code === 0;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export async function run(url, executor) {
|
|
11
|
-
const r = await executor.spawn('
|
|
12
|
-
|
|
12
|
+
const r = await executor.spawn('lighthouse', [
|
|
13
|
+
url,
|
|
13
14
|
'--output=json',
|
|
14
15
|
'--chrome-flags=--headless=new --no-sandbox',
|
|
15
16
|
'--only-categories=performance,accessibility,best-practices,seo',
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
export const checkId = 'linkinator';
|
|
2
2
|
export const tool = 'Linkinator (broken links)';
|
|
3
|
+
export const whyItMatters = "Broken links frustrate visitors and tell search engines your site isn't maintained — hurts both trust and rankings.";
|
|
3
4
|
|
|
4
5
|
export async function detect(executor) {
|
|
5
|
-
const r = await executor.spawn('
|
|
6
|
+
const r = await executor.spawn('linkinator', ['--version'], { timeoutMs: 15_000 });
|
|
6
7
|
return r.code === 0;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export async function run(url, executor) {
|
|
10
|
-
return executor.spawn('
|
|
11
|
+
return executor.spawn('linkinator', [url, '--format', 'json', '--timeout', '10000'], { timeoutMs: 60_000 });
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export function normalize(raw, durationMs) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const checkId = 'meta-og';
|
|
2
2
|
export const tool = 'Meta & Open Graph';
|
|
3
|
+
export const whyItMatters = "Controls how your site appears when shared on social media and in search results — missing tags mean ugly or blank previews.";
|
|
3
4
|
|
|
4
5
|
export async function detect() { return true; }
|
|
5
6
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
export const checkId = 'nuclei';
|
|
8
8
|
export const tool = 'Nuclei (CVE scan)';
|
|
9
|
+
export const whyItMatters = "Scans for known security vulnerabilities (CVEs) that attackers actively exploit — the kind that make the news.";
|
|
9
10
|
export const defaultTimeoutMs = 300_000;
|
|
10
11
|
|
|
11
12
|
export async function detect(executor) {
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
export const checkId = 'observatory';
|
|
2
2
|
export const tool = 'MDN HTTP Observatory';
|
|
3
|
+
export const whyItMatters = "Mozilla's security scorecard — grades your site's defenses against common web attacks like clickjacking and data injection.";
|
|
3
4
|
|
|
4
5
|
export async function detect(executor) {
|
|
5
|
-
const r = await executor.spawn('
|
|
6
|
+
const r = await executor.spawn('mdn-http-observatory-scan', ['--help'], { timeoutMs: 15_000 });
|
|
6
7
|
return r.code === 0;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export async function run(url, executor) {
|
|
10
11
|
const hostname = new URL(url).hostname;
|
|
11
|
-
return executor.spawn('
|
|
12
|
+
return executor.spawn('mdn-http-observatory-scan', [hostname], { timeoutMs: 60_000 });
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
const GRADE_SCORES = { 'A+': 100, 'A': 95, 'A-': 90, 'B+': 85, 'B': 80, 'B-': 75, 'C+': 70, 'C': 65, 'C-': 60, 'D+': 55, 'D': 50, 'D-': 45, 'F': 20 };
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
export const checkId = 'pa11y';
|
|
2
2
|
export const tool = 'Pa11y (WCAG AAA)';
|
|
3
|
+
export const whyItMatters = "Stricter accessibility testing (WCAG AAA) — catches issues that basic checks miss, like low contrast text and missing form labels.";
|
|
3
4
|
|
|
4
5
|
export async function detect(executor) {
|
|
5
|
-
const r = await executor.spawn('
|
|
6
|
+
const r = await executor.spawn('pa11y', ['--version'], { timeoutMs: 15_000 });
|
|
6
7
|
return r.code === 0;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export async function run(url, executor) {
|
|
10
|
-
return executor.spawn('
|
|
11
|
+
return executor.spawn('pa11y', [url, '--reporter', 'json', '--standard', 'WCAG2AAA'], { timeoutMs: 60_000 });
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
const TYPE_TO_SEVERITY = { error: 'serious', warning: 'moderate', notice: 'info' };
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
export const checkId = 'security-headers';
|
|
2
2
|
export const tool = 'Security Headers';
|
|
3
|
+
export const whyItMatters = "Missing headers let attackers inject malicious scripts, steal user data, or impersonate your site in browsers.";
|
|
3
4
|
|
|
4
5
|
const CHECKS = [
|
|
5
|
-
{ header: 'content-security-policy', label: 'Content-Security-Policy', weight: 2 },
|
|
6
|
-
{ header: 'strict-transport-security', label: 'Strict-Transport-Security', weight: 2 },
|
|
7
|
-
{ header: 'x-frame-options', label: 'X-Frame-Options', weight: 1 },
|
|
8
|
-
{ header: 'x-content-type-options', label: 'X-Content-Type-Options', weight: 1 },
|
|
9
|
-
{ header: 'referrer-policy', label: 'Referrer-Policy', weight: 1 },
|
|
10
|
-
{ header: 'permissions-policy', label: 'Permissions-Policy', weight: 1 },
|
|
6
|
+
{ header: 'content-security-policy', label: 'Content-Security-Policy', weight: 2, why: 'Controls which scripts/resources can load — primary defense against XSS attacks' },
|
|
7
|
+
{ header: 'strict-transport-security', label: 'Strict-Transport-Security', weight: 2, why: 'Forces HTTPS — prevents downgrade attacks that intercept unencrypted traffic' },
|
|
8
|
+
{ header: 'x-frame-options', label: 'X-Frame-Options', weight: 1, why: 'Prevents your site from being embedded in iframes — blocks clickjacking attacks' },
|
|
9
|
+
{ header: 'x-content-type-options', label: 'X-Content-Type-Options', weight: 1, why: 'Stops browsers from guessing file types — prevents script injection via disguised files' },
|
|
10
|
+
{ header: 'referrer-policy', label: 'Referrer-Policy', weight: 1, why: 'Controls what URL info leaks when users click links to other sites' },
|
|
11
|
+
{ header: 'permissions-policy', label: 'Permissions-Policy', weight: 1, why: 'Restricts which browser features (camera, mic, geolocation) the page can use' },
|
|
11
12
|
];
|
|
12
13
|
|
|
13
14
|
export async function detect() { return true; }
|
|
@@ -29,7 +30,7 @@ export function normalize(headers, durationMs) {
|
|
|
29
30
|
if (headers[c.header]) {
|
|
30
31
|
earned += c.weight;
|
|
31
32
|
} else {
|
|
32
|
-
findings.push({ severity: c.weight >= 2 ? 'serious' : 'moderate', message: `Missing ${c.label} header
|
|
33
|
+
findings.push({ severity: c.weight >= 2 ? 'serious' : 'moderate', message: `Missing ${c.label} header`, context: c.why });
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const checkId = 'ssl-cert';
|
|
2
2
|
export const tool = 'SSL Certificate';
|
|
3
|
+
export const whyItMatters = "An expired or misconfigured certificate shows scary browser warnings that immediately drive visitors away.";
|
|
3
4
|
|
|
4
5
|
export async function detect(executor) {
|
|
5
6
|
const r = await executor.spawn('openssl', ['version'], { timeoutMs: 5_000 });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const checkId = 'structured-data';
|
|
2
2
|
export const tool = 'Structured Data (JSON-LD)';
|
|
3
|
+
export const whyItMatters = "Tells Google exactly what your business does — enables rich search results like star ratings, FAQ dropdowns, and business info panels.";
|
|
3
4
|
|
|
4
5
|
export async function detect() { return true; }
|
|
5
6
|
|
|
@@ -70,9 +71,12 @@ export function normalize(html, durationMs) {
|
|
|
70
71
|
? `${scripts.length} JSON-LD block${scripts.length !== 1 ? 's' : ''} found with ${types.length} type${types.length !== 1 ? 's' : ''}: ${types.join(', ') || 'none'}`
|
|
71
72
|
: undefined;
|
|
72
73
|
|
|
74
|
+
const details = types.length ? { types, blockCount: scripts.length } : undefined;
|
|
75
|
+
|
|
73
76
|
return {
|
|
74
77
|
checkId, tool, status: isPass ? 'pass' : 'fail',
|
|
75
78
|
score, grade: null, severity: worstSev, findings, durationMs,
|
|
76
79
|
...(passSummary && { passSummary }),
|
|
80
|
+
...(details && { details }),
|
|
77
81
|
};
|
|
78
82
|
}
|
|
@@ -1,70 +1,75 @@
|
|
|
1
1
|
export const checkId = 'testssl';
|
|
2
|
-
export const tool = '
|
|
3
|
-
export const
|
|
2
|
+
export const tool = 'TLS/SSL Check';
|
|
3
|
+
export const whyItMatters = "Weak encryption lets attackers intercept data between your users and your server — passwords, form submissions, everything.";
|
|
4
4
|
|
|
5
5
|
export async function detect(executor) {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
try {
|
|
7
|
+
await import('ssl-checker');
|
|
8
|
+
return true;
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
export async function run(url, executor) {
|
|
11
15
|
const hostname = new URL(url).hostname;
|
|
12
|
-
|
|
16
|
+
const sslChecker = (await import('ssl-checker')).default;
|
|
17
|
+
const result = await sslChecker(hostname);
|
|
18
|
+
return { code: 0, stdout: JSON.stringify(result), stderr: '', timedOut: false };
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
export function normalize(raw, durationMs) {
|
|
16
22
|
if (raw.code !== 0 && !raw.stdout) {
|
|
17
|
-
return { checkId, tool, status: 'error', score: null, grade: null, severity: null, findings: [], durationMs, reason: raw.stderr || '
|
|
23
|
+
return { checkId, tool, status: 'error', score: null, grade: null, severity: null, findings: [], durationMs, reason: raw.stderr || 'ssl-checker failed' };
|
|
18
24
|
}
|
|
19
25
|
|
|
20
|
-
let
|
|
21
|
-
try {
|
|
22
|
-
|
|
23
|
-
entries = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
26
|
+
let data;
|
|
27
|
+
try { data = JSON.parse(raw.stdout); } catch {
|
|
28
|
+
return { checkId, tool, status: 'error', score: null, grade: null, severity: null, findings: [], durationMs, reason: 'failed to parse ssl-checker output' };
|
|
24
29
|
}
|
|
25
|
-
if (!Array.isArray(entries)) entries = [entries];
|
|
26
30
|
|
|
27
31
|
const findings = [];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
}
|
|
32
|
+
|
|
33
|
+
if (!data.valid) {
|
|
34
|
+
findings.push({ severity: 'critical', message: 'Certificate is not valid' });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (data.daysRemaining != null && data.daysRemaining < 30) {
|
|
38
|
+
findings.push({
|
|
39
|
+
severity: data.daysRemaining < 7 ? 'critical' : 'serious',
|
|
40
|
+
message: `Certificate expires in ${data.daysRemaining} days`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const protocol = data.protocol || data.tlsVersion || '';
|
|
45
|
+
if (protocol && !protocol.includes('TLSv1.2') && !protocol.includes('TLSv1.3')) {
|
|
46
|
+
findings.push({ severity: 'serious', message: `Weak TLS protocol: ${protocol}` });
|
|
38
47
|
}
|
|
39
48
|
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
if (data.cipher) {
|
|
50
|
+
const weak = /RC4|DES|MD5|NULL|EXPORT|anon/i;
|
|
51
|
+
if (weak.test(data.cipher)) {
|
|
52
|
+
findings.push({ severity: 'serious', message: `Weak cipher: ${data.cipher}` });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
44
55
|
|
|
45
56
|
const hasCritical = findings.some(f => f.severity === 'critical');
|
|
46
57
|
const hasSerious = findings.some(f => f.severity === 'serious');
|
|
47
|
-
|
|
48
58
|
const isPass = !hasCritical && !hasSerious;
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
|
|
60
|
+
const details = [];
|
|
61
|
+
if (data.valid) details.push('valid certificate');
|
|
62
|
+
if (data.daysRemaining != null) details.push(`${data.daysRemaining} days until expiry`);
|
|
63
|
+
if (protocol) details.push(protocol);
|
|
64
|
+
if (data.cipher) details.push(data.cipher);
|
|
65
|
+
|
|
66
|
+
const passSummary = isPass ? details.join(', ') : undefined;
|
|
55
67
|
|
|
56
68
|
return {
|
|
57
69
|
checkId, tool, status: isPass ? 'pass' : 'fail',
|
|
58
|
-
score: null, grade: null,
|
|
70
|
+
score: null, grade: null,
|
|
71
|
+
severity: hasCritical ? 'critical' : hasSerious ? 'serious' : findings.length ? 'moderate' : null,
|
|
72
|
+
findings, durationMs,
|
|
59
73
|
...(passSummary && { passSummary }),
|
|
60
74
|
};
|
|
61
75
|
}
|
|
62
|
-
|
|
63
|
-
function mapSeverity(s) {
|
|
64
|
-
const l = String(s).toUpperCase();
|
|
65
|
-
if (l === 'CRITICAL' || l === 'HIGH') return 'critical';
|
|
66
|
-
if (l === 'MEDIUM' || l === 'WARN' || l === 'WARNING') return 'serious';
|
|
67
|
-
if (l === 'LOW' || l === 'NOT OK') return 'moderate';
|
|
68
|
-
if (l === 'INFO' || l === 'OK') return 'info';
|
|
69
|
-
return 'info';
|
|
70
|
-
}
|
|
@@ -4,16 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
export const checkId = 'unlighthouse';
|
|
6
6
|
export const tool = 'Unlighthouse (full-site crawl)';
|
|
7
|
-
export const
|
|
7
|
+
export const whyItMatters = "Runs Lighthouse on every page of your site, not just the homepage — catches performance and accessibility problems hiding on inner pages.";
|
|
8
|
+
export const defaultTimeoutMs = 600_000;
|
|
8
9
|
|
|
9
10
|
export async function detect(executor) {
|
|
10
|
-
const r = await executor.spawn('
|
|
11
|
+
const r = await executor.spawn('unlighthouse', ['--version'], { timeoutMs: 15_000 });
|
|
11
12
|
return r.code === 0;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export async function run(url, executor) {
|
|
15
|
-
return executor.spawn('
|
|
16
|
-
'
|
|
16
|
+
return executor.spawn('unlighthouse', [
|
|
17
|
+
'--site', url, '--ci', '--reporter', 'json',
|
|
17
18
|
], { timeoutMs: 300_000 });
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
export const checkId = 'website-carbon';
|
|
6
6
|
export const tool = 'Website Carbon';
|
|
7
|
+
export const whyItMatters = "Estimates your site's carbon footprint per visit — smaller pages are faster, cheaper to host, and better for the environment.";
|
|
7
8
|
|
|
8
9
|
const KWH_PER_GB = 0.81;
|
|
9
10
|
const CARBON_FACTOR_GRAMS_PER_KWH = 442;
|
|
@@ -54,8 +54,7 @@ const DEFAULT_TIMEOUTS = {
|
|
|
54
54
|
lighthouse: 120_000,
|
|
55
55
|
testssl: 180_000,
|
|
56
56
|
nuclei: 300_000,
|
|
57
|
-
unlighthouse:
|
|
58
|
-
blacklight: 120_000,
|
|
57
|
+
unlighthouse: 600_000,
|
|
59
58
|
};
|
|
60
59
|
const FALLBACK_TIMEOUT = 60_000;
|
|
61
60
|
const MAX_CONCURRENCY = 4;
|
|
@@ -114,7 +113,6 @@ function guessCheckId(cmd, args) {
|
|
|
114
113
|
if (allTokens.includes('nuclei')) return 'nuclei';
|
|
115
114
|
if (allTokens.includes('unlighthouse')) return 'unlighthouse';
|
|
116
115
|
if (allTokens.includes('observatory')) return 'observatory';
|
|
117
|
-
if (allTokens.includes('blacklight')) return 'blacklight';
|
|
118
116
|
if (allTokens.includes('dig')) return 'dns';
|
|
119
117
|
if (allTokens.includes('openssl')) return 'ssl-cert';
|
|
120
118
|
return basename(cmd).replace(/\.\w+$/, '');
|
|
@@ -180,13 +178,16 @@ export async function auditSite(url, checks, opts = {}) {
|
|
|
180
178
|
|
|
181
179
|
const start = Date.now();
|
|
182
180
|
let available;
|
|
181
|
+
let detectError = '';
|
|
183
182
|
try {
|
|
184
183
|
available = await check.detect(executor);
|
|
185
|
-
} catch {
|
|
184
|
+
} catch (err) {
|
|
186
185
|
available = false;
|
|
186
|
+
detectError = err instanceof Error ? err.message : String(err);
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
if (!available) {
|
|
190
|
+
const hint = detectError ? ` (${detectError})` : ' (binary not found in PATH)';
|
|
190
191
|
return {
|
|
191
192
|
checkId: check.checkId,
|
|
192
193
|
tool: check.tool,
|
|
@@ -196,7 +197,7 @@ export async function auditSite(url, checks, opts = {}) {
|
|
|
196
197
|
severity: null,
|
|
197
198
|
findings: [],
|
|
198
199
|
durationMs: Date.now() - start,
|
|
199
|
-
reason: `${check.tool} not available`,
|
|
200
|
+
reason: `${check.tool} not available${hint}`,
|
|
200
201
|
};
|
|
201
202
|
}
|
|
202
203
|
|
|
@@ -239,6 +240,7 @@ export async function auditSite(url, checks, opts = {}) {
|
|
|
239
240
|
reason: `normalize produced invalid CheckResult: ${errors.join('; ')}`,
|
|
240
241
|
};
|
|
241
242
|
}
|
|
243
|
+
if (check.whyItMatters) result.whyItMatters = check.whyItMatters;
|
|
242
244
|
return result;
|
|
243
245
|
} catch (err) {
|
|
244
246
|
return {
|
|
@@ -120,6 +120,25 @@ function renderFindings(findings, result) {
|
|
|
120
120
|
return html;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
function renderDetails(result) {
|
|
124
|
+
if (!result?.details) return '';
|
|
125
|
+
let html = '';
|
|
126
|
+
const cats = result.details.categories;
|
|
127
|
+
if (cats && typeof cats === 'object' && !Array.isArray(cats)) {
|
|
128
|
+
const items = Object.entries(cats).map(([k, v]) => {
|
|
129
|
+
const label = k.replace(/-/g, ' ');
|
|
130
|
+
const cls = v >= 90 ? 'score-pass' : v >= 50 ? '' : 'score-fail';
|
|
131
|
+
return `<span class="${cls}" style="display:inline-block;margin-right:1rem"><strong>${esc(label)}</strong> ${v}/100</span>`;
|
|
132
|
+
});
|
|
133
|
+
html += `<div style="margin-bottom:.75rem;font-size:.9rem">${items.join('')}</div>`;
|
|
134
|
+
}
|
|
135
|
+
const types = result.details.types;
|
|
136
|
+
if (Array.isArray(types) && types.length) {
|
|
137
|
+
html += `<div style="margin-bottom:.75rem;font-size:.85rem;color:#555">Schema types: ${types.map(t => `<strong>${esc(String(t))}</strong>`).join(', ')}</div>`;
|
|
138
|
+
}
|
|
139
|
+
return html;
|
|
140
|
+
}
|
|
141
|
+
|
|
123
142
|
/**
|
|
124
143
|
* Generate a 2-3 sentence executive summary for a comparison report.
|
|
125
144
|
* @param {import('./diff.mjs').DeltaReport} delta
|
|
@@ -137,10 +156,12 @@ export function generateSummary(delta) {
|
|
|
137
156
|
const biggest = sorted[0];
|
|
138
157
|
|
|
139
158
|
let summary = '';
|
|
159
|
+
const sA = hostnameLabel(delta.urlA);
|
|
160
|
+
const sB = hostnameLabel(delta.urlB);
|
|
140
161
|
if (bWins > aWins) {
|
|
141
|
-
summary +=
|
|
162
|
+
summary += `${esc(sB)} outperforms ${esc(sA)} on ${bWins} of ${scored.length} scored dimension${scored.length > 1 ? 's' : ''}`;
|
|
142
163
|
} else if (aWins > bWins) {
|
|
143
|
-
summary +=
|
|
164
|
+
summary += `${esc(sA)} outperforms ${esc(sB)} on ${aWins} of ${scored.length} scored dimension${scored.length > 1 ? 's' : ''}`;
|
|
144
165
|
} else {
|
|
145
166
|
summary += `Sites are evenly matched across ${scored.length} scored dimension${scored.length > 1 ? 's' : ''}`;
|
|
146
167
|
}
|
|
@@ -169,6 +190,8 @@ function checkSection(result) {
|
|
|
169
190
|
</div>
|
|
170
191
|
</div>
|
|
171
192
|
<div class="check-body">
|
|
193
|
+
${result.whyItMatters ? `<p style="color:#666;font-size:.85rem;font-style:italic;margin-bottom:.5rem">${esc(result.whyItMatters)}</p>` : ''}
|
|
194
|
+
${renderDetails(result)}
|
|
172
195
|
${renderFindings(result.findings, result)}
|
|
173
196
|
</div>
|
|
174
197
|
</div>`;
|
|
@@ -236,7 +259,13 @@ function classifyFindings(a, b) {
|
|
|
236
259
|
* @param {import('./diff.mjs').DeltaReport} delta
|
|
237
260
|
* @returns {string}
|
|
238
261
|
*/
|
|
262
|
+
function hostnameLabel(url) {
|
|
263
|
+
try { return new URL(url).hostname; } catch { return url; }
|
|
264
|
+
}
|
|
265
|
+
|
|
239
266
|
export function renderComparison(delta) {
|
|
267
|
+
const labelA = hostnameLabel(delta.urlA);
|
|
268
|
+
const labelB = hostnameLabel(delta.urlB);
|
|
240
269
|
const title = `Site Comparison — ${delta.urlA} vs ${delta.urlB}`;
|
|
241
270
|
|
|
242
271
|
let html = head(title);
|
|
@@ -248,14 +277,14 @@ export function renderComparison(delta) {
|
|
|
248
277
|
const { aOnly, bOnly } = delta.summary;
|
|
249
278
|
if (aOnly > 0 || bOnly > 0) {
|
|
250
279
|
html += `<div class="asymmetric-warning">Asymmetric availability: `;
|
|
251
|
-
if (aOnly > 0) html += `${aOnly} check${aOnly > 1 ? 's' : ''} ran only for
|
|
252
|
-
if (bOnly > 0) html += `${bOnly} check${bOnly > 1 ? 's' : ''} ran only for
|
|
280
|
+
if (aOnly > 0) html += `${aOnly} check${aOnly > 1 ? 's' : ''} ran only for ${esc(labelA)}. `;
|
|
281
|
+
if (bOnly > 0) html += `${bOnly} check${bOnly > 1 ? 's' : ''} ran only for ${esc(labelB)}. `;
|
|
253
282
|
html += `Deltas for one-sided checks show N/A.</div>`;
|
|
254
283
|
}
|
|
255
284
|
|
|
256
285
|
// ── Comparison grid with drill-down links ──
|
|
257
286
|
html += '<div style="background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.05);overflow:hidden;margin-bottom:2rem">';
|
|
258
|
-
html += `<div class="compare-row"><span>Check</span><span
|
|
287
|
+
html += `<div class="compare-row"><span>Check</span><span>${esc(labelA)}</span><span>${esc(labelB)}</span><span>Delta</span></div>`;
|
|
259
288
|
|
|
260
289
|
for (const d of delta.deltas) {
|
|
261
290
|
const aDisplay = d.a && d.a.status !== 'skip'
|
|
@@ -298,18 +327,20 @@ export function renderComparison(delta) {
|
|
|
298
327
|
? `<span class="${d.deltaScore > 0 ? 'delta-pos' : 'delta-neg'}">(${d.deltaScore > 0 ? '+' : ''}${d.deltaScore})</span>`
|
|
299
328
|
: '';
|
|
300
329
|
|
|
330
|
+
const why = d.a?.whyItMatters || d.b?.whyItMatters || '';
|
|
301
331
|
html += `<div class="compare-card" id="compare-${esc(d.checkId)}">
|
|
302
332
|
<div class="compare-card-header">
|
|
303
333
|
<span class="check-title">${STATUS_ICON[d.a?.status || d.b?.status || 'skip'] || ''} ${esc(d.tool)} ${deltaLabel}</span>
|
|
304
334
|
<div class="check-meta">${aBadge} ${bBadge}</div>
|
|
305
335
|
</div>
|
|
306
|
-
<div class="compare-card-body"
|
|
336
|
+
<div class="compare-card-body">
|
|
337
|
+
${why ? `<p style="color:#666;font-size:.85rem;font-style:italic;margin-bottom:.75rem">${esc(why)}</p>` : ''}`;
|
|
307
338
|
|
|
308
339
|
if (d.availability === 'both') {
|
|
309
340
|
const { shared, aOnly: aOnlyF, bOnly: bOnlyF } = classifyFindings(d.a, d.b);
|
|
310
341
|
html += '<div class="side-by-side">';
|
|
311
|
-
html += `<div class="site-column"><h4
|
|
312
|
-
html += `<div class="site-column"><h4
|
|
342
|
+
html += `<div class="site-column"><h4>${esc(labelA)}</h4>${renderDetails(d.a)}${compareCardFindings(esc(labelA) + ' only', aOnlyF, d.a)}</div>`;
|
|
343
|
+
html += `<div class="site-column"><h4>${esc(labelB)}</h4>${renderDetails(d.b)}${compareCardFindings(esc(labelB) + ' only', bOnlyF, d.b)}</div>`;
|
|
313
344
|
html += '</div>';
|
|
314
345
|
if (shared.length) {
|
|
315
346
|
html += `<div class="finding-group-label" style="margin-top:1rem">Shared issues (both sites)</div>${renderFindings(shared)}`;
|
|
@@ -91,13 +91,14 @@ test('observatory: normalize fixture produces valid CheckResult with grade', ()
|
|
|
91
91
|
// --- testssl ---
|
|
92
92
|
import * as testssl from '../src/checks/testssl.mjs';
|
|
93
93
|
|
|
94
|
-
test('testssl: normalize fixture produces valid CheckResult
|
|
94
|
+
test('testssl: normalize ssl-checker fixture produces valid CheckResult', () => {
|
|
95
95
|
const r = testssl.normalize(ok('testssl'), 30000);
|
|
96
96
|
const { valid, errors } = validateCheckResult(r);
|
|
97
97
|
assert.ok(valid, errors.join('; '));
|
|
98
98
|
assert.equal(r.checkId, 'testssl');
|
|
99
|
-
assert.
|
|
100
|
-
assert.
|
|
99
|
+
assert.equal(r.status, 'pass');
|
|
100
|
+
assert.equal(r.findings.length, 0);
|
|
101
|
+
assert.ok(r.passSummary?.includes('TLSv1.3'));
|
|
101
102
|
});
|
|
102
103
|
|
|
103
104
|
// --- meta-og ---
|
|
@@ -10,28 +10,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
10
10
|
const fix = (id) => readFileSync(join(__dirname, 'fixtures', `${id}.json`), 'utf8');
|
|
11
11
|
const ok = (id) => ({ code: 0, stdout: fix(id), stderr: '', timedOut: false });
|
|
12
12
|
|
|
13
|
-
// --- blacklight ---
|
|
14
|
-
import * as blacklight from '../src/checks/blacklight.mjs';
|
|
15
|
-
|
|
16
|
-
test('blacklight: normalize fixture produces valid CheckResult with tracker findings', () => {
|
|
17
|
-
const r = blacklight.normalize(ok('blacklight'), 20000);
|
|
18
|
-
const { valid, errors } = validateCheckResult(r);
|
|
19
|
-
assert.ok(valid, errors.join('; '));
|
|
20
|
-
assert.equal(r.checkId, 'blacklight');
|
|
21
|
-
assert.equal(r.score, null);
|
|
22
|
-
assert.ok(r.findings.some(f => f.message.includes('Session replay')));
|
|
23
|
-
assert.ok(r.findings.some(f => f.message.includes('Canvas fingerprinting')));
|
|
24
|
-
assert.ok(r.findings.some(f => f.message.includes('Facebook Pixel')));
|
|
25
|
-
assert.ok(r.findings.some(f => f.message.includes('third-party cookies')));
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test('blacklight: clean site → no findings', () => {
|
|
29
|
-
const r = blacklight.normalize({ code: 0, stdout: '{}', stderr: '', timedOut: false }, 5000);
|
|
30
|
-
const { valid, errors } = validateCheckResult(r);
|
|
31
|
-
assert.ok(valid, errors.join('; '));
|
|
32
|
-
assert.equal(r.findings.length, 0);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
13
|
// --- unlighthouse ---
|
|
36
14
|
import * as unlighthouse from '../src/checks/unlighthouse.mjs';
|
|
37
15
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
{"valid":true,"validFrom":"2026-01-01T00:00:00.000Z","validTo":"2027-01-01T00:00:00.000Z","daysRemaining":215,"protocol":"TLSv1.3","cipher":"TLS_AES_256_GCM_SHA384"}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
// Blacklight detects runtime tracker behavior: session replay scripts,
|
|
2
|
-
// canvas fingerprinting, keyloggers, ad trackers, third-party cookies.
|
|
3
|
-
// It loads the page in headless Chromium and observes what the page does.
|
|
4
|
-
|
|
5
|
-
export const checkId = 'blacklight';
|
|
6
|
-
export const tool = 'Blacklight (tracker detection)';
|
|
7
|
-
export const defaultTimeoutMs = 120_000;
|
|
8
|
-
|
|
9
|
-
export async function detect(executor) {
|
|
10
|
-
const r = await executor.spawn('npx', ['@themarkup/blacklight-collector', '--help'], { timeoutMs: 15_000 });
|
|
11
|
-
return r.code === 0 || r.stdout.includes('blacklight');
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export async function run(url, executor) {
|
|
15
|
-
return executor.spawn('npx', ['@themarkup/blacklight-collector', url, '--json'], { timeoutMs: 120_000 });
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const TRACKER_SEVERITY = {
|
|
19
|
-
session_recorders: 'moderate',
|
|
20
|
-
canvas_fingerprinters: 'moderate',
|
|
21
|
-
key_logging: 'serious',
|
|
22
|
-
fb_pixel: 'info',
|
|
23
|
-
google_analytics: 'info',
|
|
24
|
-
third_party_trackers: 'info',
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export function normalize(raw, durationMs) {
|
|
28
|
-
if (raw.code !== 0 && !raw.stdout) {
|
|
29
|
-
return { checkId, tool, status: 'error', score: null, grade: null, severity: null, findings: [], durationMs, reason: raw.stderr || 'blacklight failed' };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
let data;
|
|
33
|
-
try { data = JSON.parse(raw.stdout); } catch {
|
|
34
|
-
return { checkId, tool, status: 'error', score: null, grade: null, severity: null, findings: [], durationMs, reason: 'failed to parse blacklight JSON' };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const findings = [];
|
|
38
|
-
|
|
39
|
-
if (data.session_recorders?.length) {
|
|
40
|
-
for (const r of data.session_recorders) {
|
|
41
|
-
findings.push({ severity: 'moderate', message: `Session replay: ${r.name || r.url || 'unknown'}`, url: r.url });
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
if (data.canvas_fingerprinters?.length) {
|
|
45
|
-
for (const f of data.canvas_fingerprinters) {
|
|
46
|
-
findings.push({ severity: 'moderate', message: `Canvas fingerprinting: ${f.name || f.url || 'unknown'}`, url: f.url });
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
if (data.key_logging?.length) {
|
|
50
|
-
for (const k of data.key_logging) {
|
|
51
|
-
findings.push({ severity: 'serious', message: `Key logging detected: ${k.name || k.url || 'unknown'}`, url: k.url });
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
if (data.fb_pixel_events?.length || data.fb_pixel) {
|
|
55
|
-
findings.push({ severity: 'info', message: `Facebook Pixel active (${(data.fb_pixel_events || []).length} events)` });
|
|
56
|
-
}
|
|
57
|
-
if (data.third_party_trackers?.length) {
|
|
58
|
-
for (const t of data.third_party_trackers.slice(0, 10)) {
|
|
59
|
-
findings.push({ severity: 'info', message: `Third-party tracker: ${t.name || t.url || 'unknown'}`, url: t.url });
|
|
60
|
-
}
|
|
61
|
-
if (data.third_party_trackers.length > 10) {
|
|
62
|
-
findings.push({ severity: 'info', message: `... and ${data.third_party_trackers.length - 10} more trackers` });
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
if (data.third_party_cookies?.length) {
|
|
66
|
-
findings.push({ severity: 'info', message: `${data.third_party_cookies.length} third-party cookies set` });
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const worstSev = findings.length ? findings.reduce((w, f) => {
|
|
70
|
-
const o = { critical: 0, serious: 1, moderate: 2, info: 3 };
|
|
71
|
-
return (o[f.severity] ?? 3) < (o[w] ?? 3) ? f.severity : w;
|
|
72
|
-
}, 'info') : null;
|
|
73
|
-
|
|
74
|
-
const hasSerious = findings.some(f => f.severity === 'serious' || f.severity === 'critical');
|
|
75
|
-
const passSummary = !hasSerious
|
|
76
|
-
? (findings.length === 0
|
|
77
|
-
? 'No trackers, fingerprinters, or session recorders detected'
|
|
78
|
-
: `No serious trackers detected (${findings.length} low-severity item${findings.length !== 1 ? 's' : ''} only)`)
|
|
79
|
-
: undefined;
|
|
80
|
-
|
|
81
|
-
return {
|
|
82
|
-
checkId, tool, status: hasSerious ? 'fail' : 'pass',
|
|
83
|
-
score: null, grade: null, severity: worstSev, findings, durationMs,
|
|
84
|
-
...(passSummary && { passSummary }),
|
|
85
|
-
};
|
|
86
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"session_recorders":[{"name":"Hotjar","url":"https://static.hotjar.com/c/hotjar-123.js"}],"canvas_fingerprinters":[{"name":"FingerprintJS","url":"https://cdn.fingerprint.com/v3"}],"key_logging":[],"fb_pixel_events":[{"type":"PageView"}],"third_party_trackers":[{"name":"Google Analytics","url":"https://www.google-analytics.com/analytics.js"},{"name":"Segment","url":"https://cdn.segment.com/analytics.js"}],"third_party_cookies":[{"name":"_ga","domain":".google.com"},{"name":"_fbp","domain":".facebook.com"}]}
|