create-claude-cabinet 0.27.4 → 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.
Files changed (44) hide show
  1. package/README.md +13 -0
  2. package/lib/cli.js +133 -13
  3. package/lib/site-audit-setup.js +84 -0
  4. package/package.json +1 -1
  5. package/templates/site-audit-runtime/bin/cc-site-audit +10 -0
  6. package/templates/site-audit-runtime/package.json +28 -0
  7. package/templates/site-audit-runtime/src/checks/axe-core.mjs +41 -0
  8. package/templates/site-audit-runtime/src/checks/blacklight.mjs +79 -0
  9. package/templates/site-audit-runtime/src/checks/dns.mjs +65 -0
  10. package/templates/site-audit-runtime/src/checks/lighthouse.mjs +71 -0
  11. package/templates/site-audit-runtime/src/checks/linkinator.mjs +53 -0
  12. package/templates/site-audit-runtime/src/checks/meta-og.mjs +67 -0
  13. package/templates/site-audit-runtime/src/checks/nuclei.mjs +77 -0
  14. package/templates/site-audit-runtime/src/checks/observatory.mjs +37 -0
  15. package/templates/site-audit-runtime/src/checks/pa11y.mjs +44 -0
  16. package/templates/site-audit-runtime/src/checks/security-headers.mjs +53 -0
  17. package/templates/site-audit-runtime/src/checks/ssl-cert.mjs +57 -0
  18. package/templates/site-audit-runtime/src/checks/structured-data.mjs +72 -0
  19. package/templates/site-audit-runtime/src/checks/testssl.mjs +61 -0
  20. package/templates/site-audit-runtime/src/checks/unlighthouse.mjs +63 -0
  21. package/templates/site-audit-runtime/src/checks/website-carbon.mjs +54 -0
  22. package/templates/site-audit-runtime/src/cli.mjs +184 -0
  23. package/templates/site-audit-runtime/src/diff.mjs +72 -0
  24. package/templates/site-audit-runtime/src/orchestrator.mjs +288 -0
  25. package/templates/site-audit-runtime/src/report.mjs +217 -0
  26. package/templates/site-audit-runtime/src/schema.mjs +138 -0
  27. package/templates/site-audit-runtime/src/security.mjs +116 -0
  28. package/templates/site-audit-runtime/tests/checks-tier1.test.mjs +262 -0
  29. package/templates/site-audit-runtime/tests/checks-tier2.test.mjs +75 -0
  30. package/templates/site-audit-runtime/tests/checks-tier3.test.mjs +70 -0
  31. package/templates/site-audit-runtime/tests/fixtures/axe-core.json +1 -0
  32. package/templates/site-audit-runtime/tests/fixtures/blacklight.json +1 -0
  33. package/templates/site-audit-runtime/tests/fixtures/lighthouse.json +1 -0
  34. package/templates/site-audit-runtime/tests/fixtures/linkinator.json +1 -0
  35. package/templates/site-audit-runtime/tests/fixtures/nuclei.json +3 -0
  36. package/templates/site-audit-runtime/tests/fixtures/observatory.json +10 -0
  37. package/templates/site-audit-runtime/tests/fixtures/pa11y.json +1 -0
  38. package/templates/site-audit-runtime/tests/fixtures/testssl.json +1 -0
  39. package/templates/site-audit-runtime/tests/fixtures/unlighthouse.json +1 -0
  40. package/templates/site-audit-runtime/tests/orchestrator.test.mjs +175 -0
  41. package/templates/site-audit-runtime/tests/report.test.mjs +128 -0
  42. package/templates/site-audit-runtime/tests/schema.test.mjs +154 -0
  43. package/templates/skills/cc-site-audit/SKILL.md +151 -0
  44. package/templates/skills/cc-site-audit/install.sh +90 -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 after templates are copied. Currently only 'verify-setup'
1119
- // (the cabinet-verify tarball builder) is wired.
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
- if (mod.postInstall === 'verify-setup') {
1124
- try {
1125
- console.log('');
1126
- const verifyResult = setupVerifyRuntime({ dryRun: !!flags.dryRun });
1127
- for (const r of verifyResult.results || []) console.log(` 📋 ${r}`);
1128
- } catch (err) {
1129
- console.log(` ⚠ cabinet-verify runtime setup failed: ${err.message}`);
1130
- console.log(' /verify install.sh will fail until the runtime is installed.');
1131
- console.log(' Re-run the installer to retry.');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.27.4",
3
+ "version": "0.28.0",
4
4
  "description": "Claude Cabinet — opinionated process scaffolding for Claude Code projects",
5
5
  "bin": {
6
6
  "create-claude-cabinet": "bin/create-claude-cabinet.js"
@@ -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
+ }