diff --git a/.github/workflows/connector-docs-verify.yaml b/.github/workflows/connector-docs-verify.yaml new file mode 100644 index 0000000..7c36424 --- /dev/null +++ b/.github/workflows/connector-docs-verify.yaml @@ -0,0 +1,87 @@ +on: + workflow_call: + inputs: + ref: + required: true + type: string + +permissions: + contents: read + pull-requests: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout workflow repo + uses: actions/checkout@v5 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: _workflow + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: _workflow/tools/mdx-lint/package-lock.json + + - name: Check docs change + id: docs-change + env: + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + REPOSITORY: ${{ github.event.repository.full_name }} + run: node _workflow/tools/connector-docs/check-docs-change.mjs + + - name: Install MDX lint dependencies + if: steps.docs-change.outputs.validate != 'false' + run: | + npm ci --ignore-scripts --silent --prefix _workflow/tools/mdx-lint 2>&1 | tail -n 1 + + - name: Checkout caller repo + if: steps.docs-change.outputs.validate != 'false' + uses: actions/checkout@v5 + with: + repository: ${{ github.event.pull_request.head.repo.full_name || github.event.repository.full_name }} + ref: ${{ inputs.ref }} + path: _caller + persist-credentials: false + + - name: Resolve docs validation + if: steps.docs-change.outputs.validate != 'false' + id: docs + shell: bash + env: + PRECHECK: ${{ steps.docs-change.outputs.validate }} + run: | + set -euo pipefail + + if [ "$PRECHECK" = "unknown" ] && [ ! -f "_caller/docs/connector.mdx" ]; then + echo "validate=false" >> "$GITHUB_OUTPUT" + echo "docs/connector.mdx missing; skipping MDX validation" + exit 0 + fi + + if [ ! -f "_caller/docs/connector.mdx" ]; then + echo "::error file=docs/connector.mdx::docs/connector.mdx changed but is missing at the checked-out ref." + exit 1 + fi + + echo "validate=true" >> "$GITHUB_OUTPUT" + + - name: Validate MDX documentation + if: steps.docs.outputs.validate == 'true' + shell: bash + run: | + node _workflow/tools/mdx-lint/mdx-lint.mjs < _caller/docs/connector.mdx + LINT_RC=$? + + if [ "$LINT_RC" -eq 0 ]; then + echo "MDX validation passed" + else + echo "::error file=docs/connector.mdx::MDX validation failed. See log output above for details." + exit 1 + fi diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml index a871216..6654df0 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify.yaml @@ -82,12 +82,32 @@ jobs: docs: runs-on: ubuntu-latest steps: + - name: Checkout workflow repo + uses: actions/checkout@v5 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: _workflow + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: _workflow/tools/mdx-lint/package-lock.json + + - name: Install MDX lint dependencies + run: | + npm ci --ignore-scripts --silent --prefix _workflow/tools/mdx-lint 2>&1 | tail -n 1 + - name: Checkout caller repo uses: actions/checkout@v5 with: - repository: ${{ github.event.repository.full_name }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.event.repository.full_name }} ref: ${{ inputs.ref }} path: _caller + persist-credentials: false - name: Check for docs/connector.mdx id: check-docs @@ -99,64 +119,11 @@ jobs: echo "No docs/connector.mdx found, skipping MDX validation" fi - - name: Setup Node - if: steps.check-docs.outputs.has_docs == 'true' - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Install MDX lint dependencies - if: steps.check-docs.outputs.has_docs == 'true' - run: | - mkdir -p /tmp/mdx-lint && cd /tmp/mdx-lint - npm init -y --silent > /dev/null 2>&1 - npm install --silent @mdx-js/mdx@3 remark-gfm@4 remark-frontmatter@5 2>&1 | tail -n 1 - - name: Validate MDX documentation if: steps.check-docs.outputs.has_docs == 'true' - shell: bash {0} + shell: bash run: | - # Inline MDX lint: compile-only (no eval), with component allowlist. - # Use bash {0} (no -e) so we can capture the exit code ourselves. - # Write the lint script to the install directory so ESM resolution works - cat > /tmp/mdx-lint/mdx-lint.mjs << 'LINT_EOF' - import { compile } from "@mdx-js/mdx"; - import remarkGfm from "remark-gfm"; - import remarkFrontmatter from "remark-frontmatter"; - - const ALLOWED = new Set([ - "Tip","Warning","Note","Info","Icon", - "Frame","Card","Tabs","Tab","Steps","Step", - ]); - - let content = ""; - for await (const chunk of process.stdin) content += chunk; - if (!content.trim()) process.exit(0); - - let compiled; - try { - compiled = String(await compile(content, { - outputFormat: "function-body", - remarkPlugins: [remarkGfm, remarkFrontmatter], - })); - } catch (err) { - console.error("mdx-lint: " + err.message); - process.exit(1); - } - - const refs = [...compiled.matchAll(/_missingMdxReference\("([^"]+)"/g)] - .map(m => m[1]) - .filter(name => !ALLOWED.has(name)); - const unique = [...new Set(refs)]; - if (unique.length > 0) { - for (const name of unique) { - console.error("mdx-lint: Unknown component <" + name + ">. Allowed: " + [...ALLOWED].join(", ")); - } - process.exit(1); - } - LINT_EOF - - node /tmp/mdx-lint/mdx-lint.mjs < _caller/docs/connector.mdx + node _workflow/tools/mdx-lint/mdx-lint.mjs < _caller/docs/connector.mdx LINT_RC=$? if [ "$LINT_RC" -eq 0 ]; then diff --git a/README.md b/README.md index 1fd4e4b..be59303 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,37 @@ jobs: | `run_tests` | No | `true` | Run `go test` | | `connector` | No | `""` | Connector name — triggers [regression testing](docs/verify-workflow.md#regression) when set | +## Connector Docs Verify Workflow + +Runs a standalone `docs/connector.mdx` validation check for connector repositories. See [detailed documentation](docs/connector-docs-verify.md) for behavior and rollout notes. + +The workflow is safe to require on every connector pull request because it always reports a status. It skips validation when `docs/connector.mdx` is unchanged, fails if that file was changed and removed, and validates MDX safety when the file changed. + +The reusable workflow checks out its own validator at the exact workflow commit, installs locked dependencies with npm lifecycle scripts disabled, and checks out caller code without persisted credentials. + +### Usage + +```yaml +name: Connector Docs + +on: + pull_request: + types: [opened, reopened, synchronize] + push: + branches: + - main + +jobs: + connector-docs: + uses: ConductorOne/github-workflows/.github/workflows/connector-docs-verify.yaml@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} +``` + +When used with the job id `connector-docs`, the required status check context is `connector-docs / validate`. + +Roll out the caller workflow before marking the check required. The shared workflow ref, such as `v4`, must include `connector-docs-verify.yaml` before connector repos call it. + ## Available Actions ### Get Baton diff --git a/docs/connector-docs-verify.md b/docs/connector-docs-verify.md new file mode 100644 index 0000000..3cdd005 --- /dev/null +++ b/docs/connector-docs-verify.md @@ -0,0 +1,102 @@ +# Connector Docs Verify Workflow + +The `connector-docs-verify.yaml` workflow runs a standalone +`docs/connector.mdx` validation check for connector repositories. + +## Overview + +This workflow is meant to be safe as a required status check on every connector +pull request: + +1. It checks out this workflow repository at the exact workflow commit. +2. It checks whether `docs/connector.mdx` changed in the pull request. +3. It installs the checked-in MDX validator dependencies with npm lifecycle + scripts disabled. +4. It checks out the caller repository at the requested ref with persisted + credentials disabled. +5. If the file is unchanged, the job exits successfully. +6. If the file changed, the job validates that the file still exists and passes + MDX safety checks. + +The required check context is stable when the caller job id is +`connector-docs`: + +```text +connector-docs / validate +``` + +## MDX Checks + +The validator compiles MDX without evaluating PR content and rejects unsafe +constructs by walking the MDX AST before compilation: + +- empty documentation +- NUL bytes or byte-order marks +- MDX imports or exports outside code fences +- MDX expression braces outside code fences +- event-handler attributes +- dangerous URL schemes after simple entity decoding +- unsupported JSX components + +Allowed JSX components: + +- `Card` +- `Check` +- `Frame` +- `Icon` +- `Info` +- `Note` +- `Step` +- `Steps` +- `Tab` +- `Tabs` +- `Tip` +- `Warning` + +Keep the allowlist aligned with the connector registry renderer and server-side +documentation validators. + +## Usage + +```yaml +name: Connector Docs + +on: + pull_request: + types: [opened, reopened, synchronize] + push: + branches: + - main + +jobs: + connector-docs: + uses: ConductorOne/github-workflows/.github/workflows/connector-docs-verify.yaml@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} +``` + +## Rollout Notes + +Do not add workflow-level `paths` filters to the caller workflow when this +check is required by branch rules. A required check that never runs leaves pull +requests stuck waiting. + +The workflow itself handles the path check and reports success when +`docs/connector.mdx` is unchanged. + +Use a staged rollout: + +1. Merge this workflow and update the shared workflow ref used by connector + repos, such as the `v4` tag, so callers can resolve + `connector-docs-verify.yaml`. +2. Add the caller workflow to connector repos without requiring the status + check yet. +3. Confirm `connector-docs / validate` appears and passes on both docs and + non-doc pull requests. +4. Require `connector-docs / validate` in branch rules only after the check is + present on targeted repos. + +The existing `verify.yaml` docs job reuses the same MDX validator for +compatibility. Use this standalone workflow as the required docs safety gate +because it always reports the `connector-docs / validate` check that branch +rules target. diff --git a/docs/verify-workflow.md b/docs/verify-workflow.md index 7231066..dcc294f 100644 --- a/docs/verify-workflow.md +++ b/docs/verify-workflow.md @@ -8,7 +8,8 @@ When a pull request is opened or code is pushed to main, the shared verify workf 1. Runs `golangci-lint` on the connector code 2. Runs `go test` (optional, enabled by default) -3. Runs baton-regression verification (optional, when `connector` is provided) +3. Validates `docs/connector.mdx` when the file exists +4. Runs baton-regression verification (optional, when `connector` is provided) ## Jobs @@ -20,6 +21,14 @@ Checks out the caller repo and runs `golangci-lint` with a 6-minute timeout. If Runs `go test -v -covermode=count -json ./...` and annotates results. Skipped if `run_tests: false`. +### docs + +Validates `docs/connector.mdx` when the file exists. The validator compiles MDX +without evaluating PR content and rejects unsupported JSX components. + +For branch rules that need a standalone docs safety check, prefer the stricter +[`connector-docs-verify.yaml`](connector-docs-verify.md) workflow. + ### regression Runs the [baton-regression](https://github.com/ConductorOne/baton-regression) verification when `connector` is non-empty. The workflow is hosted in this repo but checks out baton-regression source from main at runtime. The regression job: diff --git a/tools/connector-docs/check-docs-change.mjs b/tools/connector-docs/check-docs-change.mjs new file mode 100644 index 0000000..ea44c7c --- /dev/null +++ b/tools/connector-docs/check-docs-change.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +import { appendFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const CONNECTOR_DOCS_PATH = "docs/connector.mdx"; +const PER_PAGE = 100; +const PAGE_LIMIT = 30; + +function normalizeApiUrl(apiUrl) { + return apiUrl.replace(/\/+$/, ""); +} + +function docsPathChanged(file) { + return ( + file?.filename === CONNECTOR_DOCS_PATH || + file?.previous_filename === CONNECTOR_DOCS_PATH + ); +} + +function writeOutput(name, value) { + const line = `${name}=${value}\n`; + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, line); + } else { + process.stdout.write(line); + } +} + +export async function checkConnectorDocsChange({ + apiUrl = "https://api.github.com", + fetchFn = fetch, + prNumber, + repository, + token, +} = {}) { + if (!prNumber) { + return { validate: "unknown", reason: "not_pull_request" }; + } + if (!repository) { + throw new Error("repository is required when prNumber is set"); + } + + for (let page = 1; ; page += 1) { + const url = + `${normalizeApiUrl(apiUrl)}/repos/${repository}/pulls/${prNumber}/files` + + `?per_page=${PER_PAGE}&page=${page}`; + const headers = { + Accept: "application/vnd.github+json", + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetchFn(url, { headers }); + if (!response.ok) { + throw new Error( + `GitHub PR files request failed: ${response.status} ${response.statusText}`, + ); + } + + const files = await response.json(); + if (!Array.isArray(files)) { + throw new Error("GitHub PR files response was not an array"); + } + + if (files.some(docsPathChanged)) { + return { validate: "true", reason: "docs_path_changed" }; + } + + // GitHub's PR files API is capped at 3000 files. If the final reachable + // page is full, the file list might be incomplete; force validation rather + // than proving docs/connector.mdx unchanged from partial data. + if (page >= PAGE_LIMIT && files.length === PER_PAGE) { + return { validate: "true", reason: "api_cap" }; + } + + if (files.length < PER_PAGE) { + return { validate: "false", reason: "docs_path_unchanged" }; + } + } +} + +async function main() { + const result = await checkConnectorDocsChange({ + apiUrl: process.env.GITHUB_API_URL || "https://api.github.com", + prNumber: process.env.PR_NUMBER || "", + repository: process.env.REPOSITORY || process.env.GITHUB_REPOSITORY || "", + token: process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "", + }); + + writeOutput("validate", result.validate); + if (result.reason === "docs_path_unchanged") { + process.stdout.write(`${CONNECTOR_DOCS_PATH} unchanged; skipping MDX validation\n`); + } + if (result.reason === "api_cap") { + process.stdout.write( + "::warning::PR file list reached the GitHub API cap; validating " + + `${CONNECTOR_DOCS_PATH} because changed files cannot be proven complete.\n`, + ); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} diff --git a/tools/connector-docs/check-docs-change.test.mjs b/tools/connector-docs/check-docs-change.test.mjs new file mode 100644 index 0000000..1e9ced9 --- /dev/null +++ b/tools/connector-docs/check-docs-change.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { checkConnectorDocsChange } from "./check-docs-change.mjs"; + +function mockFetch(pages) { + const calls = []; + const fetchFn = async (url, options) => { + calls.push({ url, options }); + const files = pages[calls.length - 1] ?? []; + return { + ok: true, + status: 200, + statusText: "OK", + async json() { + return files; + }, + }; + }; + fetchFn.calls = calls; + return fetchFn; +} + +describe("checkConnectorDocsChange", () => { + it("returns unknown outside pull requests", async () => { + const result = await checkConnectorDocsChange(); + assert.deepEqual(result, { validate: "unknown", reason: "not_pull_request" }); + }); + + it("returns false when connector docs are unchanged", async () => { + const fetchFn = mockFetch([[{ filename: "README.md" }]]); + const result = await checkConnectorDocsChange({ + fetchFn, + prNumber: "12", + repository: "example/repo", + token: "token", + }); + assert.deepEqual(result, { + validate: "false", + reason: "docs_path_unchanged", + }); + assert.equal(fetchFn.calls.length, 1); + assert.equal(fetchFn.calls[0].options.headers.Authorization, "Bearer token"); + }); + + it("detects direct connector docs changes", async () => { + const fetchFn = mockFetch([[{ filename: "docs/connector.mdx" }]]); + const result = await checkConnectorDocsChange({ + fetchFn, + prNumber: "12", + repository: "example/repo", + }); + assert.deepEqual(result, { validate: "true", reason: "docs_path_changed" }); + }); + + it("detects connector docs renames", async () => { + const fetchFn = mockFetch([ + [{ filename: "docs/old-connector.mdx", previous_filename: "docs/connector.mdx" }], + ]); + const result = await checkConnectorDocsChange({ + fetchFn, + prNumber: "12", + repository: "example/repo", + }); + assert.deepEqual(result, { validate: "true", reason: "docs_path_changed" }); + }); + + it("forces validation at the GitHub PR files API cap", async () => { + const fullPage = Array.from({ length: 100 }, (_, index) => ({ + filename: `generated/file-${index}.txt`, + })); + const fetchFn = mockFetch(Array.from({ length: 30 }, () => fullPage)); + const result = await checkConnectorDocsChange({ + fetchFn, + prNumber: "12", + repository: "example/repo", + }); + assert.deepEqual(result, { validate: "true", reason: "api_cap" }); + assert.equal(fetchFn.calls.length, 30); + }); + + it("fails closed on GitHub API errors", async () => { + const fetchFn = async () => ({ + ok: false, + status: 502, + statusText: "Bad Gateway", + }); + await assert.rejects( + () => + checkConnectorDocsChange({ + fetchFn, + prNumber: "12", + repository: "example/repo", + }), + /GitHub PR files request failed: 502 Bad Gateway/, + ); + }); +}); diff --git a/tools/mdx-lint/mdx-lint.mjs b/tools/mdx-lint/mdx-lint.mjs index d4d4fd4..18e429f 100644 --- a/tools/mdx-lint/mdx-lint.mjs +++ b/tools/mdx-lint/mdx-lint.mjs @@ -1,24 +1,28 @@ #!/usr/bin/env node // -// MDX lint: validates that docs/connector.mdx parses without errors. +// MDX lint: validates that docs/connector.mdx parses without errors and avoids +// unsafe MDX constructs. // -// Uses compile() only — no run()/eval. This validates syntax (malformed tags, -// unclosed components, bad nesting) without executing any code from the input. -// Safe to run on untrusted PR content. -// -// Also checks that JSX component names are in the allowed set (compile() alone -// doesn't validate component names — that only fails at runtime). +// Uses parse()/compile() only; never run()/eval(). This validates syntax and +// walks the MDX AST without executing any code from the input. Safe to run on +// untrusted PR content. // // Usage: // node mdx-lint.mjs < docs/connector.mdx // // Exit codes: // 0 - valid MDX -// 1 - compilation error (message on stderr) +// 1 - validation or compilation error (message on stderr) + +import { pathToFileURL } from "node:url"; import { compile } from "@mdx-js/mdx"; -import remarkGfm from "remark-gfm"; import remarkFrontmatter from "remark-frontmatter"; +import remarkGfm from "remark-gfm"; +import remarkMdx from "remark-mdx"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; +import { visit } from "unist-util-visit"; // Components supported by the registry's MDX renderer (mdx-compile.mjs). // Keep in sync with the component map in the registry-api's ui/mdx-compile.mjs. @@ -30,58 +34,183 @@ const ALLOWED_COMPONENTS = new Set([ "Icon", "Frame", "Card", + "Check", "Tabs", "Tab", "Steps", "Step", ]); -async function main() { - let content = ""; - for await (const chunk of process.stdin) { - content += chunk; +const URL_ATTRIBUTE_NAMES = new Set(["href", "src", "action", "formaction"]); + +function decodeHtmlEntities(input) { + return input + .replace(/&#x([0-9a-f]+);?/gi, (_, hex) => + String.fromCodePoint(Number.parseInt(hex, 16)), + ) + .replace(/&#([0-9]+);?/g, (_, dec) => + String.fromCodePoint(Number.parseInt(dec, 10)), + ) + .replace(/:/gi, ":") + .replace(/&tab;/gi, "\t") + .replace(/&newline;/gi, "\n") + .replace(/&/gi, "&"); +} + +function containsDangerousUrl(input) { + const decoded = decodeHtmlEntities(String(input)); + const normalized = [...decoded] + .filter((ch) => !/[\s\p{C}]/u.test(ch)) + .join("") + .toLowerCase(); + + return ( + normalized.includes("javascript:") || + normalized.includes("vbscript:") || + normalized.includes("data:") + ); +} + +function at(node) { + return node.position?.start?.line + ? `line ${node.position.start.line}` + : "document"; +} + +function fail(node, message) { + throw new Error(`${at(node)} ${message}`); +} + +function validateUrlNode(node) { + if (node.url && containsDangerousUrl(node.url)) { + fail(node, "contains a dangerous URL scheme"); + } +} + +function validateJsxAttribute(attribute, node) { + if (attribute.type === "mdxJsxExpressionAttribute") { + fail(node, "contains a JSX expression attribute"); + } + if (attribute.type !== "mdxJsxAttribute") { + fail(node, "contains an unsupported JSX attribute"); + } + + const name = String(attribute.name ?? ""); + if (/^on[a-z]/i.test(name)) { + fail(node, "contains an event handler attribute"); + } + if (attribute.value && typeof attribute.value === "object") { + fail(node, "contains a JSX attribute expression"); + } + if (typeof attribute.value === "string") { + if (URL_ATTRIBUTE_NAMES.has(name.toLowerCase())) { + if (containsDangerousUrl(attribute.value)) { + fail(node, "contains a dangerous URL scheme"); + } + } + if (containsDangerousUrl(attribute.value)) { + fail(node, "contains a dangerous URL scheme"); + } + } +} + +function validateJsxElement(node) { + if (!node.name) { + fail(node, "contains a JSX fragment"); + } + if (node.name.includes(".")) { + fail(node, `contains disallowed JSX component "${node.name}"`); + } + if (!ALLOWED_COMPONENTS.has(node.name)) { + fail(node, `contains disallowed JSX component "${node.name}"`); } + for (const attribute of node.attributes ?? []) { + validateJsxAttribute(attribute, node); + } +} + +function validateTree(tree) { + visit(tree, (node) => { + switch (node.type) { + case "mdxjsEsm": + fail(node, "contains MDX import/export"); + break; + case "mdxFlowExpression": + case "mdxTextExpression": + fail(node, "contains an MDX expression"); + break; + case "mdxJsxFlowElement": + case "mdxJsxTextElement": + validateJsxElement(node); + break; + case "html": + fail(node, "contains raw HTML"); + break; + case "link": + case "image": + case "definition": + validateUrlNode(node); + break; + case "text": + if (containsDangerousUrl(node.value)) { + fail(node, "contains a dangerous URL scheme"); + } + break; + } + }); +} + +function parseMdx(content) { + const processor = unified() + .use(remarkParse) + .use(remarkMdx) + .use(remarkFrontmatter) + .use(remarkGfm); + return processor.parse(content); +} + +export async function lintMdxContent(content) { if (!content.trim()) { - process.exit(0); + throw new Error("documentation cannot be empty"); } + if (content.includes("\0")) { + throw new Error("documentation contains NUL bytes"); + } + if (content.includes("\ufeff")) { + throw new Error("documentation contains byte order marks"); + } + + const tree = parseMdx(content); + validateTree(tree); - // Compile: catches syntax errors (malformed tags, bad nesting, etc.) - let compiled; try { - compiled = String( - await compile(content, { - outputFormat: "function-body", - remarkPlugins: [remarkGfm, remarkFrontmatter], - }), - ); + await compile(content, { + outputFormat: "function-body", + remarkPlugins: [remarkGfm, remarkFrontmatter], + }); } catch (err) { - process.stderr.write(`mdx-lint: ${err.message}\n`); - process.exit(1); + throw new Error(err.message); } +} - // Check for unknown components: compile() generates _missingMdxReference("Name", true) - // for any JSX component not provided at runtime. We scan the compiled output for these - // references — this avoids false positives from angle brackets in inline text (e.g. - // `` inside backticks). - const refPattern = /_missingMdxReference\("([^"]+)"/g; - const unknown = new Set(); - let match; - while ((match = refPattern.exec(compiled)) !== null) { - const name = match[1]; - if (!ALLOWED_COMPONENTS.has(name)) { - unknown.add(name); - } +async function readStdin() { + let content = ""; + for await (const chunk of process.stdin) { + content += chunk; } + return content; +} - if (unknown.size > 0) { - for (const name of unknown) { - process.stderr.write( - `mdx-lint: Unknown component <${name}>. Allowed: ${[...ALLOWED_COMPONENTS].join(", ")}\n`, - ); - } +async function main() { + try { + await lintMdxContent(await readStdin()); + } catch (err) { + process.stderr.write(`mdx-lint: ${err.message}\n`); process.exit(1); } } -main(); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/tools/mdx-lint/mdx-lint.test.mjs b/tools/mdx-lint/mdx-lint.test.mjs new file mode 100644 index 0000000..4e687ed --- /dev/null +++ b/tools/mdx-lint/mdx-lint.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { lintMdxContent } from "./mdx-lint.mjs"; + +async function expectValid(content) { + await assert.doesNotReject(() => lintMdxContent(content)); +} + +async function expectInvalid(content, pattern) { + await assert.rejects(() => lintMdxContent(content), pattern); +} + +describe("mdx-lint policy", () => { + it("allows supported connector docs content", async () => { + await expectValid(`--- +title: Example Connector +--- + +# Example Connector + +Use this connector to sync users and groups. + + + + Create a read-only token. + + + + + +\`\`\`json +{"tenant": "{tenant}", "url": "data:image/png;base64,abc"} +\`\`\` + +Inline placeholders like \`\` and \`{tenant}\` are allowed. +`); + }); + + it("allows normal prose and safe links", async () => { + await expectValid(`This connector syncs users and groups. + +Read the public setup guide at https://example.com/docs. +`); + }); + + it("rejects unknown JSX components", async () => { + await expectInvalid("\n", /disallowed JSX component "Unknown"/); + }); + + it("rejects MDX imports and exports", async () => { + await expectInvalid('import Thing from "./thing.js"\n', /MDX import\/export/); + await expectInvalid("export const value = 1\n", /MDX import\/export/); + }); + + it("rejects MDX expressions", async () => { + await expectInvalid("{process.env.SECRET}\n", /MDX expression/); + }); + + it("rejects raw HTML elements", async () => { + await expectInvalid("
raw html
\n", /disallowed JSX component "div"/); + }); + + it("rejects event handler attributes", async () => { + await expectInvalid('\n', /event handler attribute/); + }); + + it("rejects encoded dangerous URLs", async () => { + await expectInvalid( + "[link](javascript:alert(1))\n", + /dangerous URL scheme/, + ); + }); + + it("rejects data URLs", async () => { + await expectInvalid( + "[image](data:image/png;base64,abc)\n", + /dangerous URL scheme/, + ); + }); + + it("rejects BOM and NUL bytes", async () => { + await expectInvalid("\ufeff# Title\n", /byte order marks/); + await expectInvalid("hello\0world\n", /NUL bytes/); + }); +}); diff --git a/tools/mdx-lint/package-lock.json b/tools/mdx-lint/package-lock.json index 7c3a062..9ff9c93 100644 --- a/tools/mdx-lint/package-lock.json +++ b/tools/mdx-lint/package-lock.json @@ -5,9 +5,13 @@ "packages": { "": { "dependencies": { - "@mdx-js/mdx": "^3.1.1", - "remark-frontmatter": "^5.0.0", - "remark-gfm": "^4.0.1" + "@mdx-js/mdx": "3.1.1", + "remark-frontmatter": "5.0.0", + "remark-gfm": "4.0.1", + "remark-mdx": "3.1.1", + "remark-parse": "11.0.0", + "unified": "11.0.5", + "unist-util-visit": "5.1.0" } }, "node_modules/@mdx-js/mdx": { diff --git a/tools/mdx-lint/package.json b/tools/mdx-lint/package.json index 146d832..3fe1131 100644 --- a/tools/mdx-lint/package.json +++ b/tools/mdx-lint/package.json @@ -1,9 +1,16 @@ { "private": true, "type": "module", + "scripts": { + "test": "node --test" + }, "dependencies": { - "@mdx-js/mdx": "^3.1.1", - "remark-frontmatter": "^5.0.0", - "remark-gfm": "^4.0.1" + "@mdx-js/mdx": "3.1.1", + "remark-frontmatter": "5.0.0", + "remark-gfm": "4.0.1", + "remark-mdx": "3.1.1", + "remark-parse": "11.0.0", + "unified": "11.0.5", + "unist-util-visit": "5.1.0" } }