create-claude-cabinet 0.29.6 → 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 +1 -0
- package/templates/site-audit-runtime/src/checks/dns.mjs +1 -0
- package/templates/site-audit-runtime/src/checks/lighthouse.mjs +1 -0
- package/templates/site-audit-runtime/src/checks/linkinator.mjs +1 -0
- 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 +1 -0
- 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 +2 -1
- package/templates/site-audit-runtime/src/checks/website-carbon.mjs +1 -0
- package/templates/site-audit-runtime/src/orchestrator.mjs +2 -3
- package/templates/site-audit-runtime/src/report.mjs +26 -3
- 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,5 +1,6 @@
|
|
|
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
6
|
const r = await executor.spawn('axe', ['--version'], { timeoutMs: 15_000 });
|
|
@@ -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,5 +1,6 @@
|
|
|
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) {
|
|
@@ -1,5 +1,6 @@
|
|
|
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
6
|
const r = await executor.spawn('linkinator', ['--version'], { timeoutMs: 15_000 });
|
|
@@ -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('mdn-http-observatory', ['--
|
|
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('mdn-http-observatory', [hostname], { timeoutMs: 60_000 });
|
|
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,5 +1,6 @@
|
|
|
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
6
|
const r = await executor.spawn('pa11y', ['--version'], { timeoutMs: 15_000 });
|
|
@@ -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,7 +4,8 @@
|
|
|
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
11
|
const r = await executor.spawn('unlighthouse', ['--version'], { timeoutMs: 15_000 });
|
|
@@ -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+$/, '');
|
|
@@ -242,6 +240,7 @@ export async function auditSite(url, checks, opts = {}) {
|
|
|
242
240
|
reason: `normalize produced invalid CheckResult: ${errors.join('; ')}`,
|
|
243
241
|
};
|
|
244
242
|
}
|
|
243
|
+
if (check.whyItMatters) result.whyItMatters = check.whyItMatters;
|
|
245
244
|
return result;
|
|
246
245
|
} catch (err) {
|
|
247
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
|
|
@@ -171,6 +190,8 @@ function checkSection(result) {
|
|
|
171
190
|
</div>
|
|
172
191
|
</div>
|
|
173
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)}
|
|
174
195
|
${renderFindings(result.findings, result)}
|
|
175
196
|
</div>
|
|
176
197
|
</div>`;
|
|
@@ -306,18 +327,20 @@ export function renderComparison(delta) {
|
|
|
306
327
|
? `<span class="${d.deltaScore > 0 ? 'delta-pos' : 'delta-neg'}">(${d.deltaScore > 0 ? '+' : ''}${d.deltaScore})</span>`
|
|
307
328
|
: '';
|
|
308
329
|
|
|
330
|
+
const why = d.a?.whyItMatters || d.b?.whyItMatters || '';
|
|
309
331
|
html += `<div class="compare-card" id="compare-${esc(d.checkId)}">
|
|
310
332
|
<div class="compare-card-header">
|
|
311
333
|
<span class="check-title">${STATUS_ICON[d.a?.status || d.b?.status || 'skip'] || ''} ${esc(d.tool)} ${deltaLabel}</span>
|
|
312
334
|
<div class="check-meta">${aBadge} ${bBadge}</div>
|
|
313
335
|
</div>
|
|
314
|
-
<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>` : ''}`;
|
|
315
338
|
|
|
316
339
|
if (d.availability === 'both') {
|
|
317
340
|
const { shared, aOnly: aOnlyF, bOnly: bOnlyF } = classifyFindings(d.a, d.b);
|
|
318
341
|
html += '<div class="side-by-side">';
|
|
319
|
-
html += `<div class="site-column"><h4>${esc(labelA)}</h4>${compareCardFindings(esc(labelA) + ' only', aOnlyF, d.a)}</div>`;
|
|
320
|
-
html += `<div class="site-column"><h4>${esc(labelB)}</h4>${compareCardFindings(esc(labelB) + ' only', bOnlyF, d.b)}</div>`;
|
|
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>`;
|
|
321
344
|
html += '</div>';
|
|
322
345
|
if (shared.length) {
|
|
323
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('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('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"}]}
|