@quatronic/sdk 0.2.2-canary-20260916115650 → 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/README.md +8 -0
- package/dist/{chunk-GWX26DF7.js → chunk-23PPOFZ2.js} +14 -2
- package/dist/chunk-23PPOFZ2.js.map +1 -0
- package/dist/chunk-FUPTRHT7.js +67 -0
- package/dist/chunk-FUPTRHT7.js.map +1 -0
- package/dist/chunk-X6D3TBNS.js +20 -0
- package/dist/chunk-X6D3TBNS.js.map +1 -0
- package/dist/eslint/index.d.ts +28 -0
- package/dist/eslint/index.js +248 -0
- package/dist/eslint/index.js.map +1 -0
- package/dist/next/observability/register.js +1 -1
- package/dist/next/security/index.d.ts +62 -2
- package/dist/next/security/index.js +63 -0
- package/dist/next/security/index.js.map +1 -1
- package/dist/next/security/testing.d.ts +57 -0
- package/dist/next/security/testing.js +126 -0
- package/dist/next/security/testing.js.map +1 -0
- package/dist/node/observability/register.js +1 -1
- package/dist/node/parse/index.js +5 -60
- package/dist/node/parse/index.js.map +1 -1
- package/package.json +13 -1
- package/semgrep/qdc-web.yaml +9 -1
- package/dist/chunk-GWX26DF7.js.map +0 -1
package/README.md
CHANGED
|
@@ -15,6 +15,14 @@ vulnerabilities — each one has a short unsafe version that looks correct. What
|
|
|
15
15
|
each does, and what it deliberately does not do:
|
|
16
16
|
[`docs/security-primitives.md`](docs/security-primitives.md).
|
|
17
17
|
|
|
18
|
+
An app's whole security wiring also lives here, so a fix reaches every app with
|
|
19
|
+
a version bump instead of a patch per repo: `withSecurityHeaders` and
|
|
20
|
+
`createCspReportRoute` for the proxy and the violation endpoint,
|
|
21
|
+
`@quatronic/sdk/next/security/testing`'s `securityHeaderChecks` for the test the
|
|
22
|
+
app runs against its own proxy, and `@quatronic/sdk/eslint` for the twelve
|
|
23
|
+
`qdc/*` lint rules —
|
|
24
|
+
[`docs/security-rules.md`](docs/security-rules.md).
|
|
25
|
+
|
|
18
26
|
## Permissions
|
|
19
27
|
|
|
20
28
|
The SDK ships a hybrid role/permission layer. Roles come from the Keycloak JWT;
|
|
@@ -29,7 +29,7 @@ import { hostname } from "os";
|
|
|
29
29
|
// package.json
|
|
30
30
|
var package_default = {
|
|
31
31
|
name: "@quatronic/sdk",
|
|
32
|
-
version: "0.
|
|
32
|
+
version: "0.3.0",
|
|
33
33
|
type: "module",
|
|
34
34
|
private: false,
|
|
35
35
|
publishConfig: {
|
|
@@ -84,6 +84,14 @@ var package_default = {
|
|
|
84
84
|
types: "./dist/next/security/index.d.ts",
|
|
85
85
|
import: "./dist/next/security/index.js"
|
|
86
86
|
},
|
|
87
|
+
"./next/security/testing": {
|
|
88
|
+
types: "./dist/next/security/testing.d.ts",
|
|
89
|
+
import: "./dist/next/security/testing.js"
|
|
90
|
+
},
|
|
91
|
+
"./eslint": {
|
|
92
|
+
types: "./dist/eslint/index.d.ts",
|
|
93
|
+
import: "./dist/eslint/index.js"
|
|
94
|
+
},
|
|
87
95
|
"./next/connections": {
|
|
88
96
|
types: "./dist/next/connections/index.d.ts",
|
|
89
97
|
import: "./dist/next/connections/index.js"
|
|
@@ -154,12 +162,16 @@ var package_default = {
|
|
|
154
162
|
"@better-auth/core": ">=1.6",
|
|
155
163
|
"better-auth": ">=1.6",
|
|
156
164
|
"drizzle-orm": ">=0.36",
|
|
165
|
+
eslint: ">=9",
|
|
157
166
|
next: ">=15",
|
|
158
167
|
pg: ">=8.13",
|
|
159
168
|
react: ">=18",
|
|
160
169
|
"react-dom": ">=18"
|
|
161
170
|
},
|
|
162
171
|
peerDependenciesMeta: {
|
|
172
|
+
eslint: {
|
|
173
|
+
optional: true
|
|
174
|
+
},
|
|
163
175
|
react: {
|
|
164
176
|
optional: true
|
|
165
177
|
},
|
|
@@ -427,4 +439,4 @@ function startNodeSdk(opts) {
|
|
|
427
439
|
export {
|
|
428
440
|
startNodeSdk
|
|
429
441
|
};
|
|
430
|
-
//# sourceMappingURL=chunk-
|
|
442
|
+
//# sourceMappingURL=chunk-23PPOFZ2.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/observability-core/start-node-sdk.ts","../src/observability-core/resource.ts","../package.json","../src/observability-core/noise-filter.ts","../src/observability-core/http-sanitize.ts","../src/observability-core/span-scrub.ts","../src/observability-core/scrub-span-processor.ts","../src/observability-core/format-fatal.ts"],"sourcesContent":["// Shared NodeSDK bootstrap for every server runtime (Next and plain Node). The\n// only per-runtime difference is the noise-prefix list. Importing this module's\n// caller (a register entry) must happen before pino/node:http are first imported\n// so the instrumentations can patch them.\nimport { NodeSDK } from \"@opentelemetry/sdk-node\"\nimport { OTLPTraceExporter } from \"@opentelemetry/exporter-trace-otlp-http\"\nimport { OTLPMetricExporter } from \"@opentelemetry/exporter-metrics-otlp-http\"\nimport { OTLPLogExporter } from \"@opentelemetry/exporter-logs-otlp-http\"\nimport { PeriodicExportingMetricReader } from \"@opentelemetry/sdk-metrics\"\nimport { BatchLogRecordProcessor } from \"@opentelemetry/sdk-logs\"\nimport { BatchSpanProcessor } from \"@opentelemetry/sdk-trace-base\"\nimport { HttpInstrumentation } from \"@opentelemetry/instrumentation-http\"\nimport { UndiciInstrumentation } from \"@opentelemetry/instrumentation-undici\"\nimport { PinoInstrumentation } from \"@opentelemetry/instrumentation-pino\"\nimport { PgInstrumentation } from \"@opentelemetry/instrumentation-pg\"\nimport { buildResource } from \"./resource\"\nimport { qdcSanitizeHttp, qdcTracesSampleRatio, qdcEnhancedDbReporting, qdcHealthPath } from \"./env\"\nimport { shouldIgnoreIncoming, shouldIgnoreOutgoing } from \"./noise-filter\"\nimport { sanitizeHttpSpanAttributes } from \"./http-sanitize\"\nimport { ScrubSpanProcessor } from \"./scrub-span-processor\"\nimport { formatFatal } from \"./format-fatal\"\n\nexport interface StartNodeSdkOptions {\n noisePrefixes: string[]\n}\n\nexport function startNodeSdk(opts: StartNodeSdkOptions): NodeSDK {\n const { noisePrefixes } = opts\n\n // Sampling defaults before NodeSDK reads env. Default 100% in all envs; an\n // explicit OTEL_TRACES_SAMPLER always wins.\n if (!process.env.OTEL_TRACES_SAMPLER) process.env.OTEL_TRACES_SAMPLER = \"parentbased_traceidratio\"\n if (!process.env.OTEL_TRACES_SAMPLER_ARG) process.env.OTEL_TRACES_SAMPLER_ARG = qdcTracesSampleRatio()\n\n const healthPath = qdcHealthPath()\n\n const httpInstrumentation = new HttpInstrumentation({\n ignoreIncomingRequestHook: (req) => shouldIgnoreIncoming(req, { healthPath, noisePrefixes }),\n ignoreOutgoingRequestHook: (req) => shouldIgnoreOutgoing({ path: req.path ?? undefined }, { healthPath, noisePrefixes }),\n ...(qdcSanitizeHttp()\n ? { requestHook: (span, request) => sanitizeHttpSpanAttributes(span, request as { url?: string; path?: string }) }\n : {}),\n })\n\n // Cross-module / cross-service propagation: global fetch (undici) is the natural\n // way a Next server and a Node backend call CONN_<ALIAS>_URL. instrumentation-http\n // does NOT see undici, so without this the traceparent is never injected and the\n // hop starts a new trace. Injection is unconditional on origin (server-to-server,\n // no CORS) — that is what continues the trace across modules.\n const undiciInstrumentation = new UndiciInstrumentation({\n ignoreRequestHook: (request) =>\n shouldIgnoreOutgoing({ path: (request as { path?: string }).path }, { healthPath, noisePrefixes }),\n ...(qdcSanitizeHttp()\n ? { requestHook: (span, request) =>\n sanitizeHttpSpanAttributes(span, { path: (request as { path?: string }).path }, \"stable\") }\n : {}),\n })\n\n // DB spans. Full SQL + values only off-prod (synthetic data); parametrized only\n // in prod-like envs. `requireParentSpan` drops queries that run with no active\n // parent span — connection-pool warmup, keepalive pings, startup/migration — so\n // each no longer surfaces as its own root trace. Queries inside an HTTP request\n // (or an `observe()` wrapper) still nest under that span and are kept.\n const pgInstrumentation = new PgInstrumentation({\n enhancedDatabaseReporting: qdcEnhancedDbReporting(),\n requireParentSpan: true,\n })\n\n let sdk: NodeSDK\n try {\n sdk = new NodeSDK({\n resource: buildResource(),\n spanProcessors: [new ScrubSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter()))],\n metricReader: new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter() }),\n logRecordProcessor: new BatchLogRecordProcessor(new OTLPLogExporter()),\n instrumentations: [httpInstrumentation, undiciInstrumentation, pgInstrumentation, new PinoInstrumentation()],\n })\n } catch (err: unknown) {\n process.stderr.write(`[qdc-sdk] initialization failed: ${err}\\n`)\n process.exit(1)\n }\n\n try {\n sdk.start()\n } catch (err: unknown) {\n process.stderr.write(`[qdc-sdk] SDK start failed: ${err}\\n`)\n process.exit(1)\n }\n\n const gracefulShutdown = async (signal: string): Promise<void> => {\n process.stderr.write(`[qdc-sdk] ${signal} received, shutting down\\n`)\n try { await sdk.shutdown() } catch { /* ignore */ }\n process.exit(0)\n }\n process.on(\"SIGTERM\", () => void gracefulShutdown(\"SIGTERM\"))\n process.on(\"SIGINT\", () => void gracefulShutdown(\"SIGINT\"))\n process.on(\"uncaughtException\", async (err) => {\n process.stderr.write(`[qdc-sdk] fatal: ${formatFatal(err)}\\n`)\n try { await sdk.shutdown() } finally { process.exit(1) }\n })\n process.on(\"unhandledRejection\", async (reason) => {\n process.stderr.write(`[qdc-sdk] fatal: ${formatFatal(reason)}\\n`)\n try { await sdk.shutdown() } finally { process.exit(1) }\n })\n\n return sdk\n}\n","import { type Resource, resourceFromAttributes } from \"@opentelemetry/resources\"\nimport { hostname } from \"node:os\"\nimport { qdcAppId, qdcCustomerId, qdcEnv, qdcModule } from \"./env\"\nimport pkg from \"../../package.json\" with { type: \"json\" }\n\nconst SDK_VERSION = (pkg as { version: string }).version\n\n/**\n * Maps the QDC identity env vars to the OpenTelemetry resource attributes used\n * by the SDK's server-side observability. The NodeSDK carries this resource on\n * every span and log record (the latter via the in-process PinoInstrumentation),\n * so it is the single source of truth for tenant labels.\n *\n * Identity is read from NEXT_PUBLIC_QDC_* with a fallback to legacy QDC_* (see\n * ./env). Throws if the customer or app id are missing — required to land logs\n * and traces under the correct tenant namespace.\n */\nexport function buildResourceAttributes(): Record<string, string> {\n const customerId = qdcCustomerId()\n if (!customerId)\n throw new Error(\"NEXT_PUBLIC_QDC_CUSTOMER_ID (or legacy QDC_CUSTOMER_ID) env var is required\")\n\n const appId = qdcAppId()\n if (!appId)\n throw new Error(\"NEXT_PUBLIC_QDC_APP_ID (or legacy QDC_APP_ID) env var is required\")\n\n const env = qdcEnv()\n\n const attrs: Record<string, string> = {\n \"service.name\": appId,\n \"service.namespace\": customerId,\n \"deployment.environment\": env,\n \"host.name\": hostname(),\n \"qdc.customer_id\": customerId,\n \"qdc.app_id\": appId,\n \"qdc.sdk.version\": SDK_VERSION,\n }\n const mod = qdcModule()\n if (mod) attrs[\"qdc.module\"] = mod\n return attrs\n}\n\nexport function buildResource(): Resource {\n return resourceFromAttributes(buildResourceAttributes())\n}\n","{\n \"name\": \"@quatronic/sdk\",\n \"version\": \"0.3.0\",\n \"type\": \"module\",\n \"private\": false,\n \"publishConfig\": {\n \"registry\": \"https://registry.npmjs.org\",\n \"access\": \"public\"\n },\n \"files\": [\n \"dist\",\n \"semgrep\"\n ],\n \"bin\": {\n \"qdc-setup-env\": \"./dist/cli/setup-env.js\"\n },\n \"exports\": {\n \"./auth\": {\n \"types\": \"./dist/auth/index.d.ts\",\n \"import\": \"./dist/auth/index.js\"\n },\n \"./next/auth\": {\n \"types\": \"./dist/next/auth/index.d.ts\",\n \"import\": \"./dist/next/auth/index.js\"\n },\n \"./next/better-auth\": {\n \"types\": \"./dist/next/better-auth/index.d.ts\",\n \"import\": \"./dist/next/better-auth/index.js\"\n },\n \"./observability\": {\n \"types\": \"./dist/observability/index.d.ts\",\n \"import\": \"./dist/observability/index.js\"\n },\n \"./next/observability\": {\n \"types\": \"./dist/next/observability/index.d.ts\",\n \"import\": \"./dist/next/observability/index.js\"\n },\n \"./next/observability/register\": {\n \"types\": \"./dist/next/observability/register.d.ts\",\n \"import\": \"./dist/next/observability/register.js\"\n },\n \"./node/observability\": {\n \"types\": \"./dist/node/observability/index.d.ts\",\n \"import\": \"./dist/node/observability/index.js\"\n },\n \"./node/observability/register\": {\n \"types\": \"./dist/node/observability/register.d.ts\",\n \"import\": \"./dist/node/observability/register.js\"\n },\n \"./next/config\": {\n \"types\": \"./dist/next/config/index.d.ts\",\n \"import\": \"./dist/next/config/index.js\"\n },\n \"./next/security\": {\n \"types\": \"./dist/next/security/index.d.ts\",\n \"import\": \"./dist/next/security/index.js\"\n },\n \"./next/security/testing\": {\n \"types\": \"./dist/next/security/testing.d.ts\",\n \"import\": \"./dist/next/security/testing.js\"\n },\n \"./eslint\": {\n \"types\": \"./dist/eslint/index.d.ts\",\n \"import\": \"./dist/eslint/index.js\"\n },\n \"./next/connections\": {\n \"types\": \"./dist/next/connections/index.d.ts\",\n \"import\": \"./dist/next/connections/index.js\"\n },\n \"./node/connections\": {\n \"types\": \"./dist/node/connections/index.d.ts\",\n \"import\": \"./dist/node/connections/index.js\"\n },\n \"./node/net\": {\n \"types\": \"./dist/node/net/index.d.ts\",\n \"import\": \"./dist/node/net/index.js\"\n },\n \"./node/fspath\": {\n \"types\": \"./dist/node/fspath/index.d.ts\",\n \"import\": \"./dist/node/fspath/index.js\"\n },\n \"./node/parse\": {\n \"types\": \"./dist/node/parse/index.d.ts\",\n \"import\": \"./dist/node/parse/index.js\"\n },\n \"./node/identity\": {\n \"types\": \"./dist/node/identity/index.d.ts\",\n \"import\": \"./dist/node/identity/index.js\"\n },\n \"./node/db\": {\n \"types\": \"./dist/node/db/index.d.ts\",\n \"import\": \"./dist/node/db/index.js\"\n },\n \"./next/db\": {\n \"types\": \"./dist/next/db/index.d.ts\",\n \"import\": \"./dist/next/db/index.js\"\n }\n },\n \"scripts\": {\n \"build\": \"tsup\",\n \"surface\": \"node scripts/api-surface.mjs\",\n \"surface:check\": \"node scripts/api-surface.mjs --check\",\n \"prepublishOnly\": \"pnpm build\",\n \"test\": \"vitest run\",\n \"test:watch\": \"vitest\",\n \"typecheck\": \"tsc --noEmit && tsc -p tsconfig.test.json\",\n \"lint\": \"eslint src tests\",\n \"calibrate\": \"node scripts/calibrate.mjs\"\n },\n \"dependencies\": {\n \"@opentelemetry/api\": \"^1.9.1\",\n \"@opentelemetry/context-zone\": \"2.7.1\",\n \"@opentelemetry/exporter-logs-otlp-http\": \"^0.218.0\",\n \"@opentelemetry/exporter-metrics-otlp-http\": \"^0.218.0\",\n \"@opentelemetry/exporter-trace-otlp-http\": \"^0.218.0\",\n \"@opentelemetry/instrumentation\": \"^0.218.0\",\n \"@opentelemetry/instrumentation-document-load\": \"^0.63.0\",\n \"@opentelemetry/instrumentation-fetch\": \"^0.218.0\",\n \"@opentelemetry/instrumentation-http\": \"^0.218.0\",\n \"@opentelemetry/instrumentation-pg\": \"^0.71.0\",\n \"@opentelemetry/instrumentation-pino\": \"^0.64.0\",\n \"@opentelemetry/instrumentation-undici\": \"^0.28.0\",\n \"@opentelemetry/resources\": \"^2.7.1\",\n \"@opentelemetry/sdk-logs\": \"^0.218.0\",\n \"@opentelemetry/sdk-metrics\": \"^2.7.1\",\n \"@opentelemetry/sdk-node\": \"^0.218.0\",\n \"@opentelemetry/sdk-trace-base\": \"2.7.1\",\n \"@opentelemetry/sdk-trace-web\": \"2.7.1\",\n \"jose\": \"^5.9.6\",\n \"pino\": \"^10.3.1\"\n },\n \"peerDependencies\": {\n \"@better-auth/core\": \">=1.6\",\n \"better-auth\": \">=1.6\",\n \"drizzle-orm\": \">=0.36\",\n \"eslint\": \">=9\",\n \"next\": \">=15\",\n \"pg\": \">=8.13\",\n \"react\": \">=18\",\n \"react-dom\": \">=18\"\n },\n \"peerDependenciesMeta\": {\n \"eslint\": {\n \"optional\": true\n },\n \"react\": {\n \"optional\": true\n },\n \"react-dom\": {\n \"optional\": true\n },\n \"next\": {\n \"optional\": true\n },\n \"drizzle-orm\": {\n \"optional\": true\n },\n \"pg\": {\n \"optional\": true\n },\n \"better-auth\": {\n \"optional\": true\n },\n \"@better-auth/core\": {\n \"optional\": true\n }\n },\n \"devDependencies\": {\n \"@better-auth/core\": \"^1.6.24\",\n \"@eslint/js\": \"^10.0.1\",\n \"@opentelemetry/instrumentation\": \"^0.218.0\",\n \"@opentelemetry/sdk-trace-base\": \"^2.7.0\",\n \"@testcontainers/postgresql\": \"^12.0.0\",\n \"@testing-library/react\": \"^16.1.0\",\n \"@types/node\": \"^22.10.2\",\n \"@types/pg\": \"^8.11.10\",\n \"@types/react\": \"^18.3.17\",\n \"@types/react-dom\": \"^18.3.5\",\n \"@vitest/coverage-v8\": \"^2.1.9\",\n \"better-auth\": \"^1.6.24\",\n \"drizzle-kit\": \"^0.30.1\",\n \"drizzle-orm\": \"^0.38.2\",\n \"eslint\": \"^10.5.0\",\n \"jsdom\": \"^25.0.1\",\n \"msw\": \"^2.7.0\",\n \"next\": \"^15.1.0\",\n \"pg\": \"^8.13.1\",\n \"react\": \"^18.3.1\",\n \"react-dom\": \"^18.3.1\",\n \"testcontainers\": \"^10.16.0\",\n \"tsup\": \"^8.3.5\",\n \"typescript\": \"^5.7.2\",\n \"typescript-eslint\": \"^8.61.1\",\n \"vitest\": \"^2.1.8\"\n }\n}\n","// Pure predicate deciding whether an HTTP request is infrastructural noise that\n// should never become a span. Framework-agnostic rules are built in: favicon,\n// source maps, OPTIONS, RSC navigations/prefetches (`?_rsc=` query + RSC headers),\n// and Next's `/_next/*` internals. Callers pass any additional environment-specific\n// prefixes (and an optional health path) via NoiseFilterOptions.\n\nexport interface NoiseFilterOptions {\n healthPath?: string\n noisePrefixes?: string[]\n}\n\n// `/api/auth/me` is the SDK's own session poll (PortalProvider fires it on every\n// page mount) — a heartbeat, not user activity. The meaningful auth flows keep\n// their labeled spans (Log in, Complete login, Log out, Refresh session);\n// dropping the inbound span here also drops its pg children (requireParentSpan).\nconst NOISE_EXACT = new Set([\"/favicon.ico\", \"/api/auth/me\"])\n\n// React Server Components navigations/prefetches/data-fetches. Next signals them\n// with a `?_rsc=` query param and/or an `RSC` / `Next-Router-Prefetch` request\n// header. They are real-looking routes, so a path-prefix filter misses them.\n// NOTE: `Next-Router-State-Tree` is intentionally NOT in this list — Next.js\n// sends it on server actions too (POST + Next-Action), not only on RSC navigations,\n// so it is not a safe RSC discriminator.\nconst RSC_QUERY_RE = /[?&]_rsc=/\nconst RSC_HEADERS = [\"rsc\", \"next-router-prefetch\"]\n\nexport function isNoisePath(rawUrl: string, opts: NoiseFilterOptions = {}): boolean {\n if (RSC_QUERY_RE.test(rawUrl)) return true\n const path = (rawUrl.split(\"?\")[0] ?? \"\").trim()\n if (!path) return false\n if (NOISE_EXACT.has(path)) return true\n if (path.endsWith(\".map\")) return true\n if (path.startsWith(\"/_next/\")) return true\n if ((opts.noisePrefixes ?? []).some((p) => path.startsWith(p))) return true\n if (opts.healthPath && path === opts.healthPath) return true\n return false\n}\n\nexport function shouldIgnoreIncoming(\n req: { url?: string; method?: string; headers?: Record<string, unknown> },\n opts: NoiseFilterOptions = {},\n): boolean {\n if ((req.method ?? \"\").toUpperCase() === \"OPTIONS\") return true\n const headers = req.headers ?? {}\n // A Next.js server action is real work — never noise — even though it also\n // carries router headers. Exempt it explicitly so its inbound span (and the\n // DB child gated by pg requireParentSpan) is preserved.\n if (headers[\"next-action\"] != null) return false\n if (RSC_HEADERS.some((h) => headers[h] != null)) return true\n return isNoisePath(req.url ?? \"\", opts)\n}\n\nexport function shouldIgnoreOutgoing(\n req: { path?: string },\n opts: NoiseFilterOptions = {},\n): boolean {\n return isNoisePath(req.path ?? \"\", opts)\n}\n","// Pure HTTP-span PII sanitizer. No OTel SDK import so it is unit-testable in\n// isolation. register.ts wires sanitizeHttpSpanAttributes into the\n// HttpInstrumentation requestHook when QDC_OTEL_SANITIZE_HTTP is on.\n\nconst REDACTED = \"[redacted]\"\n\nexport type SemconvMode = \"legacy\" | \"stable\" | \"dup\"\n\n// instrumentation-http emits legacy attribute names by default and the stable\n// names (url.full/url.query/url.path) only when OTEL_SEMCONV_STABILITY_OPT_IN\n// selects \"http\" (stable only) or \"http/dup\" (both).\nexport function httpSemconvMode(\n optIn: string | undefined = process.env.OTEL_SEMCONV_STABILITY_OPT_IN,\n): SemconvMode {\n const tokens = (optIn ?? \"\").split(\",\").map((t) => t.trim().toLowerCase())\n if (tokens.includes(\"http/dup\")) return \"dup\"\n if (tokens.includes(\"http\")) return \"stable\"\n return \"legacy\"\n}\n\ninterface SpanLike {\n setAttribute(key: string, value: string): unknown\n}\n\ninterface RequestLike {\n url?: string // IncomingMessage (server spans): path + query\n path?: string // ClientRequest (outgoing spans): path + query\n}\n\nexport function sanitizeHttpSpanAttributes(\n span: SpanLike,\n request: RequestLike,\n mode: SemconvMode = httpSemconvMode(),\n): void {\n const raw = request.url ?? request.path ?? \"\"\n const queryStart = raw.indexOf(\"?\")\n const hasQuery = queryStart !== -1\n const path = hasQuery ? raw.slice(0, queryStart) : raw\n\n // Legacy semconv: redact http.url (once the query is gone it only duplicates\n // http.host + http.target); http.target keeps the path. Skip http.target when\n // we have no path info, so we don't clobber the instrumentation's \"/\" default.\n if (mode === \"legacy\" || mode === \"dup\") {\n if (path) span.setAttribute(\"http.target\", path)\n span.setAttribute(\"http.url\", REDACTED)\n }\n\n // Stable semconv: the query lives in url.full / url.query; url.path is already\n // query-less, so leave it intact. Only redact url.query when a query was\n // actually present — the instrumentation omits it otherwise, and writing it\n // unconditionally would falsely signal a redacted query on clean paths.\n if (mode === \"stable\" || mode === \"dup\") {\n span.setAttribute(\"url.full\", REDACTED)\n if (hasQuery) span.setAttribute(\"url.query\", REDACTED)\n }\n}\n","// Leaf-noise predicate + attribute redaction, pure so they unit-test without the\n// OTel SDK. Wired into export by scrub-span-processor.ts. Contract:\n// docs (QDC spec 2026-07-02): the SDK drops ONLY leaf spans — spans that never\n// have children — so parent/child chains can never break. Structural framework\n// spans (BaseServer.handleRequest, AppRender.fetch, undici) stay; the portal\n// folds those at render time.\n\nexport const REDACTED = \"●●●\"\n\n// next.span_type values that are pure leaves and never carry information a\n// developer needs: sub-millisecond router/renderer bookkeeping.\nconst NEXT_LEAF_NOISE = new Set([\n \"Middleware.execute\",\n \"NextNodeServer.findPageComponents\",\n \"NextNodeServer.startResponse\",\n])\n\n// pg connection-pool bookkeeping; the actual query span (pg.query:*) stays.\nconst NOISE_SPAN_NAMES = new Set([\"pg-pool.connect\", \"pg.connect\"])\n\nexport function isNoiseLeafSpan(name: string, attributes: Record<string, unknown>): boolean {\n const nextType = attributes[\"next.span_type\"]\n if (typeof nextType === \"string\" && NEXT_LEAF_NOISE.has(nextType)) return true\n return NOISE_SPAN_NAMES.has(name)\n}\n\nconst REDACT_KEYS = new Set([\"db.connection_string\", \"db.user\"])\nconst REDACT_PREFIXES = [\"net.peer.\", \"net.sock.\", \"net.host.\", \"network.peer.\", \"network.local.\"]\n\nexport function redactSpanAttributes(attributes: Record<string, unknown>): void {\n for (const key of Object.keys(attributes)) {\n if (REDACT_KEYS.has(key) || REDACT_PREFIXES.some((p) => key.startsWith(p))) {\n attributes[key] = REDACTED\n }\n }\n}\n","import type { Context } from \"@opentelemetry/api\"\nimport type { ReadableSpan, Span, SpanProcessor } from \"@opentelemetry/sdk-trace-base\"\nimport { isNoiseLeafSpan, redactSpanAttributes } from \"./span-scrub\"\n\n/** Wraps the export processor: drops leaf-noise spans and redacts sensitive\n * attributes on everything else. Runs in ALL envs — connection strings and\n * internal IPs never belong in the telemetry backend. */\nexport class ScrubSpanProcessor implements SpanProcessor {\n constructor(private readonly inner: SpanProcessor) {}\n\n onStart(span: Span, parentContext: Context): void {\n this.inner.onStart(span, parentContext)\n }\n\n onEnd(span: ReadableSpan): void {\n if (isNoiseLeafSpan(span.name, span.attributes as Record<string, unknown>)) return\n redactSpanAttributes(span.attributes as Record<string, unknown>)\n this.inner.onEnd(span)\n }\n\n forceFlush(): Promise<void> {\n return this.inner.forceFlush()\n }\n\n shutdown(): Promise<void> {\n return this.inner.shutdown()\n }\n}\n","// A crash that reaches the SDK's last-resort handlers is often the only thing an\n// operator ever sees about it: the process dies before any log exporter flushes,\n// so all that survives is this one stderr line in the platform's container log.\n// `${err}` would reduce it to \"Error: self-signed certificate\" — no stack, no\n// cause, no error code, i.e. no way to tell which of the app's boot steps threw.\nexport function formatFatal(err: unknown): string {\n if (!(err instanceof Error)) return String(err)\n\n let out = err.stack ?? `${err.name}: ${err.message}`\n\n const code = (err as { code?: unknown }).code\n if (typeof code === \"string\" || typeof code === \"number\") out += `\\n code: ${code}`\n\n // Fetch/undici and pg wrap the real failure in `cause`; without it a boot\n // crash reads as a generic \"TypeError: fetch failed\".\n let cause: unknown = (err as { cause?: unknown }).cause\n let depth = 0\n while (cause !== undefined && depth < 3) {\n const rendered = cause instanceof Error ? (cause.stack ?? `${cause.name}: ${cause.message}`) : String(cause)\n out += `\\n caused by: ${rendered}`\n cause = cause instanceof Error ? (cause as { cause?: unknown }).cause : undefined\n depth += 1\n }\n\n return out\n}\n"],"mappings":"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","names":["REDACTED"]}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/security-core/parse.ts
|
|
2
|
+
var BoundedJsonError = class extends Error {
|
|
3
|
+
reason;
|
|
4
|
+
constructor(reason, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "BoundedJsonError";
|
|
7
|
+
this.reason = reason;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
var DEFAULT_JSON_BOUNDS = {
|
|
11
|
+
maxBytes: 1024 * 1024,
|
|
12
|
+
maxDepth: 32
|
|
13
|
+
};
|
|
14
|
+
function utf8Length(text) {
|
|
15
|
+
return new TextEncoder().encode(text).length;
|
|
16
|
+
}
|
|
17
|
+
function assertDepth(text, maxDepth) {
|
|
18
|
+
let depth = 0;
|
|
19
|
+
let inString = false;
|
|
20
|
+
let escaped = false;
|
|
21
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
22
|
+
const char = text[index];
|
|
23
|
+
if (inString) {
|
|
24
|
+
if (escaped) escaped = false;
|
|
25
|
+
else if (char === "\\") escaped = true;
|
|
26
|
+
else if (char === '"') inString = false;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (char === '"') {
|
|
30
|
+
inString = true;
|
|
31
|
+
} else if (char === "{" || char === "[") {
|
|
32
|
+
depth += 1;
|
|
33
|
+
if (depth > maxDepth) {
|
|
34
|
+
throw new BoundedJsonError(
|
|
35
|
+
"too_deep",
|
|
36
|
+
`json nests deeper than ${maxDepth} levels at offset ${index}`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
} else if (char === "}" || char === "]") {
|
|
40
|
+
depth -= 1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function parseJsonBounded(text, bounds = {}) {
|
|
45
|
+
const maxBytes = bounds.maxBytes ?? DEFAULT_JSON_BOUNDS.maxBytes;
|
|
46
|
+
const maxDepth = bounds.maxDepth ?? DEFAULT_JSON_BOUNDS.maxDepth;
|
|
47
|
+
if (text.length > maxBytes) {
|
|
48
|
+
throw new BoundedJsonError("too_large", `json exceeds ${maxBytes} bytes`);
|
|
49
|
+
}
|
|
50
|
+
const bytes = utf8Length(text);
|
|
51
|
+
if (bytes > maxBytes) {
|
|
52
|
+
throw new BoundedJsonError("too_large", `json is ${bytes} bytes, over the ${maxBytes} cap`);
|
|
53
|
+
}
|
|
54
|
+
assertDepth(text, maxDepth);
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(text);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw new BoundedJsonError("malformed", `json did not parse: ${String(error)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export {
|
|
63
|
+
BoundedJsonError,
|
|
64
|
+
DEFAULT_JSON_BOUNDS,
|
|
65
|
+
parseJsonBounded
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=chunk-FUPTRHT7.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/security-core/parse.ts"],"sourcesContent":["export type BoundedJsonReason = \"too_large\" | \"too_deep\" | \"malformed\"\n\n/**\n * A refusal to parse, whether because the payload was outside the bounds or\n * because it was not JSON at all. One error type so a route handler has one\n * `catch` to write.\n */\nexport class BoundedJsonError extends Error {\n readonly reason: BoundedJsonReason\n\n constructor(reason: BoundedJsonReason, message: string) {\n super(message)\n this.name = \"BoundedJsonError\"\n this.reason = reason\n }\n}\n\nexport interface JsonBounds {\n /** UTF-8 bytes of input to accept. Default: 1 MiB. */\n maxBytes?: number\n /** Nesting of objects and arrays to accept. Default: 32. */\n maxDepth?: number\n}\n\nexport const DEFAULT_JSON_BOUNDS: Required<JsonBounds> = {\n maxBytes: 1024 * 1024,\n maxDepth: 32,\n}\n\nfunction utf8Length(text: string): number {\n // One UTF-8 byte per UTF-16 code unit is the floor, so a string longer than\n // the cap in code units is already over it — checked by the caller before\n // this runs, which keeps the encode bounded no matter what arrives.\n return new TextEncoder().encode(text).length\n}\n\n/**\n * Measure nesting without building anything.\n *\n * This runs *before* `JSON.parse`, which is the whole point: by the time the\n * parser has materialised a 100,000-deep array the memory is already spent,\n * and whatever walks the result recursively is already the next victim.\n */\nfunction assertDepth(text: string, maxDepth: number): void {\n let depth = 0\n let inString = false\n let escaped = false\n\n for (let index = 0; index < text.length; index += 1) {\n const char = text[index]\n\n if (inString) {\n if (escaped) escaped = false\n else if (char === \"\\\\\") escaped = true\n else if (char === '\"') inString = false\n continue\n }\n\n if (char === '\"') {\n inString = true\n } else if (char === \"{\" || char === \"[\") {\n depth += 1\n if (depth > maxDepth) {\n throw new BoundedJsonError(\n \"too_deep\",\n `json nests deeper than ${maxDepth} levels at offset ${index}`,\n )\n }\n } else if (char === \"}\" || char === \"]\") {\n // A closer too many is malformed input; leave saying so to JSON.parse,\n // which reports the offset far better than this scanner could.\n depth -= 1\n }\n }\n}\n\n/**\n * `JSON.parse` with the two bounds an untrusted payload needs.\n *\n * The result is `unknown` on purpose. A generic parameter here would let a\n * caller name a type the bytes never had to match, which is how \"validated\n * input\" turns into a lie; validate the result with your schema of choice.\n */\nexport function parseJsonBounded(text: string, bounds: JsonBounds = {}): unknown {\n const maxBytes = bounds.maxBytes ?? DEFAULT_JSON_BOUNDS.maxBytes\n const maxDepth = bounds.maxDepth ?? DEFAULT_JSON_BOUNDS.maxDepth\n\n if (text.length > maxBytes) {\n throw new BoundedJsonError(\"too_large\", `json exceeds ${maxBytes} bytes`)\n }\n const bytes = utf8Length(text)\n if (bytes > maxBytes) {\n throw new BoundedJsonError(\"too_large\", `json is ${bytes} bytes, over the ${maxBytes} cap`)\n }\n\n assertDepth(text, maxDepth)\n\n try {\n return JSON.parse(text) as unknown\n } catch (error) {\n throw new BoundedJsonError(\"malformed\", `json did not parse: ${String(error)}`)\n }\n}\n"],"mappings":";AAOO,IAAM,mBAAN,cAA+B,MAAM;AAAA,EACjC;AAAA,EAET,YAAY,QAA2B,SAAiB;AACtD,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,SAAS;AAAA,EAChB;AACF;AASO,IAAM,sBAA4C;AAAA,EACvD,UAAU,OAAO;AAAA,EACjB,UAAU;AACZ;AAEA,SAAS,WAAW,MAAsB;AAIxC,SAAO,IAAI,YAAY,EAAE,OAAO,IAAI,EAAE;AACxC;AASA,SAAS,YAAY,MAAc,UAAwB;AACzD,MAAI,QAAQ;AACZ,MAAI,WAAW;AACf,MAAI,UAAU;AAEd,WAAS,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS,GAAG;AACnD,UAAM,OAAO,KAAK,KAAK;AAEvB,QAAI,UAAU;AACZ,UAAI,QAAS,WAAU;AAAA,eACd,SAAS,KAAM,WAAU;AAAA,eACzB,SAAS,IAAK,YAAW;AAClC;AAAA,IACF;AAEA,QAAI,SAAS,KAAK;AAChB,iBAAW;AAAA,IACb,WAAW,SAAS,OAAO,SAAS,KAAK;AACvC,eAAS;AACT,UAAI,QAAQ,UAAU;AACpB,cAAM,IAAI;AAAA,UACR;AAAA,UACA,0BAA0B,QAAQ,qBAAqB,KAAK;AAAA,QAC9D;AAAA,MACF;AAAA,IACF,WAAW,SAAS,OAAO,SAAS,KAAK;AAGvC,eAAS;AAAA,IACX;AAAA,EACF;AACF;AASO,SAAS,iBAAiB,MAAc,SAAqB,CAAC,GAAY;AAC/E,QAAM,WAAW,OAAO,YAAY,oBAAoB;AACxD,QAAM,WAAW,OAAO,YAAY,oBAAoB;AAExD,MAAI,KAAK,SAAS,UAAU;AAC1B,UAAM,IAAI,iBAAiB,aAAa,gBAAgB,QAAQ,QAAQ;AAAA,EAC1E;AACA,QAAM,QAAQ,WAAW,IAAI;AAC7B,MAAI,QAAQ,UAAU;AACpB,UAAM,IAAI,iBAAiB,aAAa,WAAW,KAAK,oBAAoB,QAAQ,MAAM;AAAA,EAC5F;AAEA,cAAY,MAAM,QAAQ;AAE1B,MAAI;AACF,WAAO,KAAK,MAAM,IAAI;AAAA,EACxB,SAAS,OAAO;AACd,UAAM,IAAI,iBAAiB,aAAa,uBAAuB,OAAO,KAAK,CAAC,EAAE;AAAA,EAChF;AACF;","names":[]}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// src/security-core/proxy.ts
|
|
2
|
+
var ALWAYS_EXCLUDED = ["_next/static", "_next/image", "favicon.ico"];
|
|
3
|
+
var SAFE_EXCLUSION = /^[A-Za-z0-9._/-]+$/;
|
|
4
|
+
function proxyMatcher(alsoExclude = []) {
|
|
5
|
+
for (const value of alsoExclude) {
|
|
6
|
+
if (!SAFE_EXCLUSION.test(value)) {
|
|
7
|
+
throw new Error(
|
|
8
|
+
`Invalid proxy matcher exclusion ${JSON.stringify(value)}: path segments only, [A-Za-z0-9._/-]`
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const excluded = [.../* @__PURE__ */ new Set([...alsoExclude, ...ALWAYS_EXCLUDED])];
|
|
13
|
+
return [`/((?!${excluded.join("|")}).*)`];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
ALWAYS_EXCLUDED,
|
|
18
|
+
proxyMatcher
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=chunk-X6D3TBNS.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/security-core/proxy.ts"],"sourcesContent":["// The proxy matcher. Pure string work, no `next` import.\n\n/**\n * Paths the QDC proxy never runs on: build output and the image optimizer,\n * which serve no HTML and therefore carry no nonce.\n */\nexport const ALWAYS_EXCLUDED = [\"_next/static\", \"_next/image\", \"favicon.ico\"]\n\n// An exclusion is interpolated into a regular expression, so a value carrying\n// metacharacters would rewrite the matcher rather than extend it — and a\n// matcher that quietly matches nothing is a security policy that quietly does\n// not run. Path segments only.\nconst SAFE_EXCLUSION = /^[A-Za-z0-9._/-]+$/\n\n/**\n * Builds the `config.matcher` pattern for a QDC app's `src/proxy.ts`.\n *\n * **Do not call this inside `export const config`.** Next does not execute the\n * proxy module to read that object — it extracts the value statically from the\n * AST, and a call expression is not a value it can extract. `next dev` then\n * throws `E1013` and `next build` exits 1 with \"Invalid segment configuration\n * export detected\". The matcher has to be a literal array in the app, the same\n * way `src/proxy.ts` itself has to sit at a fixed repo path.\n *\n * So this is the definition of what that literal should say, not a substitute\n * for writing it: the template renders its proxies from this function's output,\n * and `proxyMatcher.test.ts` pins the three recipe matchers against it, so a\n * change here is caught rather than silently diverging.\n *\n * `alsoExclude` is for routes an app reaches without a session — an auth\n * module's login and password-reset pages, and `api`, which answers with JSON\n * rather than HTML. Exclusions are listed before the always-excluded paths, so\n * the rendered pattern reads in the order it was written.\n */\nexport function proxyMatcher(alsoExclude: readonly string[] = []): string[] {\n for (const value of alsoExclude) {\n if (!SAFE_EXCLUSION.test(value)) {\n throw new Error(\n `Invalid proxy matcher exclusion ${JSON.stringify(value)}: path segments only, [A-Za-z0-9._/-]`,\n )\n }\n }\n const excluded = [...new Set([...alsoExclude, ...ALWAYS_EXCLUDED])]\n return [`/((?!${excluded.join(\"|\")}).*)`]\n}\n"],"mappings":";AAMO,IAAM,kBAAkB,CAAC,gBAAgB,eAAe,aAAa;AAM5E,IAAM,iBAAiB;AAsBhB,SAAS,aAAa,cAAiC,CAAC,GAAa;AAC1E,aAAW,SAAS,aAAa;AAC/B,QAAI,CAAC,eAAe,KAAK,KAAK,GAAG;AAC/B,YAAM,IAAI;AAAA,QACR,mCAAmC,KAAK,UAAU,KAAK,CAAC;AAAA,MAC1D;AAAA,IACF;AAAA,EACF;AACA,QAAM,WAAW,CAAC,GAAG,oBAAI,IAAI,CAAC,GAAG,aAAa,GAAG,eAAe,CAAC,CAAC;AAClE,SAAO,CAAC,QAAQ,SAAS,KAAK,GAAG,CAAC,MAAM;AAC1C;","names":[]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Rule, Linter } from 'eslint';
|
|
2
|
+
|
|
3
|
+
declare const rules: Record<string, Rule.RuleModule>;
|
|
4
|
+
/**
|
|
5
|
+
* Rule name to QDC id, for docs and for anything that wants to assert the set
|
|
6
|
+
* without re-deriving it from the messages.
|
|
7
|
+
*/
|
|
8
|
+
declare const RULE_IDS: Record<string, string>;
|
|
9
|
+
|
|
10
|
+
interface QdcSecurityConfigs {
|
|
11
|
+
recommended: Linter.Config[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The plugin object, typed explicitly: an inferred type here reaches into
|
|
15
|
+
* `@eslint/core` and TypeScript refuses to name it in a `.d.ts`.
|
|
16
|
+
*/
|
|
17
|
+
interface QdcSecurityPlugin {
|
|
18
|
+
meta: {
|
|
19
|
+
name: string;
|
|
20
|
+
};
|
|
21
|
+
rules: Record<string, Rule.RuleModule>;
|
|
22
|
+
configs: QdcSecurityConfigs;
|
|
23
|
+
RULE_IDS: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
declare const configs: QdcSecurityConfigs;
|
|
26
|
+
declare const qdcSecurity: QdcSecurityPlugin;
|
|
27
|
+
|
|
28
|
+
export { type QdcSecurityConfigs, type QdcSecurityPlugin, RULE_IDS, configs, qdcSecurity as default, rules };
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// src/eslint/ast.ts
|
|
2
|
+
var VETTED_CALLS = ["safeRedirect", "safeFetch", "vetTarget", "clone"];
|
|
3
|
+
function pathOf(filename) {
|
|
4
|
+
return filename.replaceAll("\\", "/");
|
|
5
|
+
}
|
|
6
|
+
function isUnder(filename, paths) {
|
|
7
|
+
const file = pathOf(filename);
|
|
8
|
+
return paths.some((path) => file.endsWith(`/${path}`) || file.includes(`/${path}/`));
|
|
9
|
+
}
|
|
10
|
+
function calleeName(node) {
|
|
11
|
+
const callee = node.callee;
|
|
12
|
+
if (!callee) return "";
|
|
13
|
+
if (callee.type === "Identifier") return callee.name ?? "";
|
|
14
|
+
if (callee.type === "MemberExpression" && callee.property?.type === "Identifier") {
|
|
15
|
+
return callee.property.name ?? "";
|
|
16
|
+
}
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
function isFixedTarget(node, allowIdentifier) {
|
|
20
|
+
if (!node) return false;
|
|
21
|
+
if (node.type === "Literal") return typeof node.value === "string";
|
|
22
|
+
if (node.type === "TemplateLiteral") return (node.expressions?.length ?? 0) === 0;
|
|
23
|
+
if (node.type === "Identifier") return allowIdentifier;
|
|
24
|
+
if (node.type === "NewExpression") {
|
|
25
|
+
return node.callee?.name === "URL" && isFixedTarget(node.arguments?.[0], allowIdentifier);
|
|
26
|
+
}
|
|
27
|
+
if (node.type === "CallExpression") return VETTED_CALLS.includes(calleeName(node));
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/eslint/rules.ts
|
|
32
|
+
var DB_DRIVERS = ["pg", "postgres", "mysql2", "mysql2/promise"];
|
|
33
|
+
var DB_LAYER = ["src/db", "src/lib/db.ts"];
|
|
34
|
+
var FS_MODULES = ["fs", "node:fs", "fs/promises", "node:fs/promises"];
|
|
35
|
+
var FS_CALLERS = ["src/scripts", "src/db"];
|
|
36
|
+
var SERVER_PATHS = ["src/app/api", "src/lib/server", "src/proxy.ts"];
|
|
37
|
+
function reporter(context, message) {
|
|
38
|
+
return (node) => {
|
|
39
|
+
context.report({ node, message });
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function listener(visitor) {
|
|
43
|
+
return visitor;
|
|
44
|
+
}
|
|
45
|
+
function meta(description, type) {
|
|
46
|
+
return { type, docs: { description }, schema: [] };
|
|
47
|
+
}
|
|
48
|
+
function ban(id, message, selectors) {
|
|
49
|
+
const text = `${id} - ${message}`;
|
|
50
|
+
return {
|
|
51
|
+
meta: meta(text, "problem"),
|
|
52
|
+
create(context) {
|
|
53
|
+
const report = reporter(context, text);
|
|
54
|
+
return listener(Object.fromEntries(selectors.map((selector) => [selector, report])));
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function banImports(id, message, modules, allowed) {
|
|
59
|
+
const text = `${id} - ${message}`;
|
|
60
|
+
return {
|
|
61
|
+
meta: meta(text, "problem"),
|
|
62
|
+
create(context) {
|
|
63
|
+
if (isUnder(context.filename, allowed)) return {};
|
|
64
|
+
const report = reporter(context, text);
|
|
65
|
+
const flag = (node) => {
|
|
66
|
+
const source = node.source;
|
|
67
|
+
if (source?.type === "Literal" && typeof source.value === "string") {
|
|
68
|
+
if (modules.includes(source.value)) report(node);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
return listener({ ImportDeclaration: flag, ImportExpression: flag });
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
var noStringEval = {
|
|
76
|
+
meta: meta("QDC-XSS-005 - no code compiled from a string", "problem"),
|
|
77
|
+
create(context) {
|
|
78
|
+
const report = reporter(
|
|
79
|
+
context,
|
|
80
|
+
"QDC-XSS-005 - compiling a string into code runs whatever the string turned out to be. Pass a function."
|
|
81
|
+
);
|
|
82
|
+
const isStringy = (node) => node?.type === "TemplateLiteral" || node?.type === "BinaryExpression" || node?.type === "Literal" && typeof node.value === "string";
|
|
83
|
+
return listener({
|
|
84
|
+
"CallExpression[callee.name='eval']": report,
|
|
85
|
+
"CallExpression[callee.name='Function']": report,
|
|
86
|
+
"NewExpression[callee.name='Function']": report,
|
|
87
|
+
"CallExpression[callee.name=/^set(Timeout|Interval)$/]": (node) => {
|
|
88
|
+
if (isStringy(node.arguments?.[0])) report(node);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
var safeRedirect = {
|
|
94
|
+
meta: meta("QDC-URL-001 - redirect to a target this app decided on", "suggestion"),
|
|
95
|
+
create(context) {
|
|
96
|
+
const report = reporter(
|
|
97
|
+
context,
|
|
98
|
+
"QDC-URL-001 - redirecting to a value that came with the request is an open redirect. Wrap it in safeRedirect from @quatronic/sdk/next/auth."
|
|
99
|
+
);
|
|
100
|
+
return listener({
|
|
101
|
+
"CallExpression[callee.object.name='NextResponse'][callee.property.name='redirect']": (node) => {
|
|
102
|
+
if (isFixedTarget(node.arguments?.[0], true)) return;
|
|
103
|
+
report(node);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
var safeFetch = {
|
|
109
|
+
meta: meta("QDC-NET-001 - server-side fetch of a caller-supplied URL", "suggestion"),
|
|
110
|
+
create(context) {
|
|
111
|
+
if (!isUnder(context.filename, SERVER_PATHS)) return {};
|
|
112
|
+
const report = reporter(
|
|
113
|
+
context,
|
|
114
|
+
"QDC-NET-001 - a URL the server did not decide on reaches the private network too. Use safeFetch from @quatronic/sdk/node/net, or connection(alias).fetch for a wired module."
|
|
115
|
+
);
|
|
116
|
+
return listener({
|
|
117
|
+
"CallExpression[callee.name='fetch']": (node) => {
|
|
118
|
+
if (isFixedTarget(node.arguments?.[0], false)) return;
|
|
119
|
+
report(node);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
var rules = {
|
|
125
|
+
"no-dangerous-html": ban(
|
|
126
|
+
"QDC-XSS-001",
|
|
127
|
+
"dangerouslySetInnerHTML renders unescaped markup. Render text, or sanitise it first.",
|
|
128
|
+
["JSXAttribute[name.name='dangerouslySetInnerHTML']"]
|
|
129
|
+
),
|
|
130
|
+
"no-inner-html": ban(
|
|
131
|
+
"QDC-XSS-002",
|
|
132
|
+
"Assigning innerHTML parses the string as markup. Use textContent.",
|
|
133
|
+
[
|
|
134
|
+
"AssignmentExpression[left.type='MemberExpression'][left.property.name=/^(innerHTML|outerHTML)$/]"
|
|
135
|
+
]
|
|
136
|
+
),
|
|
137
|
+
"no-insert-adjacent-html": ban(
|
|
138
|
+
"QDC-XSS-003",
|
|
139
|
+
"insertAdjacentHTML parses its argument as markup. Build the nodes instead.",
|
|
140
|
+
["CallExpression[callee.property.name='insertAdjacentHTML']"]
|
|
141
|
+
),
|
|
142
|
+
"no-document-write": ban(
|
|
143
|
+
"QDC-XSS-004",
|
|
144
|
+
"document.write parses its argument as markup and blocks the parser. Render it in React.",
|
|
145
|
+
["CallExpression[callee.object.name='document'][callee.property.name=/^write(ln)?$/]"]
|
|
146
|
+
),
|
|
147
|
+
"no-string-eval": noStringEval,
|
|
148
|
+
"no-raw-script-tag": ban(
|
|
149
|
+
"QDC-CSP-001",
|
|
150
|
+
"A hand-written <script> gets no nonce, and this app's CSP uses 'strict-dynamic', so it will not run. Use next/script.",
|
|
151
|
+
["JSXElement[openingElement.name.name='script']"]
|
|
152
|
+
),
|
|
153
|
+
"no-sql-interpolation": ban(
|
|
154
|
+
"QDC-SQL-001",
|
|
155
|
+
"An interpolated string reaching .execute() is the injection. Use the sql tag, which parameterises.",
|
|
156
|
+
[
|
|
157
|
+
"CallExpression[callee.property.name='execute'] > TemplateLiteral[expressions.length>0]",
|
|
158
|
+
"CallExpression[callee.property.name='execute'] > BinaryExpression[operator='+']"
|
|
159
|
+
]
|
|
160
|
+
),
|
|
161
|
+
"no-sql-raw": ban(
|
|
162
|
+
"QDC-SQL-002",
|
|
163
|
+
"sql.raw() puts its argument into the query verbatim and sanitises nothing. Interpolate through the sql tag.",
|
|
164
|
+
["MemberExpression[object.name='sql'][property.name='raw']"]
|
|
165
|
+
),
|
|
166
|
+
"no-driver-import": banImports(
|
|
167
|
+
"QDC-SQL-003",
|
|
168
|
+
"The database driver belongs in the db layer. Everywhere else, go through Drizzle.",
|
|
169
|
+
DB_DRIVERS,
|
|
170
|
+
DB_LAYER
|
|
171
|
+
),
|
|
172
|
+
"no-fs-import": banImports(
|
|
173
|
+
"QDC-FS-001",
|
|
174
|
+
"Request handlers should not reach the filesystem. If one must, use safeJoin from @quatronic/sdk/node/fspath.",
|
|
175
|
+
FS_MODULES,
|
|
176
|
+
FS_CALLERS
|
|
177
|
+
),
|
|
178
|
+
"safe-redirect": safeRedirect,
|
|
179
|
+
"safe-fetch": safeFetch
|
|
180
|
+
};
|
|
181
|
+
var RULE_IDS = {
|
|
182
|
+
"no-dangerous-html": "QDC-XSS-001",
|
|
183
|
+
"no-inner-html": "QDC-XSS-002",
|
|
184
|
+
"no-insert-adjacent-html": "QDC-XSS-003",
|
|
185
|
+
"no-document-write": "QDC-XSS-004",
|
|
186
|
+
"no-string-eval": "QDC-XSS-005",
|
|
187
|
+
"no-raw-script-tag": "QDC-CSP-001",
|
|
188
|
+
"no-sql-interpolation": "QDC-SQL-001",
|
|
189
|
+
"no-sql-raw": "QDC-SQL-002",
|
|
190
|
+
"no-driver-import": "QDC-SQL-003",
|
|
191
|
+
"no-fs-import": "QDC-FS-001",
|
|
192
|
+
"safe-redirect": "QDC-URL-001",
|
|
193
|
+
"safe-fetch": "QDC-NET-001"
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// src/eslint/index.ts
|
|
197
|
+
var plugin = {
|
|
198
|
+
meta: { name: "@quatronic/sdk/eslint" },
|
|
199
|
+
rules
|
|
200
|
+
};
|
|
201
|
+
var SRC_FILES = ["src/**/*.{ts,tsx}"];
|
|
202
|
+
var TEST_FILES = ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"];
|
|
203
|
+
var recommended = [
|
|
204
|
+
{
|
|
205
|
+
files: SRC_FILES,
|
|
206
|
+
plugins: { qdc: plugin },
|
|
207
|
+
rules: {
|
|
208
|
+
"qdc/no-dangerous-html": "error",
|
|
209
|
+
"qdc/no-inner-html": "error",
|
|
210
|
+
"qdc/no-insert-adjacent-html": "error",
|
|
211
|
+
"qdc/no-document-write": "error",
|
|
212
|
+
"qdc/no-string-eval": "error",
|
|
213
|
+
"qdc/no-raw-script-tag": "error",
|
|
214
|
+
"qdc/no-sql-interpolation": "error",
|
|
215
|
+
"qdc/no-sql-raw": "error",
|
|
216
|
+
"qdc/no-driver-import": "error",
|
|
217
|
+
"qdc/no-fs-import": "error",
|
|
218
|
+
// Advisory, and `eslint .` does not fail on warnings. Both fire on
|
|
219
|
+
// shapes that are often fine, so they prompt a look rather than block a
|
|
220
|
+
// merge; qdc.redirect.tainted and qdc.ssrf.tainted-fetch in the Semgrep
|
|
221
|
+
// pack follow the value and are what actually decide.
|
|
222
|
+
"qdc/safe-redirect": "warn",
|
|
223
|
+
"qdc/safe-fetch": "warn"
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
// Tests build markup on purpose, to prove the escaping works.
|
|
228
|
+
files: TEST_FILES,
|
|
229
|
+
plugins: { qdc: plugin },
|
|
230
|
+
rules: {
|
|
231
|
+
"qdc/no-dangerous-html": "off",
|
|
232
|
+
"qdc/no-inner-html": "off",
|
|
233
|
+
"qdc/no-insert-adjacent-html": "off",
|
|
234
|
+
"qdc/no-document-write": "off",
|
|
235
|
+
"qdc/no-raw-script-tag": "off"
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
];
|
|
239
|
+
var configs = { recommended };
|
|
240
|
+
var qdcSecurity = { ...plugin, configs, RULE_IDS };
|
|
241
|
+
var eslint_default = qdcSecurity;
|
|
242
|
+
export {
|
|
243
|
+
RULE_IDS,
|
|
244
|
+
configs,
|
|
245
|
+
eslint_default as default,
|
|
246
|
+
rules
|
|
247
|
+
};
|
|
248
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/eslint/ast.ts","../../src/eslint/rules.ts","../../src/eslint/index.ts"],"sourcesContent":["// A deliberately narrow view of the AST, for rules that are deliberately\n// syntactic.\n//\n// ESLint's own `Rule.Node` is the ESTree union. It does not describe JSX at\n// all, and it forces a narrowing cascade for every property access — in rules\n// whose whole job is to read three or four fields off a node and report it.\n// So these rules declare exactly the fields they read and cast once, at the\n// visitor boundary, rather than per line.\n//\n// Anything that has to follow a value across functions or files is a Semgrep\n// rule instead; that pack ships in `semgrep/` alongside this plugin.\nexport interface AstNode {\n type: string\n /** `Identifier.name` and `MemberExpression.property.name`. Never a JSX name. */\n name?: string\n value?: unknown\n callee?: AstNode\n object?: AstNode\n property?: AstNode\n source?: AstNode\n arguments?: (AstNode | undefined)[]\n expressions?: unknown[]\n}\n\n/** Calls whose result is a URL this app decided on, not one a caller supplied. */\nexport const VETTED_CALLS = [\"safeRedirect\", \"safeFetch\", \"vetTarget\", \"clone\"]\n\n/** POSIX form of the file being linted, so the checks read the same on Windows. */\nexport function pathOf(filename: string): string {\n return filename.replaceAll(\"\\\\\", \"/\")\n}\n\n/** Is the linted file at, or inside, one of these project paths? */\nexport function isUnder(filename: string, paths: readonly string[]): boolean {\n const file = pathOf(filename)\n return paths.some((path) => file.endsWith(`/${path}`) || file.includes(`/${path}/`))\n}\n\n/** The called name, whether the callee is `f()` or `x.f()`. */\nexport function calleeName(node: AstNode): string {\n const callee = node.callee\n if (!callee) return \"\"\n if (callee.type === \"Identifier\") return callee.name ?? \"\"\n if (callee.type === \"MemberExpression\" && callee.property?.type === \"Identifier\") {\n return callee.property.name ?? \"\"\n }\n return \"\"\n}\n\n/**\n * A target this app wrote down, rather than one that arrived with the request.\n *\n * `allowIdentifier` is the whole difference between the two advisory rules:\n * judging what a bare variable holds takes dataflow, which is what the Semgrep\n * taint rules do. `qdc/safe-redirect` lets a variable through because\n * `NextResponse.redirect(url)` after `req.nextUrl.clone()` is the canonical\n * Next redirect and appears in every generated app; `qdc/safe-fetch` does not.\n */\nexport function isFixedTarget(node: AstNode | undefined, allowIdentifier: boolean): boolean {\n if (!node) return false\n if (node.type === \"Literal\") return typeof node.value === \"string\"\n if (node.type === \"TemplateLiteral\") return (node.expressions?.length ?? 0) === 0\n if (node.type === \"Identifier\") return allowIdentifier\n if (node.type === \"NewExpression\") {\n // `new URL(\"/login\", req.url)` fixes the path; the base only ever\n // contributes this app's own origin.\n return node.callee?.name === \"URL\" && isFixedTarget(node.arguments?.[0], allowIdentifier)\n }\n if (node.type === \"CallExpression\") return VETTED_CALLS.includes(calleeName(node))\n return false\n}\n","import type { Rule } from \"eslint\"\n\nimport { type AstNode, isFixedTarget, isUnder } from \"./ast\"\n\n/**\n * The twelve QDC security lint rules.\n *\n * One rule per id, rather than twelve entries in `no-restricted-syntax`, for\n * two reasons that only show up after the fact:\n *\n * - **Severity is per rule.** `no-restricted-syntax` carries one severity for\n * its whole array, and two of these are advisory on purpose — they fire on\n * shapes that are usually fine, and the Semgrep pack does the dataflow they\n * can only approximate.\n * - **Suppression is per rule.** `// eslint-disable-next-line\n * no-restricted-syntax` switches off every selector on that line, so\n * silencing \"no inline styles\" would silently also silence the\n * `dangerouslySetInnerHTML` ban sitting next to it.\n *\n * Suppress one by its own name, and say why:\n *\n * // eslint-disable-next-line qdc/no-dangerous-html -- sanitised in renderPost()\n */\n\n/** Modules that talk to the database directly, and where that is allowed. */\nconst DB_DRIVERS = [\"pg\", \"postgres\", \"mysql2\", \"mysql2/promise\"]\nconst DB_LAYER = [\"src/db\", \"src/lib/db.ts\"]\n\n/** Node's filesystem API, and where a generated app is expected to use it. */\nconst FS_MODULES = [\"fs\", \"node:fs\", \"fs/promises\", \"node:fs/promises\"]\nconst FS_CALLERS = [\"src/scripts\", \"src/db\"]\n\n/** Where a fetch goes out with the server's own network position. */\nconst SERVER_PATHS = [\"src/app/api\", \"src/lib/server\", \"src/proxy.ts\"]\n\n/** One cast, at the boundary, instead of one per `context.report` call. */\nfunction reporter(context: Rule.RuleContext, message: string): (node: AstNode) => void {\n return (node: AstNode) => {\n context.report({ node: node as unknown as Rule.Node, message })\n }\n}\n\nfunction listener(visitor: Record<string, (node: AstNode) => void>): Rule.RuleListener {\n return visitor as unknown as Rule.RuleListener\n}\n\nfunction meta(description: string, type: \"problem\" | \"suggestion\"): Rule.RuleModule[\"meta\"] {\n return { type, docs: { description }, schema: [] }\n}\n\n/** A rule that reports every node matching one of `selectors`. */\nfunction ban(id: string, message: string, selectors: string[]): Rule.RuleModule {\n const text = `${id} - ${message}`\n return {\n meta: meta(text, \"problem\"),\n create(context) {\n const report = reporter(context, text)\n return listener(Object.fromEntries(selectors.map((selector) => [selector, report])))\n },\n }\n}\n\n/** A rule that bans importing any of `modules`, except from `allowed` paths. */\nfunction banImports(\n id: string,\n message: string,\n modules: string[],\n allowed: string[],\n): Rule.RuleModule {\n const text = `${id} - ${message}`\n return {\n meta: meta(text, \"problem\"),\n create(context) {\n if (isUnder(context.filename, allowed)) return {}\n const report = reporter(context, text)\n const flag = (node: AstNode) => {\n const source = node.source\n if (source?.type === \"Literal\" && typeof source.value === \"string\") {\n if (modules.includes(source.value)) report(node)\n }\n }\n return listener({ ImportDeclaration: flag, ImportExpression: flag })\n },\n }\n}\n\nconst noStringEval: Rule.RuleModule = {\n meta: meta(\"QDC-XSS-005 - no code compiled from a string\", \"problem\"),\n create(context) {\n const report = reporter(\n context,\n \"QDC-XSS-005 - compiling a string into code runs whatever the string turned out to be. Pass a function.\",\n )\n const isStringy = (node: AstNode | undefined) =>\n node?.type === \"TemplateLiteral\" ||\n node?.type === \"BinaryExpression\" ||\n (node?.type === \"Literal\" && typeof node.value === \"string\")\n return listener({\n \"CallExpression[callee.name='eval']\": report,\n \"CallExpression[callee.name='Function']\": report,\n \"NewExpression[callee.name='Function']\": report,\n \"CallExpression[callee.name=/^set(Timeout|Interval)$/]\": (node) => {\n if (isStringy(node.arguments?.[0])) report(node)\n },\n })\n },\n}\n\nconst safeRedirect: Rule.RuleModule = {\n meta: meta(\"QDC-URL-001 - redirect to a target this app decided on\", \"suggestion\"),\n create(context) {\n const report = reporter(\n context,\n \"QDC-URL-001 - redirecting to a value that came with the request is an open redirect. Wrap it in safeRedirect from @quatronic/sdk/next/auth.\",\n )\n return listener({\n \"CallExpression[callee.object.name='NextResponse'][callee.property.name='redirect']\": (\n node,\n ) => {\n if (isFixedTarget(node.arguments?.[0], true)) return\n report(node)\n },\n })\n },\n}\n\nconst safeFetch: Rule.RuleModule = {\n meta: meta(\"QDC-NET-001 - server-side fetch of a caller-supplied URL\", \"suggestion\"),\n create(context) {\n if (!isUnder(context.filename, SERVER_PATHS)) return {}\n const report = reporter(\n context,\n \"QDC-NET-001 - a URL the server did not decide on reaches the private network too. Use safeFetch from @quatronic/sdk/node/net, or connection(alias).fetch for a wired module.\",\n )\n return listener({\n \"CallExpression[callee.name='fetch']\": (node) => {\n if (isFixedTarget(node.arguments?.[0], false)) return\n report(node)\n },\n })\n },\n}\n\nexport const rules: Record<string, Rule.RuleModule> = {\n \"no-dangerous-html\": ban(\n \"QDC-XSS-001\",\n \"dangerouslySetInnerHTML renders unescaped markup. Render text, or sanitise it first.\",\n [\"JSXAttribute[name.name='dangerouslySetInnerHTML']\"],\n ),\n \"no-inner-html\": ban(\n \"QDC-XSS-002\",\n \"Assigning innerHTML parses the string as markup. Use textContent.\",\n [\n \"AssignmentExpression[left.type='MemberExpression'][left.property.name=/^(innerHTML|outerHTML)$/]\",\n ],\n ),\n \"no-insert-adjacent-html\": ban(\n \"QDC-XSS-003\",\n \"insertAdjacentHTML parses its argument as markup. Build the nodes instead.\",\n [\"CallExpression[callee.property.name='insertAdjacentHTML']\"],\n ),\n \"no-document-write\": ban(\n \"QDC-XSS-004\",\n \"document.write parses its argument as markup and blocks the parser. Render it in React.\",\n [\"CallExpression[callee.object.name='document'][callee.property.name=/^write(ln)?$/]\"],\n ),\n \"no-string-eval\": noStringEval,\n \"no-raw-script-tag\": ban(\n \"QDC-CSP-001\",\n \"A hand-written <script> gets no nonce, and this app's CSP uses 'strict-dynamic', so it will not run. Use next/script.\",\n [\"JSXElement[openingElement.name.name='script']\"],\n ),\n \"no-sql-interpolation\": ban(\n \"QDC-SQL-001\",\n \"An interpolated string reaching .execute() is the injection. Use the sql tag, which parameterises.\",\n [\n \"CallExpression[callee.property.name='execute'] > TemplateLiteral[expressions.length>0]\",\n \"CallExpression[callee.property.name='execute'] > BinaryExpression[operator='+']\",\n ],\n ),\n \"no-sql-raw\": ban(\n \"QDC-SQL-002\",\n \"sql.raw() puts its argument into the query verbatim and sanitises nothing. Interpolate through the sql tag.\",\n [\"MemberExpression[object.name='sql'][property.name='raw']\"],\n ),\n \"no-driver-import\": banImports(\n \"QDC-SQL-003\",\n \"The database driver belongs in the db layer. Everywhere else, go through Drizzle.\",\n DB_DRIVERS,\n DB_LAYER,\n ),\n \"no-fs-import\": banImports(\n \"QDC-FS-001\",\n \"Request handlers should not reach the filesystem. If one must, use safeJoin from @quatronic/sdk/node/fspath.\",\n FS_MODULES,\n FS_CALLERS,\n ),\n \"safe-redirect\": safeRedirect,\n \"safe-fetch\": safeFetch,\n}\n\n/**\n * Rule name to QDC id, for docs and for anything that wants to assert the set\n * without re-deriving it from the messages.\n */\nexport const RULE_IDS: Record<string, string> = {\n \"no-dangerous-html\": \"QDC-XSS-001\",\n \"no-inner-html\": \"QDC-XSS-002\",\n \"no-insert-adjacent-html\": \"QDC-XSS-003\",\n \"no-document-write\": \"QDC-XSS-004\",\n \"no-string-eval\": \"QDC-XSS-005\",\n \"no-raw-script-tag\": \"QDC-CSP-001\",\n \"no-sql-interpolation\": \"QDC-SQL-001\",\n \"no-sql-raw\": \"QDC-SQL-002\",\n \"no-driver-import\": \"QDC-SQL-003\",\n \"no-fs-import\": \"QDC-FS-001\",\n \"safe-redirect\": \"QDC-URL-001\",\n \"safe-fetch\": \"QDC-NET-001\",\n}\n","import type { Linter, Rule } from \"eslint\"\n\nimport { RULE_IDS, rules } from \"./rules\"\n\nexport { RULE_IDS, rules } from \"./rules\"\n\n/**\n * The QDC security ESLint plugin.\n *\n * It lives here rather than in each generated app because a lint rule is the\n * cheapest security control QDC has, and a rule copied into every app can only\n * be tightened by editing every app. Shipped from the SDK, a corrected rule\n * reaches an app on its next `@quatronic/sdk` bump — the same bump the app\n * pays for everything else in the package.\n *\n * Usage in a generated app's `eslint.config.mjs`:\n *\n * import qdcSecurity from \"@quatronic/sdk/eslint\"\n *\n * export default [\n * ...whatever,\n * ...qdcSecurity.configs.recommended,\n * ]\n *\n * The rules are syntactic by design. Anything that has to follow a value\n * across functions and files is a Semgrep rule instead, in this package's\n * `semgrep/` directory, run by the same CI job.\n */\nconst plugin = {\n meta: { name: \"@quatronic/sdk/eslint\" },\n rules,\n}\n\n/** Where a generated app keeps the code these rules are about. */\nconst SRC_FILES = [\"src/**/*.{ts,tsx}\"]\nconst TEST_FILES = [\"**/*.test.{ts,tsx}\", \"**/*.spec.{ts,tsx}\"]\n\nexport interface QdcSecurityConfigs {\n recommended: Linter.Config[]\n}\n\n/**\n * The plugin object, typed explicitly: an inferred type here reaches into\n * `@eslint/core` and TypeScript refuses to name it in a `.d.ts`.\n */\nexport interface QdcSecurityPlugin {\n meta: { name: string }\n rules: Record<string, Rule.RuleModule>\n configs: QdcSecurityConfigs\n RULE_IDS: Record<string, string>\n}\n\n/**\n * Two blocks, spread into a flat config in this order.\n *\n * The second one is not decoration: a test that proves escaping works has to\n * build the markup it escapes, so the XSS bans are off there. It is a separate\n * block because flat-config rule options **replace** rather than merge — a\n * later block naming a rule wins outright — so the exemption has to come after\n * the block that switches the rules on, and has to name only what it exempts.\n */\nconst recommended: Linter.Config[] = [\n {\n files: SRC_FILES,\n plugins: { qdc: plugin },\n rules: {\n \"qdc/no-dangerous-html\": \"error\",\n \"qdc/no-inner-html\": \"error\",\n \"qdc/no-insert-adjacent-html\": \"error\",\n \"qdc/no-document-write\": \"error\",\n \"qdc/no-string-eval\": \"error\",\n \"qdc/no-raw-script-tag\": \"error\",\n \"qdc/no-sql-interpolation\": \"error\",\n \"qdc/no-sql-raw\": \"error\",\n \"qdc/no-driver-import\": \"error\",\n \"qdc/no-fs-import\": \"error\",\n // Advisory, and `eslint .` does not fail on warnings. Both fire on\n // shapes that are often fine, so they prompt a look rather than block a\n // merge; qdc.redirect.tainted and qdc.ssrf.tainted-fetch in the Semgrep\n // pack follow the value and are what actually decide.\n \"qdc/safe-redirect\": \"warn\",\n \"qdc/safe-fetch\": \"warn\",\n },\n },\n {\n // Tests build markup on purpose, to prove the escaping works.\n files: TEST_FILES,\n plugins: { qdc: plugin },\n rules: {\n \"qdc/no-dangerous-html\": \"off\",\n \"qdc/no-inner-html\": \"off\",\n \"qdc/no-insert-adjacent-html\": \"off\",\n \"qdc/no-document-write\": \"off\",\n \"qdc/no-raw-script-tag\": \"off\",\n },\n },\n]\n\nexport const configs: QdcSecurityConfigs = { recommended }\n\nconst qdcSecurity: QdcSecurityPlugin = { ...plugin, configs, RULE_IDS }\n\nexport default qdcSecurity\n"],"mappings":"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","names":[]}
|