@nebulacomponents/citable 1.15.1 → 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.
- package/CHANGELOG.md +50 -0
- package/README.md +18 -5
- package/dist/universal/.agents/skills/citable/SKILL.md +51 -1
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/commands/README.md +3 -1
- package/dist/universal/.agents/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.agents/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.agents/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.agents/skills/citable/manifest.json +17 -12
- package/dist/universal/.agents/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.agents/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.agents/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.agents/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.agents/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.agents/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.agents/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.agents/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.claude/agents/citable/manifest.json +2 -2
- package/dist/universal/.claude/skills/citable/SKILL.md +51 -1
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/commands/README.md +3 -1
- package/dist/universal/.claude/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.claude/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.claude/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.claude/skills/citable/manifest.json +17 -12
- package/dist/universal/.claude/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.claude/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.claude/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.claude/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.claude/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.claude/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.claude/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.claude/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.cursor/skills/citable/SKILL.md +51 -1
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/commands/README.md +3 -1
- package/dist/universal/.cursor/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.cursor/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.cursor/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.cursor/skills/citable/manifest.json +17 -12
- package/dist/universal/.cursor/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.cursor/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.cursor/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.cursor/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.cursor/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.cursor/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.cursor/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.cursor/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.gemini/skills/citable/SKILL.md +51 -1
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/commands/README.md +3 -1
- package/dist/universal/.gemini/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.gemini/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.gemini/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.gemini/skills/citable/manifest.json +17 -12
- package/dist/universal/.gemini/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.gemini/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.gemini/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.gemini/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.gemini/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.gemini/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.gemini/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.gemini/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.github/skills/citable/SKILL.md +51 -1
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/commands/README.md +3 -1
- package/dist/universal/.github/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.github/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.github/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.github/skills/citable/manifest.json +17 -12
- package/dist/universal/.github/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.github/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.github/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.github/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.github/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.github/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.github/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.github/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.kiro/skills/citable/SKILL.md +51 -1
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/commands/README.md +3 -1
- package/dist/universal/.kiro/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.kiro/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.kiro/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.kiro/skills/citable/manifest.json +17 -12
- package/dist/universal/.kiro/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.kiro/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.kiro/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.kiro/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.kiro/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.kiro/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.kiro/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.kiro/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.opencode/skills/citable/SKILL.md +51 -1
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/commands/README.md +3 -1
- package/dist/universal/.opencode/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.opencode/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.opencode/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.opencode/skills/citable/manifest.json +17 -12
- package/dist/universal/.opencode/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.opencode/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.opencode/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.opencode/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.opencode/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.opencode/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.opencode/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.opencode/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.pi/agent/skills/citable/SKILL.md +51 -1
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/commands/README.md +3 -1
- package/dist/universal/.pi/agent/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.pi/agent/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.pi/agent/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.pi/agent/skills/citable/manifest.json +17 -12
- package/dist/universal/.pi/agent/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.pi/agent/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.pi/agent/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.qoder/skills/citable/SKILL.md +51 -1
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/commands/README.md +3 -1
- package/dist/universal/.qoder/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.qoder/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.qoder/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.qoder/skills/citable/manifest.json +17 -12
- package/dist/universal/.qoder/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.qoder/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.qoder/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.qoder/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.qoder/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.qoder/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.qoder/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.qoder/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.rovodev/skills/citable/SKILL.md +51 -1
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/commands/README.md +3 -1
- package/dist/universal/.rovodev/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.rovodev/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.rovodev/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.rovodev/skills/citable/manifest.json +17 -12
- package/dist/universal/.rovodev/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.rovodev/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.rovodev/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.rovodev/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.rovodev/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.rovodev/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.rovodev/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.rovodev/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.trae/skills/citable/SKILL.md +51 -1
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/commands/README.md +3 -1
- package/dist/universal/.trae/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.trae/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.trae/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.trae/skills/citable/manifest.json +17 -12
- package/dist/universal/.trae/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.trae/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.trae/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.trae/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.trae/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.trae/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.trae/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.trae/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.trae-cn/skills/citable/SKILL.md +51 -1
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/commands/README.md +3 -1
- package/dist/universal/.trae-cn/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.trae-cn/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.trae-cn/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.trae-cn/skills/citable/manifest.json +17 -12
- package/dist/universal/.trae-cn/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.trae-cn/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.trae-cn/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/manifest.json +208 -148
- package/package.json +1 -1
- package/schemas/cli-output-envelope.schema.json +30 -0
- package/schemas/experiment.schema.json +1 -1
- package/schemas/finding.schema.json +69 -0
- package/schemas/fleet.schema.json +93 -0
- package/schemas/funnel.schema.json +116 -0
- package/schemas/mcp-transport-envelope.schema.json +244 -0
- package/schemas/observation.schema.json +1 -1
- package/schemas/remediation-verification.schema.json +98 -0
- package/skill/SKILL.md +51 -1
- package/skill/commands/README.md +3 -1
- package/skill/commands/governance.md +12 -0
- package/skill/commands/measurement.md +20 -0
- package/skill/commands/reporting.md +16 -0
- package/src/analysis/saliency.js +119 -0
- package/src/cli/index.js +651 -13
- package/src/commands/attributeImpact.js +54 -0
- package/src/commands/audit.js +2 -2
- package/src/commands/ciWorkflow.js +63 -0
- package/src/commands/compatibility.js +205 -0
- package/src/commands/connect.js +3 -0
- package/src/commands/context.js +2 -1
- package/src/commands/edgeRules.js +196 -0
- package/src/commands/edgeSecurity.js +192 -0
- package/src/commands/exceptions.js +241 -0
- package/src/commands/experimentGuardrails.js +213 -0
- package/src/commands/fleet.js +104 -0
- package/src/commands/generateLlmsTxt.js +111 -0
- package/src/commands/goldenCorpus.js +155 -0
- package/src/commands/implementationKit.js +202 -0
- package/src/commands/inspectAeo.js +74 -0
- package/src/commands/inspectCro.js +142 -0
- package/src/commands/inspectGeo.js +52 -0
- package/src/commands/inspectSerp.js +317 -0
- package/src/commands/lintComponents.js +132 -0
- package/src/commands/monitor.js +138 -0
- package/src/commands/observe.js +6 -1
- package/src/commands/planExperiment.js +120 -0
- package/src/commands/previewCro.js +241 -0
- package/src/commands/probe.js +70 -0
- package/src/commands/remediate.js +461 -0
- package/src/commands/reportConsensus.js +48 -0
- package/src/commands/testFunnel.js +107 -0
- package/src/commands/verifyRemediation.js +204 -0
- package/src/commands/visualRegression.js +165 -0
- package/src/components/index.js +556 -0
- package/src/connectors/index.js +6 -1
- package/src/connectors/indexnow.js +431 -0
- package/src/connectors/mcp/allowlist.js +107 -0
- package/src/connectors/mcp/client.js +239 -0
- package/src/connectors/mcp/diagnostics.js +214 -0
- package/src/connectors/mcp/gscServer.js +155 -0
- package/src/connectors/mcp/http.js +130 -0
- package/src/connectors/mcp/index.js +7 -0
- package/src/connectors/mcp/pilot.js +138 -0
- package/src/connectors/mcp/pilotServer.js +80 -0
- package/src/connectors/mcp/sanitizer.js +38 -0
- package/src/connectors/mcp/stdio.js +178 -0
- package/src/crawler/ipRanges.js +161 -0
- package/src/detectors/agent.js +127 -0
- package/src/detectors/ans.js +282 -0
- package/src/detectors/crawl.js +46 -0
- package/src/detectors/cro.js +825 -0
- package/src/detectors/cwv.js +38 -1
- package/src/detectors/evd.js +101 -0
- package/src/detectors/framework.js +32 -2
- package/src/detectors/geoReco.js +408 -0
- package/src/detectors/index.js +4 -2
- package/src/detectors/link.js +112 -1
- package/src/detectors/page.js +101 -0
- package/src/detectors/schemaData.js +529 -0
- package/src/detectors/tech.js +306 -0
- package/src/extractor/page.js +186 -2
- package/src/extractor/site.js +9 -2
- package/src/installer/index.js +45 -2
- package/src/monitoring/alertDelivery.js +101 -7
- package/src/observations/attribution.js +217 -0
- package/src/registries/index.js +14 -0
- package/src/reporting/consensus.js +416 -0
- package/src/reporting/executiveExport.js +244 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governed Exception Lifecycle & Expiry Review.
|
|
3
|
+
*
|
|
4
|
+
* Provides inspection, renewal, and revocation of entries in .citable/exceptions.yaml
|
|
5
|
+
* under strict schema, reviewer authority, and audit trail rules.
|
|
6
|
+
*
|
|
7
|
+
* Governed Invariants:
|
|
8
|
+
* - Expired exceptions fail closed and are surfaced explicitly.
|
|
9
|
+
* - Renewals require an active, authorized reviewer and cannot exceed policy or renewal limits.
|
|
10
|
+
* - Revocations and renewals append immutable audit history events.
|
|
11
|
+
* - Candidate registries are validated against exception.schema.json before saving.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { loadRegistries, saveRegistry } from "../registries/index.js";
|
|
15
|
+
import { validateAgainst } from "../shared/schemaValidator.js";
|
|
16
|
+
import { parseRefDate, sha256 } from "../shared/io.js";
|
|
17
|
+
|
|
18
|
+
export function listExceptions(root, {
|
|
19
|
+
expiredOnly = false,
|
|
20
|
+
expiringSoonDays = 14,
|
|
21
|
+
refDate = null,
|
|
22
|
+
} = {}) {
|
|
23
|
+
const { registries, problems } = loadRegistries(root);
|
|
24
|
+
if (problems.length) throw new Error(`registry validation failed: ${problems.join("; ")}`);
|
|
25
|
+
|
|
26
|
+
const ref = parseRefDate(refDate);
|
|
27
|
+
const entries = registries.exceptions?.entries || [];
|
|
28
|
+
|
|
29
|
+
const evaluated = entries.map((entry) => {
|
|
30
|
+
const expiresAt = new Date(entry.expires_at);
|
|
31
|
+
const isExpired = expiresAt < ref;
|
|
32
|
+
const daysRemaining = Math.ceil((expiresAt.getTime() - ref.getTime()) / 86400000);
|
|
33
|
+
const isExpiringSoon = !isExpired && daysRemaining <= Number(expiringSoonDays) && daysRemaining >= 0;
|
|
34
|
+
|
|
35
|
+
let computedStatus = entry.status;
|
|
36
|
+
if (entry.status === "revoked") computedStatus = "revoked";
|
|
37
|
+
else if (entry.status === "superseded") computedStatus = "superseded";
|
|
38
|
+
else if (isExpired) computedStatus = "expired";
|
|
39
|
+
else if (isExpiringSoon) computedStatus = "expiring_soon";
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
exception_id: entry.exception_id,
|
|
43
|
+
policy_id: entry.policy_id,
|
|
44
|
+
status: entry.status,
|
|
45
|
+
computed_status: computedStatus,
|
|
46
|
+
is_expired: isExpired,
|
|
47
|
+
is_expiring_soon: isExpiringSoon,
|
|
48
|
+
days_remaining: daysRemaining,
|
|
49
|
+
created_at: entry.created_at,
|
|
50
|
+
expires_at: entry.expires_at,
|
|
51
|
+
renewal_count: entry.renewal_count,
|
|
52
|
+
renewal_limit: entry.renewal_limit,
|
|
53
|
+
owner_reviewer_id: entry.owner_reviewer_id,
|
|
54
|
+
reason: entry.reason,
|
|
55
|
+
finding_ids: entry.finding_ids,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const filtered = expiredOnly
|
|
60
|
+
? evaluated.filter((e) => e.is_expired)
|
|
61
|
+
: evaluated;
|
|
62
|
+
|
|
63
|
+
const summary = {
|
|
64
|
+
total: evaluated.length,
|
|
65
|
+
active: evaluated.filter((e) => e.computed_status === "approved").length,
|
|
66
|
+
expiring_soon: evaluated.filter((e) => e.is_expiring_soon).length,
|
|
67
|
+
expired: evaluated.filter((e) => e.is_expired).length,
|
|
68
|
+
revoked: evaluated.filter((e) => e.status === "revoked").length,
|
|
69
|
+
superseded: evaluated.filter((e) => e.status === "superseded").length,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
exceptions: filtered,
|
|
74
|
+
summary,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function renewException(root, {
|
|
79
|
+
id,
|
|
80
|
+
until,
|
|
81
|
+
reviewer,
|
|
82
|
+
evidence = null,
|
|
83
|
+
note = null,
|
|
84
|
+
write = false,
|
|
85
|
+
refDate = null,
|
|
86
|
+
} = {}) {
|
|
87
|
+
if (!id) throw new Error("--id <exception-id> is required to renew an exception");
|
|
88
|
+
if (!until) throw new Error("--until <YYYY-MM-DD> is required to renew an exception");
|
|
89
|
+
if (!reviewer) throw new Error("--reviewer <reviewer-id> is required to renew an exception");
|
|
90
|
+
|
|
91
|
+
const { registries, problems } = loadRegistries(root);
|
|
92
|
+
if (problems.length) throw new Error(`registry validation failed: ${problems.join("; ")}`);
|
|
93
|
+
|
|
94
|
+
const exceptions = registries.exceptions?.entries || [];
|
|
95
|
+
const entryIndex = exceptions.findIndex((e) => e.exception_id === id);
|
|
96
|
+
if (entryIndex === -1) throw new Error(`exception not found: ${id}`);
|
|
97
|
+
|
|
98
|
+
const entry = structuredClone(exceptions[entryIndex]);
|
|
99
|
+
if (entry.status === "revoked") throw new Error(`cannot renew revoked exception: ${id}`);
|
|
100
|
+
if (entry.status === "superseded") throw new Error(`cannot renew superseded exception: ${id}`);
|
|
101
|
+
|
|
102
|
+
// Verify reviewer authority
|
|
103
|
+
const reviewers = registries.reviewers?.entries || [];
|
|
104
|
+
const rev = reviewers.find((r) => r.reviewer_id === reviewer);
|
|
105
|
+
if (!rev || rev.status !== "active") {
|
|
106
|
+
throw new Error(`reviewer not found or inactive: ${reviewer}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check renewal limits
|
|
110
|
+
if (entry.renewal_count >= entry.renewal_limit) {
|
|
111
|
+
throw new Error(`exception ${id} has reached its renewal limit (${entry.renewal_limit})`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const ref = parseRefDate(refDate);
|
|
115
|
+
const untilIso = until.includes("T") ? until : `${until}T23:59:59.999Z`;
|
|
116
|
+
const newExpiry = new Date(untilIso);
|
|
117
|
+
|
|
118
|
+
if (Number.isNaN(newExpiry.getTime())) {
|
|
119
|
+
throw new Error(`invalid renewal date format: ${until}`);
|
|
120
|
+
}
|
|
121
|
+
if (newExpiry <= ref) {
|
|
122
|
+
throw new Error("renewal date must be in the future relative to the reference date");
|
|
123
|
+
}
|
|
124
|
+
if (newExpiry <= new Date(entry.expires_at)) {
|
|
125
|
+
throw new Error("renewal date must extend past current expiration date");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check review policy limits if defined
|
|
129
|
+
const policies = registries.review_policies?.entries || [];
|
|
130
|
+
const policy = policies.find((p) => p.policy_id === entry.policy_id);
|
|
131
|
+
if (policy) {
|
|
132
|
+
if (entry.renewal_count + 1 > policy.max_renewals) {
|
|
133
|
+
throw new Error(`renewal would exceed policy max_renewals (${policy.max_renewals})`);
|
|
134
|
+
}
|
|
135
|
+
const durationDays = (newExpiry.getTime() - new Date(entry.created_at).getTime()) / 86400000;
|
|
136
|
+
if (durationDays > policy.max_exception_days) {
|
|
137
|
+
throw new Error(`renewal would exceed policy max_exception_days (${policy.max_exception_days} days)`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
entry.renewal_count += 1;
|
|
142
|
+
entry.expires_at = newExpiry.toISOString();
|
|
143
|
+
|
|
144
|
+
if (evidence && !entry.evidence_ids.includes(evidence)) {
|
|
145
|
+
entry.evidence_ids.push(evidence);
|
|
146
|
+
entry.evidence_hashes[evidence] = sha256(evidence);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
entry.audit_history.push({
|
|
150
|
+
timestamp: new Date().toISOString(),
|
|
151
|
+
actor_reviewer_id: reviewer,
|
|
152
|
+
action: "renewed",
|
|
153
|
+
note: note || `Renewed until ${until} by ${reviewer}`,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const updatedEntries = [...exceptions];
|
|
157
|
+
updatedEntries[entryIndex] = entry;
|
|
158
|
+
|
|
159
|
+
const candidate = {
|
|
160
|
+
...registries.exceptions,
|
|
161
|
+
updated: new Date().toISOString(),
|
|
162
|
+
entries: updatedEntries,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const check = validateAgainst("exception.schema.json", candidate);
|
|
166
|
+
if (!check.valid) {
|
|
167
|
+
throw new Error(`renewed exception violates schema: ${check.errors.join("; ")}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (write) {
|
|
171
|
+
saveRegistry(root, "exceptions", candidate);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
exception_id: id,
|
|
176
|
+
expires_at: entry.expires_at,
|
|
177
|
+
renewal_count: entry.renewal_count,
|
|
178
|
+
renewal_limit: entry.renewal_limit,
|
|
179
|
+
written: write,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function invalidateException(root, {
|
|
184
|
+
id,
|
|
185
|
+
reason,
|
|
186
|
+
reviewer,
|
|
187
|
+
write = false,
|
|
188
|
+
} = {}) {
|
|
189
|
+
if (!id) throw new Error("--id <exception-id> is required to invalidate an exception");
|
|
190
|
+
if (!reason) throw new Error("--reason <text> is required to invalidate an exception");
|
|
191
|
+
if (!reviewer) throw new Error("--reviewer <reviewer-id> is required to record who revoked the exception");
|
|
192
|
+
|
|
193
|
+
const { registries, problems } = loadRegistries(root);
|
|
194
|
+
if (problems.length) throw new Error(`registry validation failed: ${problems.join("; ")}`);
|
|
195
|
+
|
|
196
|
+
const exceptions = registries.exceptions?.entries || [];
|
|
197
|
+
const entryIndex = exceptions.findIndex((e) => e.exception_id === id);
|
|
198
|
+
if (entryIndex === -1) throw new Error(`exception not found: ${id}`);
|
|
199
|
+
|
|
200
|
+
const entry = structuredClone(exceptions[entryIndex]);
|
|
201
|
+
if (entry.status === "revoked") throw new Error(`exception ${id} is already revoked`);
|
|
202
|
+
|
|
203
|
+
// Verify reviewer authority
|
|
204
|
+
const reviewers = registries.reviewers?.entries || [];
|
|
205
|
+
const rev = reviewers.find((r) => r.reviewer_id === reviewer);
|
|
206
|
+
if (!rev || rev.status !== "active") {
|
|
207
|
+
throw new Error(`reviewer not found or inactive: ${reviewer}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
entry.status = "revoked";
|
|
211
|
+
entry.audit_history.push({
|
|
212
|
+
timestamp: new Date().toISOString(),
|
|
213
|
+
actor_reviewer_id: reviewer,
|
|
214
|
+
action: "revoked",
|
|
215
|
+
note: reason,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const updatedEntries = [...exceptions];
|
|
219
|
+
updatedEntries[entryIndex] = entry;
|
|
220
|
+
|
|
221
|
+
const candidate = {
|
|
222
|
+
...registries.exceptions,
|
|
223
|
+
updated: new Date().toISOString(),
|
|
224
|
+
entries: updatedEntries,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const check = validateAgainst("exception.schema.json", candidate);
|
|
228
|
+
if (!check.valid) {
|
|
229
|
+
throw new Error(`revoked exception violates schema: ${check.errors.join("; ")}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (write) {
|
|
233
|
+
saveRegistry(root, "exceptions", candidate);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
exception_id: id,
|
|
238
|
+
status: "revoked",
|
|
239
|
+
written: write,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { loadRegistries } from '../registries/index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Experiment safety guardrails.
|
|
5
|
+
*
|
|
6
|
+
* Lifecycle statuses are mutually exclusive and explicitly defined:
|
|
7
|
+
* planned — declared, not yet receiving traffic decisions
|
|
8
|
+
* running — receiving traffic, sample below the computed requirement and
|
|
9
|
+
* window not elapsed; no reading may be taken
|
|
10
|
+
* inconclusive— window and sample satisfied, effect not statistically
|
|
11
|
+
* significant; absence of evidence is not evidence of absence
|
|
12
|
+
* validated — full window, full sample, no SRM, effect significant at the
|
|
13
|
+
* declared alpha for the primary metric ONLY
|
|
14
|
+
*
|
|
15
|
+
* Guardrails fail closed: missing observations, missing windows, or missing
|
|
16
|
+
* registry context produce `blocked` findings, never a silent pass.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const MIN_WINDOW_DAYS = 7;
|
|
20
|
+
const SRM_ALPHA = 0.001; // standard SRM alert threshold
|
|
21
|
+
|
|
22
|
+
/** Abramowitz–Stegun normal CDF. */
|
|
23
|
+
function normalCdf(z) {
|
|
24
|
+
const t = 1 / (1 + 0.2316419 * Math.abs(z));
|
|
25
|
+
const d = 0.3989423 * Math.exp((-z * z) / 2);
|
|
26
|
+
const p = d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274))));
|
|
27
|
+
return z >= 0 ? 1 - p : p;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function chiSquare1dfP(chi2) {
|
|
31
|
+
return 2 * (1 - normalCdf(Math.sqrt(chi2)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function requiredSamplePerVariant(baselineRate, mde, alpha = 0.05, power = 0.8) {
|
|
35
|
+
const p1 = baselineRate;
|
|
36
|
+
const p2 = p1 * (1 + mde);
|
|
37
|
+
const pBar = (p1 + p2) / 2;
|
|
38
|
+
const zAlpha = alpha === 0.01 ? 2.576 : alpha === 0.10 ? 1.645 : 1.96;
|
|
39
|
+
const zBeta = power === 0.9 ? 1.282 : power === 0.95 ? 1.645 : 0.8416;
|
|
40
|
+
const numerator = Math.pow(zAlpha * Math.sqrt(2 * pBar * (1 - pBar)) + zBeta * Math.sqrt(p1 * (1 - p1) + p2 * (1 - p2)), 2);
|
|
41
|
+
const denominator = Math.pow(p2 - p1, 2);
|
|
42
|
+
return Math.ceil(numerator / denominator);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function windowDays(str) {
|
|
46
|
+
const m = String(str || '').match(/(\d+)\s*d/i);
|
|
47
|
+
return m ? Number(m[1]) : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Core guardrail evaluation for one experiment record plus optional observed
|
|
52
|
+
* data: { control, variant, baseline_rate, mde, days_running, revenue_claimed,
|
|
53
|
+
* other_active_experiments: [{experiment_id, target_page}] }.
|
|
54
|
+
*/
|
|
55
|
+
export function evaluateExperiment(experiment, observed = {}) {
|
|
56
|
+
const findings = [];
|
|
57
|
+
const add = (guardrail_id, severity, summary, required_input = null) =>
|
|
58
|
+
findings.push({ guardrail_id, severity, summary, ...(required_input ? { required_input } : {}) });
|
|
59
|
+
|
|
60
|
+
const status = experiment.status || 'draft';
|
|
61
|
+
const declaredSplit = (experiment.variants || []).length
|
|
62
|
+
? (experiment.variants.find((v) => v.traffic_pct) || {}).traffic_pct || 50
|
|
63
|
+
: 50;
|
|
64
|
+
const window = windowDays(experiment.evaluation_window || experiment.minimum_observation_window);
|
|
65
|
+
const baseline = observed.baseline_rate ?? experiment.baseline_rate ?? null;
|
|
66
|
+
const mde = observed.mde ?? experiment.mde ?? null;
|
|
67
|
+
|
|
68
|
+
// Underpowered / required sample
|
|
69
|
+
let requiredN = null;
|
|
70
|
+
if (baseline > 0 && baseline < 1 && mde > 0) {
|
|
71
|
+
requiredN = requiredSamplePerVariant(baseline, mde);
|
|
72
|
+
if (window && window < MIN_WINDOW_DAYS) {
|
|
73
|
+
add('EXP-WINDOW-TOO-SHORT', 'high',
|
|
74
|
+
`Evaluation window ${window}d is shorter than the ${MIN_WINDOW_DAYS}d minimum; day-of-week seasonality produces high false-positive risk.`);
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
add('EXP-BASELINE-MISSING', 'blocked',
|
|
78
|
+
'No usable baseline_rate/mde declared; sample-size and power requirements cannot be established (fail closed).',
|
|
79
|
+
'required_input: baseline_rate (0-1) and mde (relative)');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Stopping early
|
|
83
|
+
if (status === 'active' || status === 'observing') {
|
|
84
|
+
const daysRunning = observed.days_running ?? null;
|
|
85
|
+
if (window && daysRunning !== null && daysRunning < window) {
|
|
86
|
+
add('EXP-STOPPING-EARLY', 'high',
|
|
87
|
+
`Experiment has run ${daysRunning}d of the declared ${window}d window; stopping now invalidates the design. No interim reading is decision-grade.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Sample-ratio mismatch
|
|
92
|
+
if (observed.control !== undefined && observed.variant !== undefined) {
|
|
93
|
+
const c = Number(observed.control);
|
|
94
|
+
const v = Number(observed.variant);
|
|
95
|
+
if (!Number.isFinite(c) || !Number.isFinite(v) || c < 0 || v < 0) {
|
|
96
|
+
add('EXP-OBSERVATIONS-INVALID', 'blocked', 'Observed counts must be non-negative numbers (fail closed).');
|
|
97
|
+
} else if (c + v >= 100) {
|
|
98
|
+
const total = c + v;
|
|
99
|
+
const share = declaredSplit / 100;
|
|
100
|
+
const expectedC = total * share;
|
|
101
|
+
const expectedV = total * (1 - share);
|
|
102
|
+
const chi2 = ((c - expectedC) ** 2) / expectedC + ((v - expectedV) ** 2) / expectedV;
|
|
103
|
+
const p = chiSquare1dfP(chi2);
|
|
104
|
+
if (p < SRM_ALPHA) {
|
|
105
|
+
add('EXP-SRM-DETECTED', 'critical',
|
|
106
|
+
`Sample-ratio mismatch: observed ${c}/${v} vs declared ${declaredSplit}/${100 - declaredSplit} split (chi-square p=${p.toExponential(2)}). Instrumentation or routing is broken; all readings from this experiment are untrustworthy until fixed.`);
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
add('EXP-SAMPLE-TOO-SMALL-FOR-SRM', 'blocked',
|
|
110
|
+
'Fewer than 100 total observations; SRM cannot be assessed yet (fail closed).');
|
|
111
|
+
}
|
|
112
|
+
} else if (status === 'active' || status === 'observing') {
|
|
113
|
+
add('EXP-OBSERVATIONS-MISSING', 'blocked',
|
|
114
|
+
'Active experiment without observed variant counts; sample ratio, power, and stopping state cannot be verified (fail closed).',
|
|
115
|
+
'required_input: observed control and variant assignment counts');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Power status against observed sample
|
|
119
|
+
if (requiredN !== null && observed.control !== undefined && observed.variant !== undefined) {
|
|
120
|
+
const perVariant = Math.min(Number(observed.control), Number(observed.variant));
|
|
121
|
+
if (perVariant < requiredN) {
|
|
122
|
+
add('EXP-UNDERPOWERED', 'medium',
|
|
123
|
+
`Current sample (${perVariant}/variant) is below the ${requiredN}/variant requirement for ${Math.round((mde ?? 0) * 100)}% MDE at 80% power; any observed difference is not decision-grade.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Revenue attribution without sufficient evidence
|
|
128
|
+
if (observed.revenue_claimed || /revenue|aov|arp/i.test(experiment.primary_metric || '')) {
|
|
129
|
+
const hasConcluded = status === 'concluded';
|
|
130
|
+
const hasSrm = findings.some((f) => f.guardrail_id === 'EXP-SRM-DETECTED');
|
|
131
|
+
if (hasSrm || !hasConcluded) {
|
|
132
|
+
add('EXP-REVENUE-UNSUPPORTED', 'high',
|
|
133
|
+
'Revenue attribution asserted without a concluded, SRM-clean experiment and verified conversion instrumentation; this remains a modeled estimate, not a measured outcome.');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Contamination across pages / incompatible variants
|
|
138
|
+
const others = observed.other_active_experiments || [];
|
|
139
|
+
for (const other of others) {
|
|
140
|
+
if (other.target_page && experiment.target_page && other.target_page === experiment.target_page) {
|
|
141
|
+
add('EXP-CONTAMINATION', 'high',
|
|
142
|
+
`Experiment ${other.experiment_id} is also active on ${other.target_page}; overlapping experiments contaminate each other's readings.`);
|
|
143
|
+
}
|
|
144
|
+
const sharedVariants = (other.variant_ids || []).filter((id) => (experiment.variant_ids || []).includes(id));
|
|
145
|
+
if (sharedVariants.length) {
|
|
146
|
+
add('EXP-VARIANT-COLLISION', 'high',
|
|
147
|
+
`Variant id(s) ${sharedVariants.join(', ')} collide with active experiment ${other.experiment_id}; assignment is ambiguous.`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Lifecycle status (mutually exclusive, explicitly derived)
|
|
152
|
+
let lifecycle;
|
|
153
|
+
const blocking = findings.some((f) => f.severity === 'blocked' || f.severity === 'critical');
|
|
154
|
+
if (status === 'draft' || status === 'observing' || (!observed.control && !observed.variant && status !== 'active')) {
|
|
155
|
+
lifecycle = 'planned';
|
|
156
|
+
} else if (blocking) {
|
|
157
|
+
lifecycle = 'inconclusive';
|
|
158
|
+
} else {
|
|
159
|
+
const perVariant = Math.min(Number(observed.control ?? 0), Number(observed.variant ?? 0));
|
|
160
|
+
const withinWindow = window ? (observed.days_running ?? Infinity) >= window : false;
|
|
161
|
+
const sampleMet = requiredN !== null ? perVariant >= requiredN : false;
|
|
162
|
+
const significant = typeof observed.p_value === 'number' ? observed.p_value < (observed.alpha ?? 0.05) : false;
|
|
163
|
+
if (withinWindow && sampleMet && significant) lifecycle = 'validated';
|
|
164
|
+
else if (withinWindow && sampleMet) lifecycle = 'inconclusive';
|
|
165
|
+
else lifecycle = 'running';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const definitions = {
|
|
169
|
+
planned: 'declared, not yet receiving traffic; no reading exists',
|
|
170
|
+
running: 'receiving traffic; sample/window incomplete; interim readings are not decision-grade',
|
|
171
|
+
inconclusive: 'window and sample satisfied but the effect is not statistically significant, or a blocking guardrail fired; absence of evidence is not evidence of absence',
|
|
172
|
+
validated: 'full window, sufficient sample, no SRM, primary metric significant at declared alpha — statistically validated for the primary metric only, never a conversion or revenue guarantee',
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
experiment_id: experiment.experiment_id,
|
|
177
|
+
registry_status: status,
|
|
178
|
+
lifecycle,
|
|
179
|
+
lifecycle_definition: definitions[lifecycle],
|
|
180
|
+
required_sample_per_variant: requiredN,
|
|
181
|
+
declared_traffic_split: `${declaredSplit}/${100 - declaredSplit}`,
|
|
182
|
+
guardrail_count: findings.length,
|
|
183
|
+
findings,
|
|
184
|
+
lifecycle_definitions: definitions,
|
|
185
|
+
note: 'lifecycle labels describe statistical state only; Citable does not guarantee conversion, ranking, or citation outcomes',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* `citable check experiment <id> [--observed-control N --observed-variant N]`
|
|
191
|
+
*/
|
|
192
|
+
export async function checkExperiment(root, options = {}) {
|
|
193
|
+
const { registries, problems } = loadRegistries(root);
|
|
194
|
+
const entries = registries?.experiments?.entries || [];
|
|
195
|
+
const id = options.experimentId || options.experiment_id;
|
|
196
|
+
if (!id) throw new Error('usage: citable check experiment <experiment-id> [--observed-control N --observed-variant N --days-running N]');
|
|
197
|
+
const experiment = entries.find((e) => e.experiment_id === id);
|
|
198
|
+
if (!experiment) {
|
|
199
|
+
throw new Error(`experiment "${id}" not found in experiments.yaml. Available: ${entries.map((e) => e.experiment_id).join(', ') || 'none'}`);
|
|
200
|
+
}
|
|
201
|
+
const observed = {
|
|
202
|
+
control: options.observedControl ?? options.observed_control,
|
|
203
|
+
variant: options.observedVariant ?? options.observed_variant,
|
|
204
|
+
days_running: options.daysRunning ?? options.days_running,
|
|
205
|
+
baseline_rate: options.baselineRate ? Number(options.baselineRate) : undefined,
|
|
206
|
+
mde: options.mde ? Number(options.mde) : undefined,
|
|
207
|
+
p_value: options.pValue ? Number(options.pValue) : undefined,
|
|
208
|
+
revenue_claimed: options.revenueClaimed ?? options.revenue_claimed ?? false,
|
|
209
|
+
other_active_experiments: entries.filter((e) => e.status === 'active' && e.experiment_id !== id),
|
|
210
|
+
};
|
|
211
|
+
const result = evaluateExperiment(experiment, observed);
|
|
212
|
+
return { ...result, registry_problems: problems };
|
|
213
|
+
}
|