create-claude-cabinet 0.27.3 → 0.28.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/README.md +13 -0
- package/lib/cli.js +133 -13
- package/lib/site-audit-setup.js +84 -0
- package/package.json +1 -1
- package/templates/site-audit-runtime/bin/cc-site-audit +10 -0
- package/templates/site-audit-runtime/package.json +28 -0
- package/templates/site-audit-runtime/src/checks/axe-core.mjs +41 -0
- package/templates/site-audit-runtime/src/checks/blacklight.mjs +79 -0
- package/templates/site-audit-runtime/src/checks/dns.mjs +65 -0
- package/templates/site-audit-runtime/src/checks/lighthouse.mjs +71 -0
- package/templates/site-audit-runtime/src/checks/linkinator.mjs +53 -0
- package/templates/site-audit-runtime/src/checks/meta-og.mjs +67 -0
- package/templates/site-audit-runtime/src/checks/nuclei.mjs +77 -0
- package/templates/site-audit-runtime/src/checks/observatory.mjs +37 -0
- package/templates/site-audit-runtime/src/checks/pa11y.mjs +44 -0
- package/templates/site-audit-runtime/src/checks/security-headers.mjs +53 -0
- package/templates/site-audit-runtime/src/checks/ssl-cert.mjs +57 -0
- package/templates/site-audit-runtime/src/checks/structured-data.mjs +72 -0
- package/templates/site-audit-runtime/src/checks/testssl.mjs +61 -0
- package/templates/site-audit-runtime/src/checks/unlighthouse.mjs +63 -0
- package/templates/site-audit-runtime/src/checks/website-carbon.mjs +54 -0
- package/templates/site-audit-runtime/src/cli.mjs +184 -0
- package/templates/site-audit-runtime/src/diff.mjs +72 -0
- package/templates/site-audit-runtime/src/orchestrator.mjs +288 -0
- package/templates/site-audit-runtime/src/report.mjs +217 -0
- package/templates/site-audit-runtime/src/schema.mjs +138 -0
- package/templates/site-audit-runtime/src/security.mjs +116 -0
- package/templates/site-audit-runtime/tests/checks-tier1.test.mjs +262 -0
- package/templates/site-audit-runtime/tests/checks-tier2.test.mjs +75 -0
- package/templates/site-audit-runtime/tests/checks-tier3.test.mjs +70 -0
- package/templates/site-audit-runtime/tests/fixtures/axe-core.json +1 -0
- package/templates/site-audit-runtime/tests/fixtures/blacklight.json +1 -0
- package/templates/site-audit-runtime/tests/fixtures/lighthouse.json +1 -0
- package/templates/site-audit-runtime/tests/fixtures/linkinator.json +1 -0
- package/templates/site-audit-runtime/tests/fixtures/nuclei.json +3 -0
- package/templates/site-audit-runtime/tests/fixtures/observatory.json +10 -0
- package/templates/site-audit-runtime/tests/fixtures/pa11y.json +1 -0
- package/templates/site-audit-runtime/tests/fixtures/testssl.json +1 -0
- package/templates/site-audit-runtime/tests/fixtures/unlighthouse.json +1 -0
- package/templates/site-audit-runtime/tests/orchestrator.test.mjs +175 -0
- package/templates/site-audit-runtime/tests/report.test.mjs +128 -0
- package/templates/site-audit-runtime/tests/schema.test.mjs +154 -0
- package/templates/skills/cc-site-audit/SKILL.md +151 -0
- package/templates/skills/cc-site-audit/install.sh +90 -0
- package/templates/skills/cc-upgrade/phases/omega-migration-detect.md +28 -0
package/README.md
CHANGED
|
@@ -114,6 +114,19 @@ Local SQLite database for actions, projects, and status tracking. Claude
|
|
|
114
114
|
reads and writes it directly — no external service needed. Skip this if
|
|
115
115
|
you already use GitHub Issues, Linear, or something else.
|
|
116
116
|
|
|
117
|
+
### Memory (included in lean)
|
|
118
|
+
|
|
119
|
+
Claude Code has built-in file memory, but no guardrails around it.
|
|
120
|
+
The memory module adds structure:
|
|
121
|
+
|
|
122
|
+
- **`/cc-remember`** — write a new memory with automatic indexing.
|
|
123
|
+
Every memory gets its own file and an entry in `MEMORY.md` so
|
|
124
|
+
`/orient` can find it next session.
|
|
125
|
+
- **`/memory`** — browse and search what Claude remembers.
|
|
126
|
+
- **Validation** — `validate-memory.mjs` checks that the index stays
|
|
127
|
+
within Claude Code's session-start budget and that every file is
|
|
128
|
+
indexed. A PostToolUse hook flags unindexed writes in real time.
|
|
129
|
+
|
|
117
130
|
### Compliance Stack (full install)
|
|
118
131
|
|
|
119
132
|
Scoped instructions in `.claude/rules/` that load by file path. An
|
package/lib/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ const { mergeSettings, healUserSettings } = require('./settings-merge');
|
|
|
8
8
|
const { create: createMetadata, read: readMetadata } = require('./metadata');
|
|
9
9
|
const { setupDb } = require('./db-setup');
|
|
10
10
|
const { setupVerifyRuntime } = require('./verify-setup');
|
|
11
|
+
const { setupSiteAuditRuntime } = require('./site-audit-setup');
|
|
11
12
|
const { reset } = require('./reset');
|
|
12
13
|
|
|
13
14
|
const VERSION = require('../package.json').version;
|
|
@@ -343,6 +344,97 @@ function generateSkillIndex(projectDir) {
|
|
|
343
344
|
return entries.length;
|
|
344
345
|
}
|
|
345
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Generate .claude/agents/cabinet-*.md wrapper definitions from installed
|
|
349
|
+
* cabinet SKILL.md files. Each wrapper is frontmatter-only and uses the
|
|
350
|
+
* `skills:` field to preload the skill body into the agent's context — the
|
|
351
|
+
* skill stays the single source of truth, so no prose is duplicated.
|
|
352
|
+
*
|
|
353
|
+
* The wrappers give each cabinet member a registered subagent identity:
|
|
354
|
+
* the transcript shows `subagent_type: cabinet-security` (trustworthy, not
|
|
355
|
+
* spoofable) instead of `general-purpose`, and `@cabinet-security` resolves
|
|
356
|
+
* as an agent type. This runs unconditionally — the identity layer benefits
|
|
357
|
+
* plan/execute/orient cabinet consultations, not just audits.
|
|
358
|
+
*
|
|
359
|
+
* Tool grants: cabinet `tools:` frontmatter is human-readable documentation
|
|
360
|
+
* (e.g., "fetch_docs (all projects -- ...)"), NOT a machine-parseable
|
|
361
|
+
* allowlist. So we grant a safe read-only base and ADD web tools only when
|
|
362
|
+
* the skill signals it needs them — never strip a capability the skill assumes.
|
|
363
|
+
*
|
|
364
|
+
* Reconciles the directory: wrappers whose cabinet skill no longer exists are
|
|
365
|
+
* deleted, so removing a member upstream doesn't leave a zombie wrapper.
|
|
366
|
+
*/
|
|
367
|
+
function generateAgentWrappers(projectDir) {
|
|
368
|
+
const skillsDir = path.join(projectDir, '.claude', 'skills');
|
|
369
|
+
if (!fs.existsSync(skillsDir)) return 0;
|
|
370
|
+
|
|
371
|
+
const agentsDir = path.join(projectDir, '.claude', 'agents');
|
|
372
|
+
|
|
373
|
+
// Discover cabinet members from installed skills.
|
|
374
|
+
const wanted = new Map(); // name -> wrapper content
|
|
375
|
+
const dirs = fs.readdirSync(skillsDir, { withFileTypes: true });
|
|
376
|
+
for (const dir of dirs) {
|
|
377
|
+
if (!dir.isDirectory() || !dir.name.startsWith('cabinet-')) continue;
|
|
378
|
+
const skillFile = path.join(skillsDir, dir.name, 'SKILL.md');
|
|
379
|
+
if (!fs.existsSync(skillFile)) continue;
|
|
380
|
+
|
|
381
|
+
const content = fs.readFileSync(skillFile, 'utf8');
|
|
382
|
+
const fm = parseFrontmatter(content);
|
|
383
|
+
if (!fm || !fm.name) continue;
|
|
384
|
+
|
|
385
|
+
const description = (fm.description || '').replace(/\s+/g, ' ').trim();
|
|
386
|
+
// YAML-safe single-line double-quoted scalar.
|
|
387
|
+
const descYaml = '"' + description.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
388
|
+
|
|
389
|
+
// Base read-only investigation tools every cabinet member gets.
|
|
390
|
+
const tools = ['Read', 'Grep', 'Glob', 'Bash'];
|
|
391
|
+
// Cabinet tools: frontmatter is descriptive text, not an allowlist —
|
|
392
|
+
// scan it for real web-tool signals and add them.
|
|
393
|
+
const toolSignal = (typeof fm.tools === 'string' ? fm.tools : '').toLowerCase();
|
|
394
|
+
if (/websearch/.test(toolSignal)) tools.push('WebSearch');
|
|
395
|
+
if (/webfetch|fetch_docs/.test(toolSignal)) tools.push('WebFetch');
|
|
396
|
+
|
|
397
|
+
// model: none of the cabinet skills declare one today; default to sonnet,
|
|
398
|
+
// but honor an explicit declaration if a member ever sets one.
|
|
399
|
+
const model = (typeof fm.model === 'string' && fm.model.trim()) || 'sonnet';
|
|
400
|
+
|
|
401
|
+
const wrapper =
|
|
402
|
+
`---\n` +
|
|
403
|
+
`name: ${fm.name}\n` +
|
|
404
|
+
`description: ${descYaml}\n` +
|
|
405
|
+
`tools: ${[...new Set(tools)].join(', ')}\n` +
|
|
406
|
+
`model: ${model}\n` +
|
|
407
|
+
`skills: [${fm.name}]\n` +
|
|
408
|
+
`---\n`;
|
|
409
|
+
|
|
410
|
+
wanted.set(fm.name, wrapper);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (wanted.size === 0) {
|
|
414
|
+
// No cabinet members installed — reconcile away any stale wrappers below,
|
|
415
|
+
// but only if the agents dir exists.
|
|
416
|
+
if (!fs.existsSync(agentsDir)) return 0;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
420
|
+
|
|
421
|
+
// Write current wrappers.
|
|
422
|
+
for (const [name, wrapper] of wanted) {
|
|
423
|
+
fs.writeFileSync(path.join(agentsDir, `${name}.md`), wrapper);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Reconcile: remove cabinet-*.md wrappers with no matching skill (zombies).
|
|
427
|
+
for (const file of fs.readdirSync(agentsDir)) {
|
|
428
|
+
if (!file.startsWith('cabinet-') || !file.endsWith('.md')) continue;
|
|
429
|
+
const name = file.slice(0, -3);
|
|
430
|
+
if (!wanted.has(name)) {
|
|
431
|
+
fs.unlinkSync(path.join(agentsDir, file));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return wanted.size;
|
|
436
|
+
}
|
|
437
|
+
|
|
346
438
|
// MODULES is the manifest: every template path here is copied into a
|
|
347
439
|
// consumer's project on install. A skill/hook/script that exists under
|
|
348
440
|
// templates/ but is NOT listed in any module never ships — that is the
|
|
@@ -487,6 +579,18 @@ const MODULES = {
|
|
|
487
579
|
],
|
|
488
580
|
postInstall: 'verify-setup',
|
|
489
581
|
},
|
|
582
|
+
'site-audit': {
|
|
583
|
+
name: 'Site Audit (deployed-site quality)',
|
|
584
|
+
description: '15-check audit for deployed websites: performance, accessibility (3 engines), security, SEO, DNS, privacy, sustainability. Standalone HTML report + comparison mode.',
|
|
585
|
+
mandatory: false,
|
|
586
|
+
default: false,
|
|
587
|
+
lean: false,
|
|
588
|
+
templates: [
|
|
589
|
+
'skills/cc-site-audit',
|
|
590
|
+
'site-audit-runtime',
|
|
591
|
+
],
|
|
592
|
+
postInstall: 'site-audit-setup',
|
|
593
|
+
},
|
|
490
594
|
};
|
|
491
595
|
|
|
492
596
|
/** Recursively collect all relative file paths under a directory. */
|
|
@@ -1115,21 +1219,26 @@ async function run() {
|
|
|
1115
1219
|
|
|
1116
1220
|
// --- Run module postInstall hooks ---
|
|
1117
1221
|
// Modules with a `postInstall` field dispatch to a matching setup
|
|
1118
|
-
// function
|
|
1119
|
-
|
|
1222
|
+
// function. Table-driven: add new runtime installers here.
|
|
1223
|
+
const POST_INSTALL_HANDLERS = {
|
|
1224
|
+
'verify-setup': setupVerifyRuntime,
|
|
1225
|
+
'site-audit-setup': setupSiteAuditRuntime,
|
|
1226
|
+
};
|
|
1120
1227
|
for (const moduleKey of selectedModules) {
|
|
1121
1228
|
const mod = MODULES[moduleKey];
|
|
1122
1229
|
if (!mod.postInstall) continue;
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1230
|
+
const handler = POST_INSTALL_HANDLERS[mod.postInstall];
|
|
1231
|
+
if (!handler) {
|
|
1232
|
+
console.log(` ⚠ Unknown postInstall handler: ${mod.postInstall}`);
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
try {
|
|
1236
|
+
console.log('');
|
|
1237
|
+
const result = handler({ dryRun: !!flags.dryRun });
|
|
1238
|
+
for (const r of result.results || []) console.log(` 📋 ${r}`);
|
|
1239
|
+
} catch (err) {
|
|
1240
|
+
console.log(` ⚠ ${mod.postInstall} failed: ${err.message}`);
|
|
1241
|
+
console.log(' Re-run the installer to retry.');
|
|
1133
1242
|
}
|
|
1134
1243
|
}
|
|
1135
1244
|
|
|
@@ -1290,6 +1399,17 @@ async function run() {
|
|
|
1290
1399
|
}
|
|
1291
1400
|
}
|
|
1292
1401
|
|
|
1402
|
+
// --- Generate cabinet agent-type wrappers ---
|
|
1403
|
+
// Unconditional (not audit-gated): the registered subagent identity benefits
|
|
1404
|
+
// plan/execute/orient cabinet consultations, not just /audit. No-op when no
|
|
1405
|
+
// cabinet members are installed.
|
|
1406
|
+
if (!flags.dryRun) {
|
|
1407
|
+
const wrapperCount = generateAgentWrappers(projectDir);
|
|
1408
|
+
if (wrapperCount > 0) {
|
|
1409
|
+
console.log(` 🪪 Generated ${wrapperCount} cabinet agent wrappers in .claude/agents/`);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1293
1413
|
// --- Write metadata ---
|
|
1294
1414
|
if (!flags.dryRun) {
|
|
1295
1415
|
createMetadata(projectDir, {
|
|
@@ -1353,4 +1473,4 @@ async function run() {
|
|
|
1353
1473
|
console.log('');
|
|
1354
1474
|
}
|
|
1355
1475
|
|
|
1356
|
-
module.exports = { run, MODULES };
|
|
1476
|
+
module.exports = { run, MODULES, generateAgentWrappers };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* site-audit-setup.js — install the @claude-cabinet/site-audit runtime
|
|
3
|
+
* to ~/.claude-cabinet/site-audit/<version>/.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors verify-setup.js: npm-pack the source, install to a versioned
|
|
6
|
+
* dir, write a current/VERSION pointer. Idempotent.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { execSync } = require('child_process');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
|
|
14
|
+
const CC_HOME = path.join(os.homedir(), '.claude-cabinet');
|
|
15
|
+
const SITE_AUDIT_BASE = path.join(CC_HOME, 'site-audit');
|
|
16
|
+
|
|
17
|
+
function setupSiteAuditRuntime(opts = {}) {
|
|
18
|
+
const dryRun = !!opts.dryRun;
|
|
19
|
+
const runtimeSourceDir =
|
|
20
|
+
opts.runtimeSourceDir || path.resolve(__dirname, '..', 'templates', 'site-audit-runtime');
|
|
21
|
+
|
|
22
|
+
const packageJsonPath = path.join(runtimeSourceDir, 'package.json');
|
|
23
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
24
|
+
throw new Error(`site-audit-setup: ${packageJsonPath} not found.`);
|
|
25
|
+
}
|
|
26
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
27
|
+
const version = pkg.version;
|
|
28
|
+
if (typeof version !== 'string' || version.length === 0) {
|
|
29
|
+
throw new Error(`site-audit-setup: ${packageJsonPath} has no version field`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const installDir = path.join(SITE_AUDIT_BASE, version, 'dist');
|
|
33
|
+
const tarballName = `claude-cabinet-site-audit-${version}.tgz`;
|
|
34
|
+
const tarballPath = path.join(installDir, tarballName);
|
|
35
|
+
const versionPointer = path.join(SITE_AUDIT_BASE, 'current', 'VERSION');
|
|
36
|
+
const results = [];
|
|
37
|
+
|
|
38
|
+
if (dryRun) {
|
|
39
|
+
results.push(`Would install @claude-cabinet/site-audit@${version}`);
|
|
40
|
+
results.push(` source: ${runtimeSourceDir}`);
|
|
41
|
+
results.push(` target: ${tarballPath}`);
|
|
42
|
+
results.push(` pointer: ${versionPointer}`);
|
|
43
|
+
return { installPath: installDir, version, status: 'dry-run', results };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (fs.existsSync(tarballPath) && fs.statSync(tarballPath).size > 1024) {
|
|
47
|
+
results.push(`@claude-cabinet/site-audit@${version} already installed (${tarballPath})`);
|
|
48
|
+
writeVersionPointer(versionPointer, version);
|
|
49
|
+
return { installPath: installDir, version, status: 'skipped', results };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (fs.existsSync(tarballPath)) fs.unlinkSync(tarballPath);
|
|
53
|
+
|
|
54
|
+
fs.mkdirSync(installDir, { recursive: true });
|
|
55
|
+
const packStdout = execSync(`npm pack --silent --pack-destination "${installDir}"`, {
|
|
56
|
+
cwd: runtimeSourceDir,
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
}).trim();
|
|
59
|
+
|
|
60
|
+
const lastLine = packStdout.split('\n').filter(Boolean).pop() || '';
|
|
61
|
+
const producedName = path.basename(lastLine);
|
|
62
|
+
if (producedName && producedName !== tarballName) {
|
|
63
|
+
const producedPath = path.join(installDir, producedName);
|
|
64
|
+
if (fs.existsSync(producedPath)) {
|
|
65
|
+
fs.renameSync(producedPath, tarballPath);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!fs.existsSync(tarballPath)) {
|
|
70
|
+
throw new Error(`site-audit-setup: tarball not found after npm pack: ${tarballPath}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
writeVersionPointer(versionPointer, version);
|
|
74
|
+
results.push(`Installed @claude-cabinet/site-audit@${version}`);
|
|
75
|
+
results.push(` ${tarballPath}`);
|
|
76
|
+
return { installPath: installDir, version, status: 'installed', results };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function writeVersionPointer(pointerPath, version) {
|
|
80
|
+
fs.mkdirSync(path.dirname(pointerPath), { recursive: true });
|
|
81
|
+
fs.writeFileSync(pointerPath, version + '\n', 'utf8');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { setupSiteAuditRuntime };
|
package/package.json
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Thin entry — all logic lives in src/cli.mjs so it stays unit-testable.
|
|
3
|
+
import { main } from '../src/cli.mjs';
|
|
4
|
+
|
|
5
|
+
main(process.argv.slice(2))
|
|
6
|
+
.then((code) => process.exit(code))
|
|
7
|
+
.catch((err) => {
|
|
8
|
+
process.stderr.write(`${err?.stack ?? err}\n`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@claude-cabinet/site-audit",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cc-site-audit": "./bin/cc-site-audit"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/",
|
|
11
|
+
"bin/"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test tests/*.test.mjs"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@axe-core/cli": "^4.10.0",
|
|
21
|
+
"@mdn/mdn-http-observatory": "^1.6.0",
|
|
22
|
+
"@themarkup/blacklight-collector": "^1.0.0",
|
|
23
|
+
"lighthouse": "^12.0.0",
|
|
24
|
+
"linkinator": "^7.0.0",
|
|
25
|
+
"pa11y": "^9.0.0",
|
|
26
|
+
"unlighthouse": "^0.16.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const checkId = 'axe-core';
|
|
2
|
+
export const tool = 'axe-core (WCAG AA)';
|
|
3
|
+
|
|
4
|
+
export async function detect(executor) {
|
|
5
|
+
const r = await executor.spawn('npx', ['@axe-core/cli', '--version'], { timeoutMs: 15_000 });
|
|
6
|
+
return r.code === 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function run(url, executor) {
|
|
10
|
+
return executor.spawn('npx', ['@axe-core/cli', url, '--exit'], { timeoutMs: 60_000 });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const IMPACT_TO_SEVERITY = { critical: 'critical', serious: 'serious', moderate: 'moderate', minor: 'info' };
|
|
14
|
+
|
|
15
|
+
export function normalize(raw, durationMs) {
|
|
16
|
+
if (raw.code !== 0 && !raw.stdout) {
|
|
17
|
+
return { checkId, tool, status: 'error', score: null, grade: null, severity: null, findings: [], durationMs, reason: raw.stderr || 'axe-core failed' };
|
|
18
|
+
}
|
|
19
|
+
let data;
|
|
20
|
+
try { data = JSON.parse(raw.stdout); } catch {
|
|
21
|
+
return { checkId, tool, status: 'error', score: null, grade: null, severity: null, findings: [], durationMs, reason: 'failed to parse axe-core JSON' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const violations = Array.isArray(data) ? data.flatMap(p => p.violations || []) : (data.violations || []);
|
|
25
|
+
const findings = violations.map(v => ({
|
|
26
|
+
severity: IMPACT_TO_SEVERITY[v.impact] || 'info',
|
|
27
|
+
message: v.description || v.id,
|
|
28
|
+
url: v.helpUrl || undefined,
|
|
29
|
+
context: v.nodes?.[0]?.html?.slice(0, 200) || undefined,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const worstSev = findings.length ? findings.reduce((w, f) => {
|
|
33
|
+
const o = { critical: 0, serious: 1, moderate: 2, info: 3 };
|
|
34
|
+
return (o[f.severity] ?? 3) < (o[w] ?? 3) ? f.severity : w;
|
|
35
|
+
}, 'info') : null;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
checkId, tool, status: findings.length === 0 ? 'pass' : 'fail',
|
|
39
|
+
score: null, grade: null, severity: worstSev, findings, durationMs,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
return {
|
|
76
|
+
checkId, tool, status: hasSerious ? 'fail' : 'pass',
|
|
77
|
+
score: null, grade: null, severity: worstSev, findings, durationMs,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export const checkId = 'dns';
|
|
2
|
+
export const tool = 'DNS & Protocol';
|
|
3
|
+
|
|
4
|
+
export async function detect(executor) {
|
|
5
|
+
const r = await executor.spawn('dig', ['-v'], { timeoutMs: 5_000 });
|
|
6
|
+
return r.code === 0 || r.stderr.includes('DiG');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function run(url, executor) {
|
|
10
|
+
const hostname = new URL(url).hostname;
|
|
11
|
+
const [dnssec, spf, dmarc, http2] = await Promise.all([
|
|
12
|
+
executor.spawn('dig', ['+dnssec', '+short', hostname], { timeoutMs: 10_000 }),
|
|
13
|
+
executor.spawn('dig', ['TXT', '+short', hostname], { timeoutMs: 10_000 }),
|
|
14
|
+
executor.spawn('dig', ['TXT', '+short', `_dmarc.${hostname}`], { timeoutMs: 10_000 }),
|
|
15
|
+
executor.spawn('curl', ['-sI', '--http2', '-o', '/dev/null', '-w', '%{http_version}', url], { timeoutMs: 10_000 }),
|
|
16
|
+
]);
|
|
17
|
+
return { hostname, dnssec, spf, dmarc, http2 };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalize(raw, durationMs) {
|
|
21
|
+
const findings = [];
|
|
22
|
+
|
|
23
|
+
if (raw.dnssec && raw.dnssec.code === 0) {
|
|
24
|
+
const out = raw.dnssec.stdout || '';
|
|
25
|
+
if (!out.includes('RRSIG')) {
|
|
26
|
+
findings.push({ severity: 'moderate', message: 'DNSSEC not enabled' });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (raw.spf && raw.spf.code === 0) {
|
|
31
|
+
const out = raw.spf.stdout || '';
|
|
32
|
+
if (!out.includes('v=spf1')) {
|
|
33
|
+
findings.push({ severity: 'moderate', message: 'No SPF record found' });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (raw.dmarc && raw.dmarc.code === 0) {
|
|
38
|
+
const out = raw.dmarc.stdout || '';
|
|
39
|
+
if (!out.includes('v=DMARC1')) {
|
|
40
|
+
findings.push({ severity: 'moderate', message: 'No DMARC record found' });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let httpVersion = null;
|
|
45
|
+
if (raw.http2 && raw.http2.code === 0) {
|
|
46
|
+
httpVersion = raw.http2.stdout.trim();
|
|
47
|
+
if (!httpVersion.startsWith('2') && !httpVersion.startsWith('3')) {
|
|
48
|
+
findings.push({ severity: 'info', message: `HTTP version ${httpVersion} (HTTP/2+ recommended)` });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const total = 4;
|
|
53
|
+
const passing = total - findings.filter(f => f.severity !== 'info').length;
|
|
54
|
+
const score = Math.round((passing / total) * 100);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
checkId, tool, status: findings.some(f => f.severity !== 'info') ? 'fail' : 'pass',
|
|
58
|
+
score, grade: null,
|
|
59
|
+
severity: findings.length ? findings.reduce((w, f) => {
|
|
60
|
+
const o = { critical: 0, serious: 1, moderate: 2, info: 3 };
|
|
61
|
+
return (o[f.severity] ?? 3) < (o[w] ?? 3) ? f.severity : w;
|
|
62
|
+
}, 'info') : null,
|
|
63
|
+
findings, durationMs,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export const checkId = 'lighthouse';
|
|
2
|
+
export const tool = 'Lighthouse';
|
|
3
|
+
export const defaultTimeoutMs = 120_000;
|
|
4
|
+
|
|
5
|
+
export async function detect(executor) {
|
|
6
|
+
const r = await executor.spawn('npx', ['lighthouse', '--version'], { timeoutMs: 15_000 });
|
|
7
|
+
return r.code === 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function run(url, executor) {
|
|
11
|
+
const r = await executor.spawn('npx', [
|
|
12
|
+
'lighthouse', url,
|
|
13
|
+
'--output=json',
|
|
14
|
+
'--chrome-flags=--headless=new --no-sandbox',
|
|
15
|
+
'--only-categories=performance,accessibility,best-practices,seo',
|
|
16
|
+
], { timeoutMs: 120_000 });
|
|
17
|
+
return r;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalize(raw, durationMs) {
|
|
21
|
+
if (raw.code !== 0 && !raw.stdout) {
|
|
22
|
+
return { checkId, tool, status: 'error', score: null, grade: null, severity: null, findings: [], durationMs, reason: raw.stderr || 'lighthouse failed' };
|
|
23
|
+
}
|
|
24
|
+
let data;
|
|
25
|
+
try { data = JSON.parse(raw.stdout); } catch {
|
|
26
|
+
return { checkId, tool, status: 'error', score: null, grade: null, severity: null, findings: [], durationMs, reason: 'failed to parse lighthouse JSON' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const cats = data.categories || {};
|
|
30
|
+
const scores = {};
|
|
31
|
+
for (const [key, cat] of Object.entries(cats)) {
|
|
32
|
+
scores[key] = Math.round((cat.score ?? 0) * 100);
|
|
33
|
+
}
|
|
34
|
+
const avg = Object.values(scores).length
|
|
35
|
+
? Math.round(Object.values(scores).reduce((a, b) => a + b, 0) / Object.values(scores).length)
|
|
36
|
+
: null;
|
|
37
|
+
|
|
38
|
+
const findings = [];
|
|
39
|
+
const audits = data.audits || {};
|
|
40
|
+
for (const [id, audit] of Object.entries(audits)) {
|
|
41
|
+
if (audit.score !== null && audit.score < 0.5 && audit.title) {
|
|
42
|
+
findings.push({
|
|
43
|
+
severity: audit.score === 0 ? 'serious' : 'moderate',
|
|
44
|
+
message: audit.title,
|
|
45
|
+
context: audit.displayValue || undefined,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const worstSev = findings.length ? findings.reduce((w, f) => {
|
|
51
|
+
const order = { critical: 0, serious: 1, moderate: 2, info: 3 };
|
|
52
|
+
return (order[f.severity] ?? 3) < (order[w] ?? 3) ? f.severity : w;
|
|
53
|
+
}, 'info') : null;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
checkId, tool, status: avg !== null && avg >= 50 ? 'pass' : 'fail',
|
|
57
|
+
score: avg, grade: scoreToGrade(avg), severity: worstSev, findings, durationMs,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function scoreToGrade(s) {
|
|
62
|
+
if (s == null) return null;
|
|
63
|
+
if (s >= 95) return 'A+';
|
|
64
|
+
if (s >= 90) return 'A';
|
|
65
|
+
if (s >= 85) return 'B+';
|
|
66
|
+
if (s >= 80) return 'B';
|
|
67
|
+
if (s >= 75) return 'C+';
|
|
68
|
+
if (s >= 70) return 'C';
|
|
69
|
+
if (s >= 60) return 'D';
|
|
70
|
+
return 'F';
|
|
71
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const checkId = 'linkinator';
|
|
2
|
+
export const tool = 'Linkinator (broken links)';
|
|
3
|
+
|
|
4
|
+
export async function detect(executor) {
|
|
5
|
+
const r = await executor.spawn('npx', ['linkinator', '--version'], { timeoutMs: 15_000 });
|
|
6
|
+
return r.code === 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function run(url, executor) {
|
|
10
|
+
return executor.spawn('npx', ['linkinator', url, '--format', 'json', '--timeout', '10000'], { timeoutMs: 60_000 });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalize(raw, durationMs) {
|
|
14
|
+
if (raw.code !== 0 && !raw.stdout) {
|
|
15
|
+
return { checkId, tool, status: 'error', score: null, grade: null, severity: null, findings: [], durationMs, reason: raw.stderr || 'linkinator failed' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let data;
|
|
19
|
+
try { data = JSON.parse(raw.stdout); } catch {
|
|
20
|
+
return { checkId, tool, status: 'error', score: null, grade: null, severity: null, findings: [], durationMs, reason: 'failed to parse linkinator JSON' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const links = data.links || [];
|
|
24
|
+
const findings = [];
|
|
25
|
+
|
|
26
|
+
for (const link of links) {
|
|
27
|
+
if (link.state === 'BROKEN') {
|
|
28
|
+
findings.push({
|
|
29
|
+
severity: 'serious',
|
|
30
|
+
message: `Broken link: ${link.url} (${link.status || 'no response'})`,
|
|
31
|
+
url: link.url,
|
|
32
|
+
context: link.parent || undefined,
|
|
33
|
+
});
|
|
34
|
+
} else if (link.state === 'SKIPPED') {
|
|
35
|
+
findings.push({
|
|
36
|
+
severity: 'info',
|
|
37
|
+
message: `Skipped: ${link.url}`,
|
|
38
|
+
url: link.url,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const broken = findings.filter(f => f.severity !== 'info').length;
|
|
44
|
+
const total = links.filter(l => l.state !== 'SKIPPED').length;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
checkId, tool, status: broken === 0 ? 'pass' : 'fail',
|
|
48
|
+
score: null, grade: null,
|
|
49
|
+
severity: broken > 0 ? 'serious' : null,
|
|
50
|
+
findings: findings.filter(f => f.severity !== 'info'),
|
|
51
|
+
durationMs,
|
|
52
|
+
};
|
|
53
|
+
}
|