@nebulacomponents/citable 1.16.0 → 1.17.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 (102) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +16 -3
  3. package/dist/universal/.agents/skills/citable/SKILL.md +47 -1
  4. package/dist/universal/.agents/skills/citable/VERSION +1 -1
  5. package/dist/universal/.agents/skills/citable/manifest.json +8 -6
  6. package/dist/universal/.agents/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  7. package/dist/universal/.agents/skills/citable/schemas/finding.schema.json +69 -0
  8. package/dist/universal/.agents/skills/citable/schemas/remediation-verification.schema.json +98 -0
  9. package/dist/universal/.claude/agents/citable/manifest.json +2 -2
  10. package/dist/universal/.claude/skills/citable/SKILL.md +47 -1
  11. package/dist/universal/.claude/skills/citable/VERSION +1 -1
  12. package/dist/universal/.claude/skills/citable/manifest.json +8 -6
  13. package/dist/universal/.claude/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  14. package/dist/universal/.claude/skills/citable/schemas/finding.schema.json +69 -0
  15. package/dist/universal/.claude/skills/citable/schemas/remediation-verification.schema.json +98 -0
  16. package/dist/universal/.cursor/skills/citable/SKILL.md +47 -1
  17. package/dist/universal/.cursor/skills/citable/VERSION +1 -1
  18. package/dist/universal/.cursor/skills/citable/manifest.json +8 -6
  19. package/dist/universal/.cursor/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  20. package/dist/universal/.cursor/skills/citable/schemas/finding.schema.json +69 -0
  21. package/dist/universal/.cursor/skills/citable/schemas/remediation-verification.schema.json +98 -0
  22. package/dist/universal/.gemini/skills/citable/SKILL.md +47 -1
  23. package/dist/universal/.gemini/skills/citable/VERSION +1 -1
  24. package/dist/universal/.gemini/skills/citable/manifest.json +8 -6
  25. package/dist/universal/.gemini/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  26. package/dist/universal/.gemini/skills/citable/schemas/finding.schema.json +69 -0
  27. package/dist/universal/.gemini/skills/citable/schemas/remediation-verification.schema.json +98 -0
  28. package/dist/universal/.github/skills/citable/SKILL.md +47 -1
  29. package/dist/universal/.github/skills/citable/VERSION +1 -1
  30. package/dist/universal/.github/skills/citable/manifest.json +8 -6
  31. package/dist/universal/.github/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  32. package/dist/universal/.github/skills/citable/schemas/finding.schema.json +69 -0
  33. package/dist/universal/.github/skills/citable/schemas/remediation-verification.schema.json +98 -0
  34. package/dist/universal/.kiro/skills/citable/SKILL.md +47 -1
  35. package/dist/universal/.kiro/skills/citable/VERSION +1 -1
  36. package/dist/universal/.kiro/skills/citable/manifest.json +8 -6
  37. package/dist/universal/.kiro/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  38. package/dist/universal/.kiro/skills/citable/schemas/finding.schema.json +69 -0
  39. package/dist/universal/.kiro/skills/citable/schemas/remediation-verification.schema.json +98 -0
  40. package/dist/universal/.opencode/skills/citable/SKILL.md +47 -1
  41. package/dist/universal/.opencode/skills/citable/VERSION +1 -1
  42. package/dist/universal/.opencode/skills/citable/manifest.json +8 -6
  43. package/dist/universal/.opencode/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  44. package/dist/universal/.opencode/skills/citable/schemas/finding.schema.json +69 -0
  45. package/dist/universal/.opencode/skills/citable/schemas/remediation-verification.schema.json +98 -0
  46. package/dist/universal/.pi/agent/skills/citable/SKILL.md +47 -1
  47. package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
  48. package/dist/universal/.pi/agent/skills/citable/manifest.json +8 -6
  49. package/dist/universal/.pi/agent/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  50. package/dist/universal/.pi/agent/skills/citable/schemas/finding.schema.json +69 -0
  51. package/dist/universal/.pi/agent/skills/citable/schemas/remediation-verification.schema.json +98 -0
  52. package/dist/universal/.qoder/skills/citable/SKILL.md +47 -1
  53. package/dist/universal/.qoder/skills/citable/VERSION +1 -1
  54. package/dist/universal/.qoder/skills/citable/manifest.json +8 -6
  55. package/dist/universal/.qoder/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  56. package/dist/universal/.qoder/skills/citable/schemas/finding.schema.json +69 -0
  57. package/dist/universal/.qoder/skills/citable/schemas/remediation-verification.schema.json +98 -0
  58. package/dist/universal/.rovodev/skills/citable/SKILL.md +47 -1
  59. package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
  60. package/dist/universal/.rovodev/skills/citable/manifest.json +8 -6
  61. package/dist/universal/.rovodev/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  62. package/dist/universal/.rovodev/skills/citable/schemas/finding.schema.json +69 -0
  63. package/dist/universal/.rovodev/skills/citable/schemas/remediation-verification.schema.json +98 -0
  64. package/dist/universal/.trae/skills/citable/SKILL.md +47 -1
  65. package/dist/universal/.trae/skills/citable/VERSION +1 -1
  66. package/dist/universal/.trae/skills/citable/manifest.json +8 -6
  67. package/dist/universal/.trae/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  68. package/dist/universal/.trae/skills/citable/schemas/finding.schema.json +69 -0
  69. package/dist/universal/.trae/skills/citable/schemas/remediation-verification.schema.json +98 -0
  70. package/dist/universal/.trae-cn/skills/citable/SKILL.md +47 -1
  71. package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
  72. package/dist/universal/.trae-cn/skills/citable/manifest.json +8 -6
  73. package/dist/universal/.trae-cn/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  74. package/dist/universal/.trae-cn/skills/citable/schemas/finding.schema.json +69 -0
  75. package/dist/universal/.trae-cn/skills/citable/schemas/remediation-verification.schema.json +98 -0
  76. package/dist/universal/manifest.json +100 -76
  77. package/package.json +1 -1
  78. package/schemas/cli-output-envelope.schema.json +30 -0
  79. package/schemas/finding.schema.json +69 -0
  80. package/schemas/remediation-verification.schema.json +98 -0
  81. package/skill/SKILL.md +47 -1
  82. package/src/analysis/saliency.js +119 -0
  83. package/src/cli/index.js +269 -6
  84. package/src/commands/audit.js +2 -2
  85. package/src/commands/ciWorkflow.js +63 -0
  86. package/src/commands/compatibility.js +205 -0
  87. package/src/commands/context.js +2 -1
  88. package/src/commands/edgeRules.js +80 -4
  89. package/src/commands/edgeSecurity.js +192 -0
  90. package/src/commands/experimentGuardrails.js +213 -0
  91. package/src/commands/goldenCorpus.js +155 -0
  92. package/src/commands/implementationKit.js +202 -0
  93. package/src/commands/inspectCro.js +60 -0
  94. package/src/commands/planExperiment.js +52 -0
  95. package/src/commands/previewCro.js +241 -0
  96. package/src/commands/remediate.js +461 -0
  97. package/src/commands/verifyRemediation.js +204 -0
  98. package/src/commands/visualRegression.js +165 -0
  99. package/src/components/index.js +556 -0
  100. package/src/detectors/cro.js +48 -0
  101. package/src/detectors/framework.js +31 -1
  102. package/src/reporting/executiveExport.js +113 -1
@@ -0,0 +1,205 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { readJson, nowIso } from '../shared/io.js';
6
+ import { buildContext } from './context.js';
7
+ import { runDetectors } from '../detectors/framework.js';
8
+ import { selectDetectors, ALL_DETECTORS } from '../detectors/index.js';
9
+
10
+ const PKG = readJson(new URL('../../package.json', import.meta.url));
11
+ const SCHEMA_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'schemas');
12
+
13
+ function checkModule(name) {
14
+ try {
15
+ execFileSync(process.execPath, ['--input-type=module', '-e', `await import('${name}')`], {
16
+ stdio: ['pipe', 'pipe', 'pipe'],
17
+ timeout: 15000,
18
+ env: { ...process.env, NODE_OPTIONS: '' },
19
+ });
20
+ return { present: true, importable: true };
21
+ } catch (err) {
22
+ const notFound = /Cannot find (package|module)/.test(String(err.stderr || err.message));
23
+ return { present: !notFound, importable: false, note: notFound ? 'not installed (optional peer dependency)' : 'installed but failed to import — adapter mismatch' };
24
+ }
25
+ }
26
+
27
+ function engineSatisfied(range, actual) {
28
+ const m = range.match(/>=\s*(\d+)/);
29
+ if (!m) return { satisfied: true, note: `unrecognized range "${range}"` };
30
+ const major = Number.parseInt(actual.split('.')[0], 10);
31
+ return { satisfied: major >= Number(m[1]), note: `requires Node >= ${m[1]}, found ${actual}` };
32
+ }
33
+
34
+ /**
35
+ * `citable compatibility` — pre-flight diagnosis of environment, optional
36
+ * adapters, project framework, registry freshness, and generated-code
37
+ * constraints. Every check states its evidence; absence of an optional
38
+ * adapter is reported, never assumed.
39
+ */
40
+ export async function compatibilityCommand(root, options = {}) {
41
+ const checks = [];
42
+
43
+ const engine = engineSatisfied(PKG.engines?.node || '>=0', process.versions.node);
44
+ checks.push({
45
+ check_id: 'NODE-ENGINE',
46
+ passed: engine.satisfied,
47
+ severity: engine.satisfied ? 'pass' : 'blocker',
48
+ detail: engine.note,
49
+ });
50
+
51
+ const peers = ['playwright', 'lighthouse', 'chrome-launcher', 'tesseract.js'];
52
+ const adapterResults = {};
53
+ for (const name of peers) {
54
+ const declared = Boolean(PKG.peerDependencies?.[name]);
55
+ const res = checkModule(name);
56
+ adapterResults[name] = { declared_optional_peer: declared, ...res };
57
+ checks.push({
58
+ check_id: `ADAPTER-${name.toUpperCase().replace('.', '-')}`,
59
+ passed: true, // absence of an optional adapter is a report, not a failure
60
+ severity: res.importable ? 'pass' : 'advisory',
61
+ detail: res.importable ? `${name} importable` : `${name}: ${res.note}`,
62
+ });
63
+ }
64
+
65
+ // Browser dependency: presence of a Chrome executable is only verified when
66
+ // chrome-launcher is importable; otherwise reported as unknown (fail open in
67
+ // reporting, fail closed in any feature that needs it).
68
+ if (adapterResults['chrome-launcher']?.importable) {
69
+ let browser = 'unknown';
70
+ try {
71
+ const mod = await import('chrome-launcher');
72
+ browser = mod.getChromePath?.() ? 'found' : 'not found';
73
+ } catch { browser = 'lookup failed'; }
74
+ checks.push({
75
+ check_id: 'BROWSER-CHROME',
76
+ passed: browser === 'found',
77
+ severity: browser === 'found' ? 'pass' : 'advisory',
78
+ detail: `Chrome executable: ${browser} (required for observe render / preview evidence; presence does not prove a successful launch)`,
79
+ });
80
+ }
81
+
82
+ // Project framework detection (for remediation format matching)
83
+ let frameworks = [];
84
+ const pkgFile = path.join(root, 'package.json');
85
+ if (fs.existsSync(pkgFile)) {
86
+ try {
87
+ const deps = { ...readJson(pkgFile).dependencies, ...readJson(pkgFile).devDependencies };
88
+ if (deps.next) frameworks.push('next');
89
+ if (deps.react) frameworks.push('react');
90
+ if (deps.vue || deps.nuxt) frameworks.push('vue');
91
+ if (deps.svelte || deps['@sveltejs/kit']) frameworks.push('svelte');
92
+ } catch { /* unreadable package.json reported below */ }
93
+ }
94
+ checks.push({
95
+ check_id: 'PROJECT-FRAMEWORK',
96
+ passed: true,
97
+ severity: 'pass',
98
+ detail: frameworks.length ? `detected: ${frameworks.join(', ')}; remediation templates available: react, vue, html` : 'no framework dependencies detected; component templates remain available as standalone HTML',
99
+ });
100
+
101
+ // Registry freshness
102
+ const citableDir = path.join(root, '.citable');
103
+ if (!fs.existsSync(citableDir)) {
104
+ checks.push({ check_id: 'REGISTRIES-PRESENT', passed: false, severity: 'advisory', detail: '.citable/ not found — run citable init' });
105
+ } else {
106
+ const yamlFiles = fs.readdirSync(citableDir).filter((f) => f.endsWith('.yaml'));
107
+ checks.push({ check_id: 'REGISTRIES-PRESENT', passed: true, severity: 'pass', detail: `${yamlFiles.length} registry/config YAML file(s) found; run citable validate registries for full referential validation` });
108
+ const registrySchemas = fs.readdirSync(SCHEMA_DIR).filter((f) => f.endsWith('.schema.json'));
109
+ checks.push({
110
+ check_id: 'SCHEMA-CONTRACTS',
111
+ passed: true,
112
+ severity: 'pass',
113
+ detail: `${registrySchemas.length} schema contracts shipped by citable ${PKG.version}; registries validated against them by citable validate registries`,
114
+ });
115
+ }
116
+
117
+ // Generated-code constraints (advisory, documented limits)
118
+ const edgeDir = path.join(root, '.citable', 'edge');
119
+ if (fs.existsSync(edgeDir)) {
120
+ for (const f of fs.readdirSync(edgeDir)) {
121
+ const size = fs.statSync(path.join(edgeDir, f)).size;
122
+ const limit = 1024 * 1024;
123
+ checks.push({
124
+ check_id: `EDGE-SIZE-${f}`,
125
+ passed: size <= limit,
126
+ severity: size <= limit ? 'pass' : 'blocker',
127
+ detail: `${f}: ${(size / 1024).toFixed(1)} KiB — Cloudflare Workers free plan limits the worker to ~1 MiB before compression (verify against your plan; this is a documented platform limit, not a measurement)`,
128
+ });
129
+ }
130
+ }
131
+
132
+ const blockers = checks.filter((c) => c.severity === 'blocker' && !c.passed);
133
+ return {
134
+ ok: blockers.length === 0,
135
+ timestamp: nowIso(),
136
+ tool_version: PKG.version,
137
+ node: process.versions.node,
138
+ engines: PKG.engines?.node || null,
139
+ framework: frameworks,
140
+ adapters: adapterResults,
141
+ checks,
142
+ blocker_count: blockers.length,
143
+ note: 'compatibility reports current environment facts; it cannot prove that a browser launch, API authorization, or collection will succeed',
144
+ };
145
+ }
146
+
147
+ /**
148
+ * `citable verify page <page>` — run the full deterministic detector set
149
+ * scoped to one page subject and return a posture verdict.
150
+ * pass = no detector currently reports this page; never an outcome guarantee.
151
+ */
152
+ export async function verifyPage(root, pageRef, { target, baseUrl, refDate, viewport = null } = {}) {
153
+ const ctx = await buildContext(root, { target, baseUrl, refDate, viewport });
154
+ if (!ctx.site) throw new Error('verify page requires a site target (built output directory or URL)');
155
+ const norm = (s) => String(s || '').replace(/\/+$/, '');
156
+ const candidates = [pageRef, pageRef.replace(/\/index\.html?$/i, '/'), pageRef.replace(/^\/+/, '')];
157
+ const wanted = pageRef.startsWith('/') ? `${norm(ctx.site.baseUrl)}${pageRef}` : pageRef;
158
+ const page = ctx.site.pages.find((p) => {
159
+ if (norm(p.url) === norm(wanted) || p.sourceFile === pageRef) return true;
160
+ if (candidates.some((c) => norm(p.url) === norm(pageRef.startsWith('/') ? `${norm(ctx.site.baseUrl)}${c}` : c))) return true;
161
+ if (norm(p.url).endsWith(norm(wanted))) return true;
162
+ return false;
163
+ });
164
+ if (!page) {
165
+ throw new Error(`page not found in audited output: ${pageRef}`);
166
+ }
167
+
168
+ const { findings, detectorsRun, detectorsSkipped, errors } = runDetectors(
169
+ selectDetectors({ namespaces: ALL_DETECTORS.map((d) => d.namespace).filter((v, i, a) => a.indexOf(v) === i) }),
170
+ { ...ctx, site: { ...ctx.site, pages: [page] } }
171
+ );
172
+ const pageFindings = findings.filter((f) => (f.subject?.url === page.url) || (f.subject?.identifier === page.url) || (f.subject?.source_file && f.subject.source_file === page.sourceFile));
173
+
174
+ const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, informational: 0 };
175
+ for (const f of pageFindings) {
176
+ const s = f.classification?.severity;
177
+ if (s in bySeverity) bySeverity[s]++;
178
+ }
179
+ const status = errors.length ? 'blocked' : (bySeverity.critical > 0 || bySeverity.high > 0) ? 'attention' : pageFindings.length ? 'attention' : 'pass';
180
+
181
+ return {
182
+ page: page.url,
183
+ source_file: page.sourceFile,
184
+ status,
185
+ finding_count: pageFindings.length,
186
+ by_severity: bySeverity,
187
+ findings: pageFindings.map((f) => ({
188
+ detector_id: f.detector_id,
189
+ severity: f.classification.severity,
190
+ confidence: f.classification.confidence,
191
+ summary: f.observation.summary,
192
+ provenance: f.provenance,
193
+ })),
194
+ provenance: {
195
+ command: 'verify page',
196
+ timestamp: nowIso(),
197
+ tool_version: PKG.version,
198
+ viewport: ctx.viewport ?? null,
199
+ detectors_run: detectorsRun,
200
+ detectors_skipped: detectorsSkipped,
201
+ errors,
202
+ definition: 'pass means no detector currently reports this page; it is not a guarantee of crawling, indexing, ranking, citation, or conversion',
203
+ },
204
+ };
205
+ }
@@ -5,7 +5,7 @@ import { loadRegistries } from '../registries/index.js';
5
5
  import { buildSiteFromDir, buildSiteFromUrl } from '../extractor/site.js';
6
6
 
7
7
  /** Assemble the shared execution context used by audit-family commands. */
8
- export async function buildContext(root, { target, baseUrl, refDate } = {}) {
8
+ export async function buildContext(root, { target, baseUrl, refDate, viewport = null } = {}) {
9
9
  const warnings = [];
10
10
  const configFile = path.join(root, '.citable', 'config.yaml');
11
11
  const config = fs.existsSync(configFile) ? readYaml(configFile) : { version: 1 };
@@ -71,6 +71,7 @@ export async function buildContext(root, { target, baseUrl, refDate } = {}) {
71
71
  observations: observations.length ? observations : null,
72
72
  snapshots,
73
73
  refDate: parseRefDate(refDate),
74
+ viewport,
74
75
  hashPage: (p) => sha256(p.text),
75
76
  warnings,
76
77
  };
@@ -1,11 +1,14 @@
1
1
  import path from "node:path";
2
+ import fs from "node:fs";
2
3
  import { loadRegistries } from "../registries/index.js";
4
+ import { auditEdgeCode } from "./edgeSecurity.js";
3
5
 
4
6
  /**
5
7
  * Generates edge rules (redirects, Cloudflare WAF expressions, or Worker scripts)
6
8
  * from Citable registries.
7
9
  */
8
- export async function exportEdgeRules(root, { format = "cloudflare-redirects", output = null } = {}) {
10
+ export async function exportEdgeRules(root, options = {}) {
11
+ const { format = "cloudflare-redirects", output = null, remediation = null } = options;
9
12
  const { registries } = loadRegistries(root);
10
13
  const crawlers = registries?.crawlers?.entries || [];
11
14
  const pages = registries?.pages?.entries || [];
@@ -32,8 +35,42 @@ export async function exportEdgeRules(root, { format = "cloudflare-redirects", o
32
35
  } else {
33
36
  content = "# Cloudflare Custom WAF Expression\n# No crawlers marked as blocked in crawlers.yaml\n";
34
37
  }
35
- } else if (format === "cloudflare-worker") {
36
- content = `// Cloudflare Worker generated by Citable Edge Adapter
38
+ } else if (format === "cloudflare-worker" || format === "cloudflare-cro" || (format === "cloudflare-worker" && options.remediation === "cro")) {
39
+ if (format === "cloudflare-cro" || options.remediation === "cro") {
40
+ content = `// Cloudflare Worker with HTMLRewriter CRO Remediation generated by Citable
41
+ export default {
42
+ async fetch(request, env, ctx) {
43
+ const url = new URL(request.url);
44
+ const response = await fetch(request);
45
+ const contentType = response.headers.get("content-type") || "";
46
+ if (!contentType.includes("text/html")) return response;
47
+
48
+ const rewriter = new HTMLRewriter()
49
+ // Remediate CRO-007: Inject W3C autocomplete tokens
50
+ .on("input[name*='name']", { element(e) { if (!e.hasAttribute("autocomplete")) e.setAttribute("autocomplete", "name"); } })
51
+ .on("input[type='email'], input[name*='email']", { element(e) { if (!e.hasAttribute("autocomplete")) e.setAttribute("autocomplete", "email"); } })
52
+ .on("input[type='tel'], input[name*='phone']", { element(e) { if (!e.hasAttribute("autocomplete")) e.setAttribute("autocomplete", "tel"); } })
53
+ // Remediate CRO-015: Enforce 48px min touch target
54
+ .on("button, a.cta-button", { element(e) {
55
+ const style = e.getAttribute("style") || "";
56
+ if (!style.includes("min-height")) e.setAttribute("style", style + "; min-height: 48px; min-width: 48px; touch-action: manipulation;");
57
+ }})
58
+ // Remediate CRO-014: Ensure noindex on confirmation receipt pages
59
+ .on("head", { element(e) {
60
+ if (/\\/(confirmation|receipt|order-complete|success)/i.test(url.pathname)) {
61
+ e.append('<meta name="robots" content="noindex, nofollow" />', { html: true });
62
+ }
63
+ }})
64
+ // Remediate CRO-011: Inject sticky mobile CTA dock before body close
65
+ .on("body", { element(e) {
66
+ e.append('<aside id="nebula-sticky-dock" style="display:none;" class="sm:hidden fixed bottom-0 left-0 right-0 z-50 p-3 bg-white border-t"><a href="/signup" class="block w-full text-center py-3 bg-blue-600 text-white font-bold rounded-lg">Get Started</a></aside>', { html: true });
67
+ }});
68
+
69
+ return rewriter.transform(response);
70
+ },
71
+ };\n`;
72
+ } else {
73
+ content = `// Cloudflare Worker generated by Citable Edge Adapter
37
74
  export default {
38
75
  async fetch(request, env, ctx) {
39
76
  const url = new URL(request.url);
@@ -58,8 +95,46 @@ export default {
58
95
  });
59
96
  },
60
97
  };\n`;
98
+ }
99
+ } else if (format === "vercel-middleware") {
100
+ content = `// Vercel Edge Middleware generated by Citable
101
+ import { NextResponse } from 'next/server';
102
+
103
+ export function middleware(request) {
104
+ const url = request.nextUrl;
105
+ const response = NextResponse.next();
106
+
107
+ // Enforce security headers & canonical routing
108
+ response.headers.set('X-Content-Type-Options', 'nosniff');
109
+ if (url.pathname === '/llms.txt') {
110
+ return NextResponse.rewrite(new URL('/resources/citable/llms.txt', request.url));
111
+ }
112
+ return response;
113
+ }
114
+
115
+ export const config = {
116
+ matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
117
+ };\n`;
118
+ } else if (format === "shopify-snippet") {
119
+ content = `{% comment %}
120
+ Nebula Components CRO Remediation Snippet for Shopify
121
+ Add {% render 'nebula-cro-remediation' %} to theme.liquid before </body>
122
+ {% endcomment %}
123
+ <script>
124
+ (function() {
125
+ document.addEventListener('DOMContentLoaded', function() {
126
+ // CRO-007: Inject autocomplete attributes
127
+ document.querySelectorAll('input[name*="name"]').forEach(function(i) { if (!i.autocomplete) i.autocomplete = 'name'; });
128
+ document.querySelectorAll('input[type="email"], input[name*="email"]').forEach(function(i) { if (!i.autocomplete) i.autocomplete = 'email'; });
129
+ // CRO-015: Enforce 48px touch targets
130
+ document.querySelectorAll('button[type="submit"], .btn').forEach(function(b) {
131
+ if (b.offsetHeight < 44) b.style.minHeight = '48px';
132
+ });
133
+ });
134
+ })();
135
+ </script>\n`;
61
136
  } else {
62
- throw new Error(`unsupported edge format "${format}". Supported: cloudflare-redirects, cloudflare-waf, cloudflare-worker`);
137
+ throw new Error(`unsupported edge format "${format}". Supported: cloudflare-redirects, cloudflare-waf, cloudflare-worker, cloudflare-cro, vercel-middleware, shopify-snippet`);
63
138
  }
64
139
 
65
140
  if (output) {
@@ -71,6 +146,7 @@ export default {
71
146
  output_path: output,
72
147
  content,
73
148
  rules_count: content.split("\n").filter((l) => l && !l.startsWith("#")).length,
149
+ security: auditEdgeCode(content, { format, name: `edge-export:${format}` }),
74
150
  };
75
151
  }
76
152
 
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Security audit for generated and user-supplied edge code.
3
+ *
4
+ * Findings are deterministic pattern checks over the code text. A clean
5
+ * result means no listed risk pattern was found; it is not a substitute for
6
+ * human security review of deployed code.
7
+ */
8
+
9
+ const WORKER_SIZE_LIMIT_BYTES = 1024 * 1024; // Cloudflare Workers ~1 MiB before compression (documented platform limit)
10
+
11
+ const RULES = [
12
+ {
13
+ rule_id: 'EDGSEC-001',
14
+ name: 'Inline script requires CSP unsafe-inline',
15
+ applies: ['shopify-snippet', 'html'],
16
+ check(content) {
17
+ const inline = /<script(?![^>]*\ssrc\s*=)[^>]*>/i.test(content);
18
+ if (!inline) return null;
19
+ return {
20
+ severity: 'advisory',
21
+ summary: 'Generated code contains an inline <script> block; sites with a strict Content-Security-Policy will block it unless a nonce or hash is added.',
22
+ remediation: 'Prefer an external script file, or add the script hash/nonce to the CSP. Record the CSP implication in deployment notes.',
23
+ };
24
+ },
25
+ },
26
+ {
27
+ rule_id: 'EDGSEC-002',
28
+ name: 'HTMLRewriter html-mode append/setAttribute with dynamic content',
29
+ applies: ['cloudflare-worker', 'cloudflare-cro'],
30
+ check(content) {
31
+ const dynamicAppend = /\.append\(\s*(?!['"`]\s*[<A-Za-z])[^,)]*?,\s*\{\s*html:\s*true/i.test(content)
32
+ || /\.append\(\s*[A-Za-z_$][\w$.\[\]]*\s*,\s*\{\s*html:\s*true/i.test(content);
33
+ if (!dynamicAppend) return null;
34
+ return {
35
+ severity: 'high',
36
+ summary: 'HTMLRewriter append uses html:true with a non-literal argument; untrusted data reaching this sink becomes HTML injection.',
37
+ remediation: 'Only append static string literals with html:true, or escape all interpolated values with a strict HTML escaper before insertion.',
38
+ };
39
+ },
40
+ },
41
+ {
42
+ rule_id: 'EDGSEC-003',
43
+ name: 'Unescaped interpolation into HTML or script context',
44
+ applies: ['cloudflare-worker', 'cloudflare-cro', 'vercel-middleware', 'shopify-snippet', 'html'],
45
+ check(content, { format }) {
46
+ const problems = [];
47
+ // JS template literals inside generated HTML strings
48
+ const htmlTemplateLiteral = /`[^`]*<(?:div|span|aside|a|p|script)[^>]*>\$\{[^}]*\}/i.test(content);
49
+ if (htmlTemplateLiteral) problems.push('template literal interpolated into HTML markup');
50
+ // Liquid object output without escape filter inside script blocks (script context never auto-escapes)
51
+ if (format === 'shopify-snippet') {
52
+ const scripts = content.match(/<script\b[^>]*>[\s\S]*?<\/script[^>]*>/gi) || [];
53
+ for (const block of scripts) {
54
+ for (const m of block.matchAll(/\{\{[^}]*\}\}/g)) {
55
+ if (!/\|\s*escape|json/.test(m[0])) problems.push(`Liquid output ${m[0]} inside <script> without escape filter`);
56
+ }
57
+ }
58
+ // also Liquid anywhere with raw/unsafe filters
59
+ for (const m of content.matchAll(/\{\{[^}]*(?:\|\s*raw|\|\s*strip_html)[^}]*\}\}/g)) {
60
+ problems.push(`Liquid output ${m[0]} uses a filter that disables escaping`);
61
+ }
62
+ }
63
+ if (problems.length === 0) return null;
64
+ return {
65
+ severity: 'high',
66
+ summary: `Unescaped dynamic interpolation in output context: ${problems.join('; ')}`,
67
+ remediation: 'Escape all dynamic values for the exact output context (HTML body, attribute, or JS string) or use static literals only.',
68
+ };
69
+ },
70
+ },
71
+ {
72
+ rule_id: 'EDGSEC-004',
73
+ name: 'Untrusted referral data written into the DOM',
74
+ applies: ['cloudflare-worker', 'cloudflare-cro', 'vercel-middleware', 'shopify-snippet', 'html'],
75
+ check(content) {
76
+ const readsReferral = /document\.referrer|location\.hash|searchParams\.get|URLSearchParams|\?~:text=:~:text/i.test(content)
77
+ || /:~:text=/i.test(content);
78
+ const dangerousSink = /innerHTML\s*=|document\.write\s*\(|insertAdjacentHTML\s*\(/.test(content);
79
+ if (readsReferral && dangerousSink) {
80
+ return {
81
+ severity: 'high',
82
+ summary: 'Code reads user-controlled referral data (referrer, URL hash, or text fragment) and also writes to HTML-parsing DOM sinks; referral fragments are attacker-controllable input.',
83
+ remediation: 'Render referral-derived values with textContent/createTextNode only, or never write them into the DOM. Treat referral fragments as untrusted data.',
84
+ };
85
+ }
86
+ if (readsReferral) {
87
+ return {
88
+ severity: 'advisory',
89
+ summary: 'Code reads user-controlled referral data (referrer or text fragment). Confirmed safe sinks in the current template, but any future change must keep this data out of HTML-parsing sinks and must not persist or transmit it without a documented consent basis.',
90
+ remediation: 'Keep referral fragments out of innerHTML/document.write; do not log, store, or transmit referral content; label analytics derived from it as untrusted input.',
91
+ };
92
+ }
93
+ return null;
94
+ },
95
+ },
96
+ {
97
+ rule_id: 'EDGSEC-005',
98
+ name: 'Potential PII collected by telemetry',
99
+ applies: ['cloudflare-worker', 'cloudflare-cro', 'vercel-middleware', 'shopify-snippet', 'html'],
100
+ check(content) {
101
+ // PII matters when used as data (object keys, assignments, values), not
102
+ // when it appears inside autocomplete/CSS selector hints.
103
+ const patterns = [
104
+ /\b(email|e_mail|fullname|first_name|last_name|phone|address|postal_code|postcode|ip_address|user_agent|birthdate|date_of_birth)\b\s*[:=]/gi,
105
+ /[:\{,]\s*["'](email|e_mail|fullname|first_name|last_name|phone|address|postal_code|postcode|ip_address|user_agent|birthdate|date_of_birth)["']\s*:/gi,
106
+ ];
107
+ const found = [];
108
+ for (const rx of patterns) {
109
+ for (const m of content.matchAll(rx)) found.push(m[0].trim());
110
+ }
111
+ if (found.length === 0) return null;
112
+ const unique = [...new Set(found)];
113
+ return {
114
+ severity: 'high',
115
+ summary: `Telemetry or edge code references potential PII fields (${unique.join(', ')}); accidental collection creates GDPR/CCPA exposure.`,
116
+ remediation: 'Remove PII from generated telemetry payloads; collect only aggregate experiment identifiers with a documented consent basis.',
117
+ };
118
+ },
119
+ },
120
+ {
121
+ rule_id: 'EDGSEC-006',
122
+ name: 'Per-request response variance without cache guidance',
123
+ applies: ['cloudflare-worker', 'cloudflare-cro', 'vercel-middleware'],
124
+ check(content) {
125
+ const variesOnRequest = /request\.headers\.get\(|cookies|cf-connecting-ip/i.test(content);
126
+ const setsCacheControl = /cache-control/i.test(content);
127
+ if (variesOnRequest && !setsCacheControl) {
128
+ return {
129
+ severity: 'medium',
130
+ summary: 'Edge code varies behavior per request (headers/cookies/IP) without setting explicit Cache-Control guidance; a shared cache may serve one visitor’s personalized response to others (cache poisoning of personalized variants).',
131
+ remediation: 'Add explicit Cache-Control (e.g. private, no-store for personalized responses) or move variance behind cacheable segmentation.',
132
+ };
133
+ }
134
+ return null;
135
+ },
136
+ },
137
+ {
138
+ rule_id: 'EDGSEC-007',
139
+ name: 'Worker size above documented platform limit',
140
+ applies: ['cloudflare-worker', 'cloudflare-cro'],
141
+ check(content) {
142
+ const size = Buffer.byteLength(content, 'utf8');
143
+ if (size <= WORKER_SIZE_LIMIT_BYTES) return null;
144
+ return {
145
+ severity: 'medium',
146
+ summary: `Generated worker is ${(size / 1024).toFixed(1)} KiB; Cloudflare Workers limit scripts to ~1 MiB before compression on many plans (documented platform limit, not a measurement).`,
147
+ remediation: 'Split the worker or strip unused remediations before deployment.',
148
+ };
149
+ },
150
+ },
151
+ ];
152
+
153
+ /**
154
+ * Audit one edge/snippet code string. Returns findings with full provenance
155
+ * fields so results are citable evidence, not terminal commentary.
156
+ */
157
+ export function auditEdgeCode(rawContent, { format = 'html', name = 'generated-edge-code', toolVersion = null, rules = RULES } = {}) {
158
+ const content = typeof rawContent === 'string' ? rawContent : String(rawContent);
159
+ const findings = [];
160
+ for (const rule of rules) {
161
+ if (rule.applies.length && !rule.applies.includes(format)) continue;
162
+ let hit = null;
163
+ try {
164
+ hit = rule.check(content, { format });
165
+ } catch (err) {
166
+ findings.push({
167
+ rule_id: `${rule.rule_id}-ERROR`,
168
+ severity: 'high',
169
+ summary: `security rule error: ${err.message}`,
170
+ remediation: 'Fix the audit rule before trusting a clean result (fail closed).',
171
+ });
172
+ continue;
173
+ }
174
+ if (hit) {
175
+ findings.push({ rule_id: rule.rule_id, rule_name: rule.name, ...hit });
176
+ }
177
+ }
178
+ const severityRank = { high: 0, medium: 1, advisory: 2 };
179
+ findings.sort((a, b) => severityRank[a.severity] - severityRank[b.severity]);
180
+ return {
181
+ target: name,
182
+ format,
183
+ byte_size: Buffer.byteLength(content, 'utf8'),
184
+ finding_count: findings.length,
185
+ high: findings.filter((f) => f.severity === 'high').length,
186
+ medium: findings.filter((f) => f.severity === 'medium').length,
187
+ advisory: findings.filter((f) => f.severity === 'advisory').length,
188
+ findings,
189
+ methodology: 'deterministic pattern audit of generated code; a clean result is not a substitute for human security review',
190
+ tool_version: toolVersion,
191
+ };
192
+ }