@legionworks/facet 1.9.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/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +23 -0
- package/README.md +158 -0
- package/dist/gallery/chunk-4g8rem85.css +656 -0
- package/dist/gallery/chunk-c6s9k9ty.js +5970 -0
- package/dist/gallery/frame/artifact.css +8424 -0
- package/dist/gallery/frame/chunks/abnfDiagram-N423BO3Z-jj74szbm.js +155 -0
- package/dist/gallery/frame/chunks/architecture-TIHT7OUA-cvdjn7dt.js +10 -0
- package/dist/gallery/frame/chunks/architectureDiagram-T3A2C74G-n4z77eg6.js +8675 -0
- package/dist/gallery/frame/chunks/blockDiagram-VBNYF7ZC-7xsqzrd1.js +3529 -0
- package/dist/gallery/frame/chunks/c4Diagram-5PPSVZJV-z2zjs7wx.js +2413 -0
- package/dist/gallery/frame/chunks/classDiagram-JCYQIIEL-ve8rngqp.js +47 -0
- package/dist/gallery/frame/chunks/classDiagram-v2-OCEON4UE-8cfaxb8z.js +47 -0
- package/dist/gallery/frame/chunks/cose-bilkent-JH36ORCC-sn92qexx.js +4822 -0
- package/dist/gallery/frame/chunks/cynefin-VYW2F7L2-tqjv913d.js +10 -0
- package/dist/gallery/frame/chunks/cynefinDiagram-MW4NZA55-c1c0dj12.js +500 -0
- package/dist/gallery/frame/chunks/dagre-VZM6K2ZE-7tyg4yt2.js +476 -0
- package/dist/gallery/frame/chunks/diagram-7IWD3JNH-y4eng9sc.js +526 -0
- package/dist/gallery/frame/chunks/diagram-B4RE2ZJO-rn3p7j5h.js +673 -0
- package/dist/gallery/frame/chunks/diagram-LBJQPF4R-w4a2cm9q.js +255 -0
- package/dist/gallery/frame/chunks/diagram-Q27KOJAE-aqc2v7wy.js +575 -0
- package/dist/gallery/frame/chunks/diagram-UB23O5K3-xncy8nyd.js +355 -0
- package/dist/gallery/frame/chunks/ebnfDiagram-BXEA7PRR-acwgxz16.js +175 -0
- package/dist/gallery/frame/chunks/erDiagram-JOGREHBK-mzp5nswt.js +1332 -0
- package/dist/gallery/frame/chunks/eventmodeling-45OFAUF4-sg07qr0c.js +10 -0
- package/dist/gallery/frame/chunks/flowDiagram-UKHOOZJN-2e5ygs44.js +29 -0
- package/dist/gallery/frame/chunks/ganttDiagram-PKOTCBZU-p2hhh7hr.js +2620 -0
- package/dist/gallery/frame/chunks/gitGraph-TEB2WS4Q-j6kw0naa.js +10 -0
- package/dist/gallery/frame/chunks/gitGraphDiagram-DS77QQ5N-4z9z8wjm.js +1347 -0
- package/dist/gallery/frame/chunks/info-DKCQHKI2-mpt80jd3.js +10 -0
- package/dist/gallery/frame/chunks/infoDiagram-6WML65LV-5rqm3mgg.js +65 -0
- package/dist/gallery/frame/chunks/ishikawaDiagram-WSZJBQD7-b51z96jy.js +975 -0
- package/dist/gallery/frame/chunks/journeyDiagram-NVQOT4AX-9jbfp0pk.js +1272 -0
- package/dist/gallery/frame/chunks/kanban-definition-27J2QSJJ-4x0vx4se.js +1116 -0
- package/dist/gallery/frame/chunks/katex-xfgk95tv.js +14034 -0
- package/dist/gallery/frame/chunks/markdown-1s5vwkf2.js +9 -0
- package/dist/gallery/frame/chunks/markdown-2h6bwsms.js +3239 -0
- package/dist/gallery/frame/chunks/markdown-2x113yh8.js +107 -0
- package/dist/gallery/frame/chunks/markdown-30b7rwmp.js +1943 -0
- package/dist/gallery/frame/chunks/markdown-356bfj4g.js +2031 -0
- package/dist/gallery/frame/chunks/markdown-44n9ea69.js +2406 -0
- package/dist/gallery/frame/chunks/markdown-56enrf2r.js +39 -0
- package/dist/gallery/frame/chunks/markdown-5avymm8t.js +725 -0
- package/dist/gallery/frame/chunks/markdown-6r4svars.js +33 -0
- package/dist/gallery/frame/chunks/markdown-6zz8efx2.js +24 -0
- package/dist/gallery/frame/chunks/markdown-7jp6prnb.js +19 -0
- package/dist/gallery/frame/chunks/markdown-809k7q41.js +55 -0
- package/dist/gallery/frame/chunks/markdown-82xmzhzd.js +1076 -0
- package/dist/gallery/frame/chunks/markdown-92n37gd4.js +7627 -0
- package/dist/gallery/frame/chunks/markdown-ag2sssf8.js +36 -0
- package/dist/gallery/frame/chunks/markdown-b4s7ntq2.js +19 -0
- package/dist/gallery/frame/chunks/markdown-c9k0fvp1.js +100 -0
- package/dist/gallery/frame/chunks/markdown-ceefymn8.js +125 -0
- package/dist/gallery/frame/chunks/markdown-d1wfbs70.js +243 -0
- package/dist/gallery/frame/chunks/markdown-dy5sgjhc.js +11022 -0
- package/dist/gallery/frame/chunks/markdown-dz3z5w30.js +30868 -0
- package/dist/gallery/frame/chunks/markdown-eg6ngz7y.js +30340 -0
- package/dist/gallery/frame/chunks/markdown-ff5wvb43.js +1149 -0
- package/dist/gallery/frame/chunks/markdown-ffg06nfz.js +86 -0
- package/dist/gallery/frame/chunks/markdown-fvfncadf.js +472 -0
- package/dist/gallery/frame/chunks/markdown-gvyszqpv.js +19 -0
- package/dist/gallery/frame/chunks/markdown-gx1p0ew0.js +88 -0
- package/dist/gallery/frame/chunks/markdown-gxye5frz.js +65 -0
- package/dist/gallery/frame/chunks/markdown-h7pzv34e.js +105 -0
- package/dist/gallery/frame/chunks/markdown-hab789t9.js +47 -0
- package/dist/gallery/frame/chunks/markdown-hjp6sj8t.js +125 -0
- package/dist/gallery/frame/chunks/markdown-jka64qgp.js +1993 -0
- package/dist/gallery/frame/chunks/markdown-k8xkn8mw.js +56 -0
- package/dist/gallery/frame/chunks/markdown-m7frq8x8.js +112 -0
- package/dist/gallery/frame/chunks/markdown-p740v4qm.js +36 -0
- package/dist/gallery/frame/chunks/markdown-pxfeh231.js +57 -0
- package/dist/gallery/frame/chunks/markdown-pzgd8z3s.js +87 -0
- package/dist/gallery/frame/chunks/markdown-qx3cngz2.js +1673 -0
- package/dist/gallery/frame/chunks/markdown-rawhfvvc.js +85 -0
- package/dist/gallery/frame/chunks/markdown-rd8w0ng9.js +49 -0
- package/dist/gallery/frame/chunks/markdown-rm2yxfjj.js +2773 -0
- package/dist/gallery/frame/chunks/markdown-s1jatrnk.js +993 -0
- package/dist/gallery/frame/chunks/markdown-sgfg5vvq.js +59 -0
- package/dist/gallery/frame/chunks/markdown-t4ethv9d.js +409 -0
- package/dist/gallery/frame/chunks/markdown-tnmabqvz.js +36 -0
- package/dist/gallery/frame/chunks/markdown-tvdpppw8.js +5292 -0
- package/dist/gallery/frame/chunks/markdown-v0x27twr.js +22 -0
- package/dist/gallery/frame/chunks/markdown-v14td68x.js +36 -0
- package/dist/gallery/frame/chunks/markdown-w6sgd54z.js +36 -0
- package/dist/gallery/frame/chunks/markdown-xw8dsk8b.js +2125 -0
- package/dist/gallery/frame/chunks/markdown-y18mve73.js +85 -0
- package/dist/gallery/frame/chunks/markdown-yengsms5.js +427 -0
- package/dist/gallery/frame/chunks/markdown-z1wc0v0d.js +1826 -0
- package/dist/gallery/frame/chunks/markdown-zazx9h4n.js +329 -0
- package/dist/gallery/frame/chunks/mermaid-de7p4v0x.js +30 -0
- package/dist/gallery/frame/chunks/mindmap-definition-FAOFIHXS-ra44cbem.js +1216 -0
- package/dist/gallery/frame/chunks/packet-7NZHBO7P-xppm5913.js +10 -0
- package/dist/gallery/frame/chunks/pegDiagram-VL7TDLO6-a2a4wg03.js +163 -0
- package/dist/gallery/frame/chunks/pie-RZYD4A2V-hnbxk152.js +10 -0
- package/dist/gallery/frame/chunks/pieDiagram-7S7Q4E2Y-f1vxjtmx.js +311 -0
- package/dist/gallery/frame/chunks/quadrantDiagram-CIZ2JOQS-ywafa6kh.js +1390 -0
- package/dist/gallery/frame/chunks/radar-I7S5WNFK-ewqd27se.js +10 -0
- package/dist/gallery/frame/chunks/railroad-3IZDKUUU-5hy4bdx3.js +10 -0
- package/dist/gallery/frame/chunks/railroad-abnf-AHOZXSZD-bh5sxz0d.js +10 -0
- package/dist/gallery/frame/chunks/railroad-ebnf-EBAXGLYW-6b9c3zeh.js +10 -0
- package/dist/gallery/frame/chunks/railroad-peg-LSFZ7HO6-avc8v39s.js +10 -0
- package/dist/gallery/frame/chunks/railroadDiagram-AXF67PYL-qc61wax4.js +131 -0
- package/dist/gallery/frame/chunks/requirementDiagram-LRYGKXZP-9bs4786c.js +1295 -0
- package/dist/gallery/frame/chunks/sankeyDiagram-W5VNT64P-awt1em8b.js +1339 -0
- package/dist/gallery/frame/chunks/sequenceDiagram-SI44F4Z6-qaf9mz3t.js +4324 -0
- package/dist/gallery/frame/chunks/sizeCapture-X5ZJPWSS-9fq685xd.js +64 -0
- package/dist/gallery/frame/chunks/stateDiagram-OKZ733FA-vx7a7hh0.js +456 -0
- package/dist/gallery/frame/chunks/stateDiagram-v2-UEYNNEHI-x4m6ws5t.js +46 -0
- package/dist/gallery/frame/chunks/swimlanes-SLNWSIFB-e4mxm1gh.js +8287 -0
- package/dist/gallery/frame/chunks/swimlanesDiagram-ULZ7WXOC-0h93e2qq.js +42 -0
- package/dist/gallery/frame/chunks/timeline-definition-Z64GVDOM-wp0bhbzg.js +1549 -0
- package/dist/gallery/frame/chunks/treeView-QDETBFTQ-hcx7n0vt.js +10 -0
- package/dist/gallery/frame/chunks/treemap-6X3UGDF4-av02skdw.js +10 -0
- package/dist/gallery/frame/chunks/vennDiagram-T6HMQDX7-pmvk1p7n.js +2566 -0
- package/dist/gallery/frame/chunks/wardley-OPB4EBWU-njsp6e40.js +10 -0
- package/dist/gallery/frame/chunks/wardleyDiagram-T6FBY63Y-eggmnv4z.js +979 -0
- package/dist/gallery/frame/chunks/xychartDiagram-ELKLHX3M-q40tnpe5.js +1954 -0
- package/dist/gallery/frame/frame.css +183 -0
- package/dist/gallery/frame/runtime/chart.js +42744 -0
- package/dist/gallery/frame/runtime/html.js +14 -0
- package/dist/gallery/frame/runtime/markdown.js +1403 -0
- package/dist/gallery/frame/runtime/mermaid.js +32 -0
- package/dist/gallery/frame/runtime/svg.js +14 -0
- package/dist/gallery/frame/runtime/tsx.js +52 -0
- package/dist/gallery/index.html +104 -0
- package/docs/reference/cli.md +163 -0
- package/docs/reference/export.md +106 -0
- package/docs/reference/html.md +215 -0
- package/docs/reference/http.md +85 -0
- package/docs/reference/mcp.md +78 -0
- package/docs/reference/security.md +119 -0
- package/docs/reference/storage.md +93 -0
- package/docs/reference/tsx.md +101 -0
- package/docs/reference/validation.md +212 -0
- package/package.json +89 -0
- package/scripts/build-gallery.ts +127 -0
- package/scripts/launch-netns.sh +67 -0
- package/skills/facet/SKILL.md +100 -0
- package/src/cli/client.ts +311 -0
- package/src/cli/commands/create.ts +49 -0
- package/src/cli/commands/doctor.ts +249 -0
- package/src/cli/commands/export.ts +179 -0
- package/src/cli/commands/instantiate.ts +35 -0
- package/src/cli/commands/list.ts +49 -0
- package/src/cli/commands/open.ts +75 -0
- package/src/cli/commands/pin.ts +39 -0
- package/src/cli/commands/promote.ts +49 -0
- package/src/cli/commands/publish.ts +122 -0
- package/src/cli/commands/read-back.ts +56 -0
- package/src/cli/commands/status.ts +114 -0
- package/src/cli/commands/watch.ts +146 -0
- package/src/cli/main.ts +522 -0
- package/src/cli/output.ts +106 -0
- package/src/cli/parser.ts +376 -0
- package/src/cli/presenter.ts +212 -0
- package/src/cli/service-metadata.ts +134 -0
- package/src/cli/spawn-service.ts +326 -0
- package/src/css.d.ts +4 -0
- package/src/gallery-web/app.ts +1581 -0
- package/src/gallery-web/export-menu.ts +121 -0
- package/src/gallery-web/export.ts +112 -0
- package/src/gallery-web/favicon.ts +54 -0
- package/src/gallery-web/frame/entries/chart.ts +6 -0
- package/src/gallery-web/frame/entries/html.ts +6 -0
- package/src/gallery-web/frame/entries/markdown.ts +6 -0
- package/src/gallery-web/frame/entries/mermaid.ts +6 -0
- package/src/gallery-web/frame/entries/svg.ts +6 -0
- package/src/gallery-web/frame/entries/tsx.ts +6 -0
- package/src/gallery-web/frame/frame-payload.ts +91 -0
- package/src/gallery-web/frame/renderer-validation.ts +13 -0
- package/src/gallery-web/frame/renderers/chart.ts +182 -0
- package/src/gallery-web/frame/renderers/dompurify-shim.ts +112 -0
- package/src/gallery-web/frame/renderers/html.ts +48 -0
- package/src/gallery-web/frame/renderers/markdown.ts +81 -0
- package/src/gallery-web/frame/renderers/mermaid.ts +120 -0
- package/src/gallery-web/frame/renderers/registry.ts +260 -0
- package/src/gallery-web/frame/renderers/svg.ts +393 -0
- package/src/gallery-web/frame/renderers/tsx.ts +61 -0
- package/src/gallery-web/frame/runtime.ts +501 -0
- package/src/gallery-web/frame/styles/artifact.css +8424 -0
- package/src/gallery-web/frame/styles/frame.css +183 -0
- package/src/gallery-web/frame/styles/html-source.css +23 -0
- package/src/gallery-web/frame/view-box.ts +33 -0
- package/src/gallery-web/frame-html.ts +72 -0
- package/src/gallery-web/index.html +104 -0
- package/src/gallery-web/session.ts +107 -0
- package/src/gallery-web/sse-client.ts +212 -0
- package/src/gallery-web/styles/app.css +420 -0
- package/src/gallery-web/styles/tokens.css +112 -0
- package/src/gallery-web/styles/verdict.css +186 -0
- package/src/gallery-web/swap.ts +64 -0
- package/src/gallery-web/theme.ts +40 -0
- package/src/gallery-web/view-state.ts +93 -0
- package/src/harness-adapters/claude-code/README.md +10 -0
- package/src/harness-adapters/claude-code/facet.sh +4 -0
- package/src/harness-adapters/codex/README.md +10 -0
- package/src/harness-adapters/codex/facet.sh +4 -0
- package/src/harness-adapters/mcp/README.md +13 -0
- package/src/harness-adapters/mcp/cli-bridge.ts +216 -0
- package/src/harness-adapters/mcp/main.ts +15 -0
- package/src/harness-adapters/mcp/server.ts +164 -0
- package/src/harness-adapters/mcp/tool-schemas.ts +53 -0
- package/src/harness-adapters/opencode/README.md +10 -0
- package/src/harness-adapters/opencode/facet.sh +4 -0
- package/src/runtime/compiled-entrypoints.ts +69 -0
- package/src/service/dispatcher.ts +717 -0
- package/src/service/export.ts +218 -0
- package/src/service/http-utils.ts +55 -0
- package/src/service/lexical/expectations.ts +210 -0
- package/src/service/lifecycle/idle-controller.ts +125 -0
- package/src/service/lifecycle/orphan-cleanup.ts +128 -0
- package/src/service/lifecycle/process-lock.ts +235 -0
- package/src/service/main.ts +136 -0
- package/src/service/process.ts +240 -0
- package/src/service/router-guards.ts +239 -0
- package/src/service/router.ts +627 -0
- package/src/service/security/auth.ts +168 -0
- package/src/service/security/host-origin.ts +138 -0
- package/src/service/security/http-guards.ts +13 -0
- package/src/service/security/leases.ts +182 -0
- package/src/service/security/token-store.ts +138 -0
- package/src/service/server.ts +356 -0
- package/src/service/store/database.ts +61 -0
- package/src/service/store/evidence-retention.ts +234 -0
- package/src/service/store/migrations.ts +167 -0
- package/src/service/store/repository-lifecycle.ts +121 -0
- package/src/service/store/repository.ts +732 -0
- package/src/service/store/schema.ts +218 -0
- package/src/service/stored-verdict.ts +97 -0
- package/src/service/stream.ts +301 -0
- package/src/service/verdict-enrichment.ts +45 -0
- package/src/shared/build/frame-bundle-plugins.ts +61 -0
- package/src/shared/build-mode.ts +28 -0
- package/src/shared/config/evidence-read.ts +98 -0
- package/src/shared/config/limits.ts +56 -0
- package/src/shared/config/paths.ts +96 -0
- package/src/shared/contracts/artifact-types.ts +2 -0
- package/src/shared/contracts/artifact.ts +98 -0
- package/src/shared/contracts/commands/_shared.ts +59 -0
- package/src/shared/contracts/commands/guards.ts +86 -0
- package/src/shared/contracts/commands/index.ts +204 -0
- package/src/shared/contracts/commands/names.ts +35 -0
- package/src/shared/contracts/commands/requests.ts +149 -0
- package/src/shared/contracts/commands/results.ts +200 -0
- package/src/shared/contracts/envelope.ts +140 -0
- package/src/shared/contracts/events.ts +41 -0
- package/src/shared/contracts/observed-counts.ts +19 -0
- package/src/shared/contracts/renderers.ts +6 -0
- package/src/shared/contracts/validation.ts +296 -0
- package/src/shared/errors/facet-error.ts +127 -0
- package/src/shared/errors/store-error.ts +68 -0
- package/src/shared/evidence-image.ts +37 -0
- package/src/shared/export.ts +31 -0
- package/src/shared/html/policy.ts +108 -0
- package/src/shared/html/style-vocabulary.ts +352 -0
- package/src/shared/logging/logger.ts +108 -0
- package/src/shared/logging/redact.ts +49 -0
- package/src/shared/security/frozen-csp.ts +13 -0
- package/src/shared/storage-version.ts +2 -0
- package/src/shared/tsx/execution.ts +29 -0
- package/src/shared/tsx/import-policy.ts +94 -0
- package/src/shared/util/dir-permissions.ts +94 -0
- package/src/shared/util/mermaid-nodes.ts +230 -0
- package/src/shared/util/process.ts +67 -0
- package/src/shared/util/time.ts +21 -0
- package/src/shared/version.ts +3 -0
- package/src/validation/sandbox/limits.ts +41 -0
- package/src/validation/sandbox/netns.ts +177 -0
- package/src/validation/tier0/chart.ts +177 -0
- package/src/validation/tier0/dom-shim.ts +54 -0
- package/src/validation/tier0/html.ts +408 -0
- package/src/validation/tier0/markdown.ts +285 -0
- package/src/validation/tier0/mermaid.ts +98 -0
- package/src/validation/tier0/runner.ts +504 -0
- package/src/validation/tier0/svg.ts +321 -0
- package/src/validation/tier0/tsx/allowlist-resolver.ts +77 -0
- package/src/validation/tier0/tsx/ast-policy-capabilities.ts +238 -0
- package/src/validation/tier0/tsx/ast-policy-shared.ts +71 -0
- package/src/validation/tier0/tsx/ast-policy.ts +181 -0
- package/src/validation/tier0/tsx/compiler.ts +156 -0
- package/src/validation/tier0/worker-dispatch.ts +211 -0
- package/src/validation/tier0/worker-entry.ts +96 -0
- package/src/validation/tier0/worker-input.ts +75 -0
- package/src/validation/tier1/browser-process.ts +147 -0
- package/src/validation/tier1/cdp-pipe.ts +281 -0
- package/src/validation/tier1/entries/chart.ts +5 -0
- package/src/validation/tier1/entries/html.ts +6 -0
- package/src/validation/tier1/entries/markdown.ts +5 -0
- package/src/validation/tier1/entries/mermaid.ts +5 -0
- package/src/validation/tier1/entries/svg.ts +5 -0
- package/src/validation/tier1/entries/tsx.ts +5 -0
- package/src/validation/tier1/frame-target.ts +162 -0
- package/src/validation/tier1/harness-entry.ts +261 -0
- package/src/validation/tier1/harness.ts +215 -0
- package/src/validation/tier1/isolated-probe.ts +94 -0
- package/src/validation/tier1/launcher.ts +177 -0
- package/src/validation/tier1/limits.ts +80 -0
- package/src/validation/tier1/nonce.ts +17 -0
- package/src/validation/tier1/protocol-probe.ts +519 -0
- package/src/validation/tier1/runner.ts +1122 -0
- package/src/validation/tier1/verdict.ts +246 -0
- package/src/validation/tier1/webp.ts +69 -0
- package/templates/README.md +88 -0
- package/templates/bar-compare.vl.json +63 -0
- package/templates/capacity-report.tsx +102 -0
- package/templates/decision-record.md +24 -0
- package/templates/deployment-state.mmd +40 -0
- package/templates/exemplar.md +91 -0
- package/templates/facet-story.tsx +799 -0
- package/templates/fleet-dashboard.html +141 -0
- package/templates/html-release-ledger.html +91 -0
- package/templates/html-status-report.html +114 -0
- package/templates/incident-console.tsx +147 -0
- package/templates/legion-boundaries.mmd +23 -0
- package/templates/legion-flow.mmd +15 -0
- package/templates/legion-sequence.mmd +14 -0
- package/templates/legion-state.mmd +7 -0
- package/templates/metric-card.svg +29 -0
- package/templates/observability-map.svg +101 -0
- package/templates/pipeline-audit.md +108 -0
- package/templates/release-metrics.vl.json +98 -0
- package/templates/service-topology.mmd +47 -0
- package/templates/status-report.md +32 -0
- package/templates/system-map.svg +53 -0
- package/templates/timeseries.vl.json +71 -0
- package/templates/tsx-interactive-counter.tsx +19 -0
- package/templates/tsx-status-report.tsx +31 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { parse, Tokenizer, TokenizerMode, type TokenHandler } from "parse5";
|
|
2
|
+
|
|
3
|
+
import { MAX_HTML_NESTING_DEPTH } from "../../shared/config/limits";
|
|
4
|
+
import type { DiscriminativeError, HtmlStructureCounts } from "../../shared/contracts/validation";
|
|
5
|
+
import {
|
|
6
|
+
HTML_STRUCTURAL_GROUPS,
|
|
7
|
+
isAllowedHtmlUrl,
|
|
8
|
+
isHtmlDeniedElement,
|
|
9
|
+
isHtmlEventHandlerAttribute,
|
|
10
|
+
isHtmlInlineStyleAttribute,
|
|
11
|
+
isHtmlUrlAttributeName,
|
|
12
|
+
isHtmlUrlBearingAttribute,
|
|
13
|
+
} from "../../shared/html/policy";
|
|
14
|
+
|
|
15
|
+
export interface HtmlParseOk {
|
|
16
|
+
readonly status: "ok";
|
|
17
|
+
readonly html: HtmlStructureCounts;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface HtmlParseFail {
|
|
21
|
+
readonly status: "error";
|
|
22
|
+
readonly html: HtmlStructureCounts;
|
|
23
|
+
readonly errors: readonly DiscriminativeError[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type HtmlParseResult = HtmlParseOk | HtmlParseFail;
|
|
27
|
+
|
|
28
|
+
interface HtmlAttribute {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly value: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const HTML_VOID_ELEMENTS = new Set([
|
|
34
|
+
"area",
|
|
35
|
+
"base",
|
|
36
|
+
"br",
|
|
37
|
+
"col",
|
|
38
|
+
"embed",
|
|
39
|
+
"hr",
|
|
40
|
+
"img",
|
|
41
|
+
"input",
|
|
42
|
+
"link",
|
|
43
|
+
"meta",
|
|
44
|
+
"param",
|
|
45
|
+
"source",
|
|
46
|
+
"track",
|
|
47
|
+
"wbr",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
function exceedsHtmlNestingDepth(text: string): boolean {
|
|
51
|
+
const openTags: string[] = [];
|
|
52
|
+
const positionsByTag = new Map<string, number[]>();
|
|
53
|
+
let exceeded = false;
|
|
54
|
+
let tokenizer: Tokenizer;
|
|
55
|
+
const handler: TokenHandler = {
|
|
56
|
+
onComment: () => {},
|
|
57
|
+
onDoctype: () => {},
|
|
58
|
+
onStartTag: (token) => {
|
|
59
|
+
if (token.selfClosing || HTML_VOID_ELEMENTS.has(token.tagName)) return;
|
|
60
|
+
const position = openTags.length;
|
|
61
|
+
openTags.push(token.tagName);
|
|
62
|
+
const positions = positionsByTag.get(token.tagName) ?? [];
|
|
63
|
+
positions.push(position);
|
|
64
|
+
positionsByTag.set(token.tagName, positions);
|
|
65
|
+
if (openTags.length > MAX_HTML_NESTING_DEPTH) {
|
|
66
|
+
exceeded = true;
|
|
67
|
+
tokenizer.pause();
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
onEndTag: (token) => {
|
|
71
|
+
const positions = positionsByTag.get(token.tagName);
|
|
72
|
+
const position = positions?.at(-1);
|
|
73
|
+
if (position === undefined) return;
|
|
74
|
+
while (openTags.length > position) {
|
|
75
|
+
const name = openTags.pop()!;
|
|
76
|
+
const entries = positionsByTag.get(name)!;
|
|
77
|
+
entries.pop();
|
|
78
|
+
if (entries.length === 0) positionsByTag.delete(name);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
onEof: () => {},
|
|
82
|
+
onCharacter: () => {},
|
|
83
|
+
onNullCharacter: () => {},
|
|
84
|
+
onWhitespaceCharacter: () => {},
|
|
85
|
+
};
|
|
86
|
+
tokenizer = new Tokenizer({}, handler);
|
|
87
|
+
tokenizer.write(text, true);
|
|
88
|
+
return exceeded;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function initialCounts(): HtmlStructureCounts {
|
|
92
|
+
return {
|
|
93
|
+
rendererRootCount: 1,
|
|
94
|
+
headingCount: 0,
|
|
95
|
+
tableCount: 0,
|
|
96
|
+
listCount: 0,
|
|
97
|
+
imageCount: 0,
|
|
98
|
+
canvasCount: 0,
|
|
99
|
+
externalImageCount: 0,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
104
|
+
return typeof value === "object" && value !== null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function elementDetails(
|
|
108
|
+
node: unknown,
|
|
109
|
+
): { readonly tagName: string; readonly attrs: readonly HtmlAttribute[] } | null {
|
|
110
|
+
if (!isRecord(node) || typeof node.tagName !== "string" || !Array.isArray(node.attrs))
|
|
111
|
+
return null;
|
|
112
|
+
const attrs: HtmlAttribute[] = [];
|
|
113
|
+
for (const attribute of node.attrs) {
|
|
114
|
+
if (
|
|
115
|
+
!isRecord(attribute) ||
|
|
116
|
+
typeof attribute.name !== "string" ||
|
|
117
|
+
typeof attribute.value !== "string"
|
|
118
|
+
) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
attrs.push({ name: attribute.name, value: attribute.value });
|
|
122
|
+
}
|
|
123
|
+
return { tagName: node.tagName.toLowerCase(), attrs };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function childNodes(node: unknown): readonly unknown[] {
|
|
127
|
+
return isRecord(node) && Array.isArray(node.childNodes) ? node.childNodes : [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function templateContent(node: unknown): unknown | null {
|
|
131
|
+
return isRecord(node) && isRecord(node.content) ? node.content : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isHttpsUrl(value: string): boolean {
|
|
135
|
+
try {
|
|
136
|
+
return new URL(value.trim()).protocol.toLowerCase() === "https:";
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isWhitespace(character: string): boolean {
|
|
143
|
+
return (
|
|
144
|
+
character === " " ||
|
|
145
|
+
character === "\t" ||
|
|
146
|
+
character === "\n" ||
|
|
147
|
+
character === "\r" ||
|
|
148
|
+
character === "\f"
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function srcsetCandidates(value: string): readonly string[] {
|
|
153
|
+
const candidates: string[] = [];
|
|
154
|
+
let index = 0;
|
|
155
|
+
while (index < value.length) {
|
|
156
|
+
while (index < value.length && (value[index] === "," || isWhitespace(value[index]!)))
|
|
157
|
+
index += 1;
|
|
158
|
+
if (index >= value.length) break;
|
|
159
|
+
const start = index;
|
|
160
|
+
while (index < value.length && !isWhitespace(value[index]!)) index += 1;
|
|
161
|
+
let candidate = value.slice(start, index);
|
|
162
|
+
while (candidate.endsWith(",")) candidate = candidate.slice(0, -1);
|
|
163
|
+
if (candidate.length > 0) candidates.push(candidate);
|
|
164
|
+
while (index < value.length && isWhitespace(value[index]!)) index += 1;
|
|
165
|
+
while (index < value.length && value[index] !== ",") index += 1;
|
|
166
|
+
if (value[index] === ",") index += 1;
|
|
167
|
+
}
|
|
168
|
+
return candidates;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function addError(errors: DiscriminativeError[], code: string, message: string): void {
|
|
172
|
+
errors.push({ code, message });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validateUrl(
|
|
176
|
+
tagName: string,
|
|
177
|
+
attributeName: string,
|
|
178
|
+
value: string,
|
|
179
|
+
counts: HtmlStructureCounts,
|
|
180
|
+
errors: DiscriminativeError[],
|
|
181
|
+
countStructure: boolean,
|
|
182
|
+
): void {
|
|
183
|
+
const candidates = attributeName === "srcset" ? srcsetCandidates(value) : [value];
|
|
184
|
+
for (const candidate of candidates) {
|
|
185
|
+
if (!isAllowedHtmlUrl(tagName, attributeName, candidate)) {
|
|
186
|
+
addError(
|
|
187
|
+
errors,
|
|
188
|
+
"html_denied_url_scheme",
|
|
189
|
+
`HTML <${tagName}> ${attributeName} contains a denied URL: ${candidate}`,
|
|
190
|
+
);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (countStructure && (tagName === "img" || tagName === "source") && isHttpsUrl(candidate)) {
|
|
194
|
+
counts.externalImageCount += 1;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function walk(root: unknown, counts: HtmlStructureCounts, errors: DiscriminativeError[]): void {
|
|
200
|
+
const stack: Array<{ readonly node: unknown; readonly countStructure: boolean }> = [
|
|
201
|
+
{ node: root, countStructure: true },
|
|
202
|
+
];
|
|
203
|
+
while (stack.length > 0) {
|
|
204
|
+
const current = stack.pop()!;
|
|
205
|
+
const element = elementDetails(current.node);
|
|
206
|
+
if (element !== null) {
|
|
207
|
+
const { tagName, attrs } = element;
|
|
208
|
+
if (isHtmlDeniedElement(tagName)) {
|
|
209
|
+
addError(errors, "html_denied_element", `HTML contains denied <${tagName}> element`);
|
|
210
|
+
}
|
|
211
|
+
if (
|
|
212
|
+
current.countStructure &&
|
|
213
|
+
(HTML_STRUCTURAL_GROUPS.headings as readonly string[]).includes(tagName)
|
|
214
|
+
)
|
|
215
|
+
counts.headingCount += 1;
|
|
216
|
+
if (
|
|
217
|
+
current.countStructure &&
|
|
218
|
+
(HTML_STRUCTURAL_GROUPS.tables as readonly string[]).includes(tagName)
|
|
219
|
+
)
|
|
220
|
+
counts.tableCount += 1;
|
|
221
|
+
if (
|
|
222
|
+
current.countStructure &&
|
|
223
|
+
(HTML_STRUCTURAL_GROUPS.lists as readonly string[]).includes(tagName)
|
|
224
|
+
)
|
|
225
|
+
counts.listCount += 1;
|
|
226
|
+
if (
|
|
227
|
+
current.countStructure &&
|
|
228
|
+
(HTML_STRUCTURAL_GROUPS.images as readonly string[]).includes(tagName)
|
|
229
|
+
)
|
|
230
|
+
counts.imageCount += 1;
|
|
231
|
+
if (
|
|
232
|
+
current.countStructure &&
|
|
233
|
+
(HTML_STRUCTURAL_GROUPS.canvases as readonly string[]).includes(tagName)
|
|
234
|
+
)
|
|
235
|
+
counts.canvasCount += 1;
|
|
236
|
+
for (const attribute of attrs) {
|
|
237
|
+
const name = attribute.name.toLowerCase();
|
|
238
|
+
if (isHtmlEventHandlerAttribute(name) || isHtmlInlineStyleAttribute(name)) {
|
|
239
|
+
addError(
|
|
240
|
+
errors,
|
|
241
|
+
"html_denied_attribute",
|
|
242
|
+
`HTML <${tagName}> contains denied ${name} attribute`,
|
|
243
|
+
);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (isHtmlUrlBearingAttribute(tagName, name)) {
|
|
247
|
+
validateUrl(tagName, name, attribute.value, counts, errors, current.countStructure);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (isHtmlUrlAttributeName(name)) {
|
|
251
|
+
addError(
|
|
252
|
+
errors,
|
|
253
|
+
"html_denied_url_scheme",
|
|
254
|
+
`HTML <${tagName}> ${name} is not an allowed URL-bearing attribute`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (tagName === "template") {
|
|
259
|
+
const content = templateContent(current.node);
|
|
260
|
+
if (content !== null) stack.push({ node: content, countStructure: false });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const children = childNodes(current.node);
|
|
264
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
265
|
+
stack.push({ node: children[index], countStructure: current.countStructure });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function parseHtml(bytes: Uint8Array): HtmlParseResult {
|
|
271
|
+
let text: string;
|
|
272
|
+
try {
|
|
273
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
274
|
+
} catch {
|
|
275
|
+
const html = initialCounts();
|
|
276
|
+
return {
|
|
277
|
+
status: "error",
|
|
278
|
+
html,
|
|
279
|
+
errors: [
|
|
280
|
+
{
|
|
281
|
+
code: "html_encoding_unsupported",
|
|
282
|
+
message: "HTML bytes are not valid UTF-8",
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const html = initialCounts();
|
|
288
|
+
if (exceedsHtmlNestingDepth(text)) {
|
|
289
|
+
return {
|
|
290
|
+
status: "error",
|
|
291
|
+
html,
|
|
292
|
+
errors: [
|
|
293
|
+
{
|
|
294
|
+
code: "html_nesting_depth_exceeded",
|
|
295
|
+
message: `HTML nesting exceeds the ${MAX_HTML_NESTING_DEPTH} element limit`,
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const recovery = detectUnsupportedRecoveryFamilies(text);
|
|
301
|
+
if (recovery !== null) {
|
|
302
|
+
return {
|
|
303
|
+
status: "error",
|
|
304
|
+
html,
|
|
305
|
+
errors: [
|
|
306
|
+
{
|
|
307
|
+
code: "html_recovery_unsupported",
|
|
308
|
+
message: recovery,
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const errors: DiscriminativeError[] = [];
|
|
314
|
+
walk(parse(text, { scriptingEnabled: false }), html, errors);
|
|
315
|
+
return errors.length === 0 ? { status: "ok", html } : { status: "error", html, errors };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Tokenize the source and detect constructs whose recovery behaviour the
|
|
320
|
+
* parse5 prediction cannot guarantee against Chromium's DOMParser. Returns
|
|
321
|
+
* the rejection message for the FIRST unsupported construct found, or null
|
|
322
|
+
* if the bytes survive.
|
|
323
|
+
*
|
|
324
|
+
* Today the only sanctioned reject is the `<select>` family: parse5 drops
|
|
325
|
+
* table-scoped markup inside select (the WHATWG "in select in table" mode
|
|
326
|
+
* keeps it on Chromium). Shrinking the accepted input set here is cheaper
|
|
327
|
+
* than guaranteeing agreement on a niche construct static HTML reports do
|
|
328
|
+
* not actually need.
|
|
329
|
+
*
|
|
330
|
+
* Tokenizing (not scanning) is non-negotiable: a string scan cannot
|
|
331
|
+
* distinguish a `<select>` tag from the same characters appearing inside
|
|
332
|
+
* a comment, an attribute value, or a raw-text / RCDATA element's content.
|
|
333
|
+
* parse5's tree builder normally toggles tokenizer modes; a naive token
|
|
334
|
+
* detector therefore scores 7/9 and still false-positives on
|
|
335
|
+
* `<textarea>` / `<title>` content. We toggle the tokenizer mode
|
|
336
|
+
* ourselves on each RCDATA / RAWTEXT element so the score is 9/9.
|
|
337
|
+
*/
|
|
338
|
+
const TABLE_SCOPED_TAGS = new Set([
|
|
339
|
+
"table",
|
|
340
|
+
"tr",
|
|
341
|
+
"td",
|
|
342
|
+
"th",
|
|
343
|
+
"tbody",
|
|
344
|
+
"thead",
|
|
345
|
+
"tfoot",
|
|
346
|
+
"caption",
|
|
347
|
+
"colgroup",
|
|
348
|
+
"col",
|
|
349
|
+
]);
|
|
350
|
+
|
|
351
|
+
/** Escapable raw-text: character references ARE decoded, only the matching
|
|
352
|
+
* end tag is recognized. */
|
|
353
|
+
const RCDATA_ELEMENTS = new Set(["textarea", "title"]);
|
|
354
|
+
|
|
355
|
+
/** Raw text: no character references decoded, no tags at all (except the
|
|
356
|
+
* matching end tag).
|
|
357
|
+
*
|
|
358
|
+
* `noscript` is deliberately absent: this file parses with
|
|
359
|
+
* `scriptingEnabled: false` (matching the frame's DOMParser), and under
|
|
360
|
+
* scripting-disabled parsing `<noscript>` content is normal markup rather
|
|
361
|
+
* than raw text. Listing it here would blind the tokenizer walk to elements
|
|
362
|
+
* the tree walk below can see. These two must agree.
|
|
363
|
+
*/
|
|
364
|
+
const RAWTEXT_ELEMENTS = new Set(["style", "xmp", "iframe", "noembed", "noframes"]);
|
|
365
|
+
|
|
366
|
+
function detectUnsupportedRecoveryFamilies(text: string): string | null {
|
|
367
|
+
let selectDepth = 0;
|
|
368
|
+
let divergent: string | null = null;
|
|
369
|
+
let tokenizer: Tokenizer;
|
|
370
|
+
const handler: TokenHandler = {
|
|
371
|
+
onComment: () => {},
|
|
372
|
+
onDoctype: () => {},
|
|
373
|
+
onStartTag: (token) => {
|
|
374
|
+
const tag = token.tagName.toLowerCase();
|
|
375
|
+
if (tag === "select") {
|
|
376
|
+
selectDepth += 1;
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (divergent !== null) return;
|
|
380
|
+
if (selectDepth > 0 && TABLE_SCOPED_TAGS.has(tag)) {
|
|
381
|
+
divergent = `HTML <select> contains table-scoped <${tag}>; parse5 drops it, Chromium DOMParser keeps it`;
|
|
382
|
+
tokenizer.pause();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// Toggle tokenizer mode so <select> / table markup inside raw text
|
|
386
|
+
// is not tokenized as a tag. This is the single detail that
|
|
387
|
+
// decides 9/9 vs 7/9.
|
|
388
|
+
if (RCDATA_ELEMENTS.has(tag)) {
|
|
389
|
+
tokenizer.state = TokenizerMode.RCDATA;
|
|
390
|
+
} else if (RAWTEXT_ELEMENTS.has(tag)) {
|
|
391
|
+
tokenizer.state = TokenizerMode.RAWTEXT;
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
onEndTag: (token) => {
|
|
395
|
+
const tag = token.tagName.toLowerCase();
|
|
396
|
+
if (tag === "select") {
|
|
397
|
+
if (selectDepth > 0) selectDepth -= 1;
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
onEof: () => {},
|
|
401
|
+
onCharacter: () => {},
|
|
402
|
+
onNullCharacter: () => {},
|
|
403
|
+
onWhitespaceCharacter: () => {},
|
|
404
|
+
};
|
|
405
|
+
tokenizer = new Tokenizer({}, handler);
|
|
406
|
+
tokenizer.write(text, true);
|
|
407
|
+
return divergent;
|
|
408
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 0 markdown parser.
|
|
3
|
+
*
|
|
4
|
+
* Marked's Lexer tokenizes the source into the CommonMark block tree
|
|
5
|
+
* without rendering. We use it to (a) confirm the source is structurally
|
|
6
|
+
* well-formed CommonMark, (b) count fenced blocks and per-language
|
|
7
|
+
* counts (cross-checking the service-side `computeLexicalExpectations`),
|
|
8
|
+
* and (c) verify raw HTML is treated as DATA, not executed.
|
|
9
|
+
*
|
|
10
|
+
* The Tier 0 renderer root expectation is one per ```mermaid fence.
|
|
11
|
+
* Marked produces inline `html` tokens for raw HTML blocks; those are
|
|
12
|
+
* counted but never attached to a DOM. The `marked` lexer therefore
|
|
13
|
+
* gives us a safe structural check without any rendering surface.
|
|
14
|
+
*
|
|
15
|
+
* External-image disclosure: the frozen CSP widens `img-src` to
|
|
16
|
+
* `https:` for every artifact type, so a markdown artifact carrying a
|
|
17
|
+
* native `` image loads that image at display time.
|
|
18
|
+
* `parseMarkdown` walks Marked's token tree (NOT a regex over the
|
|
19
|
+
* source) and tallies every image whose resolved `href` is `https:`.
|
|
20
|
+
* Marked resolves inline, reference-style, and autolink forms into the
|
|
21
|
+
* same `Tokens.Image` shape with the final `href` already populated,
|
|
22
|
+
* so a single walker pass is sufficient. The count surfaces as
|
|
23
|
+
* `observed.externalImageCount` so the verdict can downgrade the
|
|
24
|
+
* artifact to `partial:external_resources`. Raw-HTML smuggling of an
|
|
25
|
+
* `href`/`src` to `https:` remains a hard Tier 0 rejection — that
|
|
26
|
+
* pattern is hostile, not authored.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { Lexer, type Token, type Tokens } from "marked";
|
|
30
|
+
|
|
31
|
+
import type { DiscriminativeError, VerdictObserved } from "../../shared/contracts/validation";
|
|
32
|
+
import { countMermaidNodeDeclarations } from "../../shared/util/mermaid-nodes";
|
|
33
|
+
|
|
34
|
+
export interface MarkdownParseOk {
|
|
35
|
+
readonly status: "ok";
|
|
36
|
+
readonly observed: VerdictObserved;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MarkdownParseFail {
|
|
40
|
+
readonly status: "error";
|
|
41
|
+
readonly observed: VerdictObserved;
|
|
42
|
+
readonly errors: readonly DiscriminativeError[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type MarkdownParseResult = MarkdownParseOk | MarkdownParseFail;
|
|
46
|
+
|
|
47
|
+
interface MarkdownCounts {
|
|
48
|
+
totalFenced: number;
|
|
49
|
+
mermaidFenced: number;
|
|
50
|
+
mermaidNodeCount: number | null;
|
|
51
|
+
rendererRoots: number;
|
|
52
|
+
htmlTokens: number;
|
|
53
|
+
externalImageCount: number;
|
|
54
|
+
hasScript: boolean;
|
|
55
|
+
hasOnHandler: boolean;
|
|
56
|
+
hasExternalRef: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isExternalHttpsUrl(value: string): boolean {
|
|
60
|
+
// The URL constructor canonicalizes the protocol; malformed URLs
|
|
61
|
+
// (e.g. `https://[`) return false here rather than throwing, so the
|
|
62
|
+
// token walk surfaces a typed zero instead of crashing Tier 0.
|
|
63
|
+
try {
|
|
64
|
+
return new URL(value.trim()).protocol.toLowerCase() === "https:";
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Walk the marked token tree and tally the surfaces that matter for
|
|
72
|
+
* the Tier 0 verdict: fenced blocks, mermaid blocks, raw HTML blocks,
|
|
73
|
+
* native-markdown external-image references, and three structural
|
|
74
|
+
* red flags the verifier must NOT execute (a `<script>` token, an
|
|
75
|
+
* `on*=…` handler, and an external URL in any raw-HTML attribute).
|
|
76
|
+
*
|
|
77
|
+
* Marked resolves every image form (inline ``,
|
|
78
|
+
* reference-style `![alt][ref]` whose `[ref]: https://…`, autolink
|
|
79
|
+
* `![<https://…>]()`) into the same `Tokens.Image` shape with the
|
|
80
|
+
* final `href` already populated — the walker counts one `https:`
|
|
81
|
+
* image per token regardless of which authoring form produced it.
|
|
82
|
+
* Raw-HTML `src="https://…"` is the smuggling pattern and is rejected
|
|
83
|
+
* below.
|
|
84
|
+
*
|
|
85
|
+
* Container recursion enumerates every shape Marked actually emits
|
|
86
|
+
* with children: `tokens[]` (blockquote, paragraph, heading, list_item,
|
|
87
|
+
* em, strong, link, image, del, tableCell), `items[]` (list carries
|
|
88
|
+
* ListItem tokens; each item has its own `tokens[]`), `header[]`
|
|
89
|
+
* (table header cells; each cell carries its own `tokens[]`), and
|
|
90
|
+
* `rows[][]` (table body rows of cells; each cell carries its own
|
|
91
|
+
* `tokens[]`). Missing any of these silently misses every image a real
|
|
92
|
+
* status report places in a list or table — see the per-container
|
|
93
|
+
* shape table in the regression report. The walker ALSO recurses the
|
|
94
|
+
* raw-HTML red flags into the same containers: a `<script>` or `on*=`
|
|
95
|
+
* smuggled into a list item or table cell must still reject the
|
|
96
|
+
* document, the same as a top-level smuggled reference.
|
|
97
|
+
*/
|
|
98
|
+
function walkTokens(tokens: Token[], counts: MarkdownCounts): void {
|
|
99
|
+
for (const token of tokens) {
|
|
100
|
+
if (token.type === "code") {
|
|
101
|
+
const code = token as Tokens.Code;
|
|
102
|
+
counts.totalFenced += 1;
|
|
103
|
+
const lang = (code.lang ?? "").trim().toLowerCase();
|
|
104
|
+
if (lang === "mermaid") {
|
|
105
|
+
counts.mermaidFenced += 1;
|
|
106
|
+
counts.rendererRoots += 1;
|
|
107
|
+
const nodes = countMermaidNodeDeclarations(code.text);
|
|
108
|
+
counts.mermaidNodeCount =
|
|
109
|
+
counts.mermaidNodeCount === null || nodes === null
|
|
110
|
+
? null
|
|
111
|
+
: counts.mermaidNodeCount + nodes;
|
|
112
|
+
}
|
|
113
|
+
} else if (token.type === "html") {
|
|
114
|
+
const html = token as Tokens.HTML;
|
|
115
|
+
counts.htmlTokens += 1;
|
|
116
|
+
const raw = html.raw ?? html.text ?? "";
|
|
117
|
+
if (/<script[\s>]/i.test(raw)) counts.hasScript = true;
|
|
118
|
+
if (/\son[a-z]+\s*=/i.test(raw)) counts.hasOnHandler = true;
|
|
119
|
+
if (/\b(?:href|src)\s*=\s*["']https?:/i.test(raw)) counts.hasExternalRef = true;
|
|
120
|
+
} else if (token.type === "image") {
|
|
121
|
+
const image = token as Tokens.Image;
|
|
122
|
+
if (isExternalHttpsUrl(image.href ?? "")) counts.externalImageCount += 1;
|
|
123
|
+
}
|
|
124
|
+
// Recurse every container Marked actually emits with children.
|
|
125
|
+
const recurseTokens = (sub: Token[] | undefined): void => {
|
|
126
|
+
if (Array.isArray(sub)) walkTokens(sub, counts);
|
|
127
|
+
};
|
|
128
|
+
const t = token as unknown as Record<string, unknown>;
|
|
129
|
+
// tokens[] — blockquote, paragraph, heading, list_item, em,
|
|
130
|
+
// strong, link, image (inline children), del, tableCell.
|
|
131
|
+
recurseTokens(t["tokens"] as Token[] | undefined);
|
|
132
|
+
// items[] — list carries ListItem tokens (each with its own
|
|
133
|
+
// tokens[] walked above when we visit them).
|
|
134
|
+
const items = t["items"] as Token[] | undefined;
|
|
135
|
+
if (Array.isArray(items)) walkTokens(items, counts);
|
|
136
|
+
// header[] — table header cells. Each cell carries its own
|
|
137
|
+
// tokens[] walked when we visit the cell token below.
|
|
138
|
+
const headerCells = t["header"] as Array<{ tokens?: Token[] }> | undefined;
|
|
139
|
+
if (Array.isArray(headerCells)) {
|
|
140
|
+
for (const cell of headerCells) {
|
|
141
|
+
if (cell !== null && typeof cell === "object") {
|
|
142
|
+
recurseTokens(cell.tokens as Token[] | undefined);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// rows[][] — table body rows, each row is an array of cells,
|
|
147
|
+
// each cell carries its own tokens[].
|
|
148
|
+
const rows = t["rows"] as Array<Array<{ tokens?: Token[] } | null>> | undefined;
|
|
149
|
+
if (Array.isArray(rows)) {
|
|
150
|
+
for (const row of rows) {
|
|
151
|
+
if (Array.isArray(row)) {
|
|
152
|
+
for (const cell of row) {
|
|
153
|
+
if (cell !== null && typeof cell === "object") {
|
|
154
|
+
recurseTokens(cell.tokens as Token[] | undefined);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Parse the source bytes as markdown. Pure tokenization — no DOM, no
|
|
165
|
+
* render, no script execution. Raw HTML is counted (it must NOT
|
|
166
|
+
* execute, and we surface structural red flags if it tries to smuggle
|
|
167
|
+
* one) but never interpreted.
|
|
168
|
+
*/
|
|
169
|
+
export function parseMarkdown(bytes: Uint8Array): MarkdownParseResult {
|
|
170
|
+
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
171
|
+
const counts: MarkdownCounts = {
|
|
172
|
+
totalFenced: 0,
|
|
173
|
+
mermaidFenced: 0,
|
|
174
|
+
mermaidNodeCount: 0,
|
|
175
|
+
rendererRoots: 0,
|
|
176
|
+
htmlTokens: 0,
|
|
177
|
+
externalImageCount: 0,
|
|
178
|
+
hasScript: false,
|
|
179
|
+
hasOnHandler: false,
|
|
180
|
+
hasExternalRef: false,
|
|
181
|
+
};
|
|
182
|
+
let lexError: unknown = null;
|
|
183
|
+
let tokens: Token[] = [];
|
|
184
|
+
try {
|
|
185
|
+
const lexer = new Lexer({ gfm: true });
|
|
186
|
+
tokens = lexer.lex(text);
|
|
187
|
+
walkTokens(tokens, counts);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
lexError = error;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (lexError !== null) {
|
|
193
|
+
const message = lexError instanceof Error ? lexError.message : String(lexError);
|
|
194
|
+
return {
|
|
195
|
+
status: "error",
|
|
196
|
+
observed: {
|
|
197
|
+
rendererRootSvgCount: 0,
|
|
198
|
+
graphCount: 0,
|
|
199
|
+
mermaidNodeCount: counts.mermaidNodeCount ?? 0,
|
|
200
|
+
visibleSvgCount: 0,
|
|
201
|
+
externalImageCount: 0,
|
|
202
|
+
errorCount: 1,
|
|
203
|
+
opaqueRegionCount: 0,
|
|
204
|
+
},
|
|
205
|
+
errors: [{ code: "markdown_lex_error", message }],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Raw HTML must NEVER execute. A token containing a script, an
|
|
210
|
+
// `on*` handler, or an external URL surfaces as a parser error so a
|
|
211
|
+
// hostile artifact cannot smuggle executable content past Tier 0.
|
|
212
|
+
if (counts.hasScript) {
|
|
213
|
+
return {
|
|
214
|
+
status: "error",
|
|
215
|
+
observed: {
|
|
216
|
+
rendererRootSvgCount: counts.rendererRoots,
|
|
217
|
+
graphCount: counts.mermaidFenced,
|
|
218
|
+
mermaidNodeCount: counts.mermaidNodeCount ?? 0,
|
|
219
|
+
visibleSvgCount: 0,
|
|
220
|
+
externalImageCount: counts.externalImageCount,
|
|
221
|
+
errorCount: 1,
|
|
222
|
+
opaqueRegionCount: 0,
|
|
223
|
+
},
|
|
224
|
+
errors: [
|
|
225
|
+
{
|
|
226
|
+
code: "html_script_in_markdown",
|
|
227
|
+
message: "Raw HTML in markdown contains a <script> element; Tier 0 rejects it",
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (counts.hasOnHandler) {
|
|
233
|
+
return {
|
|
234
|
+
status: "error",
|
|
235
|
+
observed: {
|
|
236
|
+
rendererRootSvgCount: counts.rendererRoots,
|
|
237
|
+
graphCount: counts.mermaidFenced,
|
|
238
|
+
mermaidNodeCount: counts.mermaidNodeCount ?? 0,
|
|
239
|
+
visibleSvgCount: 0,
|
|
240
|
+
externalImageCount: counts.externalImageCount,
|
|
241
|
+
errorCount: 1,
|
|
242
|
+
opaqueRegionCount: 0,
|
|
243
|
+
},
|
|
244
|
+
errors: [
|
|
245
|
+
{
|
|
246
|
+
code: "html_event_handler_in_markdown",
|
|
247
|
+
message: "Raw HTML in markdown contains an on*= event handler; Tier 0 rejects it",
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (counts.hasExternalRef) {
|
|
253
|
+
return {
|
|
254
|
+
status: "error",
|
|
255
|
+
observed: {
|
|
256
|
+
rendererRootSvgCount: counts.rendererRoots,
|
|
257
|
+
graphCount: counts.mermaidFenced,
|
|
258
|
+
mermaidNodeCount: counts.mermaidNodeCount ?? 0,
|
|
259
|
+
visibleSvgCount: 0,
|
|
260
|
+
externalImageCount: counts.externalImageCount,
|
|
261
|
+
errorCount: 1,
|
|
262
|
+
opaqueRegionCount: 0,
|
|
263
|
+
},
|
|
264
|
+
errors: [
|
|
265
|
+
{
|
|
266
|
+
code: "html_external_reference_in_markdown",
|
|
267
|
+
message: "Raw HTML in markdown contains an external href/src URL; Tier 0 rejects it",
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
status: "ok",
|
|
275
|
+
observed: {
|
|
276
|
+
rendererRootSvgCount: counts.rendererRoots,
|
|
277
|
+
graphCount: counts.mermaidFenced,
|
|
278
|
+
mermaidNodeCount: counts.mermaidNodeCount ?? 0,
|
|
279
|
+
visibleSvgCount: 0,
|
|
280
|
+
externalImageCount: counts.externalImageCount,
|
|
281
|
+
errorCount: 0,
|
|
282
|
+
opaqueRegionCount: 0,
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|