@jimhoyd/urlcode 0.3.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/CONTRIBUTING.md +80 -0
- package/LICENSE +202 -0
- package/NOTICE +20 -0
- package/README.md +342 -0
- package/ROADMAP.md +248 -0
- package/SECURITY.md +40 -0
- package/data/agents/LICENSES/ai-robots-txt.txt +24 -0
- package/data/agents/LICENSES/crawler-user-agents.txt +23 -0
- package/data/agents/ai-crawlers.json +1240 -0
- package/data/agents/crawlers.json +10515 -0
- package/data/agents/index.d.ts +3 -0
- package/data/agents/index.js +2453 -0
- package/data/agents/monitoring.json +1520 -0
- package/data/agents/seo.json +3865 -0
- package/dist/BUILD-MANIFEST.json +60 -0
- package/dist/adapters.js +59 -0
- package/dist/agent-lists.js +59 -0
- package/dist/assets.js +129 -0
- package/dist/authoring.js +68 -0
- package/dist/aws.js +110 -0
- package/dist/build-cloudflare.js +164 -0
- package/dist/cli.js +185 -0
- package/dist/client-address.js +85 -0
- package/dist/cloudflare.js +165 -0
- package/dist/compliance-rules/baseline.js +98 -0
- package/dist/compliance-rules/privacy.js +38 -0
- package/dist/compliance-rules/shared.js +62 -0
- package/dist/compliance-rules/strict.js +61 -0
- package/dist/compliance.js +219 -0
- package/dist/config-worker.js +9 -0
- package/dist/config.js +172 -0
- package/dist/errors.js +8 -0
- package/dist/function-sources.js +54 -0
- package/dist/function-worker.js +105 -0
- package/dist/functions.js +174 -0
- package/dist/guest-api.js +114 -0
- package/dist/header-validation.js +18 -0
- package/dist/http-policy.js +74 -0
- package/dist/http-response.js +84 -0
- package/dist/index.js +11 -0
- package/dist/link-api.js +136 -0
- package/dist/link-cli.js +141 -0
- package/dist/link-events.js +76 -0
- package/dist/link-records.js +31 -0
- package/dist/link-store-worker.js +150 -0
- package/dist/link-store.js +250 -0
- package/dist/logging.js +22 -0
- package/dist/management-policy.js +41 -0
- package/dist/match.js +124 -0
- package/dist/observability.js +242 -0
- package/dist/plugins.js +74 -0
- package/dist/policies/agents.js +248 -0
- package/dist/policies/cache.js +297 -0
- package/dist/policies/compression.js +187 -0
- package/dist/policies/security.js +0 -0
- package/dist/policies/throttle.js +131 -0
- package/dist/policies.js +142 -0
- package/dist/policy.js +55 -0
- package/dist/prerender.js +165 -0
- package/dist/project-tests.js +23 -0
- package/dist/readiness.js +216 -0
- package/dist/router.js +166 -0
- package/dist/runtime.js +244 -0
- package/dist/scaffold.js +0 -0
- package/dist/scripts/operational-drills.js +75 -0
- package/dist/server.js +270 -0
- package/dist/site.js +0 -0
- package/dist/sqlite-version.js +6 -0
- package/dist/types/adapters.d.ts +12 -0
- package/dist/types/agent-lists.d.ts +5 -0
- package/dist/types/assets.d.ts +13 -0
- package/dist/types/authoring.d.ts +2 -0
- package/dist/types/aws.d.ts +35 -0
- package/dist/types/build-cloudflare.d.ts +14 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/client-address.d.ts +9 -0
- package/dist/types/cloudflare.d.ts +75 -0
- package/dist/types/compliance-rules/baseline.d.ts +12 -0
- package/dist/types/compliance-rules/privacy.d.ts +6 -0
- package/dist/types/compliance-rules/shared.d.ts +24 -0
- package/dist/types/compliance-rules/strict.d.ts +9 -0
- package/dist/types/compliance.d.ts +119 -0
- package/dist/types/config-worker.d.ts +1 -0
- package/dist/types/config.d.ts +21 -0
- package/dist/types/errors.d.ts +7 -0
- package/dist/types/function-sources.d.ts +21 -0
- package/dist/types/function-worker.d.ts +1 -0
- package/dist/types/functions.d.ts +92 -0
- package/dist/types/guest-api.d.ts +16 -0
- package/dist/types/header-validation.d.ts +2 -0
- package/dist/types/http-policy.d.ts +39 -0
- package/dist/types/http-response.d.ts +43 -0
- package/dist/types/index.d.ts +11 -0
- package/dist/types/link-api.d.ts +30 -0
- package/dist/types/link-cli.d.ts +36 -0
- package/dist/types/link-events.d.ts +27 -0
- package/dist/types/link-records.d.ts +11 -0
- package/dist/types/link-store-worker.d.ts +1 -0
- package/dist/types/link-store.d.ts +130 -0
- package/dist/types/logging.d.ts +9 -0
- package/dist/types/management-policy.d.ts +9 -0
- package/dist/types/match.d.ts +81 -0
- package/dist/types/observability.d.ts +104 -0
- package/dist/types/plugins.d.ts +27 -0
- package/dist/types/policies/agents.d.ts +86 -0
- package/dist/types/policies/cache.d.ts +82 -0
- package/dist/types/policies/compression.d.ts +41 -0
- package/dist/types/policies/security.d.ts +37 -0
- package/dist/types/policies/throttle.d.ts +55 -0
- package/dist/types/policies.d.ts +36 -0
- package/dist/types/policy.d.ts +21 -0
- package/dist/types/prerender.d.ts +31 -0
- package/dist/types/project-tests.d.ts +13 -0
- package/dist/types/readiness.d.ts +121 -0
- package/dist/types/router.d.ts +11 -0
- package/dist/types/runtime.d.ts +91 -0
- package/dist/types/scaffold.d.ts +17 -0
- package/dist/types/server.d.ts +37 -0
- package/dist/types/site.d.ts +12 -0
- package/dist/types/sqlite-version.d.ts +1 -0
- package/dist/types/types.d.ts +332 -0
- package/dist/types/vercel.d.ts +12 -0
- package/dist/types.js +177 -0
- package/dist/vercel.js +65 -0
- package/docs/AI-AUTHORING.md +113 -0
- package/docs/ASSETS.md +106 -0
- package/docs/AWS.md +77 -0
- package/docs/BEST-PRACTICES.md +267 -0
- package/docs/CAPACITY.md +208 -0
- package/docs/CLOUDFLARE.md +110 -0
- package/docs/COMPLIANCE.md +242 -0
- package/docs/DYNAMIC-LINKS.md +561 -0
- package/docs/FUNCTION-SECURITY.md +113 -0
- package/docs/HTTP.md +129 -0
- package/docs/INSTALL.md +90 -0
- package/docs/LOAD-TESTING.md +91 -0
- package/docs/LOCAL-DEVELOPMENT.md +99 -0
- package/docs/MANAGEMENT-SECURITY.md +82 -0
- package/docs/MIDDLEWARE.md +83 -0
- package/docs/MONITORING.md +132 -0
- package/docs/OBSERVABILITY.md +229 -0
- package/docs/OPERATIONAL-PROOF.md +42 -0
- package/docs/OPERATIONS.md +207 -0
- package/docs/ORGANIZATION.md +135 -0
- package/docs/PERFORMANCE.md +72 -0
- package/docs/PLUGINS.md +234 -0
- package/docs/POLICIES.md +489 -0
- package/docs/PRERENDER.md +193 -0
- package/docs/PROJECT-DIRECTION.md +51 -0
- package/docs/READINESS.md +125 -0
- package/docs/README.md +58 -0
- package/docs/RELEASE-READINESS.md +95 -0
- package/docs/RELEASE-SECURITY.md +72 -0
- package/docs/RESILIENCE.md +160 -0
- package/docs/ROUTING.md +95 -0
- package/docs/SANDBOX-REVIEW.md +59 -0
- package/docs/SCAFFOLDING.md +72 -0
- package/docs/SECURITY-AUDIT.md +124 -0
- package/docs/SITE.md +150 -0
- package/docs/SPECIFICATION.md +232 -0
- package/docs/SPIKE-EXTENSIONS.md +475 -0
- package/docs/STANDARDS.md +303 -0
- package/docs/STARTERS.md +51 -0
- package/docs/TUNNELS.md +75 -0
- package/docs/TYPESCRIPT.md +102 -0
- package/docs/VERCEL.md +107 -0
- package/docs/YAML-GUIDE.md +521 -0
- package/docs/YAML-REFERENCE.md +328 -0
- package/docs/policies/agents.md +182 -0
- package/docs/policies/cache.md +139 -0
- package/docs/policies/compression.md +168 -0
- package/docs/policies/security.md +160 -0
- package/docs/policies/throttle.md +103 -0
- package/examples/assets/.env.example +1 -0
- package/examples/assets/.gitattributes +2 -0
- package/examples/assets/Makefile +30 -0
- package/examples/assets/README.md +18 -0
- package/examples/assets/functions/hello.mjs +3 -0
- package/examples/assets/gitignore.template +7 -0
- package/examples/assets/public/about.html +2 -0
- package/examples/assets/public/assets/example.txt +1 -0
- package/examples/assets/public/guide.txt +1 -0
- package/examples/assets/tests/requests.json +44 -0
- package/examples/assets/urlcode.yaml +27 -0
- package/examples/aws/README.md +13 -0
- package/examples/aws/handler.mjs +5 -0
- package/examples/aws/public/index.html +3 -0
- package/examples/aws/public/notes.txt +1 -0
- package/examples/aws/template.yaml +33 -0
- package/examples/aws/tests/requests.json +9 -0
- package/examples/aws/urlcode.yaml +19 -0
- package/examples/cloudflare/README.md +18 -0
- package/examples/cloudflare/package.json +14 -0
- package/examples/cloudflare/tests/requests.json +9 -0
- package/examples/cloudflare/urlcode.yaml +24 -0
- package/examples/cloudflare/wrangler.toml +5 -0
- package/examples/compliance/README.md +20 -0
- package/examples/compliance/rules.mjs +65 -0
- package/examples/cookbook/README.md +21 -0
- package/examples/cookbook/functions/choice.mjs +4 -0
- package/examples/cookbook/functions/echo.mjs +3 -0
- package/examples/cookbook/functions/hello.mjs +3 -0
- package/examples/cookbook/functions/text.mjs +3 -0
- package/examples/cookbook/middleware/headers.mjs +6 -0
- package/examples/cookbook/public/about.html +1 -0
- package/examples/cookbook/public/assets/index.html +1 -0
- package/examples/cookbook/public/assets/site.css +1 -0
- package/examples/cookbook/public/favicon.svg +1 -0
- package/examples/cookbook/public/guide.txt +1 -0
- package/examples/cookbook/public/llms.txt +6 -0
- package/examples/cookbook/routes/code.yaml +55 -0
- package/examples/cookbook/routes/files.yaml +17 -0
- package/examples/cookbook/routes/policies.yaml +28 -0
- package/examples/cookbook/routes/redirects.yaml +38 -0
- package/examples/cookbook/routes/responses.yaml +26 -0
- package/examples/cookbook/tests/requests.json +254 -0
- package/examples/cookbook/urlcode.yaml +22 -0
- package/examples/live-links/README.md +11 -0
- package/examples/live-links/tests/requests.json +6 -0
- package/examples/live-links/urlcode.yaml +16 -0
- package/examples/monitoring/blackbox-jobs.yaml +37 -0
- package/examples/monitoring/prometheus-rules.yaml +88 -0
- package/examples/monitoring/prometheus-scrape.yaml +20 -0
- package/examples/monitoring/vector.toml +65 -0
- package/examples/prerender/README.md +48 -0
- package/examples/prerender/functions/page.mjs +6 -0
- package/examples/prerender/middleware/template.mjs +29 -0
- package/examples/prerender/prerender.d.mts +4 -0
- package/examples/prerender/prerender.mjs +63 -0
- package/examples/prerender/tests/requests.json +39 -0
- package/examples/prerender/urlcode.yaml +50 -0
- package/examples/tunnel/dev-with-ngrok.sh +61 -0
- package/examples/vercel/README.md +13 -0
- package/examples/vercel/api/index.js +5 -0
- package/examples/vercel/package.json +8 -0
- package/examples/vercel/public/index.html +3 -0
- package/examples/vercel/public/notes.txt +1 -0
- package/examples/vercel/tests/requests.json +48 -0
- package/examples/vercel/urlcode.yaml +19 -0
- package/examples/vercel/vercel.json +10 -0
- package/llms.txt +46 -0
- package/package.json +123 -0
- package/schemas/urlcode.schema.json +1230 -0
- package/starters/default/.gitattributes +1 -0
- package/starters/default/Makefile +30 -0
- package/starters/default/README.md +34 -0
- package/starters/default/functions/hello.mjs +3 -0
- package/starters/default/gitignore.template +8 -0
- package/starters/default/middleware/headers.mjs +6 -0
- package/starters/default/routes/functions.yaml +20 -0
- package/starters/default/routes/marketing/links.yaml +7 -0
- package/starters/default/starter.json +5 -0
- package/starters/default/tests/requests.json +56 -0
- package/starters/default/urlcode.yaml +8 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { oshp, rfc9111, rfc9110, rfc6585, rfc9309, breach, management, active, functionLike, hasSecrets, yamlHeader, handlerCacheControl, directive, emittedSecurityHeaders } from './shared.js';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
export const profile = 'baseline';
|
|
5
|
+
|
|
6
|
+
export const securityHeaders = {
|
|
7
|
+
id: 'oshp/security-headers', title: 'Every active route carries a security headers profile', standard: oshp, severity: 'medium', appliesTo: 'route',
|
|
8
|
+
check({ route, effective }) {
|
|
9
|
+
if (!active(route) || effective.security) return [];
|
|
10
|
+
return [{ message: `${route.path} has no policies.security, so responses carry none of the OSHP headers`, remediation: 'Declare policies.security (headers: oshp) at the project level or on the route' }];
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const hstsOrigin = {
|
|
15
|
+
id: 'oshp/hsts-origin', title: 'HSTS is only reachable on an https origin', standard: { ...oshp, section: 'Strict-Transport-Security' }, severity: 'low', appliesTo: 'project',
|
|
16
|
+
check({ plan, policies, origin }) {
|
|
17
|
+
const emitting = plan.inventory.filter(route => active(route) && emittedSecurityHeaders(policies[route.path], plan.policies?.[route.path]).has('strict-transport-security'));
|
|
18
|
+
if (!emitting.length || (origin && origin.startsWith('https:'))) return [];
|
|
19
|
+
return [{
|
|
20
|
+
message: origin ? `${emitting.length} route(s) declare Strict-Transport-Security but the declared origin ${origin} is not https, so the runtime never emits it` : `${emitting.length} route(s) declare Strict-Transport-Security but no public origin was declared, so whether it is emitted is unknown`,
|
|
21
|
+
remediation: 'Serve behind TLS and declare it with --origin https://your.host (or URLCODE_ORIGIN); the runtime emits HSTS only on an https origin',
|
|
22
|
+
}];
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const secretsCompression = {
|
|
27
|
+
id: 'breach/secrets-compression', title: 'Routes bound to secrets are never compressed', standard: breach, severity: 'high', appliesTo: 'route',
|
|
28
|
+
check({ route, config, effective }) {
|
|
29
|
+
if (!hasSecrets(config) || effective.compression?.allowWithSecrets !== true) return [];
|
|
30
|
+
return [{ message: `${route.path} binds secrets and sets policies.compression.allowWithSecrets, so a compressed response can leak secret-derived bytes through length`, remediation: 'Remove allowWithSecrets (the runtime then skips compression on the route) or set policies.compression: false on the route' }];
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const secretsNoStore = {
|
|
35
|
+
id: 'rfc9111/secrets-no-store', title: 'Routes bound to secrets declare no-store or private caching', standard: { ...rfc9111, section: '5.2.2.5 no-store, 5.2.2.7 private' }, severity: 'medium', appliesTo: 'route',
|
|
36
|
+
check({ route, config, effective }) {
|
|
37
|
+
if (!hasSecrets(config)) return [];
|
|
38
|
+
const problems = [];
|
|
39
|
+
const strategy = effective.cache?.strategy;
|
|
40
|
+
if (strategy && !['no-store','private'].includes(strategy)) problems.push(`policies.cache.strategy ${strategy}`);
|
|
41
|
+
const yaml = yamlHeader(config, 'cache-control');
|
|
42
|
+
if (yaml !== undefined && !directive(yaml, 'no-store') && !directive(yaml, 'private')) problems.push(`response header Cache-Control "${yaml}"`);
|
|
43
|
+
const handler = handlerCacheControl(config);
|
|
44
|
+
if (handler?.declared && !directive(handler.value, 'no-store') && !directive(handler.value, 'private')) problems.push(`handler cacheControl "${handler.value}"`);
|
|
45
|
+
if (!problems.length) return [];
|
|
46
|
+
return [{ message: `${route.path} binds secrets but declares ${problems.join(' and ')}, which lets a shared cache store a secret-derived response`, remediation: 'Use policies.cache strategy no-store or private, or a Cache-Control with no-store or private, on routes that bind secrets' }];
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const cacheControlDeclared = {
|
|
51
|
+
id: 'rfc9111/cache-control-declared', title: 'Every declared response states its Cache-Control', standard: rfc9111, severity: 'low', appliesTo: 'route',
|
|
52
|
+
check({ route, config, effective }) {
|
|
53
|
+
if (!active(route) || effective.cache) return [];
|
|
54
|
+
if ((route.handler === 'respond' || route.handler === 'redirect') && yamlHeader(config, 'cache-control') === undefined) {
|
|
55
|
+
return [{ message: `${route.path} (${route.handler}) declares no Cache-Control, so the runtime's default no-store applies`, remediation: 'State the intent: response.headers Cache-Control, or a policies.cache strategy' }];
|
|
56
|
+
}
|
|
57
|
+
const handler = handlerCacheControl(config);
|
|
58
|
+
if (handler && !handler.declared) return [{ message: `${route.path} (${route.handler}) declares no cacheControl, so the asset handler's default no-cache applies`, remediation: `Set ${route.handler}.cacheControl or a policies.cache strategy` }];
|
|
59
|
+
return [];
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const throttleFunctions = {
|
|
64
|
+
id: 'rfc6585/throttle-functions', title: 'Function and middleware routes declare a request budget', standard: rfc6585, severity: 'medium', appliesTo: 'route',
|
|
65
|
+
check({ route, effective }) {
|
|
66
|
+
if (!active(route) || !functionLike(route) || effective.throttle) return [];
|
|
67
|
+
return [{ message: `${route.path} runs guest code without policies.throttle, so one client can occupy the function pool`, remediation: 'Declare policies.throttle (quota, window, partition) on the route or the project' }];
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const robots = {
|
|
72
|
+
id: 'rfc9309/robots', title: 'Crawlers are addressed by an agents policy or a robots.txt route', standard: rfc9309, severity: 'low', appliesTo: 'project',
|
|
73
|
+
check({ plan, policies, routes }) {
|
|
74
|
+
const agents = plan.inventory.some(route => active(route) && policies[route.path]?.agents);
|
|
75
|
+
const robotsRoute = plan.inventory.find(route => route.path === '/robots.txt');
|
|
76
|
+
if (agents || (robotsRoute && active(robotsRoute) && routes['/robots.txt']?.respond)) return [];
|
|
77
|
+
return [{ message: 'No active route declares policies.agents and no /robots.txt respond route exists, so crawlers get no instruction', remediation: 'Add a /robots.txt respond route (RFC 9309) or declare policies.agents with a deny list' }];
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const expiredRoutes = {
|
|
82
|
+
id: 'rfc9110/expired-routes', title: 'Expired routes still present in YAML', standard: { ...rfc9110, section: '15.5.11 410 Gone' }, severity: 'info', appliesTo: 'project',
|
|
83
|
+
check({ plan }) {
|
|
84
|
+
const expired = plan.inventory.filter(route => route.state === 'expired').map(route => route.path);
|
|
85
|
+
if (!expired.length) return [];
|
|
86
|
+
return [{ message: `${expired.length} route(s) are past their expires and answer 410: ${expired.join(', ')}`, remediation: 'Remove routes whose 410 window has served its purpose, or keep them deliberately' }];
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const managementPrivate = {
|
|
91
|
+
id: 'ops/management-private', title: 'Management and probe listeners stay private', standard: management, severity: 'info', appliesTo: 'project',
|
|
92
|
+
check({ document }) {
|
|
93
|
+
if (document.dynamicLinks !== true) return [];
|
|
94
|
+
return [{ message: 'dynamicLinks is enabled; the link management API and the /_urlcode probes are meant for a private bind', remediation: 'Run `urlcode links api` on a private interface with an operator auth file, and keep /_urlcode/health and /_urlcode/ready internal' }];
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const rules = Object.freeze([securityHeaders, hstsOrigin, secretsCompression, secretsNoStore, cacheControlDeclared, throttleFunctions, robots, expiredRoutes, managementPrivate]);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { monitoring, linkChannel, active } from './shared.js';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
export const profile = 'privacy';
|
|
5
|
+
|
|
6
|
+
// These rules check the host settings the operator declares for the
|
|
7
|
+
// deployment under review (`host.requestLog`, `host.linkEvents`,
|
|
8
|
+
// `host.includeCode`), because the runtime's log guarantees are settings of
|
|
9
|
+
// the serving process, not of the project. An undeclared setting is reported
|
|
10
|
+
// as unknown rather than assumed.
|
|
11
|
+
export const requestLogMinimal = {
|
|
12
|
+
id: 'privacy/request-log-minimal', title: 'Request log records stay minimal', standard: monitoring, severity: 'medium', appliesTo: 'project',
|
|
13
|
+
check({ host }) {
|
|
14
|
+
if (host.requestLog === 'minimal') return [];
|
|
15
|
+
if (host.requestLog === null) return [{ severity: 'info', message: 'The deployment request log level was not declared, so the check cannot confirm it is minimal', remediation: 'Declare the deployment setting: --request-log minimal|detailed on audit, or host.requestLog in runCompliance' }];
|
|
16
|
+
return [{ message: 'The deployment logs with --request-log detailed, which adds the method and matched route pattern to every request record', remediation: 'Use the default minimal log unless per-route error rates are required; detailed records still carry no path, query, header or body' }];
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const linkEventsOff = {
|
|
21
|
+
id: 'privacy/link-events-off', title: 'Link events stay off unless declared', standard: linkChannel, severity: 'medium', appliesTo: 'project',
|
|
22
|
+
check({ document, host }) {
|
|
23
|
+
if (document.dynamicLinks !== true || host.linkEvents === false) return [];
|
|
24
|
+
if (host.linkEvents === null) return [{ severity: 'info', message: 'dynamicLinks is enabled and the deployment did not declare whether the link event channel is on', remediation: 'Declare host.linkEvents (and host.includeCode) for the deployment under review' }];
|
|
25
|
+
if (host.includeCode === true) return [{ severity: 'high', message: 'The link event channel is on with includeCode, so every event names the short code a person followed', remediation: 'Leave includeCode off; the channel then redacts the code and reports only route, outcome and timing' }];
|
|
26
|
+
return [{ message: 'The link event channel is on, so per-click records leave the process', remediation: 'Enable linkEvents only for a declared click-collection purpose and keep the collector bounded' }];
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const detailedParameters = {
|
|
31
|
+
id: 'privacy/detailed-log-parameters', title: 'No detailed logging on parameterised routes', standard: monitoring, severity: 'low', appliesTo: 'route',
|
|
32
|
+
check({ route, config, host }) {
|
|
33
|
+
if (host.requestLog !== 'detailed' || !active(route) || !((config.parameters?.length ?? 0) > 0)) return [];
|
|
34
|
+
return [{ message: `${route.path} takes parameters and the deployment logs detailed records; each record names the pattern and method (never parameter values)`, remediation: 'Keep --request-log minimal on deployments serving parameterised routes, or accept that per-pattern latency and error rates are recorded' }];
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const rules = Object.freeze([requestLogMinimal, linkEventsOff, detailedParameters]);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { profiles as securityProfiles } from '../policies/security.js';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
// Facts every rule module reads the same way. Nothing here guesses: each
|
|
6
|
+
// helper reads the YAML route configuration, the runtime's test plan or the
|
|
7
|
+
// effective policy the runtime compiled from that YAML.
|
|
8
|
+
export const oshp = Object.freeze({ name: 'OWASP Secure Headers Project', reference: 'https://owasp.org/www-project-secure-headers/', section: 'Best practices' });
|
|
9
|
+
export const rfc9111 = Object.freeze({ name: 'RFC 9111 HTTP Caching', reference: 'https://www.rfc-editor.org/rfc/rfc9111', section: '5.2 Cache-Control' });
|
|
10
|
+
export const rfc9110 = Object.freeze({ name: 'RFC 9110 HTTP Semantics', reference: 'https://www.rfc-editor.org/rfc/rfc9110' });
|
|
11
|
+
export const rfc6585 = Object.freeze({ name: 'RFC 6585 Additional HTTP Status Codes', reference: 'https://www.rfc-editor.org/rfc/rfc6585', section: '4. 429 Too Many Requests' });
|
|
12
|
+
export const rfc9309 = Object.freeze({ name: 'RFC 9309 Robots Exclusion Protocol', reference: 'https://www.rfc-editor.org/rfc/rfc9309' });
|
|
13
|
+
export const breach = Object.freeze({ name: 'BREACH (compression side channel)', reference: 'https://www.breachattack.com/', section: 'Mitigations' });
|
|
14
|
+
export const monitoring = Object.freeze({ name: 'URLCode logging guarantees', reference: 'docs/MONITORING.md', section: 'Log records' });
|
|
15
|
+
export const linkChannel = Object.freeze({ name: 'URLCode link event channel', reference: 'docs/MONITORING.md', section: 'The link event channel' });
|
|
16
|
+
export const management = Object.freeze({ name: 'URLCode management security', reference: 'docs/MANAGEMENT-SECURITY.md' });
|
|
17
|
+
export const agentLists = Object.freeze({ name: 'URLCode bundled agent lists', reference: 'docs/policies/agents.md', section: 'Bundled lists' });
|
|
18
|
+
|
|
19
|
+
export const active = (route ) => route.state === 'active';
|
|
20
|
+
export const functionLike = (route ) => route.handler === 'function' || route.middleware > 0;
|
|
21
|
+
export const hasSecrets = (config ) => Object.keys(config?.secrets ?? {}).length > 0;
|
|
22
|
+
|
|
23
|
+
// YAML response header by name, case-insensitive; Set-Cookie may be an array.
|
|
24
|
+
export function yamlHeader(config , name ) {
|
|
25
|
+
const key = name.toLowerCase();
|
|
26
|
+
for (const [header, value] of Object.entries(config?.response?.headers ?? {})) if (header.toLowerCase() === key) return value;
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
export function yamlHeaderBytes(config ) {
|
|
30
|
+
let size = 0;
|
|
31
|
+
for (const [name, value] of Object.entries(config?.response?.headers ?? {})) for (const item of Array.isArray(value) ? value : [value]) size += Buffer.byteLength(name + String(item));
|
|
32
|
+
return size;
|
|
33
|
+
}
|
|
34
|
+
// Asset handlers carry their Cache-Control on the handler (src/assets.js
|
|
35
|
+
// defaults it to no-cache when absent).
|
|
36
|
+
export function handlerCacheControl(config ) {
|
|
37
|
+
for (const key of ['page','static','download'] ) { const handler = config?.[key]; if (handler) return { declared: handler.cacheControl !== undefined, value: handler.cacheControl ?? 'no-cache' }; }
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
export const directive = (value , token ) => new RegExp(`(?:^|,)\\s*${token}\\s*(?:=|,|$)`, 'i').test(String(value ?? ''));
|
|
41
|
+
|
|
42
|
+
// The header names a route's security policy emits, computed the way
|
|
43
|
+
// src/policies/security.js compiles them: profile table minus `unset`, plus
|
|
44
|
+
// `set`. The runtime's describe map is preferred when the plan carries it.
|
|
45
|
+
export function emittedSecurityHeaders(effective , policy ) {
|
|
46
|
+
if (policy?.security?.emits) return new Set([...policy.security.emits, ...(policy.security.set ?? [])]);
|
|
47
|
+
const config = effective?.security;
|
|
48
|
+
if (!config) return new Set();
|
|
49
|
+
const table = new Set((securityProfiles[config.headers ?? 'oshp'] ?? []).map(([key]) => key));
|
|
50
|
+
for (const raw of config.unset ?? []) table.delete(String(raw).toLowerCase());
|
|
51
|
+
for (const raw of Object.keys(config.set ?? {})) table.add(raw.toLowerCase());
|
|
52
|
+
return table;
|
|
53
|
+
}
|
|
54
|
+
export function securityHeaderBytes(effective , policy ) {
|
|
55
|
+
if (typeof policy?.security?.bytes === 'number') return policy.security.bytes;
|
|
56
|
+
const config = effective?.security;
|
|
57
|
+
if (!config) return 0;
|
|
58
|
+
const unset = new Set((config.unset ?? []).map(raw => String(raw).toLowerCase()));
|
|
59
|
+
const set = Object.entries(config.set ?? {});
|
|
60
|
+
const kept = (securityProfiles[config.headers ?? 'oshp'] ?? []).filter(([key]) => !unset.has(key) && !set.some(([raw]) => raw.toLowerCase() === key));
|
|
61
|
+
return [...kept, ...set].reduce((n, [key, value]) => n + Buffer.byteLength(key + value), 0);
|
|
62
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { rules as baseline } from './baseline.js';
|
|
2
|
+
import { oshp, rfc9110, rfc6585, agentLists, active, functionLike, yamlHeader, yamlHeaderBytes, emittedSecurityHeaders, securityHeaderBytes } from './shared.js';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
export const profile = 'strict';
|
|
6
|
+
|
|
7
|
+
// The runtime caps a response at 16 KiB of headers; the security policy
|
|
8
|
+
// bounds its own static headers at half of that so the handler keeps room.
|
|
9
|
+
export const headerBudgetBytes = 8192;
|
|
10
|
+
|
|
11
|
+
export const csp = {
|
|
12
|
+
id: 'oshp/csp', title: 'Every active route emits a Content-Security-Policy', standard: { ...oshp, section: 'Content-Security-Policy' }, severity: 'medium', appliesTo: 'route',
|
|
13
|
+
check({ route, config, effective, policy }) {
|
|
14
|
+
if (!active(route) || emittedSecurityHeaders(effective, policy).has('content-security-policy') || yamlHeader(config, 'content-security-policy') !== undefined) return [];
|
|
15
|
+
return [{ message: `${route.path} emits no Content-Security-Policy (profile ${effective.security?.headers ?? 'none'})`, remediation: 'Use the oshp profile, or set Content-Security-Policy through policies.security.set or response.headers' }];
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const throttleAll = {
|
|
20
|
+
id: 'rfc6585/throttle-all', title: 'Every active route declares a request budget', standard: rfc6585, severity: 'medium', appliesTo: 'route',
|
|
21
|
+
check({ route, effective }) {
|
|
22
|
+
if (!active(route) || functionLike(route) || effective.throttle) return [];
|
|
23
|
+
return [{ message: `${route.path} has no policies.throttle`, remediation: 'Declare policies.throttle at the project level so every route carries a budget' }];
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const listsPinned = {
|
|
28
|
+
id: 'agents/lists-pinned', title: 'Agent deny and allow lists are pinned to a revision', standard: agentLists, severity: 'low', appliesTo: 'route',
|
|
29
|
+
check({ route, effective, policy }) {
|
|
30
|
+
if (!active(route) || !effective.agents) return [];
|
|
31
|
+
const unpinned = [];
|
|
32
|
+
for (const side of ['deny','allow'] ) {
|
|
33
|
+
const used = policy.agents?.[side];
|
|
34
|
+
if (Array.isArray(used)) { for (const list of used) if (!list.revision || list.revision === 'project') unpinned.push(list.name); }
|
|
35
|
+
else for (const entry of effective.agents[side] ?? []) if (typeof entry === 'string' && entry.endsWith('.json')) unpinned.push(entry);
|
|
36
|
+
}
|
|
37
|
+
if (!unpinned.length) return [];
|
|
38
|
+
return [{ message: `${route.path} uses project list file(s) without an upstream revision: ${unpinned.join(', ')}`, remediation: 'Prefer the bundled lists (ai-crawlers, crawlers, seo, monitoring), which the audit reports with their pinned upstream revision, or record the revision your list was built from' }];
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const redirectHttps = {
|
|
43
|
+
id: 'rfc9110/redirect-https', title: 'Redirect targets are https', standard: { ...rfc9110, section: '15.4 Redirection 3xx' }, severity: 'medium', appliesTo: 'route',
|
|
44
|
+
check({ route, config }) {
|
|
45
|
+
const url = config.redirect?.url;
|
|
46
|
+
if (!active(route) || typeof url !== 'string' || !/^http:/i.test(url)) return [];
|
|
47
|
+
return [{ message: `${route.path} redirects to a plain http URL`, remediation: 'Point the redirect at an https URL so the hop after this runtime stays encrypted' }];
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const headerBudget = {
|
|
52
|
+
id: 'http/header-budget', title: 'Declared response headers stay under the static budget', standard: { ...rfc9110, section: '5.4 Field Limits' }, severity: 'low', appliesTo: 'route',
|
|
53
|
+
check({ route, config, effective, policy }) {
|
|
54
|
+
if (!active(route)) return [];
|
|
55
|
+
const bytes = yamlHeaderBytes(config) + securityHeaderBytes(effective, policy);
|
|
56
|
+
if (bytes <= headerBudgetBytes) return [];
|
|
57
|
+
return [{ message: `${route.path} declares ${bytes} bytes of static response headers, over the ${headerBudgetBytes}-byte budget that leaves room for handler headers under the runtime's 16 KiB cap`, remediation: 'Trim response.headers or the security set/profile so static headers use at most half of the 16 KiB response header limit' }];
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const rules = Object.freeze([...baseline, csp, throttleAll, listsPinned, redirectHttps, headerBudget]);
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { relative, isAbsolute, sep } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { assert, ConfigError } from './errors.js';
|
|
5
|
+
import { loadDocument } from './config.js';
|
|
6
|
+
import { effectivePolicies } from './policies.js';
|
|
7
|
+
import * as baseline from './compliance-rules/baseline.js';
|
|
8
|
+
import * as strict from './compliance-rules/strict.js';
|
|
9
|
+
import * as privacy from './compliance-rules/privacy.js';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
// Compliance rules: standards-referenced checks over a project's declared
|
|
13
|
+
// configuration and the runtime's own facts (the test plan, the effective
|
|
14
|
+
// policy per route, the origin and host settings the operator states). The
|
|
15
|
+
// contract mirrors host plugins: the runtime ships built-in rules, an operator
|
|
16
|
+
// adds or overrides rules with code kept outside the project, and the CLI
|
|
17
|
+
// reports pass/fail with the reference each finding rests on. A rule never
|
|
18
|
+
// sends a request, reads a binding or runs guest code; it only reads what
|
|
19
|
+
// `runCompliance` hands it. A passing run is not a certification of a
|
|
20
|
+
// deployment: it says the declared configuration matches the rule set.
|
|
21
|
+
//
|
|
22
|
+
// { id: 'oshp/hsts', kebab-case, namespaced
|
|
23
|
+
// title,
|
|
24
|
+
// standard: { name, reference, section? }, reference is a URL or an RFC
|
|
25
|
+
// severity: 'high' | 'medium' | 'low' | 'info',
|
|
26
|
+
// appliesTo: 'project' | 'route',
|
|
27
|
+
// check(context) → findings[] }
|
|
28
|
+
//
|
|
29
|
+
// Project context: { document, routes, plan, policies, origin, target, host }
|
|
30
|
+
// Route context adds: { route (inventory entry), config (YAML), policy
|
|
31
|
+
// (describe map), effective (effectivePolicies for the route) }.
|
|
32
|
+
// A finding: { rule, severity, route?, message, remediation, standard }.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
/** What a rule's check returns per finding; severity and route default to the rule's and the context's. */
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
/** The host settings under review, null where the operator declared nothing. */
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
/** What an operator rules module exports, after loadComplianceRules checked it. */
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
/** The started server or runtime under review: its project root and test plan. */
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
export const severities = Object.freeze(['high','medium','low','info']);
|
|
66
|
+
export const idPattern = /^[a-z][a-z0-9-]{0,31}\/[a-z][a-z0-9-]{0,63}$/;
|
|
67
|
+
const referencePattern = /^(?:https?:\/\/\S+|RFC ?\d{3,5}|docs\/[A-Za-z0-9./-]+\.md)$/;
|
|
68
|
+
const hostKeys = ['requestLog','linkEvents','includeCode'];
|
|
69
|
+
const isSeverity = (value ) => (severities ).includes(value);
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
const isProfileName = (value ) => Object.hasOwn(builtinProfiles, value);
|
|
73
|
+
export const builtinProfiles = Object.freeze({
|
|
74
|
+
[baseline.profile]: baseline.rules,
|
|
75
|
+
[strict.profile]: strict.rules,
|
|
76
|
+
[privacy.profile]: privacy.rules,
|
|
77
|
+
});
|
|
78
|
+
export const profileNames = Object.freeze([...Object.keys(builtinProfiles), 'none']);
|
|
79
|
+
|
|
80
|
+
export function validateRule(rule ) {
|
|
81
|
+
assert(rule && typeof rule === 'object' && !Array.isArray(rule), 'Compliance rule must be an object');
|
|
82
|
+
const candidate = rule ; // trust boundary: operator code, checked field by field below
|
|
83
|
+
assert(typeof candidate.id === 'string' && idPattern.test(candidate.id), `Compliance rule id ${JSON.stringify(candidate.id)} must be namespaced kebab-case (namespace/name)`);
|
|
84
|
+
const where = `Compliance rule "${candidate.id}"`;
|
|
85
|
+
assert(typeof candidate.title === 'string' && candidate.title.trim().length && candidate.title.length <= 160, `${where} needs a title`);
|
|
86
|
+
const standard = candidate.standard ;
|
|
87
|
+
assert(standard && typeof standard === 'object' && !Array.isArray(standard), `${where} needs a standard { name, reference }`);
|
|
88
|
+
assert(typeof standard.name === 'string' && standard.name.trim().length && standard.name.length <= 120, `${where} standard needs a name`);
|
|
89
|
+
assert(typeof standard.reference === 'string' && referencePattern.test(standard.reference) && standard.reference.length <= 512, `${where} standard.reference must be a URL, an RFC number or a docs/ path`);
|
|
90
|
+
assert(standard.section === undefined || (typeof standard.section === 'string' && standard.section.length <= 120), `${where} standard.section must be a string`);
|
|
91
|
+
assert(isSeverity(candidate.severity), `${where} severity must be one of ${severities.join(', ')}`);
|
|
92
|
+
assert(candidate.appliesTo === 'project' || candidate.appliesTo === 'route', `${where} appliesTo must be project or route`);
|
|
93
|
+
assert(typeof candidate.check === 'function', `${where} check must be a function`);
|
|
94
|
+
return rule ;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function validateRules(rules ) {
|
|
98
|
+
assert(Array.isArray(rules) && rules.length <= 256, 'Compliance rules must be an array of at most 256 entries');
|
|
99
|
+
const seen = new Set ();
|
|
100
|
+
for (const rule of rules ) {
|
|
101
|
+
const checked = validateRule(rule);
|
|
102
|
+
assert(!seen.has(checked.id), `Duplicate compliance rule "${checked.id}"`);
|
|
103
|
+
seen.add(checked.id);
|
|
104
|
+
}
|
|
105
|
+
return rules ; // every entry was just checked
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The rule set for a run: a built-in profile, plus operator rules applied the
|
|
109
|
+
// way a rules module declares them (add, then override, then disable).
|
|
110
|
+
export function resolveRules({ profile = 'baseline', rules = [], override = {}, disable = [] } = {}) {
|
|
111
|
+
assert(profileNames.includes(profile), `Unknown compliance profile "${profile}"; use ${profileNames.join(', ')}`);
|
|
112
|
+
const set = new Map ((isProfileName(profile) ? builtinProfiles[profile] : []).map(rule => [rule.id, rule]));
|
|
113
|
+
for (const rule of validateRules(rules)) {
|
|
114
|
+
assert(!set.has(rule.id), `Compliance rule "${rule.id}" already exists; use override to change it`);
|
|
115
|
+
set.set(rule.id, rule);
|
|
116
|
+
}
|
|
117
|
+
assert(override && typeof override === 'object' && !Array.isArray(override), 'Compliance override must map rule ids to partial rules');
|
|
118
|
+
for (const [id, partial] of Object.entries(override )) {
|
|
119
|
+
assert(set.has(id), `Compliance override names unknown rule "${id}"`);
|
|
120
|
+
assert(partial && typeof partial === 'object' && !Array.isArray(partial) && ((partial ).id === undefined || (partial ).id === id), `Compliance override for "${id}" must be a partial rule with the same id`);
|
|
121
|
+
set.set(id, validateRule({ ...set.get(id), ...partial, id }));
|
|
122
|
+
}
|
|
123
|
+
assert(Array.isArray(disable) && disable.every(id => typeof id === 'string'), 'Compliance disable must list rule ids');
|
|
124
|
+
for (const id of disable ) { assert(set.has(id), `Compliance disable names unknown rule "${id}"`); set.delete(id); }
|
|
125
|
+
return [...set.values()];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Operator rules live outside the project, like the binding policy: they are
|
|
129
|
+
// trusted host code and a project must never be able to ship its own verdict.
|
|
130
|
+
export async function loadComplianceRules(file , project ) {
|
|
131
|
+
if (!file) return undefined;
|
|
132
|
+
assert(isAbsolute(file) && ['.mjs','.js'].includes(file.slice(file.lastIndexOf('.'))), 'Compliance rules must be an absolute path to an ES module (.mjs or .js)');
|
|
133
|
+
const root = await realpath(project), path = await realpath(file);
|
|
134
|
+
const rel = relative(root, path);
|
|
135
|
+
assert(isAbsolute(rel) || rel === '..' || rel.startsWith('..' + sep), 'Compliance rules must be outside the application project');
|
|
136
|
+
assert((await stat(path)).isFile() && (await stat(path)).size <= 1048576, 'Compliance rules exceed 1 MiB');
|
|
137
|
+
const module = (await import(pathToFileURL(path).href)) ; // trust boundary: an operator module, checked below
|
|
138
|
+
const rules = module.rules ?? [];
|
|
139
|
+
assert(Array.isArray(rules), 'Compliance rules module must export an array named rules');
|
|
140
|
+
const disable = module.disable ?? [];
|
|
141
|
+
const override = module.override ?? {};
|
|
142
|
+
assert(Array.isArray(disable) && disable.every(id => typeof id === 'string' && idPattern.test(id)), 'Compliance rules module disable must list rule ids');
|
|
143
|
+
assert(override && typeof override === 'object' && !Array.isArray(override), 'Compliance rules module override must map rule ids to partial rules');
|
|
144
|
+
validateRules(rules);
|
|
145
|
+
return { rules: rules , disable: disable , override: override };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function validateHost(host = {}) {
|
|
149
|
+
assert(host && typeof host === 'object' && !Array.isArray(host) && Object.keys(host).every(key => hostKeys.includes(key)), `Compliance host settings accept ${hostKeys.join(', ')}`);
|
|
150
|
+
const { requestLog, linkEvents, includeCode } = host ;
|
|
151
|
+
assert(requestLog === undefined || requestLog === 'minimal' || requestLog === 'detailed', 'Compliance host.requestLog must be minimal or detailed');
|
|
152
|
+
assert(linkEvents === undefined || typeof linkEvents === 'boolean', 'Compliance host.linkEvents must be a boolean');
|
|
153
|
+
assert(includeCode === undefined || typeof includeCode === 'boolean', 'Compliance host.includeCode must be a boolean');
|
|
154
|
+
return { requestLog: requestLog ?? null, linkEvents: linkEvents ?? null, includeCode: includeCode ?? null };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function finding(rule , raw , route ) {
|
|
158
|
+
const candidate = raw ; // what the rule returned, checked below
|
|
159
|
+
assert(candidate && typeof candidate === 'object' && typeof candidate.message === 'string' && candidate.message.length, `Compliance rule "${rule.id}" returned a finding without a message`);
|
|
160
|
+
assert(typeof candidate.remediation === 'string' && candidate.remediation.length, `Compliance rule "${rule.id}" returned a finding without a remediation`);
|
|
161
|
+
const severity = candidate.severity ?? rule.severity;
|
|
162
|
+
assert(isSeverity(severity), `Compliance rule "${rule.id}" returned an unknown severity`);
|
|
163
|
+
const out = { rule: rule.id, severity, message: candidate.message, remediation: candidate.remediation, standard: { ...rule.standard } };
|
|
164
|
+
const path = candidate.route ?? route;
|
|
165
|
+
if (path !== undefined) { assert(typeof path === 'string', `Compliance rule "${rule.id}" returned a non-string route`); out.route = path; }
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function evaluate (rule , context , route ) {
|
|
170
|
+
let result ;
|
|
171
|
+
try { result = await rule.check(context); }
|
|
172
|
+
catch (error) { throw new ConfigError(`Compliance rule "${rule.id}" failed: ${(error )?.message ?? error}`); }
|
|
173
|
+
if (result === undefined || result === null || result === false) return [];
|
|
174
|
+
const list = Array.isArray(result) ? result : [result];
|
|
175
|
+
assert(list.length <= 1000, `Compliance rule "${rule.id}" returned too many findings`);
|
|
176
|
+
return list.map(raw => finding(rule, raw, route));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Runs a rule set against a started server or a runtime. Both expose `root`
|
|
180
|
+
// and `testPlan()`; the YAML is re-read from the root so rules see what the
|
|
181
|
+
// runtime compiled rather than a copy an operator could hand in. `origin` is
|
|
182
|
+
// the public origin the operator declares (never a forwarded header) and
|
|
183
|
+
// `host` the logging settings of the deployment under review; both are
|
|
184
|
+
// evidence, so the report echoes them.
|
|
185
|
+
export async function runCompliance(app , { rules = [], profile = 'baseline', ignore = [], origin, target = 'node', host, override, disable } = {}) {
|
|
186
|
+
assert(app && typeof app.testPlan === 'function' && typeof app.root === 'string', 'runCompliance needs a started server or runtime');
|
|
187
|
+
assert(Array.isArray(ignore) && ignore.every(id => typeof id === 'string' && idPattern.test(id)), 'Compliance ignore must list rule ids');
|
|
188
|
+
assert(origin === undefined || origin === null || (typeof origin === 'string' && /^https?:\/\/[^/\s]+$/.test(origin)), 'Compliance origin must be a bare HTTP(S) origin');
|
|
189
|
+
const set = resolveRules({ profile, rules, override, disable }).filter(rule => !ignore.includes(rule.id));
|
|
190
|
+
const loaded = await loadDocument(app.root);
|
|
191
|
+
const plan = app.testPlan();
|
|
192
|
+
const policies = {};
|
|
193
|
+
for (const [pattern, config] of Object.entries(loaded.routes)) policies[pattern] = effectivePolicies(loaded.document, config);
|
|
194
|
+
const hostSettings = validateHost(host);
|
|
195
|
+
const base = { document: loaded.document, routes: loaded.routes, plan, policies, origin: origin ?? null, target, host: hostSettings };
|
|
196
|
+
const findings = [];
|
|
197
|
+
for (const rule of set) {
|
|
198
|
+
if (rule.appliesTo === 'project') { findings.push(...await evaluate(rule, base)); continue; }
|
|
199
|
+
for (const route of plan.inventory) {
|
|
200
|
+
const context = { ...base, route, config: loaded.routes[route.path] ?? {}, policy: plan.policies?.[route.path] ?? {}, effective: policies[route.path] ?? {} };
|
|
201
|
+
findings.push(...await evaluate(rule, context, route.path));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const order = new Map(severities.map((s, i) => [s, i]));
|
|
205
|
+
findings.sort((a, b) => order.get(a.severity) - order.get(b.severity) || a.rule.localeCompare(b.rule) || (a.route ?? '').localeCompare(b.route ?? ''));
|
|
206
|
+
const counts = { high: 0, medium: 0, low: 0, info: 0 };
|
|
207
|
+
for (const f of findings) counts[f.severity]++;
|
|
208
|
+
const active = plan.inventory.filter(r => r.state === 'active').length;
|
|
209
|
+
return {
|
|
210
|
+
profile, rules: set.length, ruleIds: set.map(rule => rule.id), ignored: [...ignore], findings, counts, pass: counts.high === 0,
|
|
211
|
+
evidence: {
|
|
212
|
+
routes: plan.inventory.length, active, dynamicLinks: plan.dynamicLinks === true,
|
|
213
|
+
policies: [...new Set(Object.values(policies).flatMap(Object.keys))].sort(),
|
|
214
|
+
files: loaded.files.map(file => relative(loaded.root, file)),
|
|
215
|
+
origin: origin ?? null, target, host: hostSettings,
|
|
216
|
+
scope: 'declared configuration and runtime facts; not a deployment or certification',
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import {parentPort,workerData} from 'node:worker_threads';
|
|
2
|
+
import {loadDocumentInWorker} from './config.js';
|
|
3
|
+
|
|
4
|
+
import {ConfigError} from './errors.js';
|
|
5
|
+
if (!parentPort) throw new Error('config-worker.ts must run as a worker thread');
|
|
6
|
+
const data = workerData ; // trust boundary: config.ts is the only spawner
|
|
7
|
+
const post = (result ) => parentPort .postMessage(result);
|
|
8
|
+
try { post({value:await loadDocumentInWorker(data.project)}); }
|
|
9
|
+
catch(error) { post({error:error instanceof ConfigError?error.message:'Configuration loading failed'}); }
|