hbengine 0.2.3
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 +20 -0
- package/engine-cli/connect.mjs +73 -0
- package/engine-cli/hbengine.mjs +565 -0
- package/engine-cli/package.json +1 -0
- package/engine-cli/templates.mjs +14 -0
- package/engine-conformance/analyse-worker.mjs +12 -0
- package/engine-conformance/analyse.mjs +4458 -0
- package/engine-conformance/bundle.mjs +208 -0
- package/engine-conformance/clock.mjs +294 -0
- package/engine-conformance/csp.go +56 -0
- package/engine-conformance/csp.json +6 -0
- package/engine-conformance/csp.mjs +20 -0
- package/engine-conformance/gzip-sizes.mjs +9 -0
- package/engine-conformance/package.json +1 -0
- package/engine-conformance/picture.mjs +55 -0
- package/engine-conformance/report.go +349 -0
- package/engine-conformance/run.mjs +1217 -0
- package/engine-conformance/scan.mjs +476 -0
- package/engine-conformance/testdata/clock-overlay/bundle/assets.json +1 -0
- package/engine-conformance/testdata/clock-overlay/bundle/game.css +17 -0
- package/engine-conformance/testdata/clock-overlay/bundle/game.js +83 -0
- package/engine-conformance/testdata/clock-overlay/bundle/hbtest.json +4 -0
- package/engine-conformance/testdata/clock-overlay/bundle/index.html +34 -0
- package/engine-conformance/testdata/clock-probe/bundle/assets.json +1 -0
- package/engine-conformance/testdata/clock-probe/bundle/game.css +17 -0
- package/engine-conformance/testdata/clock-probe/bundle/game.js +65 -0
- package/engine-conformance/testdata/clock-probe/bundle/hbtest.json +4 -0
- package/engine-conformance/testdata/clock-probe/bundle/index.html +35 -0
- package/engine-conformance/testdata/clock-probe/bundle/probe-worker.js +46 -0
- package/engine-conformance/testdata/clock-probe/bundle/probe.js +210 -0
- package/engine-conformance/testdata/clock-probe/math/dice.tsv +100 -0
- package/engine-conformance/testdata/clock-probe/math/manifest.json +2925 -0
- package/engine-conformance/testdata/clock-worker/bundle/assets.json +1 -0
- package/engine-conformance/testdata/clock-worker/bundle/clock-worker.js +14 -0
- package/engine-conformance/testdata/clock-worker/bundle/game.css +17 -0
- package/engine-conformance/testdata/clock-worker/bundle/game.js +61 -0
- package/engine-conformance/testdata/clock-worker/bundle/hbtest.json +4 -0
- package/engine-conformance/testdata/clock-worker/bundle/index.html +35 -0
- package/engine-conformance/testdata/clock-worker/math/dice.tsv +100 -0
- package/engine-conformance/testdata/clock-worker/math/manifest.json +2925 -0
- package/engine-conformance/travel.mjs +1209 -0
- package/engine-conformance/vendor/acorn/LICENSE +21 -0
- package/engine-conformance/vendor/acorn/acorn.mjs +6266 -0
- package/engine-conformance/wasm-releases.json +168 -0
- package/engine-examples/README.md +23 -0
- package/engine-examples/coin/AGENTS.md +3 -0
- package/engine-examples/coin/README.md +7 -0
- package/engine-examples/coin/bundle/assets.json +1 -0
- package/engine-examples/coin/bundle/game.css +5 -0
- package/engine-examples/coin/bundle/game.js +19 -0
- package/engine-examples/coin/bundle/hbtest.json +5 -0
- package/engine-examples/coin/bundle/index.html +16 -0
- package/engine-examples/coin/math/coin.tsv +2 -0
- package/engine-examples/coin/math/manifest.json +17 -0
- package/engine-examples/crash/AGENTS.md +3 -0
- package/engine-examples/crash/README.md +14 -0
- package/engine-examples/crash/bundle/assets.json +1 -0
- package/engine-examples/crash/bundle/game.css +8 -0
- package/engine-examples/crash/bundle/game.js +90 -0
- package/engine-examples/crash/bundle/hbtest.json +5 -0
- package/engine-examples/crash/bundle/index.html +20 -0
- package/engine-examples/crash/math/crash-01.tsv +2 -0
- package/engine-examples/crash/math/crash-02.tsv +2 -0
- package/engine-examples/crash/math/crash-03.tsv +2 -0
- package/engine-examples/crash/math/crash-04.tsv +2 -0
- package/engine-examples/crash/math/crash-05.tsv +2 -0
- package/engine-examples/crash/math/crash-06.tsv +2 -0
- package/engine-examples/crash/math/crash-07.tsv +2 -0
- package/engine-examples/crash/math/crash-08.tsv +2 -0
- package/engine-examples/crash/math/crash-09.tsv +2 -0
- package/engine-examples/crash/math/crash-10.tsv +2 -0
- package/engine-examples/crash/math/crash-11.tsv +2 -0
- package/engine-examples/crash/math/crash-12.tsv +2 -0
- package/engine-examples/crash/math/crash-13.tsv +2 -0
- package/engine-examples/crash/math/crash-14.tsv +2 -0
- package/engine-examples/crash/math/crash-15.tsv +2 -0
- package/engine-examples/crash/math/crash-16.tsv +2 -0
- package/engine-examples/crash/math/crash-17.tsv +2 -0
- package/engine-examples/crash/math/crash-18.tsv +2 -0
- package/engine-examples/crash/math/crash-19.tsv +2 -0
- package/engine-examples/crash/math/crash-20.tsv +2 -0
- package/engine-examples/crash/math/crash-21.tsv +2 -0
- package/engine-examples/crash/math/crash-22.tsv +2 -0
- package/engine-examples/crash/math/crash-23.tsv +2 -0
- package/engine-examples/crash/math/crash-24.tsv +2 -0
- package/engine-examples/crash/math/crash-25.tsv +2 -0
- package/engine-examples/crash/math/crash-26.tsv +2 -0
- package/engine-examples/crash/math/crash-27.tsv +2 -0
- package/engine-examples/crash/math/crash-28.tsv +2 -0
- package/engine-examples/crash/math/crash-29.tsv +2 -0
- package/engine-examples/crash/math/crash-30.tsv +2 -0
- package/engine-examples/crash/math/crash-31.tsv +2 -0
- package/engine-examples/crash/math/crash-32.tsv +2 -0
- package/engine-examples/crash/math/crash-33.tsv +2 -0
- package/engine-examples/crash/math/crash-34.tsv +2 -0
- package/engine-examples/crash/math/crash-35.tsv +2 -0
- package/engine-examples/crash/math/crash-36.tsv +2 -0
- package/engine-examples/crash/math/crash-37.tsv +2 -0
- package/engine-examples/crash/math/crash-38.tsv +2 -0
- package/engine-examples/crash/math/crash-39.tsv +2 -0
- package/engine-examples/crash/math/crash-40.tsv +2 -0
- package/engine-examples/crash/math/crash-41.tsv +2 -0
- package/engine-examples/crash/math/crash-42.tsv +2 -0
- package/engine-examples/crash/math/crash-43.tsv +2 -0
- package/engine-examples/crash/math/crash-44.tsv +2 -0
- package/engine-examples/crash/math/crash-45.tsv +2 -0
- package/engine-examples/crash/math/crash-46.tsv +2 -0
- package/engine-examples/crash/math/crash-47.tsv +2 -0
- package/engine-examples/crash/math/crash-48.tsv +2 -0
- package/engine-examples/crash/math/crash-49.tsv +2 -0
- package/engine-examples/crash/math/crash-50.tsv +2 -0
- package/engine-examples/crash/math/crash-51.tsv +2 -0
- package/engine-examples/crash/math/crash-52.tsv +2 -0
- package/engine-examples/crash/math/crash-53.tsv +2 -0
- package/engine-examples/crash/math/crash-54.tsv +2 -0
- package/engine-examples/crash/math/crash-55.tsv +2 -0
- package/engine-examples/crash/math/crash-56.tsv +2 -0
- package/engine-examples/crash/math/crash-57.tsv +2 -0
- package/engine-examples/crash/math/crash-58.tsv +2 -0
- package/engine-examples/crash/math/crash-59.tsv +2 -0
- package/engine-examples/crash/math/crash-60.tsv +2 -0
- package/engine-examples/crash/math/crash-61.tsv +2 -0
- package/engine-examples/crash/math/crash-62.tsv +2 -0
- package/engine-examples/crash/math/crash-63.tsv +2 -0
- package/engine-examples/crash/math/crash-64.tsv +2 -0
- package/engine-examples/crash/math/gen.mjs +31 -0
- package/engine-examples/crash/math/manifest.json +1 -0
- package/engine-examples/dice/AGENTS.md +3 -0
- package/engine-examples/dice/README.md +25 -0
- package/engine-examples/dice/bundle/assets.json +1 -0
- package/engine-examples/dice/bundle/game.css +17 -0
- package/engine-examples/dice/bundle/game.js +61 -0
- package/engine-examples/dice/bundle/hbtest.json +4 -0
- package/engine-examples/dice/bundle/index.html +34 -0
- package/engine-examples/dice/math/dice.tsv +100 -0
- package/engine-examples/dice/math/manifest.json +2925 -0
- package/engine-examples/keno/AGENTS.md +3 -0
- package/engine-examples/keno/README.md +11 -0
- package/engine-examples/keno/bundle/assets.json +1 -0
- package/engine-examples/keno/bundle/game.css +14 -0
- package/engine-examples/keno/bundle/game.js +86 -0
- package/engine-examples/keno/bundle/hbtest.json +4 -0
- package/engine-examples/keno/bundle/i18n.json +342 -0
- package/engine-examples/keno/bundle/index.html +20 -0
- package/engine-examples/keno/bundle/model.json +1 -0
- package/engine-examples/keno/math/gen.mjs +35 -0
- package/engine-examples/keno/math/keno-picks-1.tsv +2 -0
- package/engine-examples/keno/math/keno-picks-10.tsv +11 -0
- package/engine-examples/keno/math/keno-picks-2.tsv +3 -0
- package/engine-examples/keno/math/keno-picks-3.tsv +4 -0
- package/engine-examples/keno/math/keno-picks-4.tsv +5 -0
- package/engine-examples/keno/math/keno-picks-5.tsv +6 -0
- package/engine-examples/keno/math/keno-picks-6.tsv +7 -0
- package/engine-examples/keno/math/keno-picks-7.tsv +8 -0
- package/engine-examples/keno/math/keno-picks-8.tsv +9 -0
- package/engine-examples/keno/math/keno-picks-9.tsv +10 -0
- package/engine-examples/keno/math/manifest.json +1 -0
- package/engine-examples/keno/math-kit/pack.mjs +165 -0
- package/engine-examples/keno/math-kit/package.json +8 -0
- package/engine-examples/keno/math-kit/slot.mjs +94 -0
- package/engine-examples/keno/math-kit/tumble.mjs +94 -0
- package/engine-examples/lines/AGENTS.md +3 -0
- package/engine-examples/lines/README.md +20 -0
- package/engine-examples/lines/bundle/assets.json +1 -0
- package/engine-examples/lines/bundle/game.css +16 -0
- package/engine-examples/lines/bundle/game.js +125 -0
- package/engine-examples/lines/bundle/hbtest.json +4 -0
- package/engine-examples/lines/bundle/i18n.json +242 -0
- package/engine-examples/lines/bundle/index.html +23 -0
- package/engine-examples/lines/bundle/model.json +1 -0
- package/engine-examples/lines/math/gen.mjs +54 -0
- package/engine-examples/lines/math-kit/pack.mjs +165 -0
- package/engine-examples/lines/math-kit/package.json +8 -0
- package/engine-examples/lines/math-kit/slot.mjs +94 -0
- package/engine-examples/lines/math-kit/tumble.mjs +94 -0
- package/engine-examples/mines/AGENTS.md +3 -0
- package/engine-examples/mines/README.md +13 -0
- package/engine-examples/mines/bundle/assets.json +1 -0
- package/engine-examples/mines/bundle/game.css +12 -0
- package/engine-examples/mines/bundle/game.js +58 -0
- package/engine-examples/mines/bundle/hbtest.json +8 -0
- package/engine-examples/mines/bundle/index.html +16 -0
- package/engine-examples/mines/math/gen.mjs +40 -0
- package/engine-examples/mines/math/manifest.json +1 -0
- package/engine-examples/mines/math/mines-1-1.tsv +2 -0
- package/engine-examples/mines/math/mines-1-2.tsv +2 -0
- package/engine-examples/mines/math/mines-1-22.tsv +2 -0
- package/engine-examples/mines/math/mines-1-23.tsv +2 -0
- package/engine-examples/mines/math/mines-1-24.tsv +2 -0
- package/engine-examples/mines/math/mines-1-3.tsv +2 -0
- package/engine-examples/mines/math/mines-10-1.tsv +2 -0
- package/engine-examples/mines/math/mines-10-10.tsv +2 -0
- package/engine-examples/mines/math/mines-10-11.tsv +2 -0
- package/engine-examples/mines/math/mines-10-12.tsv +2 -0
- package/engine-examples/mines/math/mines-10-13.tsv +2 -0
- package/engine-examples/mines/math/mines-10-14.tsv +2 -0
- package/engine-examples/mines/math/mines-10-15.tsv +2 -0
- package/engine-examples/mines/math/mines-10-2.tsv +2 -0
- package/engine-examples/mines/math/mines-10-3.tsv +2 -0
- package/engine-examples/mines/math/mines-10-4.tsv +2 -0
- package/engine-examples/mines/math/mines-10-5.tsv +2 -0
- package/engine-examples/mines/math/mines-10-6.tsv +2 -0
- package/engine-examples/mines/math/mines-10-7.tsv +2 -0
- package/engine-examples/mines/math/mines-10-8.tsv +2 -0
- package/engine-examples/mines/math/mines-10-9.tsv +2 -0
- package/engine-examples/mines/math/mines-11-1.tsv +2 -0
- package/engine-examples/mines/math/mines-11-10.tsv +2 -0
- package/engine-examples/mines/math/mines-11-11.tsv +2 -0
- package/engine-examples/mines/math/mines-11-12.tsv +2 -0
- package/engine-examples/mines/math/mines-11-13.tsv +2 -0
- package/engine-examples/mines/math/mines-11-14.tsv +2 -0
- package/engine-examples/mines/math/mines-11-2.tsv +2 -0
- package/engine-examples/mines/math/mines-11-3.tsv +2 -0
- package/engine-examples/mines/math/mines-11-4.tsv +2 -0
- package/engine-examples/mines/math/mines-11-5.tsv +2 -0
- package/engine-examples/mines/math/mines-11-6.tsv +2 -0
- package/engine-examples/mines/math/mines-11-7.tsv +2 -0
- package/engine-examples/mines/math/mines-11-8.tsv +2 -0
- package/engine-examples/mines/math/mines-11-9.tsv +2 -0
- package/engine-examples/mines/math/mines-12-1.tsv +2 -0
- package/engine-examples/mines/math/mines-12-10.tsv +2 -0
- package/engine-examples/mines/math/mines-12-11.tsv +2 -0
- package/engine-examples/mines/math/mines-12-12.tsv +2 -0
- package/engine-examples/mines/math/mines-12-13.tsv +2 -0
- package/engine-examples/mines/math/mines-12-2.tsv +2 -0
- package/engine-examples/mines/math/mines-12-3.tsv +2 -0
- package/engine-examples/mines/math/mines-12-4.tsv +2 -0
- package/engine-examples/mines/math/mines-12-5.tsv +2 -0
- package/engine-examples/mines/math/mines-12-6.tsv +2 -0
- package/engine-examples/mines/math/mines-12-7.tsv +2 -0
- package/engine-examples/mines/math/mines-12-8.tsv +2 -0
- package/engine-examples/mines/math/mines-12-9.tsv +2 -0
- package/engine-examples/mines/math/mines-13-1.tsv +2 -0
- package/engine-examples/mines/math/mines-13-10.tsv +2 -0
- package/engine-examples/mines/math/mines-13-11.tsv +2 -0
- package/engine-examples/mines/math/mines-13-12.tsv +2 -0
- package/engine-examples/mines/math/mines-13-2.tsv +2 -0
- package/engine-examples/mines/math/mines-13-3.tsv +2 -0
- package/engine-examples/mines/math/mines-13-4.tsv +2 -0
- package/engine-examples/mines/math/mines-13-5.tsv +2 -0
- package/engine-examples/mines/math/mines-13-6.tsv +2 -0
- package/engine-examples/mines/math/mines-13-7.tsv +2 -0
- package/engine-examples/mines/math/mines-13-8.tsv +2 -0
- package/engine-examples/mines/math/mines-13-9.tsv +2 -0
- package/engine-examples/mines/math/mines-14-1.tsv +2 -0
- package/engine-examples/mines/math/mines-14-10.tsv +2 -0
- package/engine-examples/mines/math/mines-14-11.tsv +2 -0
- package/engine-examples/mines/math/mines-14-2.tsv +2 -0
- package/engine-examples/mines/math/mines-14-3.tsv +2 -0
- package/engine-examples/mines/math/mines-14-4.tsv +2 -0
- package/engine-examples/mines/math/mines-14-5.tsv +2 -0
- package/engine-examples/mines/math/mines-14-6.tsv +2 -0
- package/engine-examples/mines/math/mines-14-7.tsv +2 -0
- package/engine-examples/mines/math/mines-14-8.tsv +2 -0
- package/engine-examples/mines/math/mines-14-9.tsv +2 -0
- package/engine-examples/mines/math/mines-15-1.tsv +2 -0
- package/engine-examples/mines/math/mines-15-10.tsv +2 -0
- package/engine-examples/mines/math/mines-15-2.tsv +2 -0
- package/engine-examples/mines/math/mines-15-3.tsv +2 -0
- package/engine-examples/mines/math/mines-15-4.tsv +2 -0
- package/engine-examples/mines/math/mines-15-5.tsv +2 -0
- package/engine-examples/mines/math/mines-15-6.tsv +2 -0
- package/engine-examples/mines/math/mines-15-7.tsv +2 -0
- package/engine-examples/mines/math/mines-15-8.tsv +2 -0
- package/engine-examples/mines/math/mines-15-9.tsv +2 -0
- package/engine-examples/mines/math/mines-16-1.tsv +2 -0
- package/engine-examples/mines/math/mines-16-2.tsv +2 -0
- package/engine-examples/mines/math/mines-16-3.tsv +2 -0
- package/engine-examples/mines/math/mines-16-4.tsv +2 -0
- package/engine-examples/mines/math/mines-16-5.tsv +2 -0
- package/engine-examples/mines/math/mines-16-6.tsv +2 -0
- package/engine-examples/mines/math/mines-16-7.tsv +2 -0
- package/engine-examples/mines/math/mines-16-8.tsv +2 -0
- package/engine-examples/mines/math/mines-16-9.tsv +2 -0
- package/engine-examples/mines/math/mines-17-1.tsv +2 -0
- package/engine-examples/mines/math/mines-17-2.tsv +2 -0
- package/engine-examples/mines/math/mines-17-3.tsv +2 -0
- package/engine-examples/mines/math/mines-17-4.tsv +2 -0
- package/engine-examples/mines/math/mines-17-5.tsv +2 -0
- package/engine-examples/mines/math/mines-17-6.tsv +2 -0
- package/engine-examples/mines/math/mines-17-7.tsv +2 -0
- package/engine-examples/mines/math/mines-17-8.tsv +2 -0
- package/engine-examples/mines/math/mines-18-1.tsv +2 -0
- package/engine-examples/mines/math/mines-18-2.tsv +2 -0
- package/engine-examples/mines/math/mines-18-3.tsv +2 -0
- package/engine-examples/mines/math/mines-18-4.tsv +2 -0
- package/engine-examples/mines/math/mines-18-5.tsv +2 -0
- package/engine-examples/mines/math/mines-18-6.tsv +2 -0
- package/engine-examples/mines/math/mines-18-7.tsv +2 -0
- package/engine-examples/mines/math/mines-19-1.tsv +2 -0
- package/engine-examples/mines/math/mines-19-2.tsv +2 -0
- package/engine-examples/mines/math/mines-19-3.tsv +2 -0
- package/engine-examples/mines/math/mines-19-4.tsv +2 -0
- package/engine-examples/mines/math/mines-19-5.tsv +2 -0
- package/engine-examples/mines/math/mines-19-6.tsv +2 -0
- package/engine-examples/mines/math/mines-2-1.tsv +2 -0
- package/engine-examples/mines/math/mines-2-19.tsv +2 -0
- package/engine-examples/mines/math/mines-2-2.tsv +2 -0
- package/engine-examples/mines/math/mines-2-20.tsv +2 -0
- package/engine-examples/mines/math/mines-2-21.tsv +2 -0
- package/engine-examples/mines/math/mines-2-22.tsv +2 -0
- package/engine-examples/mines/math/mines-2-23.tsv +2 -0
- package/engine-examples/mines/math/mines-2-3.tsv +2 -0
- package/engine-examples/mines/math/mines-2-4.tsv +2 -0
- package/engine-examples/mines/math/mines-2-5.tsv +2 -0
- package/engine-examples/mines/math/mines-20-1.tsv +2 -0
- package/engine-examples/mines/math/mines-20-2.tsv +2 -0
- package/engine-examples/mines/math/mines-20-3.tsv +2 -0
- package/engine-examples/mines/math/mines-20-4.tsv +2 -0
- package/engine-examples/mines/math/mines-20-5.tsv +2 -0
- package/engine-examples/mines/math/mines-21-1.tsv +2 -0
- package/engine-examples/mines/math/mines-21-2.tsv +2 -0
- package/engine-examples/mines/math/mines-21-3.tsv +2 -0
- package/engine-examples/mines/math/mines-21-4.tsv +2 -0
- package/engine-examples/mines/math/mines-22-1.tsv +2 -0
- package/engine-examples/mines/math/mines-22-2.tsv +2 -0
- package/engine-examples/mines/math/mines-22-3.tsv +2 -0
- package/engine-examples/mines/math/mines-23-1.tsv +2 -0
- package/engine-examples/mines/math/mines-23-2.tsv +2 -0
- package/engine-examples/mines/math/mines-24-1.tsv +2 -0
- package/engine-examples/mines/math/mines-3-1.tsv +2 -0
- package/engine-examples/mines/math/mines-3-17.tsv +2 -0
- package/engine-examples/mines/math/mines-3-18.tsv +2 -0
- package/engine-examples/mines/math/mines-3-19.tsv +2 -0
- package/engine-examples/mines/math/mines-3-2.tsv +2 -0
- package/engine-examples/mines/math/mines-3-20.tsv +2 -0
- package/engine-examples/mines/math/mines-3-21.tsv +2 -0
- package/engine-examples/mines/math/mines-3-22.tsv +2 -0
- package/engine-examples/mines/math/mines-3-3.tsv +2 -0
- package/engine-examples/mines/math/mines-3-4.tsv +2 -0
- package/engine-examples/mines/math/mines-3-5.tsv +2 -0
- package/engine-examples/mines/math/mines-3-6.tsv +2 -0
- package/engine-examples/mines/math/mines-4-1.tsv +2 -0
- package/engine-examples/mines/math/mines-4-13.tsv +2 -0
- package/engine-examples/mines/math/mines-4-14.tsv +2 -0
- package/engine-examples/mines/math/mines-4-15.tsv +2 -0
- package/engine-examples/mines/math/mines-4-16.tsv +2 -0
- package/engine-examples/mines/math/mines-4-17.tsv +2 -0
- package/engine-examples/mines/math/mines-4-18.tsv +2 -0
- package/engine-examples/mines/math/mines-4-19.tsv +2 -0
- package/engine-examples/mines/math/mines-4-2.tsv +2 -0
- package/engine-examples/mines/math/mines-4-20.tsv +2 -0
- package/engine-examples/mines/math/mines-4-21.tsv +2 -0
- package/engine-examples/mines/math/mines-4-3.tsv +2 -0
- package/engine-examples/mines/math/mines-4-4.tsv +2 -0
- package/engine-examples/mines/math/mines-4-5.tsv +2 -0
- package/engine-examples/mines/math/mines-4-6.tsv +2 -0
- package/engine-examples/mines/math/mines-4-7.tsv +2 -0
- package/engine-examples/mines/math/mines-4-8.tsv +2 -0
- package/engine-examples/mines/math/mines-4-9.tsv +2 -0
- package/engine-examples/mines/math/mines-5-1.tsv +2 -0
- package/engine-examples/mines/math/mines-5-10.tsv +2 -0
- package/engine-examples/mines/math/mines-5-11.tsv +2 -0
- package/engine-examples/mines/math/mines-5-12.tsv +2 -0
- package/engine-examples/mines/math/mines-5-13.tsv +2 -0
- package/engine-examples/mines/math/mines-5-14.tsv +2 -0
- package/engine-examples/mines/math/mines-5-15.tsv +2 -0
- package/engine-examples/mines/math/mines-5-16.tsv +2 -0
- package/engine-examples/mines/math/mines-5-17.tsv +2 -0
- package/engine-examples/mines/math/mines-5-18.tsv +2 -0
- package/engine-examples/mines/math/mines-5-19.tsv +2 -0
- package/engine-examples/mines/math/mines-5-2.tsv +2 -0
- package/engine-examples/mines/math/mines-5-20.tsv +2 -0
- package/engine-examples/mines/math/mines-5-3.tsv +2 -0
- package/engine-examples/mines/math/mines-5-4.tsv +2 -0
- package/engine-examples/mines/math/mines-5-5.tsv +2 -0
- package/engine-examples/mines/math/mines-5-6.tsv +2 -0
- package/engine-examples/mines/math/mines-5-7.tsv +2 -0
- package/engine-examples/mines/math/mines-5-8.tsv +2 -0
- package/engine-examples/mines/math/mines-5-9.tsv +2 -0
- package/engine-examples/mines/math/mines-6-1.tsv +2 -0
- package/engine-examples/mines/math/mines-6-10.tsv +2 -0
- package/engine-examples/mines/math/mines-6-11.tsv +2 -0
- package/engine-examples/mines/math/mines-6-12.tsv +2 -0
- package/engine-examples/mines/math/mines-6-13.tsv +2 -0
- package/engine-examples/mines/math/mines-6-14.tsv +2 -0
- package/engine-examples/mines/math/mines-6-15.tsv +2 -0
- package/engine-examples/mines/math/mines-6-16.tsv +2 -0
- package/engine-examples/mines/math/mines-6-17.tsv +2 -0
- package/engine-examples/mines/math/mines-6-18.tsv +2 -0
- package/engine-examples/mines/math/mines-6-19.tsv +2 -0
- package/engine-examples/mines/math/mines-6-2.tsv +2 -0
- package/engine-examples/mines/math/mines-6-3.tsv +2 -0
- package/engine-examples/mines/math/mines-6-4.tsv +2 -0
- package/engine-examples/mines/math/mines-6-5.tsv +2 -0
- package/engine-examples/mines/math/mines-6-6.tsv +2 -0
- package/engine-examples/mines/math/mines-6-7.tsv +2 -0
- package/engine-examples/mines/math/mines-6-8.tsv +2 -0
- package/engine-examples/mines/math/mines-6-9.tsv +2 -0
- package/engine-examples/mines/math/mines-7-1.tsv +2 -0
- package/engine-examples/mines/math/mines-7-10.tsv +2 -0
- package/engine-examples/mines/math/mines-7-11.tsv +2 -0
- package/engine-examples/mines/math/mines-7-12.tsv +2 -0
- package/engine-examples/mines/math/mines-7-13.tsv +2 -0
- package/engine-examples/mines/math/mines-7-14.tsv +2 -0
- package/engine-examples/mines/math/mines-7-15.tsv +2 -0
- package/engine-examples/mines/math/mines-7-16.tsv +2 -0
- package/engine-examples/mines/math/mines-7-17.tsv +2 -0
- package/engine-examples/mines/math/mines-7-18.tsv +2 -0
- package/engine-examples/mines/math/mines-7-2.tsv +2 -0
- package/engine-examples/mines/math/mines-7-3.tsv +2 -0
- package/engine-examples/mines/math/mines-7-4.tsv +2 -0
- package/engine-examples/mines/math/mines-7-5.tsv +2 -0
- package/engine-examples/mines/math/mines-7-6.tsv +2 -0
- package/engine-examples/mines/math/mines-7-7.tsv +2 -0
- package/engine-examples/mines/math/mines-7-8.tsv +2 -0
- package/engine-examples/mines/math/mines-7-9.tsv +2 -0
- package/engine-examples/mines/math/mines-8-1.tsv +2 -0
- package/engine-examples/mines/math/mines-8-10.tsv +2 -0
- package/engine-examples/mines/math/mines-8-11.tsv +2 -0
- package/engine-examples/mines/math/mines-8-12.tsv +2 -0
- package/engine-examples/mines/math/mines-8-13.tsv +2 -0
- package/engine-examples/mines/math/mines-8-14.tsv +2 -0
- package/engine-examples/mines/math/mines-8-15.tsv +2 -0
- package/engine-examples/mines/math/mines-8-16.tsv +2 -0
- package/engine-examples/mines/math/mines-8-17.tsv +2 -0
- package/engine-examples/mines/math/mines-8-2.tsv +2 -0
- package/engine-examples/mines/math/mines-8-3.tsv +2 -0
- package/engine-examples/mines/math/mines-8-4.tsv +2 -0
- package/engine-examples/mines/math/mines-8-5.tsv +2 -0
- package/engine-examples/mines/math/mines-8-6.tsv +2 -0
- package/engine-examples/mines/math/mines-8-7.tsv +2 -0
- package/engine-examples/mines/math/mines-8-8.tsv +2 -0
- package/engine-examples/mines/math/mines-8-9.tsv +2 -0
- package/engine-examples/mines/math/mines-9-1.tsv +2 -0
- package/engine-examples/mines/math/mines-9-10.tsv +2 -0
- package/engine-examples/mines/math/mines-9-11.tsv +2 -0
- package/engine-examples/mines/math/mines-9-12.tsv +2 -0
- package/engine-examples/mines/math/mines-9-13.tsv +2 -0
- package/engine-examples/mines/math/mines-9-14.tsv +2 -0
- package/engine-examples/mines/math/mines-9-15.tsv +2 -0
- package/engine-examples/mines/math/mines-9-16.tsv +2 -0
- package/engine-examples/mines/math/mines-9-2.tsv +2 -0
- package/engine-examples/mines/math/mines-9-3.tsv +2 -0
- package/engine-examples/mines/math/mines-9-4.tsv +2 -0
- package/engine-examples/mines/math/mines-9-5.tsv +2 -0
- package/engine-examples/mines/math/mines-9-6.tsv +2 -0
- package/engine-examples/mines/math/mines-9-7.tsv +2 -0
- package/engine-examples/mines/math/mines-9-8.tsv +2 -0
- package/engine-examples/mines/math/mines-9-9.tsv +2 -0
- package/engine-examples/plinko/AGENTS.md +3 -0
- package/engine-examples/plinko/README.md +9 -0
- package/engine-examples/plinko/bundle/assets.json +1 -0
- package/engine-examples/plinko/bundle/game.css +5 -0
- package/engine-examples/plinko/bundle/game.js +92 -0
- package/engine-examples/plinko/bundle/hbtest.json +4 -0
- package/engine-examples/plinko/bundle/index.html +16 -0
- package/engine-examples/plinko/bundle/model.json +1 -0
- package/engine-examples/plinko/math/gen.mjs +40 -0
- package/engine-examples/plinko/math/manifest.json +1 -0
- package/engine-examples/plinko/math/plinko-12-high.tsv +13 -0
- package/engine-examples/plinko/math/plinko-12-low.tsv +13 -0
- package/engine-examples/plinko/math/plinko-12-medium.tsv +13 -0
- package/engine-examples/plinko/math/plinko-16-high.tsv +17 -0
- package/engine-examples/plinko/math/plinko-16-low.tsv +17 -0
- package/engine-examples/plinko/math/plinko-16-medium.tsv +17 -0
- package/engine-examples/plinko/math/plinko-8-high.tsv +9 -0
- package/engine-examples/plinko/math/plinko-8-low.tsv +9 -0
- package/engine-examples/plinko/math/plinko-8-medium.tsv +9 -0
- package/engine-examples/plinko/math-kit/pack.mjs +165 -0
- package/engine-examples/plinko/math-kit/package.json +8 -0
- package/engine-examples/plinko/math-kit/slot.mjs +94 -0
- package/engine-examples/plinko/math-kit/tumble.mjs +94 -0
- package/engine-examples/slot/AGENTS.md +3 -0
- package/engine-examples/slot/README.md +26 -0
- package/engine-examples/slot/bundle/assets.json +1 -0
- package/engine-examples/slot/bundle/game.css +19 -0
- package/engine-examples/slot/bundle/game.js +42 -0
- package/engine-examples/slot/bundle/hbtest.json +4 -0
- package/engine-examples/slot/bundle/index.html +23 -0
- package/engine-examples/slot/math/gen.mjs +79 -0
- package/engine-examples/slot/math/manifest.json +1 -0
- package/engine-examples/slot/math/slot.tsv +110592 -0
- package/engine-examples/tower/AGENTS.md +3 -0
- package/engine-examples/tower/README.md +21 -0
- package/engine-examples/tower/bundle/assets.json +1 -0
- package/engine-examples/tower/bundle/game.css +8 -0
- package/engine-examples/tower/bundle/game.js +37 -0
- package/engine-examples/tower/bundle/hbtest.json +5 -0
- package/engine-examples/tower/bundle/index.html +16 -0
- package/engine-examples/tower/math/floor.tsv +4 -0
- package/engine-examples/tower/math/manifest.json +61 -0
- package/engine-examples/tumble/AGENTS.md +3 -0
- package/engine-examples/tumble/README.md +18 -0
- package/engine-examples/tumble/bundle/assets.json +1 -0
- package/engine-examples/tumble/bundle/game.css +13 -0
- package/engine-examples/tumble/bundle/game.js +119 -0
- package/engine-examples/tumble/bundle/hbtest.json +4 -0
- package/engine-examples/tumble/bundle/i18n.json +122 -0
- package/engine-examples/tumble/bundle/index.html +20 -0
- package/engine-examples/tumble/bundle/model.json +1 -0
- package/engine-examples/tumble/math/gen.mjs +38 -0
- package/engine-examples/tumble/math-kit/pack.mjs +165 -0
- package/engine-examples/tumble/math-kit/package.json +8 -0
- package/engine-examples/tumble/math-kit/slot.mjs +94 -0
- package/engine-examples/tumble/math-kit/tumble.mjs +94 -0
- package/engine-examples/wheel/README.md +14 -0
- package/engine-examples/wheel/bundle/assets.json +1 -0
- package/engine-examples/wheel/bundle/game.css +7 -0
- package/engine-examples/wheel/bundle/game.js +51 -0
- package/engine-examples/wheel/bundle/hbtest.json +4 -0
- package/engine-examples/wheel/bundle/index.html +19 -0
- package/engine-examples/wheel/math/high.tsv +24 -0
- package/engine-examples/wheel/math/low.tsv +24 -0
- package/engine-examples/wheel/math/manifest.json +48 -0
- package/engine-examples/wheel/math/medium.tsv +24 -0
- package/engine-math/pack.mjs +165 -0
- package/engine-math/package.json +1 -0
- package/engine-math/slot.mjs +94 -0
- package/engine-math/tumble.mjs +94 -0
- package/engine-mcp/package.json +1 -0
- package/engine-mcp/server.mjs +151 -0
- package/engine-sdk/GUIDE.md +855 -0
- package/engine-sdk/assets.js +92 -0
- package/engine-sdk/autoplay.js +82 -0
- package/engine-sdk/bet-field.js +391 -0
- package/engine-sdk/kit/fiat-coins.js +51 -0
- package/engine-sdk/kit/numeric-input.js +116 -0
- package/engine-sdk/kit/wallet-format.js +423 -0
- package/engine-sdk/marks.js +22 -0
- package/engine-sdk/messages.js +289 -0
- package/engine-sdk/money.js +89 -0
- package/engine-sdk/package.json +1 -0
- package/engine-sdk/protocol.js +263 -0
- package/engine-sdk/sdk.d.ts +138 -0
- package/engine-sdk/sdk.js +330 -0
- package/engine-sdk/stake.js +68 -0
- package/engine-sdk/ui.d.ts +36 -0
- package/engine-sdk/ui.js +340 -0
- package/engine-shell/core/faults.js +127 -0
- package/engine-shell/core/rgs.js +193 -0
- package/engine-shell/core/router.js +917 -0
- package/engine-shell/host/boot.js +408 -0
- package/engine-shell/host/engine-v1.js +438 -0
- package/engine-shell/host/fair.js +285 -0
- package/engine-shell/host/fonts/SOURCE.json +11 -0
- package/engine-shell/host/fonts/figtree-400.woff2 +0 -0
- package/engine-shell/host/fonts/figtree-500.woff2 +0 -0
- package/engine-shell/host/fonts/figtree-600.woff2 +0 -0
- package/engine-shell/host/fonts/red-hat-display-700.woff2 +0 -0
- package/engine-shell/host/index.html +230 -0
- package/engine-shell/host/page.js +809 -0
- package/engine-shell/host/sandbox-panel.js +681 -0
- package/engine-shell/host/sandbox-scenarios.js +554 -0
- package/engine-shell/host/site-bridge.js +87 -0
- package/engine-shell/host/strings.js +916 -0
- package/engine-shell/package.json +1 -0
- package/engine-shell/verifiers/badcat/coin.tsv +2 -0
- package/engine-shell/verifiers/badcat/dice-2.tsv +11 -0
- package/engine-shell/verifiers/badcat/dice-5.tsv +26 -0
- package/engine-shell/verifiers/badcat/dice-9.tsv +46 -0
- package/engine-shell/verifiers/badcat/manifest.json +3344 -0
- package/engine-shell/verifiers/badcat/rps.tsv +3 -0
- package/engine-shell/verifiers/badcat/triple.tsv +66 -0
- package/engine-shell/verifiers/engine-verify.js +358 -0
- package/engine-shell/verifiers/package.json +5 -0
- package/engine-shell/verifiers/parity/manifest.json +27 -0
- package/engine-shell/verifiers/parity/wide.tsv +5 -0
- package/engine-shell/verifiers/parity-vectors.json +244 -0
- package/engine-shell/verifiers/vectors.json +100 -0
- package/package.json +10 -0
|
@@ -0,0 +1,4458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The upload's code analyser: every script of a bundle parsed (acorn, vendored
|
|
3
|
+
* under vendor/acorn/) and walked, so what a game's JavaScript reaches is read
|
|
4
|
+
* from its syntax rather than matched by a pattern that `window['ev'+'al']`
|
|
5
|
+
* defeats. scan.mjs runs it on a worker thread with a deadline (bundle.mjs
|
|
6
|
+
* reads the bundle as one) and turns what it finds into the report's
|
|
7
|
+
* findings; this file knows nothing of files.
|
|
8
|
+
*
|
|
9
|
+
* Two kinds of finding. A refusal (`fail`) is a way to run code or reach an
|
|
10
|
+
* API that a reader of the bundle would not see: eval and the Function
|
|
11
|
+
* constructor in any of their spellings, the global object indexed by a
|
|
12
|
+
* computed name, names decoded or concatenated at run time, strings handed to
|
|
13
|
+
* timers, `with`, document.write and other markup-from-text, a <script> built
|
|
14
|
+
* at run time, code in attributes, dynamic import of anything but a path, an
|
|
15
|
+
* enumerated global, obfuscation, and — at a markup or URL sink — a value of
|
|
16
|
+
* which nothing is written down. A warning names, with file and line, what
|
|
17
|
+
* review must look at: the clock, the time zone, the language, the browser's
|
|
18
|
+
* identity, long and computed timers, state kept on the device, text fields,
|
|
19
|
+
* credential words, browser randomness, requests built at run time, frames, a
|
|
20
|
+
* computed part where a tag or an attribute name goes.
|
|
21
|
+
*
|
|
22
|
+
* Soundness, stated plainly. Names are resolved through scopes, so a local
|
|
23
|
+
* `self` or `parent` is not the window. A name bound to a global — the global
|
|
24
|
+
* object, document, navigator, WebAssembly, eval, any of them — is that global
|
|
25
|
+
* wherever it is used: through declarations, later assignments, `||`,
|
|
26
|
+
* destructuring, a for…of over a written list, an element of a literal, an
|
|
27
|
+
* IIFE's parameters, a function returning `this` or the global object however
|
|
28
|
+
* it is called, an element method handing it on (`[window].pop()`,
|
|
29
|
+
* `Array.of(window)[0]`, `Object.values({ w: window })[0]`), a Map or a
|
|
30
|
+
* property it was written to, `await`, an export of another file, a global
|
|
31
|
+
* another classic script set. `this` in a function that is not a method may be
|
|
32
|
+
* the global object and is judged as such (a direct `this[k] = v` write is
|
|
33
|
+
* allowed). A frame's window is the global object of another realm of the same
|
|
34
|
+
* origin; `document.location` is `location`. A method is judged however it is
|
|
35
|
+
* called: directly, bare (`insertAdjacentHTML(…)`), through `.call`, `.apply`,
|
|
36
|
+
* `Reflect.apply`, `Function.prototype.call.call`, or through its property
|
|
37
|
+
* descriptor (`Object.getOwnPropertyDescriptor(Element.prototype,
|
|
38
|
+
* 'innerHTML').set.call(el, v)` is `el.innerHTML = v`); one kept in a name,
|
|
39
|
+
* bound or taken as a descriptor is refused, since what it is later called
|
|
40
|
+
* with is not read. A host object (navigator, location, screen, crypto,
|
|
41
|
+
* performance, Intl, navigation) by a computed name, also through
|
|
42
|
+
* Reflect.get, is refused unless every name the key may be is written.
|
|
43
|
+
*
|
|
44
|
+
* Strings are read as what their written-down pieces make: a concatenation, a
|
|
45
|
+
* template, a `.join`, a literal `.replace`, `.split('').reverse()`, `atob` of
|
|
46
|
+
* a literal are evaluated exactly, wherever they stand (a constant spelled
|
|
47
|
+
* across pieces in a helper is judged there, as a literal is), and markup, an
|
|
48
|
+
* attribute name or a URL spelled across pieces is judged as the whole.
|
|
49
|
+
*
|
|
50
|
+
* At a markup or URL sink (innerHTML and its kin, href, src, action,
|
|
51
|
+
* protocol, location, open, setAttribute of a URL) a value is followed to
|
|
52
|
+
* where it is written (the resolver): through the names it was bound to,
|
|
53
|
+
* objects and lists written down, the game's own functions (what they return,
|
|
54
|
+
* their parameters bound to the arguments at their call sites in whatever
|
|
55
|
+
* file, when every reference to the function is a call), properties and
|
|
56
|
+
* attributes written and read back, destructuring, imports (the exports of
|
|
57
|
+
* the file named, a JSON module), globals of other classic scripts, and the
|
|
58
|
+
* page's own elements (an attribute as written in the page, together with
|
|
59
|
+
* every write to it the bundle makes). What comes from outside the code is
|
|
60
|
+
* refused: text decoded or rewritten at run time, a response body, storage,
|
|
61
|
+
* a cookie, a message's data (addEventListener('message', …) in every
|
|
62
|
+
* spelling, onmessage), typed text, a field's value, the window's name, the
|
|
63
|
+
* address (location.*, document.URL, URLSearchParams: the game can write it
|
|
64
|
+
* and read it back decoded). A value none of whose alternatives is written
|
|
65
|
+
* down anywhere the analyser reads is refused (markup-source); one written is
|
|
66
|
+
* read as written, and an alternative not written beside it (`ART[s] || s`, a
|
|
67
|
+
* call site handing a value the analyser cannot place) stands as a computed
|
|
68
|
+
* part, named for review. As a URL, the written start of each alternative
|
|
69
|
+
* fixes its scheme: a start that could still spell javascript: ('java' + x),
|
|
70
|
+
* a data: document of a type not written, or nothing written at the start is
|
|
71
|
+
* refused; the page's own address (location.origin + path, location.pathname
|
|
72
|
+
* …) keeps the page's scheme; `URL.createObjectURL` is a blob: URL; a media
|
|
73
|
+
* element the code made (new Image(), createElement('img')) loads a resource
|
|
74
|
+
* and runs nothing. `new URL(rel, base).href` with `rel` written and relative
|
|
75
|
+
* is on the base's origin; with `rel` not written down it is refused, since
|
|
76
|
+
* `new URL('javascript:…', base)` keeps its own scheme, base or no base. A
|
|
77
|
+
* Blob, File or Response typed text/html is read as a page,
|
|
78
|
+
* application/xhtml+xml and any +xml type as XML (prefixes dropped, a DTD
|
|
79
|
+
* subset refused); its options followed to where they are written (options
|
|
80
|
+
* not written down may type anything: refused); a protocol set to javascript
|
|
81
|
+
* makes a link run.
|
|
82
|
+
*
|
|
83
|
+
* Bounds fail closed. The resolver is bounded in depth, steps, nesting,
|
|
84
|
+
* alternatives and returns; what a bound cuts short is a cut leaf, never
|
|
85
|
+
* nothing: at a sink it is unread (refused), and where the global object is
|
|
86
|
+
* asked about, a value cut short is the global object whenever the
|
|
87
|
+
* expression, or anything its names were given, mentions one
|
|
88
|
+
* (globalMentioned). A name that once held a global keeps it among its
|
|
89
|
+
* values whatever it is later given (`let w = window; if (c) w = {}; w[k]`
|
|
90
|
+
* is refused), and an object whose prototype is the global object
|
|
91
|
+
* (Object.create(window), { __proto__: window }, setPrototypeOf) is the
|
|
92
|
+
* global object.
|
|
93
|
+
*
|
|
94
|
+
* What the analyser does not follow, stated as limits: the source (fetched,
|
|
95
|
+
* decoded) of what the game's own functions return — `t(key)` of fetched
|
|
96
|
+
* i18n inside a template is read by its shape (every text file of the bundle
|
|
97
|
+
* is walked as data instead), and a decoded or rewritten value reached only
|
|
98
|
+
* through a return is a computed part; a computed part of a markup string in
|
|
99
|
+
* text position (`'<b>' + name + '</b>'`), which stands in as a word; an
|
|
100
|
+
* element the code makes at run time, which no lookup of the page finds (a
|
|
101
|
+
* write to it reaches reads through its own name only); what a function
|
|
102
|
+
* handed to code the analyser does not see (an argument, an event handler) is
|
|
103
|
+
* called with. What it cannot follow it cannot refuse, which is why the
|
|
104
|
+
* play-tests, the review and the browser's own policy stand behind it.
|
|
105
|
+
*/
|
|
106
|
+
import { parse } from './vendor/acorn/acorn.mjs';
|
|
107
|
+
|
|
108
|
+
export const LIMITS = Object.freeze({
|
|
109
|
+
// Names of one or two letters are a minifier's. Measured on what we have:
|
|
110
|
+
// hand-written games (Bad Cat, Kraken, every template) keep 0–72% of their
|
|
111
|
+
// names that short (Kraken's art.js, dense Pixi drawing code, is the 72%);
|
|
112
|
+
// esbuild, terser, Pixi's and lodash's builds are at 98–100%. Line length
|
|
113
|
+
// is not a signal: Bad Cat is hand-written code with 300–500-character
|
|
114
|
+
// lines, and identifier entropy does not separate the two populations.
|
|
115
|
+
shortNameRatio: 0.9,
|
|
116
|
+
// Below this many names a file is small enough to read whatever its style.
|
|
117
|
+
minNames: 20,
|
|
118
|
+
// javascript-obfuscator names everything `_0x<hex>`; three such names is not an accident.
|
|
119
|
+
hexNames: 3,
|
|
120
|
+
// Printable ASCII written as `\x..` or `\u....` escapes hides a word from a
|
|
121
|
+
// reader and does nothing else; eight such strings in one file is a tool's work.
|
|
122
|
+
escapedStrings: 8,
|
|
123
|
+
// A timer past this is a delay a play-test does not wait for.
|
|
124
|
+
longTimerMs: 60 * 1000,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// What each rule means, once; scan.mjs prints the text with the locations.
|
|
128
|
+
export const RULES = Object.freeze({
|
|
129
|
+
syntax: { level: 'fail', text: 'does not parse as JavaScript (as a module or a script): the analyser cannot read it, and a browser would refuse it' },
|
|
130
|
+
eval: { level: 'fail', text: 'reaches eval: runs code built from strings' },
|
|
131
|
+
'function-constructor': { level: 'fail', text: 'reaches the Function constructor (Function, or the .constructor of a function, called, passed on, kept in a name or constructed with arguments): runs code built from strings' },
|
|
132
|
+
'computed-global': { level: 'fail', text: 'reaches the global object (window, globalThis, self, top, parent, frames, document, or `this` in a function that is not a method) by a name computed at run time, or enumerates it (for…in, Object.keys, { ...window }, { ...rest } = window): what it reaches cannot be read' },
|
|
133
|
+
'hidden-name': { level: 'fail', text: 'spells a property name at run time (decoded, concatenated, reversed or written in escapes) where a plain name would do: hides what the code reaches' },
|
|
134
|
+
'string-timer': { level: 'fail', text: 'hands a string to setTimeout or setInterval: code built from strings' },
|
|
135
|
+
with: { level: 'fail', text: 'uses a with statement: the names inside it resolve at run time' },
|
|
136
|
+
'document-write': { level: 'fail', text: 'writes markup into a document at run time (document.write): code the analyser never read' },
|
|
137
|
+
'script-element': { level: 'fail', text: 'builds a <script> at run time (createElement, or a <script> tag in text): its text would run as code the analyser never read; load scripts with <script src> in the page or import() of a path' },
|
|
138
|
+
'inline-handler': { level: 'fail', text: 'code in an attribute (onclick=…, javascript:, setAttribute("on…")): runs under the page\'s inline allowance where no reader looks; attach handlers from a script' },
|
|
139
|
+
'hidden-markup': { level: 'fail', text: 'builds markup or a URL from text the analyser cannot read exactly (decoded at run time, rewritten by a pattern, fetched (response.text(), .json()), read from storage, a cookie, the address, a message or a field): what the browser parses the analyser never read; write the markup down, or put text in the page with textContent' },
|
|
140
|
+
'markup-source': { level: 'fail', text: 'puts text of which nothing is written down into the page, or follows it as an address (innerHTML = x, insertAdjacentHTML(…, x), el.href = x, with x not written anywhere the analyser reads: not a literal, a written object or list, a function of the game returning one, an export of another file, an attribute of the page): what the browser parses the analyser never read; write the markup down, or put text in the page with textContent' },
|
|
141
|
+
'data-file': { level: 'fail', text: 'a data file the analyser cannot read as the page would: a .json that does not parse, or a file of more than a million values; fix the JSON, or split the file' },
|
|
142
|
+
'attribute-name': { level: 'fail', text: 'sets or creates an attribute whose name is computed at run time (setAttribute(name, …), toggleAttribute, createAttribute): a handler could be set where no reader looks; write the name down (a for…of over a list of written names is read)' },
|
|
143
|
+
'method-reference': { level: 'fail', text: 'keeps or binds a method that sets attributes or builds elements or markup (el.setAttribute.bind(el), const f = el.insertAdjacentHTML, document.createElement passed on): what it is later called with is not read; call it where it is used' },
|
|
144
|
+
'blob-source': { level: 'fail', text: 'builds code or a document from text not written down (new Blob([code], { type: "text/javascript" }) with code computed): code the analyser never read; put a worker in a .js file' },
|
|
145
|
+
websocket: { level: 'fail', text: 'opens a socket; the game has no network of its own' },
|
|
146
|
+
webrtc: { level: 'fail', text: 'opens a peer connection (WebRTC); the game has no network of its own' },
|
|
147
|
+
'service-worker': { level: 'fail', text: 'reaches the service worker registry: a game has no worker that outlives its page' },
|
|
148
|
+
'import-url': { level: 'fail', text: 'imports code from a URL (http, data, blob): everything a game runs ships in its bundle as a file' },
|
|
149
|
+
'import-dynamic': { level: 'fail', text: 'imports code from an address built at run time: the analyser cannot read what loads; import a literal path' },
|
|
150
|
+
'own-server': { level: 'fail', text: 'calls a server of its own (/api/…); saved data goes through engine.state' },
|
|
151
|
+
'report-path': { level: 'fail', text: "names the policy's report path (/csp-report): only the browser posts there, on a refusal" },
|
|
152
|
+
'meta-csp': { level: 'fail', text: 'sets its own CSP' },
|
|
153
|
+
navigation: { level: 'fail', text: 'rewrites where the page points (base, refresh, object/embed, srcdoc)' },
|
|
154
|
+
'import-map': { level: 'fail', text: 'ships an import map: it redirects module names to addresses the analyser does not follow; import by path' },
|
|
155
|
+
'data-url-code': { level: 'fail', text: 'loads a script or a frame from a data: or blob: URL, or writes a data: URL whose document the analyser cannot read or that holds a script or a handler: code that is not a file of the bundle' },
|
|
156
|
+
'svg-script': { level: 'fail', text: 'an SVG or XML document with a <script> (under any namespace prefix): it runs as a page when framed; keep scripts in .js files' },
|
|
157
|
+
'xml-markup': { level: 'fail', text: 'an XML document the analyser does not read as the browser renders it: an internal DTD subset (<!DOCTYPE … [ … ]>), an <!ENTITY> or <!ATTLIST> declaration (entities and attribute defaults become markup), or an <?xml-stylesheet?>; export the art as plain SVG without a DTD' },
|
|
158
|
+
minified: { level: 'fail', text: 'minified or obfuscated (nine in ten names are one or two letters) with no source map in the bundle: a reviewer cannot read it; ship readable code, or the .map with sourcesContent beside it' },
|
|
159
|
+
obfuscated: { level: 'fail', text: 'obfuscated: names or strings written to defeat a reader' },
|
|
160
|
+
'wasm-source': { level: 'fail', text: 'WebAssembly: a module the analyser cannot read; only a known library\'s released module (by content hash) run by that library\'s own code is accepted (GUIDE §3.6)' },
|
|
161
|
+
analyser: { level: 'fail', text: 'the code could not be analysed in time' },
|
|
162
|
+
clock: { level: 'warn', text: 'reads the date or time (Date, Temporal, performance.timeOrigin): review checks nothing depends on the day' },
|
|
163
|
+
timezone: { level: 'warn', text: 'reads the time zone, or formats by locale (getTimezoneOffset, Intl.DateTimeFormat, toLocale…)' },
|
|
164
|
+
locale: { level: 'warn', text: "reads the language outside the SDK (navigator.language, Intl): engine.config.locale is the player's language" },
|
|
165
|
+
fingerprint: { level: 'warn', text: "reads the browser's identity (userAgent, platform, hardware, screen depth, webdriver): review checks no behaviour depends on it" },
|
|
166
|
+
timer: { level: 'warn', text: 'a timer past 60 s, or with a delay computed at run time: review checks what fires then' },
|
|
167
|
+
'local-storage': { level: 'warn', text: 'keeps state on the device (localStorage, sessionStorage, indexedDB, cookies): per device only, and a counter kept there can fire after N rounds; anything a player keeps across devices goes through engine.state' },
|
|
168
|
+
'input-element': { level: 'warn', text: 'creates a text field or a form: a game takes nothing typed but the stake; review checks it asks for no login, password or key' },
|
|
169
|
+
'element-name': { level: 'warn', text: 'creates an element named at run time (createElement(name)): could be a script or a field; review checks the names' },
|
|
170
|
+
'markup-parts': { level: 'warn', text: 'builds markup with a computed part where a tag or an attribute name goes, or as an unquoted attribute value (`<${tag}>`, `<img ${attrs}>`, `src=${u}`), or puts a value into the page one of whose alternatives is not written down (`ART[s] || s`): a part carrying an attribute would run; review checks what each part can be' },
|
|
171
|
+
'credential-words': { level: 'warn', text: 'its text names a password, key or code: review checks the player is asked for none' },
|
|
172
|
+
'browser-rng': { level: 'warn', text: 'browser randomness (Math.random, crypto.getRandomValues): allowed for visuals only; review checks no outcome comes from it' },
|
|
173
|
+
network: { level: 'warn', text: "a request whose address is built at run time, or XMLHttpRequest, EventSource, sendBeacon: the policy keeps it on the bundle's origin; review checks what it fetches" },
|
|
174
|
+
'blob-code': { level: 'warn', text: 'code from a blob (a Worker on a built URL, URL.createObjectURL, a JavaScript Blob): the policy allows blob: workers; review reads what goes in' },
|
|
175
|
+
'frame-reach': { level: 'warn', text: 'reaches the parent or top frame (parent, top, frames, opener, postMessage to a frame): the shell listens to the SDK only' },
|
|
176
|
+
navigate: { level: 'warn', text: "moves the page or opens a window (location, document.location, Reflect.set(window, 'location'), navigation.navigate, window.open): the shell's policy blocks it; review checks why it tries" },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const GLOBAL_OBJECTS = new Set(['window', 'globalThis', 'self', 'top', 'parent', 'frames', 'opener']);
|
|
180
|
+
const FRAME_NAMES = new Set(['top', 'parent', 'frames', 'opener']);
|
|
181
|
+
// A Map, not an object: the name looked up is the file's (`window.hasOwnProperty`,
|
|
182
|
+
// a bare `toString()`), and on an object every Object.prototype key is found.
|
|
183
|
+
const NETWORK_CLASSES = new Map(Object.entries({ WebSocket: 'websocket', RTCPeerConnection: 'webrtc', webkitRTCPeerConnection: 'webrtc', mozRTCPeerConnection: 'webrtc', RTCDataChannel: 'webrtc' }));
|
|
184
|
+
const DECODERS = new Set(['atob', 'unescape', 'decodeURIComponent', 'decodeURI']);
|
|
185
|
+
const FINGERPRINT_NAV = new Set(['userAgent', 'userAgentData', 'platform', 'hardwareConcurrency', 'deviceMemory', 'vendor', 'vendorSub', 'productSub', 'appVersion', 'oscpu', 'cpuClass', 'plugins', 'mimeTypes', 'doNotTrack', 'maxTouchPoints', 'connection', 'webdriver', 'getBattery', 'bluetooth', 'usb', 'mediaDevices', 'keyboard', 'pdfViewerEnabled', 'buildID']);
|
|
186
|
+
const FINGERPRINT_SCREEN = new Set(['colorDepth', 'pixelDepth', 'availWidth', 'availHeight', 'availLeft', 'availTop', 'orientation', 'isExtended']);
|
|
187
|
+
const TIMEZONE_METHODS = new Set(['getTimezoneOffset', 'toLocaleDateString', 'toLocaleTimeString', 'toLocaleString', 'resolvedOptions']);
|
|
188
|
+
const ENUMERATORS = new Set(['keys', 'values', 'entries', 'getOwnPropertyNames', 'getOwnPropertyDescriptors', 'getOwnPropertySymbols']);
|
|
189
|
+
const DESCRIPTOR_READERS = new Set(['get', 'getOwnPropertyDescriptor', 'has', 'set', 'deleteProperty', 'defineProperty']);
|
|
190
|
+
const INPUT_TAGS = new Set(['input', 'textarea', 'form', 'select']);
|
|
191
|
+
// How many values an expression may take and still be read one by one
|
|
192
|
+
// (a conditional of conditionals), and how long a chain of pieces is read.
|
|
193
|
+
const EXACT_VALUES = 64;
|
|
194
|
+
const EXACT_PIECES = 4096;
|
|
195
|
+
// How many nodes a walk over one value reads before the value is refused as
|
|
196
|
+
// too large to read (hiddenSource, outside); a hand-written expression is
|
|
197
|
+
// far smaller, and a walk that stopped short would pass what it had not seen.
|
|
198
|
+
const WALK_STEPS = 100000;
|
|
199
|
+
// How many nodes one reading of what a cut value mentions (globalMentioned) visits before what is left is taken
|
|
200
|
+
// to mention anything; a hostile depth is found within a few hundred.
|
|
201
|
+
const MENTION_STEPS = 100000;
|
|
202
|
+
// How many steps the resolver spends on one judged node (the names, members,
|
|
203
|
+
// calls and files a value is followed through) before what is left is a cut;
|
|
204
|
+
// how many alternatives a name may hold; how many returns a function may have;
|
|
205
|
+
// how deep a chain of followed values goes (names and calls: a chain of member
|
|
206
|
+
// reads is read link by link, however long); how many writes a file records;
|
|
207
|
+
// how many elements a page or a file's markup strings may hold before lookups
|
|
208
|
+
// in them are unknown. Past any of them what is left is a cut leaf: unread at
|
|
209
|
+
// a sink (refused), and the global object where it is asked about when what
|
|
210
|
+
// was left mentions one (globalOfLeaves, globalMentioned) — never passed.
|
|
211
|
+
const QUERY_STEPS = 200000;
|
|
212
|
+
const VALUE_NODES = 32;
|
|
213
|
+
const RETURNS = 64;
|
|
214
|
+
const RESOLVE_DEPTH = 96;
|
|
215
|
+
// How deep the resolver's calls may nest in all (resolve, exact, spelled, a
|
|
216
|
+
// function's returns, a list's elements, one calling another): past it a value
|
|
217
|
+
// is unknown. The stack is the bound the depth bounds above cannot give when
|
|
218
|
+
// they compose.
|
|
219
|
+
const NESTING = 320;
|
|
220
|
+
const WRITES = 5000;
|
|
221
|
+
const ELEMENTS = 100000;
|
|
222
|
+
// A data file of more than this many values is not read: refused.
|
|
223
|
+
export const DATA_VALUES = 1000000;
|
|
224
|
+
// A property named .constructor is Function when the object is a function, and
|
|
225
|
+
// the analyser cannot tell a function from a class instance. Reading a static
|
|
226
|
+
// property off it (`this.constructor.DEFAULTS`, `.constructor.name`) reaches
|
|
227
|
+
// nothing that runs code, so any written-down property is allowed but these,
|
|
228
|
+
// which call Function or hand it on; a computed property, a call, `new` with
|
|
229
|
+
// arguments, or keeping it in a name is refused.
|
|
230
|
+
const CONSTRUCTOR_DENIED = new Set(['call', 'apply', 'bind', 'constructor', 'valueOf', '__proto__']);
|
|
231
|
+
// The globals whose members, calls and constructions the rules below judge. A
|
|
232
|
+
// name assigned one of these with no declaration (`w = navigator` in a classic
|
|
233
|
+
// script) is tracked as that global; an assignment of anything else to an
|
|
234
|
+
// undeclared name says nothing (`location = url` stays the real location).
|
|
235
|
+
// The interfaces whose prototype holds a host object's methods and getters: `Document.prototype.write` is document.write.
|
|
236
|
+
const INTERFACE_HOSTS = new Map(Object.entries({ Window: 'window', Document: 'document', HTMLDocument: 'document', Navigator: 'navigator', Location: 'location', Screen: 'screen', Performance: 'performance', Navigation: 'navigation' }));
|
|
237
|
+
const HOST_NAMES = new Set([...GLOBAL_OBJECTS, 'document', 'navigator', 'location', 'navigation', 'screen', 'Intl', 'WebAssembly', 'Math', 'crypto', 'performance', 'URL', 'URLSearchParams', 'Reflect', 'Object', 'String', 'JSON', 'Array', 'Promise', ...DECODERS, 'eval', 'Function', 'Date', 'Temporal', 'localStorage', 'sessionStorage', 'indexedDB', 'caches', 'XMLHttpRequest', 'EventSource', 'open', 'prompt', ...NETWORK_CLASSES.keys(), 'setTimeout', 'setInterval', 'fetch', 'importScripts', 'Worker', 'SharedWorker', 'Blob', 'File', 'Proxy']);
|
|
238
|
+
// Text that comes from outside the code at run time, by the call or the member
|
|
239
|
+
// that reads it: a response body, a storage item, a cookie, the address, a
|
|
240
|
+
// field's value, a message, typed text. Markup or a URL built from it is markup
|
|
241
|
+
// the analyser never read (hidden-markup), however the game names it.
|
|
242
|
+
const BODY_READERS = new Set(['text', 'json', 'arrayBuffer', 'blob', 'formData', 'bytes', 'readText']);
|
|
243
|
+
const OUTSIDE_MEMBERS = new Map(Object.entries({ responseText: 'a response', response: 'a response', value: "a field's value" }));
|
|
244
|
+
const STORAGE_NAMES = new Set(['localStorage', 'sessionStorage']);
|
|
245
|
+
// Calls that hand their argument's text on as their value: JSON.parse(t), String(t), Object.values(o) …
|
|
246
|
+
const PASS_THROUGH = new Map(Object.entries({ JSON: ['parse'], Object: ['values', 'entries', 'keys', 'assign', 'fromEntries', 'freeze', 'create'], Array: ['from', 'of'], Promise: ['resolve', 'all', 'allSettled', 'race', 'any'] }));
|
|
247
|
+
// String methods whose result is made of their receiver's text (and concat's arguments).
|
|
248
|
+
const STRING_METHODS = new Set(['trim', 'trimStart', 'trimEnd', 'replace', 'replaceAll', 'slice', 'substring', 'substr', 'at', 'charAt', 'toString', 'valueOf', 'toLowerCase', 'toUpperCase', 'toLocaleLowerCase', 'toLocaleUpperCase', 'normalize', 'repeat', 'padStart', 'padEnd', 'concat', 'join', 'split', 'reverse', 'sort', 'filter', 'flat', 'then', 'catch', 'finally']);
|
|
249
|
+
// Names a game has no reason to spell at run time. An exact match of what the
|
|
250
|
+
// literal fragments spell (forwards or reversed) is refused; the shorter list
|
|
251
|
+
// is also refused as a word inside a longer spelled string.
|
|
252
|
+
const SENSITIVE_TOKENS = new Set(['eval', 'Function', 'constructor', 'WebAssembly', 'XMLHttpRequest', 'WebSocket', 'RTCPeerConnection', 'RTCDataChannel', 'importScripts', '__proto__', 'getPrototypeOf', 'setPrototypeOf', 'defineProperty', 'getOwnPropertyDescriptor', 'serviceWorker', 'sendBeacon', 'insertAdjacentHTML', 'createContextualFragment', 'innerHTML', 'outerHTML', 'srcdoc', 'writeln', 'cookie', 'localStorage', 'sessionStorage', 'indexedDB', 'globalThis', 'postMessage', 'setTimeout', 'setInterval', 'atob', 'fetch', 'Blob', 'Worker', 'SharedWorker', 'EventSource', 'Reflect', 'Proxy', 'Date', 'Intl', 'navigator', 'crypto', 'document', 'location', 'createElement', 'setAttribute', 'userAgent', 'platform']);
|
|
253
|
+
const SENSITIVE_EXACT = new Set([...SENSITIVE_TOKENS, 'open', 'top', 'self', 'parent', 'window', 'write', 'frames', 'opener', 'import', 'prototype', 'screen', 'apply', 'call', 'bind']);
|
|
254
|
+
|
|
255
|
+
// The methods whose arguments the rules judge (an attribute's name, markup, a
|
|
256
|
+
// tag): called directly, through .call/.apply/Reflect.apply they are read;
|
|
257
|
+
// kept in a name, bound or passed on they are refused, since what they are
|
|
258
|
+
// later called with is not read.
|
|
259
|
+
const JUDGED_METHODS = new Set(['setAttribute', 'setAttributeNS', 'toggleAttribute', 'createAttribute', 'createAttributeNS', 'insertAdjacentHTML', 'setHTMLUnsafe', 'parseHTMLUnsafe', 'createContextualFragment', 'parseFromString', 'createElement', 'createElementNS']);
|
|
260
|
+
// Properties whose value the page parses as markup, and those whose value is a URL.
|
|
261
|
+
const MARKUP_PROPS = new Set(['innerHTML', 'outerHTML']);
|
|
262
|
+
const URL_PROPS = new Set(['href', 'src', 'action', 'formAction', 'data', 'poster', 'ping']);
|
|
263
|
+
// Array methods that hand each written element to a callback's first parameter.
|
|
264
|
+
const EACH_METHODS = new Set(['forEach', 'map', 'some', 'every', 'filter', 'find', 'findIndex', 'flatMap']);
|
|
265
|
+
// Nodes whose `test` reads a value as true or false.
|
|
266
|
+
const TESTED = new Set(['IfStatement', 'ConditionalExpression', 'WhileStatement', 'DoWhileStatement', 'ForStatement']);
|
|
267
|
+
// MIME types a Blob, a Response or a data: URL is a document or a script under.
|
|
268
|
+
// An XHTML document is XML: read as the browser renders XML (prefixes dropped,
|
|
269
|
+
// a DTD subset refused), not as HTML.
|
|
270
|
+
const JS_MIME = /^\s*(?:text|application)\/(?:x-)?(?:java|ecma)script\b/i;
|
|
271
|
+
const PAGE_MIME = /^\s*text\/html\s*(?:;|$)/i;
|
|
272
|
+
const XML_MIME = /^\s*(?:image\/svg\+xml|text\/xml|application\/xml|application\/xhtml\+xml|[\w.-]+\/[\w.-]+\+xml)\s*(?:;|$)/i;
|
|
273
|
+
// Elements whose src is a resource the browser fetches and never runs: an
|
|
274
|
+
// address not written down there is a stated limit, not code. Only an element
|
|
275
|
+
// made by the code itself is known to be one (new Image(), createElement('img')).
|
|
276
|
+
const MEDIA_TAGS = new Set(['img', 'picture', 'source', 'video', 'audio', 'track']);
|
|
277
|
+
// The written start of a URL the page's address gives: a scheme of the page's
|
|
278
|
+
// (http or https), a path from the root. The rest is computed.
|
|
279
|
+
const ADDRESS_HEADS = new Map(Object.entries({ href: 'http:///', origin: 'http://', protocol: 'http:', pathname: '/', host: '', hostname: '', port: '', search: '', hash: '', username: '', password: '' }));
|
|
280
|
+
const DOCUMENT_ADDRESS = new Map(Object.entries({ URL: 'href', documentURI: 'href', baseURI: 'href', referrer: 'host' }));
|
|
281
|
+
// Element properties that mirror an attribute: a write to one is read back by the other.
|
|
282
|
+
const ATTR_PROPS = new Map(Object.entries({ id: 'id', src: 'src', href: 'href', action: 'action', formAction: 'formaction', title: 'title', alt: 'alt', className: 'class', name: 'name', type: 'type', lang: 'lang', dir: 'dir', rel: 'rel', target: 'target', download: 'download', poster: 'poster', data: 'data', value: 'value', srcdoc: 'srcdoc', innerHTML: 'innerHTML', outerHTML: 'outerHTML', textContent: 'textContent', innerText: 'textContent', text: 'textContent' }));
|
|
283
|
+
// Methods that find elements of the page by id, selector or tag.
|
|
284
|
+
const DOM_LOOKUPS = new Set(['getElementById', 'querySelector', 'querySelectorAll', 'closest', 'getElementsByTagName', 'getElementsByClassName', 'getElementsByName']);
|
|
285
|
+
// Array methods whose result is a list of the receiver's elements (or of a callback's returns).
|
|
286
|
+
const LIST_METHODS = new Set(['filter', 'slice', 'concat', 'reverse', 'sort', 'flat', 'toSorted', 'toReversed', 'toSpliced', 'with', 'splice', 'map', 'flatMap']);
|
|
287
|
+
const ELEMENT_METHODS = new Set(['pop', 'shift', 'at', 'find', 'findLast']);
|
|
288
|
+
// String methods whose result is made of substrings of the receiver (what they
|
|
289
|
+
// cut out stays contiguous, so a handler in the result was a handler in the
|
|
290
|
+
// receiver); any other method (replace, sort, normalize, repeat, padStart …)
|
|
291
|
+
// may spell a word the receiver did not and is a computed part when not exact.
|
|
292
|
+
const SUBSTRING_METHODS = new Set(['trim', 'trimStart', 'trimEnd', 'slice', 'substring', 'substr', 'toString', 'valueOf']);
|
|
293
|
+
// A data: URL: its MIME type, its parameters (;base64 among them) and its body.
|
|
294
|
+
const DATA_URL = /^\s*data:([^,;]*)((?:;[^,;]*)*),([\s\S]*)$/i;
|
|
295
|
+
// Where a computed part of a markup string stands, so the tag walk tells it
|
|
296
|
+
// from a word written down: a private-use character no file writes.
|
|
297
|
+
const MARKER = '';
|
|
298
|
+
const MARKERS = /[\u0000]/g;
|
|
299
|
+
// Where a copy of markup the page already holds stands (el.innerHTML read
|
|
300
|
+
// back): written, judged where it was written, nothing of it to walk here.
|
|
301
|
+
const COPY = '\uE001';
|
|
302
|
+
const COPIES = /\uE001/g;
|
|
303
|
+
|
|
304
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
305
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
306
|
+
// A javascript: URL as the browser reads one: tabs and newlines dropped anywhere
|
|
307
|
+
// in it, leading C0 controls and spaces stripped (`\u0001javascript:` runs).
|
|
308
|
+
const isJavascriptUrl = (v) => /^javascript:/i.test(v.replace(/[\t\n\r]/g, '').replace(/^[\u0000- ]+/, ''));
|
|
309
|
+
// A <script> under any namespace prefix: in an XML document `<svg:script>` is
|
|
310
|
+
// a script; a closing `</script>` on its own ends one the page opened.
|
|
311
|
+
const SCRIPT_TAG = /<\/?(?:[\w.-]+:)?script\b/i;
|
|
312
|
+
// A string that holds a tag: walked as markup (analyseHtml), since what it
|
|
313
|
+
// builds through innerHTML and its kin runs as the page's own.
|
|
314
|
+
const MARKUP_IN_TEXT = /<[a-z]/i;
|
|
315
|
+
// Markup rules that read a tag under any namespace prefix (an XHTML document's <h:base>).
|
|
316
|
+
const META_CSP = /<(?:[\w.-]+:)?meta[^>]+http-equiv\s*=\s*["']?content-security-policy/i;
|
|
317
|
+
const NAVIGATION = /<(?:[\w.-]+:)?base\b|<(?:[\w.-]+:)?meta[^>]+http-equiv\s*=\s*["']?refresh|<(?:[\w.-]+:)?(?:object|embed)\b|\bsrcdoc\s*=/i;
|
|
318
|
+
const INPUT_TAG = /<(input|textarea|form|select)\b([^>]*)|\bcontenteditable\b/i;
|
|
319
|
+
const PLAIN_INPUT_TYPES = /\btype\s*=\s*["']?(?:range|checkbox|radio|button|hidden|submit|reset|color|file|image)\b/i;
|
|
320
|
+
const CREDENTIAL_WORDS = /\b(?:password|passcode|passphrase|seed phrase|recovery phrase|private key|secret key|2fa|two-factor|one-time (?:code|password)|verification code|log ?in|sign ?in)\b/i;
|
|
321
|
+
const HEX_NAME = /^_0x[0-9a-f]{4,}$/i;
|
|
322
|
+
const ESCAPE = /\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|u\{[0-9a-fA-F]+\})/g;
|
|
323
|
+
|
|
324
|
+
// ---- scopes -----------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
class Scope {
|
|
327
|
+
constructor(parent, fn, thisGlobal, analysis) {
|
|
328
|
+
this.parent = parent;
|
|
329
|
+
this.fn = fn; // a `var` declared below lands here
|
|
330
|
+
this.thisGlobal = thisGlobal; // `this` here may be the global object (a script's top level; a function that is not a method, called bare)
|
|
331
|
+
this.analysis = analysis ?? parent?.analysis ?? null; // the file this scope belongs to (a value followed into another file is read in that file's scopes)
|
|
332
|
+
this.dynamic = parent?.dynamic ?? false; // a call's scope (parameters bound at one call site): never cached
|
|
333
|
+
// name → what the name is bound to, as far as syntax tells:
|
|
334
|
+
// { alias: 'name', name } a global, by its name ('window' for the global object under any of its names)
|
|
335
|
+
// { alias: 'string', spelled, built, decoded, many } a string: what its literal fragments spell; built when not one plain literal
|
|
336
|
+
// { alias: 'number', value } a number written in literals
|
|
337
|
+
// { alias: 'strings', values } a list of written strings
|
|
338
|
+
// { alias: 'fn', node } a function written here (followed for what it returns)
|
|
339
|
+
// { alias: 'value', nodes: [{ node, scope }] } anything else written: the expressions the name was given, read lazily (resolve)
|
|
340
|
+
// { alias: 'import', from, name } an import: the export of that name from the module `from` names
|
|
341
|
+
// { alias: 'module', from } a namespace import (import * as ns)
|
|
342
|
+
// {} nothing is known
|
|
343
|
+
// Any of them may carry `hidden`: why the value is text the analyser
|
|
344
|
+
// never read (decoded, rewritten, fetched, read from storage …), which
|
|
345
|
+
// refuses it at a markup or URL sink wherever the name is used; and a
|
|
346
|
+
// parameter carries `param: { fn, index }`, read from the function's
|
|
347
|
+
// call sites when every reference to the function is a call.
|
|
348
|
+
this.names = new Map();
|
|
349
|
+
}
|
|
350
|
+
declare(name, info) { if (!this.names.has(name)) this.names.set(name, info || {}); }
|
|
351
|
+
resolve(name) { for (let s = this; s; s = s.parent) { const b = s.names.get(name); if (b) return b; } return null; }
|
|
352
|
+
fnScope() { let s = this; while (!s.fn) s = s.parent; return s; }
|
|
353
|
+
root() { let s = this; while (s.parent) s = s.parent; return s; }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const SCOPE_KINDS = new Set(['Program', 'FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', 'BlockStatement', 'ForStatement', 'ForInStatement', 'ForOfStatement', 'SwitchStatement', 'CatchClause', 'StaticBlock', 'ClassDeclaration', 'ClassExpression']);
|
|
357
|
+
const FUNCTIONS = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
|
|
358
|
+
|
|
359
|
+
// The names a binding pattern introduces, without recursion (a pattern can be
|
|
360
|
+
// nested as deep as a hostile file likes).
|
|
361
|
+
function patternNames(pattern, out = []) {
|
|
362
|
+
const stack = [pattern];
|
|
363
|
+
while (stack.length) {
|
|
364
|
+
const p = stack.pop();
|
|
365
|
+
if (!p) continue;
|
|
366
|
+
switch (p.type) {
|
|
367
|
+
case 'Identifier': out.push(p); break;
|
|
368
|
+
case 'ObjectPattern': for (const prop of p.properties) stack.push(prop.type === 'RestElement' ? prop.argument : prop.value); break;
|
|
369
|
+
case 'ArrayPattern': for (const e of p.elements) stack.push(e); break;
|
|
370
|
+
case 'AssignmentPattern': stack.push(p.left); break;
|
|
371
|
+
case 'RestElement': stack.push(p.argument); break;
|
|
372
|
+
default: break; // a MemberExpression target in a destructuring assignment binds nothing
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Which children of a node are expressions, which bind names, which are
|
|
379
|
+
// nothing (a static property name, a label).
|
|
380
|
+
function childCtx(parent, key, ctx) {
|
|
381
|
+
switch (parent.type) {
|
|
382
|
+
case 'MemberExpression': return key === 'property' && !parent.computed ? 'skip' : 'expr';
|
|
383
|
+
case 'Property': return key === 'key' ? (parent.computed ? 'expr' : 'skip') : ctx;
|
|
384
|
+
case 'MethodDefinition': case 'PropertyDefinition': return key === 'key' && !parent.computed ? 'skip' : 'expr';
|
|
385
|
+
case 'LabeledStatement': case 'BreakStatement': case 'ContinueStatement': return key === 'label' ? 'skip' : 'expr';
|
|
386
|
+
case 'ImportSpecifier': case 'ImportDefaultSpecifier': case 'ImportNamespaceSpecifier': return 'skip';
|
|
387
|
+
case 'ExportSpecifier': return key === 'exported' ? 'skip' : 'expr';
|
|
388
|
+
case 'MetaProperty': return 'skip';
|
|
389
|
+
case 'VariableDeclarator': return key === 'id' ? 'bind' : 'expr';
|
|
390
|
+
case 'FunctionDeclaration': case 'FunctionExpression': case 'ArrowFunctionExpression': return key === 'id' ? 'skip' : key === 'params' ? 'bind' : 'expr';
|
|
391
|
+
case 'ClassDeclaration': case 'ClassExpression': return key === 'id' ? 'skip' : 'expr';
|
|
392
|
+
case 'CatchClause': return key === 'param' ? 'bind' : 'expr';
|
|
393
|
+
case 'AssignmentPattern': return key === 'right' ? 'expr' : ctx;
|
|
394
|
+
case 'RestElement': case 'ArrayPattern': case 'ObjectPattern': return ctx;
|
|
395
|
+
default: return 'expr';
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const SKIP_KEYS = new Set(['type', 'start', 'end', 'loc', 'range', 'raw']);
|
|
400
|
+
function* children(node) {
|
|
401
|
+
for (const key in node) {
|
|
402
|
+
if (SKIP_KEYS.has(key)) continue;
|
|
403
|
+
const v = node[key];
|
|
404
|
+
if (Array.isArray(v)) { for (const c of v) if (c && typeof c.type === 'string') yield [key, c]; }
|
|
405
|
+
else if (v && typeof v.type === 'string') yield [key, v];
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---- expression helpers -----------------------------------------------------
|
|
410
|
+
|
|
411
|
+
// The expression an optional chain wraps; and the value of `a = x`, which is x,
|
|
412
|
+
// so `(a = window)[k]` indexes the window and `a = b = window` binds both.
|
|
413
|
+
// (A compound assignment's value depends on what the target held: it is read
|
|
414
|
+
// where its operator is known, in globalName, isStringy and spelled.)
|
|
415
|
+
const unchain = (n) => {
|
|
416
|
+
while (n && (n.type === 'ChainExpression' || (n.type === 'AssignmentExpression' && n.operator === '='))) n = n.type === 'ChainExpression' ? n.expression : n.right;
|
|
417
|
+
return n;
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// The string a literal expression is, or null: a string literal, or a template
|
|
421
|
+
// with no expressions in it.
|
|
422
|
+
function literalString(n) {
|
|
423
|
+
n = unchain(n);
|
|
424
|
+
if (!n) return null;
|
|
425
|
+
if (n.type === 'Literal' && typeof n.value === 'string') return n.value;
|
|
426
|
+
if (n.type === 'TemplateLiteral' && n.expressions.length === 0) return n.quasis.map((q) => q.value.cooked ?? '').join('');
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// A member's property name when it is written down: `.name`, `['name']`,
|
|
431
|
+
// `[0]`, a template with no expressions. null when it is computed at run time.
|
|
432
|
+
function staticKey(member) {
|
|
433
|
+
if (!member.computed) return member.property.type === 'Identifier' ? member.property.name : null; // a #private name is nobody's global
|
|
434
|
+
const p = member.property;
|
|
435
|
+
if (p.type === 'Literal' && (typeof p.value === 'string' || typeof p.value === 'number')) return String(p.value);
|
|
436
|
+
return literalString(p);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// The member a destructuring property reads: `{ eval: e } = window` reads
|
|
440
|
+
// window.eval, `{ 'eval': e }` and `{ [k]: e }` read it by a computed key (a
|
|
441
|
+
// literal key is read as the literal, like `window['eval']`). The node stands
|
|
442
|
+
// where the property does, so a finding names its line.
|
|
443
|
+
function memberOf(object, prop) {
|
|
444
|
+
const computed = prop.computed || prop.key.type !== 'Identifier';
|
|
445
|
+
return { type: 'MemberExpression', object, property: prop.key, computed, optional: false, loc: prop.loc, start: prop.start, end: prop.end };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// A string literal whose source spells an identifier with escapes: `'\x65val'`.
|
|
449
|
+
function escapedIdentifier(n) {
|
|
450
|
+
return n && n.type === 'Literal' && typeof n.value === 'string' && typeof n.raw === 'string' && n.raw.includes('\\') && IDENTIFIER.test(n.value);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function decodeBase64(s) { try { return Buffer.from(s, 'base64').toString('latin1'); } catch { return null; } }
|
|
454
|
+
|
|
455
|
+
function fromCharCodes(args) {
|
|
456
|
+
let out = '';
|
|
457
|
+
for (const a of args) {
|
|
458
|
+
if (a.type !== 'Literal' || typeof a.value !== 'number') return null;
|
|
459
|
+
try { out += String.fromCodePoint(a.value); } catch { return null; }
|
|
460
|
+
}
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// The operands of a chain of `+`, in source order, without recursion: acorn
|
|
465
|
+
// nests `a + b + c + …` to the left, as deep as a file has pieces.
|
|
466
|
+
function plusChain(n) {
|
|
467
|
+
const out = [];
|
|
468
|
+
while (n && n.type === 'BinaryExpression' && n.operator === '+') { out.push(n.right); n = n.left; }
|
|
469
|
+
out.push(n);
|
|
470
|
+
return out.reverse();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// The elements of an array written down, or null (a spread, or not an array).
|
|
474
|
+
function elementsOf(n) {
|
|
475
|
+
n = unchain(n);
|
|
476
|
+
if (!n || n.type !== 'ArrayExpression' || n.elements.some((e) => !e || e.type === 'SpreadElement')) return null;
|
|
477
|
+
return n.elements;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Every string of a set joined with every string of another, bounded.
|
|
481
|
+
function product(a, b) {
|
|
482
|
+
if (!a || !b || a.length * b.length > EXACT_VALUES) return null;
|
|
483
|
+
const out = [];
|
|
484
|
+
for (const x of a) for (const y of b) out.push(x + y);
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const PERCENT = /%([0-9a-f]{2})/gi;
|
|
489
|
+
function unescapeString(s) { return s.replace(/%u([0-9a-f]{4})/gi, (_, h) => String.fromCharCode(parseInt(h, 16))).replace(PERCENT, (_, h) => String.fromCharCode(parseInt(h, 16))); }
|
|
490
|
+
|
|
491
|
+
// ---- synthetic nodes ---------------------------------------------------------
|
|
492
|
+
// Values the resolver makes that no parser does: an element of a list, an
|
|
493
|
+
// element of the page, its dataset, the page's own markup, a parsed JSON file,
|
|
494
|
+
// a piece of a string (one of what split or match cut), a literal it made up.
|
|
495
|
+
// Each takes the position of the node it stands for, so a finding has a line.
|
|
496
|
+
const at = (node) => ({ loc: node?.loc, start: node?.start, end: node?.end });
|
|
497
|
+
const literalNode = (value, node) => ({ type: 'Literal', value, raw: null, ...at(node) });
|
|
498
|
+
const elementOf = (list, scope, index, node) => ({ type: 'ElementOf', list, scope, index: index ?? null, ...at(node ?? list) });
|
|
499
|
+
const pieceOf = (of, scope, node) => ({ type: 'Piece', of, scope, ...at(node ?? of) });
|
|
500
|
+
const jsonValue = (value, node) => ({ type: 'JsonValue', value, ...at(node) });
|
|
501
|
+
const pageMarkup = (node) => ({ type: 'PageMarkup', ...at(node) });
|
|
502
|
+
const datasetOf = (element, node) => ({ type: 'Dataset', element, ...at(node) });
|
|
503
|
+
// The leaf an unresolved value is: the node itself in its scope.
|
|
504
|
+
const leaf = (node, scope) => ({ node, scope });
|
|
505
|
+
// The leaf a value the resolver could not read to the end is: a bound (depth,
|
|
506
|
+
// steps, nesting, alternatives, returns) cut it short. A cut is never
|
|
507
|
+
// nothing: at a sink it is unread (refused), and where the global object is
|
|
508
|
+
// asked about it is the global object whenever the expression, or anything
|
|
509
|
+
// its names were given, mentions one (globalMentioned): what was not read
|
|
510
|
+
// may be it. Fail closed, never pass unread.
|
|
511
|
+
const cutLeaf = (node, scope) => ({ node, scope: staticOf(scope), cut: true });
|
|
512
|
+
// The scope a node is read in when asked about again: its own, not a call's (a parameter there is every call
|
|
513
|
+
// site's argument, which holds this call's), so what a cut mentions is read once, whatever call it was cut in.
|
|
514
|
+
const staticOf = (scope) => { let s = scope; while (s && s.dynamic) s = s.parent; return s ?? scope; };
|
|
515
|
+
const CUT_NODE = Object.freeze({ type: 'Cut' });
|
|
516
|
+
const kebab = (s) => s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
|
|
517
|
+
// The leftmost name a member chain stands on (`a.b.c` → 'a'), or '*' for `this`, a call or anything unnamed.
|
|
518
|
+
// A position path: where a value is read — a member by key ({ m: key }, null when computed) or a call ({ c: true }),
|
|
519
|
+
// innermost first, as a list with its memo key and length kept on each link; null is the value itself.
|
|
520
|
+
const posMember = (k, next = null) => ({ m: k, c: false, next, len: (next?.len ?? 0) + 1, key: 'm:' + (k ?? '*') + (next ? '|' + next.key : '') });
|
|
521
|
+
const posCall = (next = null) => ({ m: null, c: true, next, len: (next?.len ?? 0) + 1, key: 'c' + (next ? '|' + next.key : '') });
|
|
522
|
+
const posKey = (pos) => pos?.key ?? '';
|
|
523
|
+
const hasCall = (pos) => { for (let p = pos; p; p = p.next) if (p.c) return true; return false; };
|
|
524
|
+
const baseName = (n) => { let x = unchain(n); while (x && x.type === 'MemberExpression') x = unchain(x.object); return x && x.type === 'Identifier' ? x.name : '*'; };
|
|
525
|
+
// A fresh object the code makes: nothing written to the page's elements reaches it.
|
|
526
|
+
const FRESH = new Set(['ObjectExpression', 'ArrayExpression', 'NewExpression', 'Literal', 'TemplateLiteral', 'BinaryExpression', 'FunctionExpression', 'ArrowFunctionExpression', 'ClassExpression', 'JsonValue']);
|
|
527
|
+
|
|
528
|
+
// ---- the analysis -----------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
class Analysis {
|
|
531
|
+
constructor(lineOffset) {
|
|
532
|
+
this.lineOffset = lineOffset;
|
|
533
|
+
this.findings = [];
|
|
534
|
+
this.wasm = [];
|
|
535
|
+
this.names = 0; this.short = 0; this.hexNames = 0; this.escapedStrings = 0;
|
|
536
|
+
this.scopes = new Map();
|
|
537
|
+
this.paramStrings = new Map(); // a callback → the written strings its first parameter takes
|
|
538
|
+
this.paramLists = new Map(); // a callback → the list whose elements its first parameter takes ({ list, scope })
|
|
539
|
+
this.paramHidden = new Map(); // a callback → why its first parameter is text from outside the code (a .then of a fetched body, a message handler)
|
|
540
|
+
this.thenRead = new WeakSet(); // the .then links of a chain already read from its base
|
|
541
|
+
this.seen = new Set();
|
|
542
|
+
// Pass 1 records, read at the end of it: every call (a function's call
|
|
543
|
+
// sites), every reference to a name (a function referenced otherwise than
|
|
544
|
+
// called has call sites the analyser cannot see), every write to a
|
|
545
|
+
// property or an attribute (read back where the same thing is read),
|
|
546
|
+
// every message handler and .then callback given by name.
|
|
547
|
+
this.calls = []; this.refs = []; this.writes = []; this.handlers = []; this.handed = []; this.open = new Set();
|
|
548
|
+
this.sites = new Map(); // a function node → [{ args, scope }]
|
|
549
|
+
this.escaped = new Set(); // function nodes referenced otherwise than as a callee
|
|
550
|
+
this.returns = new Map(); // a function node → the expressions it returns
|
|
551
|
+
this.exports = new Map(); // an exported name → { local } | { from, name }
|
|
552
|
+
this.stars = []; // `export * from` sources
|
|
553
|
+
this.imports = []; // the module specifiers imported
|
|
554
|
+
this.mode = null;
|
|
555
|
+
// What the resolver may read beyond this file, given by the scan
|
|
556
|
+
// (analyse-worker) before pass 2: the other modules by specifier, the
|
|
557
|
+
// pool of the bundle's globals and writes, the pages this code runs in.
|
|
558
|
+
this.modules = null; // (specifier) → { analysis } | { json } | null
|
|
559
|
+
this.pool = null; // { globals: Map name → [{ binding, scope }], writes: Map key → [write], written: [PageElement] }
|
|
560
|
+
this.pages = null; // [{ elements: [PageElement], overflow }]
|
|
561
|
+
this.written = []; // elements the code writes into the page as markup strings (for the pool)
|
|
562
|
+
this.writtenOverflow = false;
|
|
563
|
+
this.resolving = new Set(); // nodes being resolved (a cycle adds nothing)
|
|
564
|
+
this.fallback = new Set(); // nodes re-entered in a call's scope, being read in their own scope (resolveStatic)
|
|
565
|
+
this.spreading = new Set(); // object literals being read for their members (a spread of itself adds nothing)
|
|
566
|
+
this.cache = new Map(); // node → leaves, for a node read in its own scope
|
|
567
|
+
this.mentioned = new Map(); // node → Map(position → the global its syntax (and its names' values) mentions, or null) (globalMentioned)
|
|
568
|
+
this.bases = new Map(); // a member chain's node → the name it stands on (baseName, kept: a chain as long as a file is asked at every link)
|
|
569
|
+
this.globalNames = new Map(); // node → what globalName answered, in its own scope, once the scopes are built
|
|
570
|
+
this.hopsCut = 0; // how often globalName stopped at its bound (an answer given under one is not kept)
|
|
571
|
+
this.methodOf = new Map(); // a function node → the object literal it is a method of ({ node, scope }): its `this`
|
|
572
|
+
this.paramValues = new Map(); // a callback → the expression its first parameter takes ({ node, scope }): a .then's receiver
|
|
573
|
+
this.steps = 0; // the resolver's budget for the node being judged
|
|
574
|
+
this.nesting = 0; // how deep the resolver's calls are nested right now
|
|
575
|
+
}
|
|
576
|
+
// The resolver's budget: one node judged spends at most QUERY_STEPS steps
|
|
577
|
+
// following names, members, calls and files; past it nothing is claimed.
|
|
578
|
+
spend() { return --this.steps < 0; }
|
|
579
|
+
begin() { this.steps = QUERY_STEPS; }
|
|
580
|
+
// One more level of the resolver's nesting; false past the cap (the caller gives up).
|
|
581
|
+
enter() { if (this.nesting >= NESTING) return false; this.nesting++; return true; }
|
|
582
|
+
leave() { this.nesting--; }
|
|
583
|
+
// One finding per rule, line and text: a literal walked on its own and
|
|
584
|
+
// again as a piece of the markup it is joined into is named once.
|
|
585
|
+
add(rule, node, what) {
|
|
586
|
+
const line = (node?.loc?.start.line ?? 1) + this.lineOffset;
|
|
587
|
+
const id = `${rule}\0${line}\0${what}`;
|
|
588
|
+
if (this.seen.has(id)) return;
|
|
589
|
+
this.seen.add(id);
|
|
590
|
+
this.findings.push({ level: RULES[rule].level, rule, line, what });
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ---- the resolver -----------------------------------------------------------
|
|
594
|
+
// The expressions a value can be, as far as syntax tells: through the names
|
|
595
|
+
// it was bound to (in any of their forms), an import (the export of another
|
|
596
|
+
// file of the bundle), a global another classic script set, a member of a
|
|
597
|
+
// written object or list (or of a parsed JSON file, or of an element of the
|
|
598
|
+
// page: its attributes), a call of a function written here (what it
|
|
599
|
+
// returns, its parameters bound to the call's arguments), a method of a
|
|
600
|
+
// written object, an element taken from a list (find, pop, at, for…of, a
|
|
601
|
+
// callback's parameter), a property or attribute written somewhere and read
|
|
602
|
+
// back. Returns leaves, { node, scope }: a leaf is a literal, a template, a
|
|
603
|
+
// concatenation, a function, a host object's member, an element of the
|
|
604
|
+
// page, a parsed JSON value — or the node itself when nothing further is
|
|
605
|
+
// read (a value the analyser cannot place). Bounded in steps, depth and
|
|
606
|
+
// alternatives; a cycle adds nothing; past a bound nothing is claimed.
|
|
607
|
+
resolve(n, scope, depth = 0) {
|
|
608
|
+
n = unchain(n);
|
|
609
|
+
if (!n) return [];
|
|
610
|
+
if (depth > RESOLVE_DEPTH || this.spend() || !this.enter()) return [cutLeaf(n, scope)];
|
|
611
|
+
try { return this.resolveIn(n, scope, depth); } finally { this.leave(); }
|
|
612
|
+
}
|
|
613
|
+
resolveIn(n, scope, depth) {
|
|
614
|
+
const cacheable = !scope.dynamic && scope.analysis === this && (n.type === 'Identifier' || n.type === 'MemberExpression' || n.type === 'CallExpression');
|
|
615
|
+
if (cacheable && this.cache.has(n)) return this.cache.get(n);
|
|
616
|
+
// (A node asked about again while it is being read is a cycle. In its own scope it adds nothing: `let a =
|
|
617
|
+
// b, b = a` has no value. In a call's scope the same node is another question each time — `return x` of a
|
|
618
|
+
// function called inside its own argument, id(id(window)), a function calling itself — and is not read
|
|
619
|
+
// again in that scope (a recursion would be read to the depth bound, branching at every level): it is read
|
|
620
|
+
// once in its own scope instead, where a parameter is the argument of every call site, which holds what
|
|
621
|
+
// this call's does; what that cannot read is a cut: unread, and the global object when what is bound here
|
|
622
|
+
// mentions one.)
|
|
623
|
+
if (this.resolving.has(n)) return scope.dynamic ? this.resolveStatic(n, scope, depth) : [];
|
|
624
|
+
this.resolving.add(n);
|
|
625
|
+
let out;
|
|
626
|
+
try { out = this.resolveSwitch(n, scope, depth); } finally { this.resolving.delete(n); }
|
|
627
|
+
// (Kept even when a bound cut it short: a chain as long as a file is then read once; the node left unread
|
|
628
|
+
// itself is never kept, and is read afresh when a cut value is read again (globalOfLeaves).)
|
|
629
|
+
if (cacheable && this.steps > 0) this.cache.set(n, out);
|
|
630
|
+
return out;
|
|
631
|
+
}
|
|
632
|
+
// A node re-entered in a call's scope, read in its own scope (the function's), once.
|
|
633
|
+
resolveStatic(n, scope, depth) {
|
|
634
|
+
if (this.fallback.has(n)) return [];
|
|
635
|
+
let st = scope;
|
|
636
|
+
while (st && st.dynamic) st = st.parent;
|
|
637
|
+
if (!st) return [cutLeaf(n, scope)];
|
|
638
|
+
this.fallback.add(n);
|
|
639
|
+
let out;
|
|
640
|
+
try { out = this.resolveSwitch(n, st, depth); } finally { this.fallback.delete(n); }
|
|
641
|
+
// (A name read as nothing in its own scope — a parameter of a function called from where the analyser does not
|
|
642
|
+
// see — may hold what this call bound it to: a cut, read from its binding here. Anything else unread in its own
|
|
643
|
+
// scope — a call of a callee not followed, a member of an object not read into — is as unread here.)
|
|
644
|
+
return this.unread(out, n) && n.type === 'Identifier' ? [cutLeaf(n, scope)] : out;
|
|
645
|
+
}
|
|
646
|
+
resolveSwitch(n, scope, depth) {
|
|
647
|
+
{
|
|
648
|
+
switch (n.type) {
|
|
649
|
+
case 'Identifier': return this.resolveName(n, scope, depth);
|
|
650
|
+
case 'MemberExpression': case 'CallExpression': return this.resolveChain(n, scope, depth);
|
|
651
|
+
case 'LogicalExpression': return this.union(this.resolve(n.left, scope, depth + 1), this.resolve(n.right, scope, depth + 1));
|
|
652
|
+
case 'ConditionalExpression': return this.union(this.resolve(n.consequent, scope, depth + 1), this.resolve(n.alternate, scope, depth + 1));
|
|
653
|
+
case 'SequenceExpression': return this.resolve(n.expressions[n.expressions.length - 1], scope, depth + 1);
|
|
654
|
+
case 'AwaitExpression': return this.resolve(n.argument, scope, depth + 1);
|
|
655
|
+
case 'AssignmentExpression': return n.operator === '=' ? this.resolve(n.right, scope, depth + 1) : [leaf(n, scope)];
|
|
656
|
+
case 'ElementOf': return this.elementsOf(n.list, n.scope, depth + 1, n.index, n);
|
|
657
|
+
// `this` in a method of an object literal is that literal (its properties are read through it).
|
|
658
|
+
case 'ThisExpression': { const m = this.literalOfThis(scope); return m ? [leaf(m.node, m.scope)] : [leaf(n, scope)]; }
|
|
659
|
+
default: return [leaf(n, scope)];
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
union(...lists) {
|
|
664
|
+
const seen = new Set(), out = [];
|
|
665
|
+
let cut = false;
|
|
666
|
+
for (const list of lists) for (const l of list) {
|
|
667
|
+
if (l.cut) { if (!cut) { cut = true; out.push(l); } continue; }
|
|
668
|
+
if (seen.has(l.node)) continue;
|
|
669
|
+
seen.add(l.node);
|
|
670
|
+
if (out.length <= EXACT_VALUES * 4) { out.push(l); continue; }
|
|
671
|
+
// Past the bound the rest is dropped: a leaf left out only makes a value less written, never more —
|
|
672
|
+
// unless it is, or may resolve to, the global object (a global by name, `this`, a member or a call
|
|
673
|
+
// not followed): that one is kept, or the value is cut, so nothing dropped passes as not the global.
|
|
674
|
+
const x = l.node;
|
|
675
|
+
if (x.type === 'Identifier' || x.type === 'ThisExpression') { if (this.globalOfLeaves([l], null, 0, 0, null) != null) out.push(l); }
|
|
676
|
+
else if (!cut && (x.type === 'MemberExpression' || x.type === 'CallExpression' || x.type === 'ElementOf' || x.type === 'ObjectExpression' || x.type === 'AwaitExpression')) { cut = true; out.push(cutLeaf(CUT_NODE, null)); }
|
|
677
|
+
}
|
|
678
|
+
return out;
|
|
679
|
+
}
|
|
680
|
+
// Is this leaf the node itself, unread? (A cut leaf is not unread: it is what was not read, and may be anything.)
|
|
681
|
+
unread(leaves, n) { return leaves.length === 1 && leaves[0].node === n && !leaves[0].cut; }
|
|
682
|
+
// The object literal whose method this scope is in: `this` there. An arrow's this is its surroundings'.
|
|
683
|
+
literalOfThis(scope) {
|
|
684
|
+
for (let s = scope; s; s = s.parent) {
|
|
685
|
+
if (!s.fn) continue;
|
|
686
|
+
const fn = s.node;
|
|
687
|
+
if (!fn) return null;
|
|
688
|
+
if (fn.type === 'ArrowFunctionExpression') continue;
|
|
689
|
+
return (s.analysis ?? this).methodOf.get(fn) ?? null;
|
|
690
|
+
}
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
resolveName(n, scope, depth) {
|
|
695
|
+
const b = scope.resolve(n.name);
|
|
696
|
+
if (!b) {
|
|
697
|
+
if (n.name === 'undefined') return [leaf(n, scope)];
|
|
698
|
+
const pool = this.globalsNamed(n.name, scope);
|
|
699
|
+
return pool.length ? this.union(...pool.map((p) => this.resolveBinding(p.binding, p.scope, n, depth))) : [leaf(n, scope)];
|
|
700
|
+
}
|
|
701
|
+
return this.resolveBinding(b, scope, n, depth);
|
|
702
|
+
}
|
|
703
|
+
// The bundle's globals of a name: what other classic scripts (or a module's
|
|
704
|
+
// window.X = …) bound it to, each with the scope it was written in.
|
|
705
|
+
globalsNamed(name, scope) {
|
|
706
|
+
const pool = (scope.analysis ?? this).pool;
|
|
707
|
+
return pool?.globals?.get(name) ?? [];
|
|
708
|
+
}
|
|
709
|
+
resolveBinding(b, scope, n, depth) {
|
|
710
|
+
switch (b.alias) {
|
|
711
|
+
// (A name given more expressions than are kept (cut) holds, besides them, what was not read.)
|
|
712
|
+
case 'value': { const out = this.union(...b.nodes.map((v) => this.resolve(v.node, v.scope, depth + 1))); return b.cut ? this.union(out, [cutLeaf(n, scope)]) : out; }
|
|
713
|
+
case 'fn': return [leaf(b.node, b.scope ?? scope)];
|
|
714
|
+
// `arguments` of a function: the arguments written at each of its call sites, as a list; unknown
|
|
715
|
+
// when the function is called from where the analyser does not see (escaped).
|
|
716
|
+
case 'arguments': {
|
|
717
|
+
const a = scope.analysis ?? this;
|
|
718
|
+
const sites = a.sites.get(b.fn);
|
|
719
|
+
if (!sites || a.escaped.has(b.fn) || (a.open.has(b.fn) && !a.pool)) return [leaf(n, scope)];
|
|
720
|
+
return this.union(...sites.map((s) => [leaf({ type: 'ArrayExpression', elements: s.args, ...at(n) }, s.scope)]));
|
|
721
|
+
}
|
|
722
|
+
case 'import': {
|
|
723
|
+
const t = this.followExport(b.from, b.name, scope, depth);
|
|
724
|
+
if (!t) return [leaf(n, scope)];
|
|
725
|
+
if (t.json !== undefined) return [leaf(jsonValue(t.json, n), scope)];
|
|
726
|
+
if (t.module) return [leaf({ type: 'Namespace', module: t.module, ...at(n) }, scope)];
|
|
727
|
+
return t.binding ? this.resolveBinding(t.binding, t.scope, { type: 'Identifier', name: t.local, ...at(n) }, depth + 1) : [];
|
|
728
|
+
}
|
|
729
|
+
case 'module': { const m = this.moduleNamed(b.from, scope); return m ? [leaf({ type: 'Namespace', module: m, ...at(n) }, scope)] : [leaf(n, scope)]; }
|
|
730
|
+
case 'name': {
|
|
731
|
+
// (The global by its own name, marked as such, at the root: never the identifier asked about, which would read as unread.)
|
|
732
|
+
if (GLOBAL_OBJECTS.has(b.name) || HOST_NAMES.has(b.name)) return [leaf({ type: 'Identifier', name: b.name, global: true, ...at(n) }, scope.root())];
|
|
733
|
+
const pool = this.globalsNamed(b.name, scope);
|
|
734
|
+
return pool.length ? this.union(...pool.map((p) => this.resolveBinding(p.binding, p.scope, n, depth))) : [leaf(n, scope)];
|
|
735
|
+
}
|
|
736
|
+
// (A plain string or number another file bound the name to is read as the literal it spells; one built from
|
|
737
|
+
// pieces, or a list, is read where the name stands — exact() and spelled() read a file's own strings directly.)
|
|
738
|
+
case 'string': return b.spelled != null && !b.built && !b.many && scope.analysis !== this ? [leaf(literalNode(b.spelled, n), scope)] : [leaf(n, scope)];
|
|
739
|
+
case 'number': return scope.analysis !== this ? [leaf(literalNode(b.value, n), scope)] : [leaf(n, scope)];
|
|
740
|
+
case 'strings': return [leaf(n, scope)];
|
|
741
|
+
default: {
|
|
742
|
+
if (b.param) return this.resolveParam(b.param, scope, n, depth);
|
|
743
|
+
return [leaf(n, scope)];
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
// A parameter of a function every reference to which is a call: one of the
|
|
748
|
+
// arguments written at those calls (or its default when none is given).
|
|
749
|
+
// A function referenced otherwise (passed on, exported, a method, kept in a
|
|
750
|
+
// name read elsewhere) is called from where the analyser does not see.
|
|
751
|
+
resolveParam({ fn, index, pattern, id }, scope, n, depth) {
|
|
752
|
+
const a = scope.analysis ?? this;
|
|
753
|
+
const sites = a.sites.get(fn);
|
|
754
|
+
if (!sites || a.escaped.has(fn) || (a.open.has(fn) && !a.pool)) return [leaf(n, scope)];
|
|
755
|
+
const fnScope = a.scopes.get(fn) ?? scope;
|
|
756
|
+
const p = fn.params[index];
|
|
757
|
+
const out = [];
|
|
758
|
+
for (const s of sites) {
|
|
759
|
+
if (s.args.slice(0, index + 1).some((x) => x.type === 'SpreadElement')) return [leaf(n, scope)];
|
|
760
|
+
const arg = s.args[index];
|
|
761
|
+
const isUndefined = arg && unchain(arg).type === 'Identifier' && unchain(arg).name === 'undefined';
|
|
762
|
+
if (pattern) {
|
|
763
|
+
// `function f({ a, b: [c] })`: what the pattern reads for this name off the argument written there.
|
|
764
|
+
if (!arg) { out.push(leaf(n, scope)); continue; }
|
|
765
|
+
const entry = this.patternAliases(pattern, arg, s.scope).find(([pid]) => pid === id);
|
|
766
|
+
if (!entry || (!entry[1].alias && !entry[1].hidden)) out.push(leaf(n, scope)); else out.push(...this.resolveBinding(entry[1], s.scope, n, depth + 1));
|
|
767
|
+
}
|
|
768
|
+
else if ((!arg || isUndefined) && p?.type === 'AssignmentPattern') out.push(...this.resolve(p.right, fnScope, depth + 1));
|
|
769
|
+
else if (!arg) out.push(leaf(literalNode(undefined, n), scope));
|
|
770
|
+
else out.push(...this.resolve(arg, s.scope, depth + 1));
|
|
771
|
+
// (Past the bound union keeps what may be the global object and drops the rest.)
|
|
772
|
+
if (out.length > EXACT_VALUES * 8) break;
|
|
773
|
+
}
|
|
774
|
+
return this.union(out);
|
|
775
|
+
}
|
|
776
|
+
moduleNamed(from, scope) { const a = scope.analysis ?? this; return a.modules ? a.modules(from) : null; }
|
|
777
|
+
// The binding an export of another module names: { binding, scope }, a
|
|
778
|
+
// namespace re-export { module }, a JSON module { json }, or null when the
|
|
779
|
+
// module is not in the bundle or has no such export.
|
|
780
|
+
followExport(from, name, scope, depth, seen = new Set()) {
|
|
781
|
+
const m = this.moduleNamed(from, scope);
|
|
782
|
+
if (!m || depth > RESOLVE_DEPTH) return null;
|
|
783
|
+
if (m.json !== undefined) return name === 'default' ? { json: m.json } : null;
|
|
784
|
+
const a = m.analysis;
|
|
785
|
+
if (!a || seen.has(a)) return null;
|
|
786
|
+
seen.add(a);
|
|
787
|
+
const e = a.exports.get(name);
|
|
788
|
+
if (e) {
|
|
789
|
+
if (e.local != null) { const b = a.root.names.get(e.local); return b ? { binding: b, scope: a.root, local: e.local } : null; }
|
|
790
|
+
if (e.name === '*') { const inner = this.moduleNamed(e.from, a.root); return inner ? { module: inner } : null; }
|
|
791
|
+
return this.followExport(e.from, e.name, a.root, depth + 1, seen);
|
|
792
|
+
}
|
|
793
|
+
if (name === 'default') return null;
|
|
794
|
+
for (const star of a.stars) { const t = this.followExport(star, name, a.root, depth + 1, seen); if (t) return t; }
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
// Every export of a namespace, as leaves.
|
|
798
|
+
namespaceMembers(module, scope, depth, seen = new Set()) {
|
|
799
|
+
const a = module.analysis;
|
|
800
|
+
if (!a || seen.has(a) || depth > RESOLVE_DEPTH) return [];
|
|
801
|
+
seen.add(a);
|
|
802
|
+
const out = [];
|
|
803
|
+
for (const [, e] of a.exports) {
|
|
804
|
+
if (out.length > EXACT_VALUES * 8) break;
|
|
805
|
+
if (e.local != null) { const b = a.root.names.get(e.local); if (b) out.push(...this.resolveBinding(b, a.root, { type: 'Identifier', name: e.local }, depth + 1)); }
|
|
806
|
+
else { const t = this.followExport(e.from, e.name, a.root, depth + 1); if (t?.binding) out.push(...this.resolveBinding(t.binding, t.scope, { type: 'Identifier', name: t.local }, depth + 1)); }
|
|
807
|
+
}
|
|
808
|
+
for (const star of a.stars) { const inner = this.moduleNamed(star, a.root); if (inner?.analysis) out.push(...this.namespaceMembers(inner, a.root, depth + 1, seen)); }
|
|
809
|
+
return this.union(out);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
resolveChain(n, scope, depth) {
|
|
813
|
+
// A chain of member reads and method calls (`a.b.c.d`, `[[window]][0][0]`, `f().g().g()`, `p.then(f).then(g)`) is
|
|
814
|
+
// read from its base up, link by link, without recursing (acorn nests it as deep as the file is long, and the
|
|
815
|
+
// depth bound is for values followed through names and calls, not for syntax): the base once, then each link's
|
|
816
|
+
// members, or its call's value, of what the link below gave. Each link read whole is kept, so a chain is read
|
|
817
|
+
// once however often its links are asked about.
|
|
818
|
+
const cacheable = !scope.dynamic && scope.analysis === this;
|
|
819
|
+
const chain = [];
|
|
820
|
+
let x = n, kept = null;
|
|
821
|
+
// (Down to the base, or to the first link already read and kept: a chain is walked once, from whichever
|
|
822
|
+
// end it is asked — the calls of a file are gathered innermost first.)
|
|
823
|
+
for (;;) {
|
|
824
|
+
if (x !== n && cacheable && this.cache.has(x)) { kept = this.cache.get(x); break; }
|
|
825
|
+
if (x.type === 'MemberExpression') { chain.push(x); x = unchain(x.object); continue; }
|
|
826
|
+
if (x.type === 'CallExpression') { const c = unchain(x.callee); if (c.type === 'MemberExpression') { chain.push(x); x = unchain(c.object); continue; } }
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
829
|
+
chain.reverse();
|
|
830
|
+
// (The base: a call of a function by name or of a function expression is read as the call it is; anything
|
|
831
|
+
// else — a name, a literal, `this`, an element — as a value.)
|
|
832
|
+
let objects = kept ?? (x.type === 'CallExpression' ? this.resolveCalled(x, scope, depth) : this.resolve(x, scope, depth + 1));
|
|
833
|
+
for (const m of chain) {
|
|
834
|
+
if (this.spend()) { objects = [cutLeaf(m, scope)]; break; }
|
|
835
|
+
objects = m.type === 'MemberExpression' ? this.memberStep(m, objects, scope, depth) : this.resolveCalled(m, scope, depth, objects);
|
|
836
|
+
if (m !== n && cacheable && this.steps > 0) this.cache.set(m, objects);
|
|
837
|
+
}
|
|
838
|
+
return objects;
|
|
839
|
+
}
|
|
840
|
+
// The global object (or document, location) as a leaf, marked as such.
|
|
841
|
+
globalLeaf(name, scope, n) { return [leaf({ type: 'Identifier', name, global: true, ...at(n) }, scope.root())]; }
|
|
842
|
+
// One member read, given what its object resolves to.
|
|
843
|
+
memberStep(n, objects, scope, depth) {
|
|
844
|
+
const k = this.key(n, scope);
|
|
845
|
+
const obj = unchain(n.object);
|
|
846
|
+
// window.X is the global X.
|
|
847
|
+
const g = this.globalName(obj, scope, depth + 1);
|
|
848
|
+
if (g != null && GLOBAL_OBJECTS.has(g)) {
|
|
849
|
+
// (The object the global object by a cut's over-approximation — a value read short that mentions one — is
|
|
850
|
+
// not known to be it: its member is cut too, never the global X read as nothing.)
|
|
851
|
+
const objCut = obj.type !== 'Identifier' && obj.type !== 'ThisExpression' && objects.some((l) => l.cut);
|
|
852
|
+
if (k == null) return [objCut ? cutLeaf(n, scope) : leaf(n, scope)];
|
|
853
|
+
// window.self, window.top, frames[0]: the global object itself (of this or another realm of the origin); window.document the document.
|
|
854
|
+
if (GLOBAL_OBJECTS.has(k) || /^\d+$/.test(k)) return this.globalLeaf('window', scope, n);
|
|
855
|
+
if (k === 'document') return this.globalLeaf('document', scope, n);
|
|
856
|
+
const root = scope.root();
|
|
857
|
+
const b = root.names.get(k);
|
|
858
|
+
const own = b ? this.resolveBinding(b, root, n, depth) : [];
|
|
859
|
+
const pool = this.globalsNamed(k, scope).map((p) => this.resolveBinding(p.binding, p.scope, n, depth));
|
|
860
|
+
const reads = this.readsOf(n, scope).map((w) => this.resolve(w.value, w.scope, depth + 1));
|
|
861
|
+
const out = this.union(own, ...pool, ...reads, objCut ? [cutLeaf(n, scope)] : []);
|
|
862
|
+
return out.length ? out : [leaf(n, scope)];
|
|
863
|
+
}
|
|
864
|
+
// document.defaultView is the window; document.location the location; a frame's window and document theirs.
|
|
865
|
+
if (g === 'document' && k === 'defaultView') return this.globalLeaf('window', scope, n);
|
|
866
|
+
if (g === 'document' && k === 'location') return this.globalLeaf('location', scope, n);
|
|
867
|
+
if (k === 'contentWindow') return this.globalLeaf('window', scope, n);
|
|
868
|
+
if (k === 'contentDocument' || k === 'ownerDocument') return this.globalLeaf('document', scope, n);
|
|
869
|
+
const out = [];
|
|
870
|
+
for (const w of this.readsOf(n, scope)) out.push(...this.resolve(w.value, w.scope, depth + 1));
|
|
871
|
+
// An element read (`L[0]`, `L[i]`) reads what was pushed onto the list too.
|
|
872
|
+
if (k == null || /^\d+$/.test(k)) for (const w of this.elementWrites(obj, scope)) out.push(...this.resolve(w.value, w.scope, depth + 1));
|
|
873
|
+
let unknown = false;
|
|
874
|
+
for (const o of objects) {
|
|
875
|
+
// (A receiver read short: the member is as unread, and the cut — the expression left unread — is kept as it is.)
|
|
876
|
+
if (o.cut) { out.push({ ...o, pos: posMember(k, o.pos ?? null) }); continue; }
|
|
877
|
+
const m = this.membersOf(o, k, n, scope, depth);
|
|
878
|
+
if (m === null) unknown = true; else out.push(...m);
|
|
879
|
+
}
|
|
880
|
+
if (unknown || (!objects.length && !out.length)) out.push(leaf(n, scope));
|
|
881
|
+
return this.union(out);
|
|
882
|
+
}
|
|
883
|
+
// The values a member of a leaf can be; null when the leaf is something the
|
|
884
|
+
// analyser does not read into (the member stands as itself); a cut leaf
|
|
885
|
+
// when a bound stopped the reading.
|
|
886
|
+
membersOf(o, k, n, scope, depth) {
|
|
887
|
+
// (Bounded as resolve is: an object spreading itself, `{ ...T, a }` bound to T, would be read without end.)
|
|
888
|
+
if (depth > RESOLVE_DEPTH || this.spend() || !this.enter()) return [cutLeaf(n, scope)];
|
|
889
|
+
try { return this.membersIn(o, k, n, scope, depth); } finally { this.leave(); }
|
|
890
|
+
}
|
|
891
|
+
membersIn(o, k, n, scope, depth) {
|
|
892
|
+
const x = o.node;
|
|
893
|
+
// (An object read for its members while it is being read for them — `{ ...T, a }` bound to T, read through
|
|
894
|
+
// its own spread — is a cycle: it adds nothing, and is not read to the depth bound.)
|
|
895
|
+
if (x.type === 'ObjectExpression') { if (this.spreading.has(x)) return []; this.spreading.add(x); try { return this.membersInObject(x, o, k, n, scope, depth); } finally { this.spreading.delete(x); } }
|
|
896
|
+
switch (x.type) {
|
|
897
|
+
|
|
898
|
+
case 'ArrayExpression': {
|
|
899
|
+
if (k === 'length') return [];
|
|
900
|
+
if (k != null && !/^\d+$/.test(k)) return [];
|
|
901
|
+
return this.elementsOf(x, o.scope, depth + 1, k == null ? null : +k);
|
|
902
|
+
}
|
|
903
|
+
case 'Namespace': {
|
|
904
|
+
if (k == null) return this.namespaceMembers(x.module, scope, depth + 1);
|
|
905
|
+
const t = this.exportOf(x.module, k, scope, depth);
|
|
906
|
+
if (!t) return [];
|
|
907
|
+
if (t.json !== undefined) return [leaf(jsonValue(t.json, n), scope)];
|
|
908
|
+
if (t.module) return [leaf({ type: 'Namespace', module: t.module, ...at(n) }, scope)];
|
|
909
|
+
return t.binding ? this.resolveBinding(t.binding, t.scope, { type: 'Identifier', name: t.local, ...at(n) }, depth + 1) : [];
|
|
910
|
+
}
|
|
911
|
+
case 'JsonValue': {
|
|
912
|
+
const v = x.value;
|
|
913
|
+
if (v === null || typeof v !== 'object') return [];
|
|
914
|
+
if (k == null) return (Array.isArray(v) ? v : Object.values(v)).slice(0, EXACT_VALUES * 4).map((c) => leaf(jsonValue(c, n), scope));
|
|
915
|
+
if (Array.isArray(v)) return /^\d+$/.test(k) && +k < v.length ? [leaf(jsonValue(v[+k], n), scope)] : [];
|
|
916
|
+
return Object.prototype.hasOwnProperty.call(v, k) ? [leaf(jsonValue(v[k], n), scope)] : [];
|
|
917
|
+
}
|
|
918
|
+
case 'PageElement': return this.elementMember(x, k, n, scope);
|
|
919
|
+
case 'Identifier': {
|
|
920
|
+
// A list of written strings, by index.
|
|
921
|
+
const b = o.scope.resolve(x.name);
|
|
922
|
+
if (b?.alias !== 'strings') return null;
|
|
923
|
+
if (k == null) return b.values.map((v) => leaf(literalNode(v, n), scope));
|
|
924
|
+
if (/^\d+$/.test(k)) return +k < b.values.length ? [leaf(literalNode(b.values[+k], n), scope)] : [];
|
|
925
|
+
return k === 'length' ? [] : null;
|
|
926
|
+
}
|
|
927
|
+
case 'Dataset': {
|
|
928
|
+
if (k == null) return [...x.element.attrs].filter(([a]) => a.startsWith('data-')).map(([, v]) => leaf(literalNode(v, n), scope));
|
|
929
|
+
const v = x.element.attrs.get('data-' + kebab(k));
|
|
930
|
+
return v == null ? [] : [leaf(literalNode(v, n), scope)];
|
|
931
|
+
}
|
|
932
|
+
case 'CallExpression': {
|
|
933
|
+
const c = unchain(x.callee);
|
|
934
|
+
if (c.type !== 'MemberExpression') return null;
|
|
935
|
+
const ok = this.key(c, o.scope), oo = this.globalName(c.object, o.scope, depth + 1);
|
|
936
|
+
if (oo === 'Object' && ok === 'fromEntries') {
|
|
937
|
+
const out = [];
|
|
938
|
+
for (const pair of this.elementsOf(x.arguments[0], o.scope, depth + 1, null)) {
|
|
939
|
+
if (pair.node.type !== 'ArrayExpression') { out.push(leaf(n, scope)); continue; }
|
|
940
|
+
const [key, value] = pair.node.elements;
|
|
941
|
+
if (!value || value.type === 'SpreadElement') { out.push(leaf(n, scope)); continue; }
|
|
942
|
+
if (k != null && key) { const kv = this.exact(key, pair.scope); if (kv && !kv.includes(k)) continue; }
|
|
943
|
+
out.push(...this.resolve(value, pair.scope, depth + 1));
|
|
944
|
+
}
|
|
945
|
+
return out;
|
|
946
|
+
}
|
|
947
|
+
if (oo === 'Object' && ['assign', 'freeze', 'seal', 'preventExtensions', 'create'].includes(ok)) {
|
|
948
|
+
const out = [];
|
|
949
|
+
for (const a of x.arguments.slice(0, ok === 'assign' ? undefined : 1)) for (const s of this.resolve(a, o.scope, depth + 1)) { const m = this.membersOf(s, k, n, scope, depth + 1); if (m === null) out.push(leaf(n, scope)); else out.push(...m); }
|
|
950
|
+
return out;
|
|
951
|
+
}
|
|
952
|
+
if (oo === 'JSON' && ok === 'parse') {
|
|
953
|
+
const s = this.exact(x.arguments[0], o.scope);
|
|
954
|
+
if (!s || s.length !== 1) return null;
|
|
955
|
+
try { return this.membersOf(leaf(jsonValue(JSON.parse(s[0]), n), scope), k, n, scope, depth + 1); } catch { return []; }
|
|
956
|
+
}
|
|
957
|
+
// A list (Array.of(window)[0], Object.values(o)[0], list.filter(f)[i]): its elements.
|
|
958
|
+
if ((k == null || /^\d+$/.test(k)) && ((oo === 'Array' && (ok === 'from' || ok === 'of')) || (oo === 'Object' && (ok === 'values' || ok === 'keys' || ok === 'entries')) || (oo === 'Promise' && ok === 'all') || LIST_METHODS.has(ok) || ok === 'split' || ok === 'querySelectorAll' || ok === 'getElementsByTagName' || ok === 'getElementsByClassName')) {
|
|
959
|
+
const els = this.elementsOf(x, o.scope, depth + 1, k == null ? null : +k);
|
|
960
|
+
return els.length === 1 && els[0].node.type === 'ElementOf' && els[0].node.list === x && !els[0].cut ? null : els;
|
|
961
|
+
}
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
default: return null;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
membersInObject(x, o, k, n, scope, depth) {
|
|
968
|
+
// The members under this key, in source order: a later property of the same written name
|
|
969
|
+
// overrides what came before it (a spread among them); a spread of an object not read into
|
|
970
|
+
// (unknown) makes the member unknown beside what is written, unless a property of that name
|
|
971
|
+
// follows it. Bounded in leaves (union cuts past the bound).
|
|
972
|
+
let out = [], unknown = false;
|
|
973
|
+
const take = (list) => { for (const l of list) out.push(l); return out.length <= EXACT_VALUES * 8; };
|
|
974
|
+
for (const p of x.properties) {
|
|
975
|
+
if (out.length > EXACT_VALUES * 8) break;
|
|
976
|
+
if (p.type === 'SpreadElement') {
|
|
977
|
+
for (const s of this.resolve(p.argument, o.scope, depth + 1)) {
|
|
978
|
+
if (s.cut) { out.push({ ...s, pos: posMember(k, s.pos ?? null) }); continue; }
|
|
979
|
+
const m = this.membersOf(s, k, n, scope, depth + 1);
|
|
980
|
+
if (m === null) unknown = true; else take(m);
|
|
981
|
+
}
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
if (p.type !== 'Property') continue;
|
|
985
|
+
const name = p.computed ? (() => { const v = this.exact(p.key, o.scope); return v && v.length === 1 ? v[0] : null; })() : p.key.type === 'Identifier' ? p.key.name : String(p.key.value);
|
|
986
|
+
// `{ __proto__: base }`: the members of base are read through this object (`{ __proto__: window }[k]` is judged by globalName).
|
|
987
|
+
if (name === '__proto__' && !p.computed && !p.shorthand && p.kind === 'init' && k !== '__proto__') {
|
|
988
|
+
for (const s of this.resolve(p.value, o.scope, depth + 1)) {
|
|
989
|
+
if (s.cut) { out.push({ ...s, pos: posMember(k, s.pos ?? null) }); continue; }
|
|
990
|
+
const m = this.membersOf(s, k, n, scope, depth + 1);
|
|
991
|
+
if (m === null) unknown = true; else take(m);
|
|
992
|
+
}
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
if (k != null && name != null && name !== k) continue;
|
|
996
|
+
if (p.kind === 'set') continue;
|
|
997
|
+
// (A property of exactly the name read, written here, is the value from here on: what came before is overridden.)
|
|
998
|
+
if (k != null && name === k && p.kind === 'init') { out = []; unknown = false; }
|
|
999
|
+
if (p.kind === 'get') take(this.callReturns(p.value, [], o.scope, depth + 1, o.scope.analysis));
|
|
1000
|
+
else take(this.resolve(p.value, o.scope, depth + 1));
|
|
1001
|
+
}
|
|
1002
|
+
if (unknown) out.push(leaf(n, scope));
|
|
1003
|
+
return this.union(out);
|
|
1004
|
+
}
|
|
1005
|
+
exportOf(module, name, scope, depth) {
|
|
1006
|
+
const a = module.analysis;
|
|
1007
|
+
if (!a) return module.json !== undefined && name === 'default' ? { json: module.json } : null;
|
|
1008
|
+
const e = a.exports.get(name);
|
|
1009
|
+
if (e) {
|
|
1010
|
+
if (e.local != null) { const b = a.root.names.get(e.local); return b ? { binding: b, scope: a.root, local: e.local } : null; }
|
|
1011
|
+
if (e.name === '*') { const inner = this.moduleNamed(e.from, a.root); return inner ? { module: inner } : null; }
|
|
1012
|
+
return this.followExport(e.from, e.name, a.root, depth + 1);
|
|
1013
|
+
}
|
|
1014
|
+
for (const star of a.stars) { const t = this.followExport(star, name, a.root, depth + 1); if (t) return t; }
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
// An element of the page read by a property: its attribute as written in
|
|
1018
|
+
// the page (a write to it elsewhere is read by readsOf); its dataset; its
|
|
1019
|
+
// markup (the page's own, judged as the page); anything else unknown.
|
|
1020
|
+
elementMember(el, k, n, scope) {
|
|
1021
|
+
if (k == null) return null;
|
|
1022
|
+
if (k === 'dataset') return [leaf(datasetOf(el, n), scope)];
|
|
1023
|
+
if (k === 'innerHTML' || k === 'outerHTML') return [leaf(pageMarkup(n), scope)];
|
|
1024
|
+
const attr = ATTR_PROPS.get(k);
|
|
1025
|
+
if (attr == null || attr === 'textContent') return null;
|
|
1026
|
+
const v = el.attrs.get(attr);
|
|
1027
|
+
return [leaf(literalNode(v ?? '', n), scope)];
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
resolveCalled(n, scope, depth, receiver = null) {
|
|
1031
|
+
const c = unchain(n.callee), args = n.arguments;
|
|
1032
|
+
if (FUNCTIONS.has(c.type)) return this.callReturns(c, args, scope, depth);
|
|
1033
|
+
if (c.type === 'Identifier') {
|
|
1034
|
+
const g = this.globalName(c, scope, depth + 1);
|
|
1035
|
+
if (g != null && (HOST_NAMES.has(g) || g === 'String' || g === 'structuredClone')) return g === 'structuredClone' && args[0] ? this.resolve(args[0], scope, depth + 1) : [leaf(n, scope)];
|
|
1036
|
+
const leaves = this.resolve(c, scope, depth + 1);
|
|
1037
|
+
if (this.unread(leaves, c)) return [leaf(n, scope)];
|
|
1038
|
+
const out = [];
|
|
1039
|
+
for (const l of leaves) { if (FUNCTIONS.has(l.node.type)) out.push(...this.callReturns(l.node, args, scope, depth, l.scope.analysis)); else if (l.cut) out.push({ ...l, pos: posCall(l.pos ?? null) }); else out.push(leaf(n, scope)); }
|
|
1040
|
+
return this.union(out);
|
|
1041
|
+
}
|
|
1042
|
+
if (c.type !== 'MemberExpression') return [leaf(n, scope)];
|
|
1043
|
+
const k = this.key(c, scope), o = this.globalName(c.object, scope, depth + 1);
|
|
1044
|
+
// Object.values/keys/entries, Array.from/of, Promise.resolve/all: lists and pass-throughs (elementsOf reads the lists).
|
|
1045
|
+
if (o === 'Promise' && (k === 'resolve' || k === 'reject') && args[0]) return this.resolve(args[0], scope, depth + 1);
|
|
1046
|
+
if (o === 'Object' && (k === 'values' || k === 'keys' || k === 'entries' || k === 'fromEntries' || k === 'assign' || k === 'freeze' || k === 'create')) return [leaf(n, scope)];
|
|
1047
|
+
if (o === 'JSON' && k === 'parse') { const s = this.exact(args[0], scope); if (s && s.length === 1) { try { return [leaf(jsonValue(JSON.parse(s[0]), n), scope)]; } catch { return []; } } return [leaf(n, scope)]; }
|
|
1048
|
+
// (A lookup of one element is the element; one of several is a list, read by elementsOf.)
|
|
1049
|
+
if (o === 'document' && DOM_LOOKUPS.has(k)) return k === 'querySelectorAll' || k.startsWith('getElementsBy') ? [leaf(n, scope)] : this.lookups(n, k, args, scope, depth);
|
|
1050
|
+
if (o != null && HOST_NAMES.has(o)) return [leaf(n, scope)];
|
|
1051
|
+
if (k == null) return [leaf(n, scope)];
|
|
1052
|
+
// p.then(cb): what the callback returns (its parameter the chain's value); the chain itself is not descended twice.
|
|
1053
|
+
if (k === 'then' || k === 'catch' || k === 'finally') {
|
|
1054
|
+
const cb = args[0] && unchain(args[0]);
|
|
1055
|
+
if (k === 'then' && cb && FUNCTIONS.has(cb.type)) return this.callReturns(cb, [c.object], scope, depth);
|
|
1056
|
+
return [leaf(n, scope)];
|
|
1057
|
+
}
|
|
1058
|
+
// A method of a written object: what it returns. An element of a list: pop, shift, at, find.
|
|
1059
|
+
const objects = receiver ?? this.resolve(c.object, scope, depth + 1);
|
|
1060
|
+
const out = [];
|
|
1061
|
+
let unknown = this.unread(objects, unchain(c.object)) || !objects.length;
|
|
1062
|
+
const cuts = [];
|
|
1063
|
+
if (!unknown) for (const ob of objects) {
|
|
1064
|
+
if (ob.cut) { cuts.push({ ...ob, pos: posMember(k, posCall(ob.pos ?? null)) }); continue; }
|
|
1065
|
+
// Only a written object (or a namespace) has methods of its own; a list, a string, an element has the runtime's.
|
|
1066
|
+
if (ob.node.type !== 'ObjectExpression' && ob.node.type !== 'Namespace' && !(ob.node.type === 'CallExpression' && this.membersOf(ob, k, n, scope, depth) !== null)) { unknown = true; continue; }
|
|
1067
|
+
const m = this.membersOf(ob, k, n, scope, depth);
|
|
1068
|
+
if (m === null) { unknown = true; continue; }
|
|
1069
|
+
if (!m.length) continue;
|
|
1070
|
+
for (const l of m) { if (FUNCTIONS.has(l.node.type)) out.push(...this.callReturns(l.node, args, scope, depth, l.scope.analysis)); else if (l.cut) cuts.push(l); else unknown = true; }
|
|
1071
|
+
}
|
|
1072
|
+
// (A receiver read short may be anything, a written object among it: its method's value is as unread, the cut kept.)
|
|
1073
|
+
out.push(...cuts);
|
|
1074
|
+
if (!unknown) return this.union(out);
|
|
1075
|
+
// The receiver is not a written object: the method is the runtime's.
|
|
1076
|
+
if (ELEMENT_METHODS.has(k)) { const i = k === 'at' && args[0] ? this.fold(args[0], scope) : null; return this.union(out, this.elementsOf(c.object, scope, depth + 1, i != null && i >= 0 ? i : null)); }
|
|
1077
|
+
if (k === 'get' || k === 'getAttribute' || k === 'getItem') {
|
|
1078
|
+
const reads = this.readsOf(n, scope).map((w) => this.resolve(w.value, w.scope, depth + 1));
|
|
1079
|
+
// new Map([[k, v]]).get(k): the entry written.
|
|
1080
|
+
const entries = [];
|
|
1081
|
+
if (k === 'get') for (const ob of objects) if (ob.node.type === 'NewExpression' && this.globalName(ob.node.callee, ob.scope) === 'Map' && ob.node.arguments[0]) {
|
|
1082
|
+
for (const pair of this.elementsOf(ob.node.arguments[0], ob.scope, depth + 1, null)) {
|
|
1083
|
+
if (pair.node.type !== 'ArrayExpression' || !pair.node.elements[1] || pair.node.elements[1].type === 'SpreadElement') { entries.push(leaf(n, scope)); continue; }
|
|
1084
|
+
const want = args[0] ? this.exact(args[0], scope) : null, have = this.exact(pair.node.elements[0], pair.scope);
|
|
1085
|
+
if (want && have && !want.some((w) => have.includes(w))) continue;
|
|
1086
|
+
entries.push(...this.resolve(pair.node.elements[1], pair.scope, depth + 1));
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (k === 'getAttribute') { const name = this.exact(args[0], scope); for (const ob of objects) if (ob.node.type === 'PageElement' && name) for (const a of name) entries.push(leaf(literalNode(ob.node.attrs.get(a.toLowerCase()) ?? '', n), scope)); }
|
|
1090
|
+
const all = this.union(out, ...reads, entries);
|
|
1091
|
+
return all.length ? all : [leaf(n, scope)];
|
|
1092
|
+
}
|
|
1093
|
+
if (DOM_LOOKUPS.has(k)) return k === 'querySelectorAll' || k.startsWith('getElementsBy') ? this.union(out, [leaf(n, scope)]) : this.union(out, this.lookups(n, k, args, scope, depth));
|
|
1094
|
+
return this.union(out, [leaf(n, scope)]);
|
|
1095
|
+
}
|
|
1096
|
+
// What a function written here returns when called with these arguments:
|
|
1097
|
+
// each of its return expressions, read in a scope of its own where the
|
|
1098
|
+
// parameters are the arguments (a missing one its default). A function
|
|
1099
|
+
// whose scope the analyser has not built yet (a hoisted declaration, in
|
|
1100
|
+
// pass 1) returns nothing it can read. A function calling itself adds
|
|
1101
|
+
// nothing on the second visit: what it returns is made of what the first
|
|
1102
|
+
// visit reads.
|
|
1103
|
+
callReturns(fn, args, callerScope, depth, owner = null) {
|
|
1104
|
+
if (depth > RESOLVE_DEPTH || this.spend() || !this.enter()) return [{ ...cutLeaf(fn, callerScope), pos: posCall() }];
|
|
1105
|
+
try { return this.callReturnsIn(fn, args, callerScope, depth, owner); } finally { this.leave(); }
|
|
1106
|
+
}
|
|
1107
|
+
callReturnsIn(fn, args, callerScope, depth, owner) {
|
|
1108
|
+
owner = owner && owner.scopes.has(fn) ? owner : this.ownerOf(fn, callerScope);
|
|
1109
|
+
const fnScope = owner?.scopes.get(fn);
|
|
1110
|
+
if (!fnScope) return [{ ...cutLeaf(fn, callerScope), pos: posCall() }];
|
|
1111
|
+
// (Under the body's block, where the function's own declarations live; the parameters re-declared here shadow the function's.)
|
|
1112
|
+
const bodyScope = fn.body?.type === 'BlockStatement' ? owner.scopes.get(fn.body) ?? fnScope : fnScope;
|
|
1113
|
+
const cs = new Scope(bodyScope, false, fnScope.thisGlobal, owner);
|
|
1114
|
+
cs.dynamic = true;
|
|
1115
|
+
// `arguments` of this call: the arguments written here, as a list (an arrow's is its surroundings').
|
|
1116
|
+
if (fn.type !== 'ArrowFunctionExpression') cs.declare('arguments', { alias: 'value', nodes: [{ node: { type: 'ArrayExpression', elements: args, ...at(fn) }, scope: callerScope }] });
|
|
1117
|
+
let spread = false;
|
|
1118
|
+
fn.params.forEach((p, i) => {
|
|
1119
|
+
const arg = spread ? null : args[i];
|
|
1120
|
+
if (arg && arg.type === 'SpreadElement') spread = true;
|
|
1121
|
+
const missing = !arg || spread || (unchain(arg).type === 'Identifier' && unchain(arg).name === 'undefined' && !callerScope.resolve('undefined'));
|
|
1122
|
+
const value = (node, s) => ({ alias: 'value', nodes: [{ node, scope: s }] });
|
|
1123
|
+
if (p.type === 'Identifier') cs.declare(p.name, spread ? {} : value(missing ? literalNode(undefined, p) : arg, callerScope));
|
|
1124
|
+
else if (p.type === 'AssignmentPattern' && p.left.type === 'Identifier') cs.declare(p.left.name, spread ? {} : missing ? value(p.right, cs) : value(arg, callerScope));
|
|
1125
|
+
else if (p.type === 'RestElement' && p.argument.type === 'Identifier') cs.declare(p.argument.name, spread ? {} : value({ type: 'ArrayExpression', elements: args.slice(i), ...at(p) }, callerScope));
|
|
1126
|
+
else if (!spread && arg) for (const [id, info] of this.patternAliases(p, arg, callerScope)) cs.declare(id.name, info);
|
|
1127
|
+
else for (const id of patternNames(p)) cs.declare(id.name, {});
|
|
1128
|
+
});
|
|
1129
|
+
const out = [];
|
|
1130
|
+
const returns = this.returnsOf(fn, owner);
|
|
1131
|
+
for (const r of returns) out.push(...this.resolve(r, cs, depth + 1));
|
|
1132
|
+
// (A function of more returns than are read returns, besides them, what was not read.)
|
|
1133
|
+
if (returns.cut) out.push({ ...cutLeaf(fn, callerScope), pos: posCall() });
|
|
1134
|
+
return this.union(out);
|
|
1135
|
+
}
|
|
1136
|
+
// The file a function belongs to: the one whose scopes hold it (its leaf's file, the caller's, this one).
|
|
1137
|
+
ownerOf(fn, scope) { const a = scope.analysis ?? this; return a.scopes.has(fn) ? a : this.scopes.has(fn) ? this : a; }
|
|
1138
|
+
// The expressions a function returns: an arrow's body, the argument of
|
|
1139
|
+
// every return statement in its body (not those of functions inside it).
|
|
1140
|
+
returnsOf(fn, owner = this) {
|
|
1141
|
+
if (owner.returns.has(fn)) return owner.returns.get(fn);
|
|
1142
|
+
const out = [];
|
|
1143
|
+
if (fn.type === 'ArrowFunctionExpression' && fn.expression) out.push(fn.body);
|
|
1144
|
+
else {
|
|
1145
|
+
const stack = [fn.body];
|
|
1146
|
+
let steps = 0;
|
|
1147
|
+
while (stack.length && steps++ < WALK_STEPS && out.length <= RETURNS) {
|
|
1148
|
+
const x = stack.pop();
|
|
1149
|
+
if (!x || FUNCTIONS.has(x.type)) continue;
|
|
1150
|
+
if (x.type === 'ReturnStatement') { if (x.argument) out.push(x.argument); continue; }
|
|
1151
|
+
for (const [, child] of children(x)) stack.push(child);
|
|
1152
|
+
}
|
|
1153
|
+
// Past the bound (too many returns, a body too large to walk) the rest is not read: the first
|
|
1154
|
+
// returns are kept, and the function is marked as returning more than was read (a cut).
|
|
1155
|
+
if (out.length > RETURNS || stack.length) { out.length = Math.min(out.length, RETURNS); out.cut = true; }
|
|
1156
|
+
}
|
|
1157
|
+
owner.returns.set(fn, out);
|
|
1158
|
+
return out;
|
|
1159
|
+
}
|
|
1160
|
+
// The elements a list can hold (the one at `index` when given): of an array
|
|
1161
|
+
// written down (spreads flattened), of a list of written strings, of a parsed
|
|
1162
|
+
// JSON array, of a list method's result (filter, slice, map's callback
|
|
1163
|
+
// returns …), of Object.values/keys/entries, Array.from/of, Promise.all,
|
|
1164
|
+
// of the page's elements a selector finds, of a string split (a Piece of
|
|
1165
|
+
// it). An unknown list has an unknown element.
|
|
1166
|
+
elementsOf(list, scope, depth, index, self = null) {
|
|
1167
|
+
// (The unknown element is one node, however often it is asked for: a leaf that is itself, unread.)
|
|
1168
|
+
const unknown = self ?? elementOf(list, scope, index);
|
|
1169
|
+
const unknownElement = () => [leaf(unknown, scope)];
|
|
1170
|
+
if (!list) return unknownElement();
|
|
1171
|
+
if (depth > RESOLVE_DEPTH || this.spend() || !this.enter()) return [cutLeaf(unknown, scope)];
|
|
1172
|
+
try { return this.elementsIn(list, scope, depth, index, unknownElement, unknown); } finally { this.leave(); }
|
|
1173
|
+
}
|
|
1174
|
+
elementsIn(list, scope, depth, index, unknownElement, unknown) {
|
|
1175
|
+
const leaves = this.resolve(list, scope, depth + 1);
|
|
1176
|
+
const out = [];
|
|
1177
|
+
// What was pushed onto the list (push, unshift, add; `L[i] = v`) is an element of it, at any index.
|
|
1178
|
+
for (const w of this.elementWrites(list, scope)) out.push(...this.resolve(w.value, w.scope, depth + 1));
|
|
1179
|
+
for (const l of leaves) {
|
|
1180
|
+
const x = l.node;
|
|
1181
|
+
// (A list read short holds, besides what was read, elements not read: the cut kept.)
|
|
1182
|
+
if (l.cut) { out.push({ ...l, pos: posMember(index == null ? null : String(index), l.pos ?? null) }); continue; }
|
|
1183
|
+
switch (x.type) {
|
|
1184
|
+
case 'ArrayExpression': {
|
|
1185
|
+
if (index == null) { for (const e of x.elements) { if (!e) continue; if (e.type === 'SpreadElement') out.push(...this.elementsOf(e.argument, l.scope, depth + 1, null)); else out.push(...this.resolve(e, l.scope, depth + 1)); } break; }
|
|
1186
|
+
let i = 0, found = false;
|
|
1187
|
+
for (const e of x.elements) {
|
|
1188
|
+
if (e && e.type === 'SpreadElement') {
|
|
1189
|
+
const inner = unchain(e.argument);
|
|
1190
|
+
if (inner?.type === 'ArrayExpression' && inner.elements.every((y) => y && y.type !== 'SpreadElement')) { if (index < i + inner.elements.length) { out.push(...this.resolve(inner.elements[index - i], l.scope, depth + 1)); found = true; break; } i += inner.elements.length; continue; }
|
|
1191
|
+
// A spread of unknown length: the element at the index is any of what follows, or of the spread.
|
|
1192
|
+
out.push(...this.elementsOf(e.argument, l.scope, depth + 1, null));
|
|
1193
|
+
for (const rest of x.elements.slice(x.elements.indexOf(e) + 1)) { if (!rest) continue; if (rest.type === 'SpreadElement') out.push(...this.elementsOf(rest.argument, l.scope, depth + 1, null)); else out.push(...this.resolve(rest, l.scope, depth + 1)); }
|
|
1194
|
+
found = true; break;
|
|
1195
|
+
}
|
|
1196
|
+
if (i === index) { if (e) out.push(...this.resolve(e, l.scope, depth + 1)); found = true; break; }
|
|
1197
|
+
i++;
|
|
1198
|
+
}
|
|
1199
|
+
if (!found) out.push(leaf(literalNode(undefined, list), scope));
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
1202
|
+
case 'Identifier': {
|
|
1203
|
+
const b = l.scope.resolve(x.name);
|
|
1204
|
+
if (b && b.alias === 'strings') { const vs = index == null ? b.values : [b.values[index]]; for (const v of vs) if (v != null) out.push(leaf(literalNode(v, list), scope)); break; }
|
|
1205
|
+
out.push(...unknownElement());
|
|
1206
|
+
break;
|
|
1207
|
+
}
|
|
1208
|
+
case 'JsonValue': {
|
|
1209
|
+
const v = x.value;
|
|
1210
|
+
if (!Array.isArray(v)) { out.push(...unknownElement()); break; }
|
|
1211
|
+
const items = index == null ? v.slice(0, EXACT_VALUES * 4) : index < v.length ? [v[index]] : [];
|
|
1212
|
+
for (const c of items) out.push(leaf(jsonValue(c, list), scope));
|
|
1213
|
+
break;
|
|
1214
|
+
}
|
|
1215
|
+
case 'CallExpression': {
|
|
1216
|
+
const c = unchain(x.callee);
|
|
1217
|
+
if (c.type !== 'MemberExpression') { out.push(...unknownElement()); break; }
|
|
1218
|
+
const k = this.key(c, l.scope), o = this.globalName(c.object, l.scope, depth + 1);
|
|
1219
|
+
if (o === 'Object' && k === 'values') { for (const ob of this.resolve(x.arguments[0], l.scope, depth + 1)) { const m = this.membersOf(ob, null, x, l.scope, depth + 1); if (m === null) out.push(...unknownElement()); else out.push(...m); } break; }
|
|
1220
|
+
if (o === 'Object' && k === 'keys') { for (const ob of this.resolve(x.arguments[0], l.scope, depth + 1)) { if (ob.node.type === 'ObjectExpression' && ob.node.properties.every((p) => p.type === 'Property' && !p.computed)) for (const p of ob.node.properties) out.push(leaf(literalNode(p.key.type === 'Identifier' ? p.key.name : String(p.key.value), x), scope)); else if (ob.node.type === 'JsonValue' && ob.node.value && typeof ob.node.value === 'object') for (const key of Object.keys(ob.node.value)) out.push(leaf(literalNode(key, x), scope)); else out.push(...unknownElement()); } break; }
|
|
1221
|
+
if (o === 'Object' && k === 'entries') { for (const ob of this.resolve(x.arguments[0], l.scope, depth + 1)) { if (ob.node.type === 'ObjectExpression' && ob.node.properties.every((p) => p.type === 'Property' && !p.computed && p.kind === 'init')) for (const p of ob.node.properties) out.push(leaf({ type: 'ArrayExpression', elements: [literalNode(p.key.type === 'Identifier' ? p.key.name : String(p.key.value), p.key), p.value], ...at(p) }, ob.scope)); else out.push(...unknownElement()); } break; }
|
|
1222
|
+
if (o === 'Array' && k === 'from') { if (x.arguments[1] && FUNCTIONS.has(unchain(x.arguments[1]).type)) out.push(...this.callReturns(unchain(x.arguments[1]), [elementOf(x.arguments[0], l.scope, null)], l.scope, depth + 1, l.scope.analysis)); else out.push(...this.elementsOf(x.arguments[0], l.scope, depth + 1, index)); break; }
|
|
1223
|
+
if (o === 'Array' && k === 'of') { for (const a of x.arguments) out.push(...this.resolve(a, l.scope, depth + 1)); break; }
|
|
1224
|
+
if (o === 'Promise' && (k === 'all' || k === 'allSettled')) { out.push(...this.elementsOf(x.arguments[0], l.scope, depth + 1, index)); break; }
|
|
1225
|
+
if (o === 'document' && (k === 'querySelectorAll' || k === 'getElementsByTagName' || k === 'getElementsByClassName' || k === 'getElementsByName')) { out.push(...this.lookups(x, k, x.arguments, l.scope, depth)); break; }
|
|
1226
|
+
if (o != null && HOST_NAMES.has(o)) { out.push(...unknownElement()); break; }
|
|
1227
|
+
if (k === 'map' || k === 'flatMap') {
|
|
1228
|
+
const cb = x.arguments[0] && unchain(x.arguments[0]);
|
|
1229
|
+
if (!cb || !FUNCTIONS.has(cb.type)) { out.push(...unknownElement()); break; }
|
|
1230
|
+
const rs = this.callReturns(cb, [elementOf(c.object, l.scope, null), literalNode(undefined, x)], l.scope, depth + 1, l.scope.analysis);
|
|
1231
|
+
if (k === 'flatMap') for (const r of rs) { if (r.node.type === 'ArrayExpression') out.push(...this.elementsOf(r.node, r.scope, depth + 1, null)); else out.push(r); } else out.push(...rs);
|
|
1232
|
+
break;
|
|
1233
|
+
}
|
|
1234
|
+
if (LIST_METHODS.has(k)) { out.push(...this.elementsOf(c.object, l.scope, depth + 1, k === 'slice' || k === 'filter' || k === 'reverse' || k === 'sort' ? null : index)); if (k === 'concat') for (const a of x.arguments) out.push(...this.elementsOf(a, l.scope, depth + 1, null)); break; }
|
|
1235
|
+
if (k === 'split' || k === 'match' || k === 'matchAll' || k === 'exec') { out.push(leaf(pieceOf(x, l.scope, list), scope)); break; }
|
|
1236
|
+
if (k === 'querySelectorAll' || k === 'getElementsByTagName' || k === 'getElementsByClassName') { out.push(...this.lookups(x, k, x.arguments, l.scope, depth)); break; }
|
|
1237
|
+
out.push(...unknownElement());
|
|
1238
|
+
break;
|
|
1239
|
+
}
|
|
1240
|
+
case 'NewExpression': {
|
|
1241
|
+
const g = this.globalName(x.callee, l.scope, depth + 1);
|
|
1242
|
+
if ((g === 'Set' || g === 'Array') && x.arguments[0] && x.arguments.length === 1) { out.push(...this.elementsOf(x.arguments[0], l.scope, depth + 1, index)); break; }
|
|
1243
|
+
out.push(...unknownElement());
|
|
1244
|
+
break;
|
|
1245
|
+
}
|
|
1246
|
+
default: out.push(...unknownElement());
|
|
1247
|
+
}
|
|
1248
|
+
if (out.length > EXACT_VALUES * 8) break;
|
|
1249
|
+
}
|
|
1250
|
+
return this.union(out);
|
|
1251
|
+
}
|
|
1252
|
+
// The writes that put an element onto the thing a list read reads: `L.push(v)`,
|
|
1253
|
+
// `L.unshift(v)`, `S.add(v)`, `L[i] = v` with i not written, on the same name
|
|
1254
|
+
// (the same binding or the same global). A write by a written key is a
|
|
1255
|
+
// property, not an element.
|
|
1256
|
+
elementWrites(list, scope) {
|
|
1257
|
+
const x = unchain(list);
|
|
1258
|
+
if (!x || x.type !== 'Identifier') return [];
|
|
1259
|
+
const a = scope.analysis ?? this;
|
|
1260
|
+
const b = scope.resolve(x.name);
|
|
1261
|
+
const global = b ? (b.alias === 'name' && (GLOBAL_OBJECTS.has(b.name) || HOST_NAMES.has(b.name)) ? b.name : null) : x.name;
|
|
1262
|
+
if (!b && !global) return [];
|
|
1263
|
+
const out = [];
|
|
1264
|
+
for (const w of [...(this.writeIndex(a).get('*') ?? []), ...(global != null ? a.pool?.writes?.get('*') ?? [] : [])]) {
|
|
1265
|
+
const wt = this.targetOf(w);
|
|
1266
|
+
if (!wt || wt.path.length || wt.key != null) continue;
|
|
1267
|
+
if ((b && wt.binding === b) || (global != null && wt.global === global)) out.push({ value: w.value, scope: w.scope });
|
|
1268
|
+
if (out.length > EXACT_VALUES * 4) break;
|
|
1269
|
+
}
|
|
1270
|
+
return out;
|
|
1271
|
+
}
|
|
1272
|
+
// This file's writes by key ('*' for a computed key or an element), indexed once.
|
|
1273
|
+
writeIndex(a) {
|
|
1274
|
+
if (!a.writesByKey) { a.writesByKey = new Map(); for (const w of a.writes) { if (!this.targetOf(w)) continue; const key = w.key ?? '*'; if (!a.writesByKey.has(key)) a.writesByKey.set(key, []); a.writesByKey.get(key).push(w); } }
|
|
1275
|
+
return a.writesByKey;
|
|
1276
|
+
}
|
|
1277
|
+
// The elements of the page a lookup finds: by id, by selector (its last
|
|
1278
|
+
// compound), by tag or class. The pages this code runs in are the scan's
|
|
1279
|
+
// (none for a one-file analysis, which knows nothing of them); the
|
|
1280
|
+
// elements the bundle's code writes as markup strings count too, since a
|
|
1281
|
+
// selector finds them once written. Unknown when no page is known, a
|
|
1282
|
+
// page or the written markup overflowed, or the argument is not written.
|
|
1283
|
+
lookups(n, k, args, scope, depth) {
|
|
1284
|
+
const a = scope.analysis ?? this;
|
|
1285
|
+
const unknown = [leaf(n, scope)];
|
|
1286
|
+
if (!a.pages || !a.pages.length || a.pages.some((p) => p.overflow) || a.pool?.writtenOverflow) return unknown;
|
|
1287
|
+
const arg = args[0] ? this.exact(args[0], scope) : null;
|
|
1288
|
+
if (!arg || !arg.length) return unknown;
|
|
1289
|
+
// (The pages' elements indexed once per file; each lookup found once.)
|
|
1290
|
+
if (!a.pageIndex) {
|
|
1291
|
+
const all = [...a.pages.flatMap((p) => p.elements), ...(a.pool?.written ?? [])];
|
|
1292
|
+
const byId = new Map(), marked = [];
|
|
1293
|
+
for (const e of all) { const id = e.attrs.get('id'); if (id == null) continue; if (e.written && id.includes(MARKER)) marked.push(e); else { if (!byId.has(id)) byId.set(id, []); byId.get(id).push(e); } }
|
|
1294
|
+
a.pageIndex = { all, byId, marked, found: new Map() };
|
|
1295
|
+
}
|
|
1296
|
+
const { all, byId, marked, found: cache } = a.pageIndex;
|
|
1297
|
+
const out = [];
|
|
1298
|
+
for (const v of arg) {
|
|
1299
|
+
const key = `${k}\0${v}`;
|
|
1300
|
+
let found = cache.get(key);
|
|
1301
|
+
if (found === undefined) {
|
|
1302
|
+
if (k === 'getElementById') found = [...(byId.get(v) ?? []), ...marked];
|
|
1303
|
+
else if (k === 'getElementsByTagName') found = v === '*' ? all : all.filter((e) => e.tag === v.toLowerCase());
|
|
1304
|
+
else if (k === 'getElementsByClassName') found = all.filter((e) => (e.attrs.get('class') ?? '').split(/\s+/).includes(v) || (e.written && (e.attrs.get('class') ?? '').includes(MARKER)));
|
|
1305
|
+
else if (k === 'getElementsByName') found = all.filter((e) => e.attrs.get('name') === v || (e.written && e.attrs.get('name')?.includes(MARKER)));
|
|
1306
|
+
else found = matchSelector(all, v);
|
|
1307
|
+
cache.set(key, found);
|
|
1308
|
+
}
|
|
1309
|
+
if (found == null) return unknown;
|
|
1310
|
+
// getElementById and querySelector return the first match; the
|
|
1311
|
+
// analyser reads them all (which one is first is the page's business).
|
|
1312
|
+
for (const e of found.slice(0, EXACT_VALUES * 4)) out.push(leaf(e, scope));
|
|
1313
|
+
}
|
|
1314
|
+
// A lookup that finds nothing in the pages: the element is made at run time, unknown.
|
|
1315
|
+
return out.length ? this.union(out) : unknown;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// ---- writes read back ----------------------------------------------------
|
|
1319
|
+
// What was written to the thing a member read reads: `o.k = v` / `o.k`,
|
|
1320
|
+
// `el.dataset.x = v` / `el.dataset.x`, `el.setAttribute('x', v)` /
|
|
1321
|
+
// `el.getAttribute('x')`, `m.set(k, v)` / `m.get(k)`, `arr.push(v)` /
|
|
1322
|
+
// `arr[i]`, `history.replaceState(s)` / `history.state`, `window.name = v`
|
|
1323
|
+
// / `name`. A write reaches a read when both are on the same name (the
|
|
1324
|
+
// same binding, or the same global), or when both are on elements of the
|
|
1325
|
+
// page that may be the same element (the same static element, or either
|
|
1326
|
+
// found at run time by a lookup the analyser could not place), with the
|
|
1327
|
+
// same key. A write on a fresh object of the code (new Image(), a literal)
|
|
1328
|
+
// reaches only reads through the same name.
|
|
1329
|
+
readsOf(n, scope) {
|
|
1330
|
+
const read = this.target(n, scope);
|
|
1331
|
+
if (!read) return [];
|
|
1332
|
+
const a = scope.analysis ?? this;
|
|
1333
|
+
const candidates = [];
|
|
1334
|
+
// A read by a computed key reads only what the same name was given (any key); a read by a
|
|
1335
|
+
// written key reads every write of that key, in this file and (the pool) the others.
|
|
1336
|
+
this.writeIndex(a);
|
|
1337
|
+
// (A push, unshift or add writes an element, which an element read (no key) reads; a property read never does.)
|
|
1338
|
+
if (read.key != null) candidates.push(...(a.writesByKey.get(read.key) ?? []));
|
|
1339
|
+
else if (read.binding) for (const list of a.writesByKey.values()) for (const w of list) if (w.target.binding === read.binding) candidates.push(w);
|
|
1340
|
+
if (a.pool?.writes && read.key != null) candidates.push(...(a.pool.writes.get(read.key) ?? []));
|
|
1341
|
+
const out = [];
|
|
1342
|
+
const seen = new Set();
|
|
1343
|
+
// (The read's place is found once; a write's once ever, kept on the write.)
|
|
1344
|
+
read.elements = candidates.length ? this.staticElements(read) : null;
|
|
1345
|
+
for (const w of candidates) {
|
|
1346
|
+
if (seen.has(w) || out.length > EXACT_VALUES * 4) continue;
|
|
1347
|
+
seen.add(w);
|
|
1348
|
+
if (this.reaches(w, read, scope)) out.push({ value: w.value, scope: w.scope });
|
|
1349
|
+
}
|
|
1350
|
+
return out;
|
|
1351
|
+
}
|
|
1352
|
+
// Where a member read or write lands: its root (a binding, a global name,
|
|
1353
|
+
// a DOM lookup or an unknown object) and its key (the last property, an
|
|
1354
|
+
// attribute's name, or null when computed).
|
|
1355
|
+
target(n, scope) {
|
|
1356
|
+
let path = [];
|
|
1357
|
+
let x = unchain(n);
|
|
1358
|
+
let key = null;
|
|
1359
|
+
if (x.type === 'CallExpression') {
|
|
1360
|
+
const c = unchain(x.callee), k = this.key(c, scope);
|
|
1361
|
+
if (c.type !== 'MemberExpression') return null;
|
|
1362
|
+
if (k === 'getAttribute' || k === 'setAttribute' || k === 'getItem' || k === 'setItem') { const v = this.exact(x.arguments[0], scope); key = v && v.length === 1 ? (k.endsWith('Attribute') ? v[0].toLowerCase() : v[0]) : null; }
|
|
1363
|
+
else if (k === 'get' || k === 'set') { const v = this.exact(x.arguments[0], scope); key = v && v.length === 1 ? v[0] : null; }
|
|
1364
|
+
else if (k === 'push' || k === 'unshift' || k === 'add') key = null;
|
|
1365
|
+
else if (k === 'replaceState' || k === 'pushState') key = 'state';
|
|
1366
|
+
else return null;
|
|
1367
|
+
x = unchain(c.object);
|
|
1368
|
+
} else if (x.type === 'MemberExpression') {
|
|
1369
|
+
key = this.key(x, scope);
|
|
1370
|
+
x = unchain(x.object);
|
|
1371
|
+
if (x.type === 'MemberExpression' && this.key(x, scope) === 'dataset') { key = key == null ? null : 'data-' + kebab(key); x = unchain(x.object); }
|
|
1372
|
+
else if (key != null && ATTR_PROPS.has(key)) key = ATTR_PROPS.get(key);
|
|
1373
|
+
} else return null;
|
|
1374
|
+
while (x.type === 'MemberExpression') { if (path.length > 8) return null; path.unshift(this.key(x, scope)); x = unchain(x.object); }
|
|
1375
|
+
// window.X.… is the global X's.
|
|
1376
|
+
if (x.type === 'Identifier' && path.length && GLOBAL_OBJECTS.has(this.globalName(x, scope))) { const name = path.shift(); if (name == null) return { root: null, binding: null, global: null, path, key, scope }; x = { type: 'Identifier', name, ...at(x) }; }
|
|
1377
|
+
if (x.type === 'ThisExpression') return { root: x, binding: null, global: null, path, key, scope, unknown: true };
|
|
1378
|
+
if (x.type === 'Identifier') {
|
|
1379
|
+
const b = scope.resolve(x.name);
|
|
1380
|
+
if (b && (b.alias === 'name' && (GLOBAL_OBJECTS.has(b.name) || HOST_NAMES.has(b.name)))) return { root: x, binding: null, global: b.name, path, key, scope };
|
|
1381
|
+
if (b) return { root: x, binding: b, global: null, path, key, scope };
|
|
1382
|
+
return { root: x, binding: null, global: x.name, path, key, scope };
|
|
1383
|
+
}
|
|
1384
|
+
return { root: x, binding: null, global: null, path, key, scope, unknown: x.type !== 'CallExpression' && x.type !== 'NewExpression' };
|
|
1385
|
+
}
|
|
1386
|
+
reaches(w, read, scope) {
|
|
1387
|
+
const wt = this.targetOf(w);
|
|
1388
|
+
if (!wt) return false;
|
|
1389
|
+
if (w.key != null && read.key != null && w.key !== read.key) return false;
|
|
1390
|
+
if (wt.path.length !== read.path.length || wt.path.some((p, i) => p != null && read.path[i] != null && p !== read.path[i])) return false;
|
|
1391
|
+
if (wt.binding && read.binding && wt.binding === read.binding) return true;
|
|
1392
|
+
if (wt.global != null || read.global != null) return wt.global != null && wt.global === read.global;
|
|
1393
|
+
// Both on things the analyser can place (elements of the page, objects made at one place in the code): they
|
|
1394
|
+
// meet when they share one; two names for the same new Image() meet, two different elements never do.
|
|
1395
|
+
if (w.elements === undefined) w.elements = this.staticElements(wt);
|
|
1396
|
+
const we = w.elements, re = read.elements;
|
|
1397
|
+
if (we && re) return [...we].some((e) => re.has(e));
|
|
1398
|
+
if (wt.binding && read.binding) return false;
|
|
1399
|
+
// Elements of the page: the same element, or either unknown.
|
|
1400
|
+
if (wt.binding || read.binding) {
|
|
1401
|
+
const b = wt.binding ?? read.binding, other = wt.binding ? read : wt;
|
|
1402
|
+
if (!other.unknown && other.root?.type !== 'CallExpression' && other.root?.type !== 'MemberExpression') return false;
|
|
1403
|
+
const leaves = this.resolveBinding(b, wt.binding ? wt.scope : read.scope, wt.binding ? wt.root : read.root, 0);
|
|
1404
|
+
if (leaves.every((l) => FRESH.has(l.node.type) || this.freshCall(l))) return false;
|
|
1405
|
+
return true;
|
|
1406
|
+
}
|
|
1407
|
+
return this.domLike(wt, scope) && this.domLike(read, scope);
|
|
1408
|
+
}
|
|
1409
|
+
// The elements of the page a target stands on, when every leaf is one; null when any is not (unknown, or not an element).
|
|
1410
|
+
staticElements(t) {
|
|
1411
|
+
if (t.unknown || !t.root) return null;
|
|
1412
|
+
const leaves = t.binding ? this.resolveBinding(t.binding, t.scope, t.root, 0) : this.resolve(t.root, t.scope, 0);
|
|
1413
|
+
if (!leaves.length || !leaves.every((l) => l.node.type === 'PageElement' || l.node.type === 'NewExpression' || l.node.type === 'ObjectExpression' || l.node.type === 'ArrayExpression' || this.freshCall(l))) return null;
|
|
1414
|
+
return new Set(leaves.map((l) => l.node));
|
|
1415
|
+
}
|
|
1416
|
+
// A call that makes a fresh element or object: createElement, Object.create, structuredClone.
|
|
1417
|
+
freshCall(l) {
|
|
1418
|
+
if (l.node.type !== 'CallExpression') return false;
|
|
1419
|
+
const c = unchain(l.node.callee);
|
|
1420
|
+
const k = c.type === 'MemberExpression' ? this.key(c, l.scope) : null;
|
|
1421
|
+
return k === 'createElement' || k === 'createElementNS' || k === 'createTextNode' || k === 'createDocumentFragment' || k === 'cloneNode' || k === 'create';
|
|
1422
|
+
}
|
|
1423
|
+
domLike(t, scope) {
|
|
1424
|
+
if (t.unknown) return true;
|
|
1425
|
+
if (t.domLike !== undefined) return t.domLike;
|
|
1426
|
+
const leaves = this.resolve(t.root, t.scope ?? scope, 0);
|
|
1427
|
+
t.domLike = leaves.some((l) => !FRESH.has(l.node.type) && !this.freshCall(l) && l.node.type !== 'Namespace');
|
|
1428
|
+
return t.domLike;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// The strings an expression can be, exactly, when every piece is written
|
|
1432
|
+
// down: a literal, a template, a concatenation, a conditional (both
|
|
1433
|
+
// branches), a const bound to a literal or to a list of literals, a
|
|
1434
|
+
// `.join` of written strings, `.split('').reverse()`, a literal `.replace`,
|
|
1435
|
+
// `.toLowerCase`, `atob`/`decodeURIComponent`/`String.fromCharCode` of
|
|
1436
|
+
// literals, a number written in literals. null when any piece is computed.
|
|
1437
|
+
// Bounded in values (EXACT_VALUES) and pieces (EXACT_PIECES); past either
|
|
1438
|
+
// bound the expression is computed, which refuses.
|
|
1439
|
+
exact(n, scope, depth = 0) {
|
|
1440
|
+
n = unchain(n);
|
|
1441
|
+
if (!n) return null;
|
|
1442
|
+
// (A literal is read whatever the budget: it costs nothing to read.)
|
|
1443
|
+
if (n.type === 'Literal') return typeof n.value === 'string' ? [n.value] : typeof n.value === 'number' || typeof n.value === 'boolean' || n.value === null ? [String(n.value)] : n.value === undefined && n.raw === null ? ['undefined'] : null;
|
|
1444
|
+
if (n.type === 'TemplateLiteral' && n.expressions.length === 0) return [n.quasis.map((q) => q.value.cooked ?? '').join('')];
|
|
1445
|
+
if (depth > 64 || this.spend() || !this.enter()) return null;
|
|
1446
|
+
try { return this.exactIn(n, scope, depth); } finally { this.leave(); }
|
|
1447
|
+
}
|
|
1448
|
+
exactIn(n, scope, depth) {
|
|
1449
|
+
switch (n.type) {
|
|
1450
|
+
case 'MemberExpression': case 'AwaitExpression': case 'ElementOf': case 'JsonValue': return this.exactLeaves(n, scope, depth);
|
|
1451
|
+
case 'Piece': case 'PageMarkup': case 'PageElement': case 'Dataset': case 'Namespace': return null;
|
|
1452
|
+
case 'TemplateLiteral': {
|
|
1453
|
+
let out = [''];
|
|
1454
|
+
for (let i = 0; i < n.quasis.length; i++) {
|
|
1455
|
+
out = product(out, [n.quasis[i].value.cooked ?? '']);
|
|
1456
|
+
if (i < n.expressions.length) out = product(out, this.exact(n.expressions[i], scope, depth + 1));
|
|
1457
|
+
if (!out) return null;
|
|
1458
|
+
}
|
|
1459
|
+
return out;
|
|
1460
|
+
}
|
|
1461
|
+
case 'BinaryExpression': {
|
|
1462
|
+
if (n.operator !== '+') return null;
|
|
1463
|
+
const pieces = plusChain(n);
|
|
1464
|
+
if (pieces.length > EXACT_PIECES) return null;
|
|
1465
|
+
let out = [''];
|
|
1466
|
+
for (const p of pieces) { out = product(out, this.exact(p, scope, depth + 1)); if (!out) return null; }
|
|
1467
|
+
return out;
|
|
1468
|
+
}
|
|
1469
|
+
case 'ConditionalExpression': { const a = this.exact(n.consequent, scope, depth + 1), b = this.exact(n.alternate, scope, depth + 1); return a && b && a.length + b.length <= EXACT_VALUES ? [...a, ...b] : null; }
|
|
1470
|
+
case 'LogicalExpression': { const a = this.exact(n.left, scope, depth + 1), b = this.exact(n.right, scope, depth + 1); return a && b && a.length + b.length <= EXACT_VALUES ? [...a, ...b] : null; }
|
|
1471
|
+
case 'SequenceExpression': return this.exact(n.expressions[n.expressions.length - 1], scope, depth + 1);
|
|
1472
|
+
case 'UnaryExpression': case 'Identifier': {
|
|
1473
|
+
if (n.type === 'Identifier') {
|
|
1474
|
+
const b = scope.resolve(n.name);
|
|
1475
|
+
if (!b && n.name === 'undefined') return ['undefined'];
|
|
1476
|
+
if (b && b.alias === 'string' && !b.built && b.spelled != null) return b.spelled.split('\u0000');
|
|
1477
|
+
if (b && b.alias === 'number') return [String(b.value)];
|
|
1478
|
+
if (b && (b.alias === 'string' || b.alias === 'strings' || b.alias === 'fn')) return null;
|
|
1479
|
+
return this.exactLeaves(n, scope, depth);
|
|
1480
|
+
}
|
|
1481
|
+
const v = this.fold(n, scope);
|
|
1482
|
+
return v == null ? null : [String(v)];
|
|
1483
|
+
}
|
|
1484
|
+
case 'CallExpression': return this.exactCall(n, scope, depth) ?? this.exactLeaves(n, scope, depth);
|
|
1485
|
+
default: return null;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
// The exact values of what a value resolves to (resolve), when every
|
|
1489
|
+
// alternative is written down and the alternatives are few.
|
|
1490
|
+
exactLeaves(n, scope, depth) {
|
|
1491
|
+
const leaves = this.resolve(n, scope);
|
|
1492
|
+
if (this.unread(leaves, n)) return n.type === 'ElementOf' ? this.exactElement(n, scope, depth) : null;
|
|
1493
|
+
const out = [];
|
|
1494
|
+
for (const l of leaves) {
|
|
1495
|
+
const v = this.exactLeaf(l.node, l.scope, depth + 1);
|
|
1496
|
+
if (!v) return null;
|
|
1497
|
+
out.push(...v);
|
|
1498
|
+
if (out.length > EXACT_VALUES) return null;
|
|
1499
|
+
}
|
|
1500
|
+
return [...new Set(out)];
|
|
1501
|
+
}
|
|
1502
|
+
exactLeaf(x, s, depth) {
|
|
1503
|
+
switch (x.type) {
|
|
1504
|
+
case 'JsonValue': return typeof x.value === 'string' ? [x.value] : typeof x.value === 'number' || typeof x.value === 'boolean' || x.value === null ? [String(x.value)] : null;
|
|
1505
|
+
case 'ElementOf': return this.exactElement(x, s, depth);
|
|
1506
|
+
case 'Identifier': {
|
|
1507
|
+
const b = s.resolve(x.name);
|
|
1508
|
+
if (!b) return x.name === 'undefined' ? ['undefined'] : null;
|
|
1509
|
+
if (b.alias === 'string' && !b.built && b.spelled != null) return b.spelled.split('\u0000');
|
|
1510
|
+
if (b.alias === 'number') return [String(b.value)];
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
case 'CallExpression': return this.exactCall(x, s, depth);
|
|
1514
|
+
case 'MemberExpression': case 'Piece': case 'PageMarkup': case 'PageElement': case 'Dataset': case 'Namespace': case 'AwaitExpression': return null;
|
|
1515
|
+
default: return this.exact(x, s, depth);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
exactElement(n, scope, depth) {
|
|
1519
|
+
const list = this.exactList(n.list, n.scope, depth + 1);
|
|
1520
|
+
if (!list) return null;
|
|
1521
|
+
if (n.index != null) return n.index < list.length ? [list[n.index]] : ['undefined'];
|
|
1522
|
+
return list.length && list.length <= EXACT_VALUES ? [...new Set(list)] : null;
|
|
1523
|
+
}
|
|
1524
|
+
// A call of a string function, evaluated exactly; null when it is no such
|
|
1525
|
+
// call (a function of the game's, read by exactLeaves) or not exact.
|
|
1526
|
+
exactCall(n, scope, depth) {
|
|
1527
|
+
{
|
|
1528
|
+
{
|
|
1529
|
+
const c = unchain(n.callee), args = n.arguments;
|
|
1530
|
+
if (args.some((a) => a.type === 'SpreadElement')) return null;
|
|
1531
|
+
if (c.type === 'Identifier') {
|
|
1532
|
+
const g = this.globalName(c, scope), a = args[0] ? this.exact(args[0], scope, depth + 1) : null;
|
|
1533
|
+
if (g === 'String') return args[0] ? a : [''];
|
|
1534
|
+
if (!a || a.length !== 1) return null;
|
|
1535
|
+
if (g === 'atob') { const d = decodeBase64(a[0]); return d == null ? null : [d]; }
|
|
1536
|
+
if (g === 'decodeURIComponent' || g === 'decodeURI') { try { return [decodeURIComponent(a[0])]; } catch { return null; } }
|
|
1537
|
+
if (g === 'unescape') return [unescapeString(a[0])];
|
|
1538
|
+
return null;
|
|
1539
|
+
}
|
|
1540
|
+
if (c.type !== 'MemberExpression') return null;
|
|
1541
|
+
const k = this.key(c, scope);
|
|
1542
|
+
if (this.globalName(c.object, scope) === 'String' && (k === 'fromCharCode' || k === 'fromCodePoint')) { const s = fromCharCodes(args); return s == null ? null : [s]; }
|
|
1543
|
+
if (k === 'join') {
|
|
1544
|
+
const list = this.exactList(c.object, scope, depth + 1);
|
|
1545
|
+
const sep = args[0] ? this.exact(args[0], scope, depth + 1) : [','];
|
|
1546
|
+
if (!list || !sep || sep.length !== 1) return null;
|
|
1547
|
+
return [list.join(sep[0])];
|
|
1548
|
+
}
|
|
1549
|
+
const recv = this.exact(c.object, scope, depth + 1);
|
|
1550
|
+
if (!recv) return null;
|
|
1551
|
+
const lit = (i) => { const v = args[i] ? this.exact(args[i], scope, depth + 1) : null; return v && v.length === 1 ? v[0] : null; };
|
|
1552
|
+
const num = (i) => { if (!args[i]) return undefined; const v = this.fold(args[i], scope); return v == null ? null : v; };
|
|
1553
|
+
switch (k) {
|
|
1554
|
+
case 'toLowerCase': case 'toLocaleLowerCase': return recv.map((s) => s.toLowerCase());
|
|
1555
|
+
case 'toUpperCase': case 'toLocaleUpperCase': return recv.map((s) => s.toUpperCase());
|
|
1556
|
+
case 'trim': return recv.map((s) => s.trim());
|
|
1557
|
+
case 'trimStart': case 'trimEnd': return recv.map((s) => s[k]());
|
|
1558
|
+
case 'toString': case 'valueOf': return args.length === 0 ? recv : null;
|
|
1559
|
+
case 'normalize': return args.length === 0 ? recv.map((s) => s.normalize()) : null;
|
|
1560
|
+
case 'concat': { let out = recv; for (const a of args) { out = product(out, this.exact(a, scope, depth + 1)); if (!out) return null; } return out; }
|
|
1561
|
+
case 'repeat': { const t = num(0); return t == null || t < 0 || t > 64 ? null : recv.map((s) => s.repeat(t)); }
|
|
1562
|
+
case 'slice': case 'substring': case 'substr': { const a = num(0), b = num(1); if (a === null || b === null) return null; return recv.map((s) => (b === undefined ? s[k](a ?? 0) : s[k](a ?? 0, b))); }
|
|
1563
|
+
case 'padStart': case 'padEnd': { const t = num(0), f = args[1] ? lit(1) : ' '; return t == null || t > 4096 || f == null ? null : recv.map((s) => s[k](t, f)); }
|
|
1564
|
+
case 'replace': case 'replaceAll': {
|
|
1565
|
+
// A written string replaced by a written string with no `$` pattern is exact; a RegExp is not simulated.
|
|
1566
|
+
const from = lit(0), to = lit(1);
|
|
1567
|
+
if (from == null || to == null || to.includes('$') || (args[0] && unchain(args[0]).type !== 'Literal' && this.exact(args[0], scope, depth + 1) == null)) return null;
|
|
1568
|
+
if (unchain(args[0]).type === 'Literal' && unchain(args[0]).regex) return null;
|
|
1569
|
+
return recv.map((s) => (k === 'replaceAll' ? s.split(from).join(to) : s.replace(from, to)));
|
|
1570
|
+
}
|
|
1571
|
+
default: return null;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
// The written elements of an array expression, after `.split(written)`
|
|
1577
|
+
// and `.reverse()` applied to written strings: ['a', 'b'], 'ab'.split(''),
|
|
1578
|
+
// 'ba'.split('').reverse(). Each element one exact string; else null. A
|
|
1579
|
+
// list that is not written here is read through what it resolves to.
|
|
1580
|
+
exactList(n, scope, depth) {
|
|
1581
|
+
n = unchain(n);
|
|
1582
|
+
if (!n || depth > 64 || this.spend()) return null;
|
|
1583
|
+
if (n.type === 'ArrayExpression') {
|
|
1584
|
+
const out = [];
|
|
1585
|
+
for (const e of n.elements) { if (!e || e.type === 'SpreadElement') return this.exactListLeaves(n, scope, depth); const v = this.exact(e, scope, depth + 1); if (!v || v.length !== 1) return null; out.push(v[0]); }
|
|
1586
|
+
return out;
|
|
1587
|
+
}
|
|
1588
|
+
if (n.type === 'Identifier') { const b = scope.resolve(n.name); if (b && b.alias === 'strings') return [...b.values]; if (b && (b.alias === 'string' || b.alias === 'number' || b.alias === 'fn')) return null; return this.exactListLeaves(n, scope, depth); }
|
|
1589
|
+
if (n.type === 'MemberExpression' || n.type === 'AwaitExpression') return this.exactListLeaves(n, scope, depth);
|
|
1590
|
+
if (n.type !== 'CallExpression') return null;
|
|
1591
|
+
const c = unchain(n.callee);
|
|
1592
|
+
if (c.type !== 'MemberExpression') return this.exactListLeaves(n, scope, depth);
|
|
1593
|
+
const k = staticKey(c);
|
|
1594
|
+
if (k === 'reverse' && n.arguments.length === 0) { const l = this.exactList(c.object, scope, depth + 1); return l ? l.reverse() : null; }
|
|
1595
|
+
if (k === 'split') {
|
|
1596
|
+
const recv = this.exact(c.object, scope, depth + 1), sep = n.arguments[0] ? this.exact(n.arguments[0], scope, depth + 1) : null;
|
|
1597
|
+
if (!recv || recv.length !== 1 || !sep || sep.length !== 1 || n.arguments.length > 1) return null;
|
|
1598
|
+
const parts = recv[0].split(sep[0]);
|
|
1599
|
+
return parts.length > EXACT_PIECES ? null : parts;
|
|
1600
|
+
}
|
|
1601
|
+
if (k === 'concat') { let l = this.exactList(c.object, scope, depth + 1); for (const a of n.arguments) { const m = this.exactList(a, scope, depth + 1); if (!l || !m) return null; l = l.concat(m); } return l; }
|
|
1602
|
+
if (k === 'slice' || k === 'filter' || k === 'sort' || k === 'flat' || k === 'map' || k === 'toSorted' || k === 'toReversed' || k === 'values' || k === 'keys' || k === 'from' || k === 'of' || k === 'all') return this.exactListLeaves(n, scope, depth);
|
|
1603
|
+
return null;
|
|
1604
|
+
}
|
|
1605
|
+
// The exact strings of every element a list resolves to (elementsOf), in
|
|
1606
|
+
// the order the analyser reads them, when each is one written string.
|
|
1607
|
+
exactListLeaves(n, scope, depth) {
|
|
1608
|
+
const elements = this.elementsOf(n, scope, 0, null);
|
|
1609
|
+
if (elements.some((l) => l.node.type === 'ElementOf' && l.node.list === n)) return null;
|
|
1610
|
+
const out = [];
|
|
1611
|
+
for (const l of elements) {
|
|
1612
|
+
const v = this.exactLeaf(l.node, l.scope, depth + 1);
|
|
1613
|
+
if (!v || v.length !== 1) return null;
|
|
1614
|
+
out.push(v[0]);
|
|
1615
|
+
if (out.length > EXACT_PIECES) return null;
|
|
1616
|
+
}
|
|
1617
|
+
return out;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// Why a markup or URL value cannot be read exactly, when it is built from
|
|
1621
|
+
// text decoded at run time (atob, decodeURI, String.fromCharCode, a
|
|
1622
|
+
// TextDecoder, of something not written down) or from markup rewritten by
|
|
1623
|
+
// a pattern (a RegExp or computed `.replace`, `.sort`, `.map`, `.filter`,
|
|
1624
|
+
// a computed `.slice` of text that holds a tag). null when neither.
|
|
1625
|
+
hiddenSource(n, scope, opts = {}) {
|
|
1626
|
+
return this.rewritten(n, scope) ?? this.outside(n, scope, 0, opts);
|
|
1627
|
+
}
|
|
1628
|
+
// The decoder or pattern rewrite part of hiddenSource, on the expression's own shape.
|
|
1629
|
+
rewritten(n, scope) {
|
|
1630
|
+
const stack = [n];
|
|
1631
|
+
let steps = 0;
|
|
1632
|
+
while (stack.length) {
|
|
1633
|
+
if (steps++ > WALK_STEPS) return 'an expression of too many parts to read';
|
|
1634
|
+
const x = unchain(stack.pop());
|
|
1635
|
+
if (!x || FUNCTIONS.has(x.type)) continue;
|
|
1636
|
+
if (x.type === 'CallExpression') {
|
|
1637
|
+
const c = unchain(x.callee);
|
|
1638
|
+
if (this.isDecoder(x, scope) && this.exact(x, scope) == null) return 'text decoded at run time';
|
|
1639
|
+
if (c.type === 'MemberExpression') {
|
|
1640
|
+
const k = staticKey(c);
|
|
1641
|
+
// (A substring, a split, a filter keep the pieces as written: no new word can appear. A map's or sort's callback
|
|
1642
|
+
// written here is read for what it returns; one not written down may spell anything.)
|
|
1643
|
+
const rewrites = ['replace', 'replaceAll', 'sort', 'toSorted', 'splice', 'reverse', 'toReversed', 'with', 'normalize'].includes(k) || (['map', 'flatMap', 'filter', 'reduce'].includes(k) && !this.callbackOf(x.arguments[0], scope));
|
|
1644
|
+
if (rewrites && this.exact(x, scope) == null && this.exactList(x, scope, 0) == null) {
|
|
1645
|
+
const held = this.spelled(c.object, scope);
|
|
1646
|
+
// (The page's own markup read back, el.innerHTML.replace(…), is markup too.)
|
|
1647
|
+
const markupRead = unchain(c.object).type === 'MemberExpression' && MARKUP_PROPS.has(this.key(unchain(c.object), scope));
|
|
1648
|
+
if (markupRead || (held != null && (MARKUP_IN_TEXT.test(held) || /script|javascript|on[a-z]+\s*=/i.test(held)))) return 'markup rewritten at run time';
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
for (const [, child] of children(x)) stack.push(child);
|
|
1653
|
+
}
|
|
1654
|
+
return null;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// Why a markup or URL value is text from outside the code, when it is: the
|
|
1658
|
+
// value itself, a piece of it, or a name it was bound to is a response body
|
|
1659
|
+
// (`.text()`, `.json()`, `.arrayBuffer()`, `responseText`), a storage item,
|
|
1660
|
+
// a cookie, a field's value, a message's data, typed text (`prompt`). (The
|
|
1661
|
+
// page's address is not: what the game writes there is judged as a URL
|
|
1662
|
+
// sink, and the shell sets the rest.) Read along the value's own shape
|
|
1663
|
+
// (pieces, template parts, branches, `await`, member reads, string methods,
|
|
1664
|
+
// `.then` chains, the calls that hand their argument on), never into a call
|
|
1665
|
+
// of the game's own functions: `t(key)` and `art(sym)` are the game's, and
|
|
1666
|
+
// what they return is a stated limit. null when nothing of the kind.
|
|
1667
|
+
outside(n, scope, depth = 0, opts = {}) {
|
|
1668
|
+
if (depth > 32) return 'an expression nested too deep to read';
|
|
1669
|
+
const stack = [{ node: n, scope }];
|
|
1670
|
+
const push = (list, s) => { for (const e of list) stack.push({ node: e, scope: s }); };
|
|
1671
|
+
const seen = new Set();
|
|
1672
|
+
let steps = 0;
|
|
1673
|
+
while (stack.length) {
|
|
1674
|
+
// An expression too large to read is one the analyser did not read.
|
|
1675
|
+
if (steps++ > WALK_STEPS) return 'an expression of too many parts to read';
|
|
1676
|
+
const { node, scope: s } = stack.pop();
|
|
1677
|
+
const x = unchain(node);
|
|
1678
|
+
if (!x || seen.has(x)) continue;
|
|
1679
|
+
seen.add(x);
|
|
1680
|
+
switch (x.type) {
|
|
1681
|
+
case 'Identifier': {
|
|
1682
|
+
const b = s.resolve(x.name);
|
|
1683
|
+
const why = (binding) => (binding.hidden && !(opts.url && binding.addressOnly) ? binding.hidden : null);
|
|
1684
|
+
if (b && why(b)) return why(b);
|
|
1685
|
+
if (!b) { for (const p of this.globalsNamed(x.name, s)) { if (why(p.binding)) return why(p.binding); if (p.binding.alias === 'value') for (const v of p.binding.nodes) stack.push(v); } break; }
|
|
1686
|
+
if (b.alias === 'value' || (b.alias === 'string' && b.addressOnly)) for (const v of b.nodes ?? []) stack.push(v);
|
|
1687
|
+
else if (b.alias === 'import') { const t = this.followExport(b.from, b.name, s, depth); if (t?.binding) { if (why(t.binding)) return why(t.binding); if (t.binding.alias === 'value') for (const v of t.binding.nodes) stack.push(v); } }
|
|
1688
|
+
else if (b.param) {
|
|
1689
|
+
// The arguments written at the call sites: decoded text there is decoded text here.
|
|
1690
|
+
const a = s.analysis ?? this; const sites = a.sites.get(b.param.fn);
|
|
1691
|
+
if (sites && !a.escaped.has(b.param.fn) && !(a.open.has(b.param.fn) && !a.pool)) for (const site of sites) { const arg = site.args[b.param.index]; if (!arg) continue; const r = this.rewritten(arg, site.scope); if (r) return r; stack.push({ node: arg, scope: site.scope }); }
|
|
1692
|
+
}
|
|
1693
|
+
break;
|
|
1694
|
+
}
|
|
1695
|
+
case 'ElementOf': stack.push({ node: x.list, scope: x.scope }); break;
|
|
1696
|
+
case 'Piece': stack.push({ node: x.of, scope: x.scope }); break;
|
|
1697
|
+
case 'AwaitExpression': case 'UnaryExpression': case 'SpreadElement': stack.push({ node: x.argument, scope: s }); break;
|
|
1698
|
+
case 'BinaryExpression': if (x.operator === '+') push(plusChain(x), s); break;
|
|
1699
|
+
case 'TemplateLiteral': push(x.expressions, s); break;
|
|
1700
|
+
case 'ConditionalExpression': push([x.consequent, x.alternate], s); break;
|
|
1701
|
+
case 'LogicalExpression': push([x.left, x.right], s); break;
|
|
1702
|
+
case 'SequenceExpression': stack.push({ node: x.expressions[x.expressions.length - 1], scope: s }); break;
|
|
1703
|
+
case 'AssignmentExpression': stack.push({ node: x.right, scope: s }); break; // a compound assignment's value holds its right side
|
|
1704
|
+
case 'ArrayExpression': push(x.elements, s); break;
|
|
1705
|
+
case 'ObjectExpression': for (const p of x.properties) stack.push({ node: p.type === 'Property' ? p.value : p.argument, scope: s }); break;
|
|
1706
|
+
case 'MemberExpression': {
|
|
1707
|
+
const k = this.key(x, s), o = this.globalName(x.object, s);
|
|
1708
|
+
if (o === 'document' && k === 'cookie') return 'a cookie';
|
|
1709
|
+
if (o != null && STORAGE_NAMES.has(o)) return 'device storage';
|
|
1710
|
+
if (k != null && OUTSIDE_MEMBERS.has(k) && !(o != null && (GLOBAL_OBJECTS.has(o) || o === 'document'))) return OUTSIDE_MEMBERS.get(k);
|
|
1711
|
+
// The address: the game can write it (location.hash =, history.replaceState, a link's fragment) and read it back; what
|
|
1712
|
+
// it holds is not written in the code. At a URL sink the page's own address keeps its scheme (its shape says so, spelled).
|
|
1713
|
+
if (o === 'location' && k != null && !opts.url) return "the page's address";
|
|
1714
|
+
if (o === 'document' && k != null && DOCUMENT_ADDRESS.has(k) && (!opts.url || k === 'referrer')) return k === 'referrer' ? 'the referrer' : "the page's address";
|
|
1715
|
+
if (k === 'name' && o != null && GLOBAL_OBJECTS.has(o)) return "the window's name";
|
|
1716
|
+
if (k === 'searchParams') return "the page's address, decoded";
|
|
1717
|
+
for (const w of this.readsOf(x, s)) { const r = this.rewritten(w.value, w.scope); if (r) return r; stack.push({ node: w.value, scope: w.scope }); }
|
|
1718
|
+
stack.push({ node: x.object, scope: s });
|
|
1719
|
+
break;
|
|
1720
|
+
}
|
|
1721
|
+
case 'NewExpression': {
|
|
1722
|
+
const g = this.globalName(x.callee, s);
|
|
1723
|
+
if (g === 'URLSearchParams' || g === 'URL') { push(x.arguments, s); break; }
|
|
1724
|
+
push(x.arguments, s); break; // new Map(Object.entries(fetched)), new String(t)
|
|
1725
|
+
}
|
|
1726
|
+
case 'CallExpression': {
|
|
1727
|
+
const c = unchain(x.callee);
|
|
1728
|
+
if (FUNCTIONS.has(c.type)) { for (const r of this.returnsOf(c)) stack.push({ node: r, scope: this.scopes.get(c) ?? s }); break; }
|
|
1729
|
+
if (c.type === 'Identifier') {
|
|
1730
|
+
const g = this.globalName(c, s);
|
|
1731
|
+
if (g === 'prompt') return 'typed text';
|
|
1732
|
+
if (g === 'String' || g === 'structuredClone') push(x.arguments, s);
|
|
1733
|
+
break;
|
|
1734
|
+
}
|
|
1735
|
+
if (c.type !== 'MemberExpression') break;
|
|
1736
|
+
const k = this.key(c, s), o = this.globalName(c.object, s);
|
|
1737
|
+
if (k != null && BODY_READERS.has(k) && x.arguments.length === 0) return 'text fetched or read at run time';
|
|
1738
|
+
if (o != null && STORAGE_NAMES.has(o)) return 'device storage';
|
|
1739
|
+
// URLSearchParams.get(), url.searchParams.get(): the address, percent-decoded.
|
|
1740
|
+
if ((k === 'get' || k === 'getAll' || k === 'entries' || k === 'values' || k === 'forEach') && this.isSearchParams(c.object, s)) return "the page's address, decoded";
|
|
1741
|
+
// list.map(fn), Array.from(list, fn), list.reduce(fn, init): the value is what the callback returns (its
|
|
1742
|
+
// parameter is an element of the list, hidden when the list is: read where the parameter is used).
|
|
1743
|
+
if (k === 'map' || k === 'flatMap' || k === 'reduce' || (o === 'Array' && k === 'from' && x.arguments[1])) {
|
|
1744
|
+
const cb = this.callbackOf(k === 'from' ? x.arguments[1] : x.arguments[0], s);
|
|
1745
|
+
if (cb) { const owner = cb.scope.analysis ?? this; for (const r of this.returnsOf(cb.node, owner)) stack.push({ node: r, scope: owner.scopes.get(cb.node) ?? s }); }
|
|
1746
|
+
else stack.push({ node: c.object, scope: s });
|
|
1747
|
+
if (k === 'reduce' && x.arguments[1]) stack.push({ node: x.arguments[1], scope: s });
|
|
1748
|
+
break;
|
|
1749
|
+
}
|
|
1750
|
+
if (o != null && PASS_THROUGH.get(o)?.includes(k)) { push(x.arguments, s); break; }
|
|
1751
|
+
if (k === 'get' || k === 'getAll' || k === 'at' || k === 'getAttribute' || k === 'getItem') { for (const w of this.readsOf(x, s)) { const r = this.rewritten(w.value, w.scope); if (r) return r; stack.push({ node: w.value, scope: w.scope }); } stack.push({ node: c.object, scope: s }); break; } // a Map or a list of fetched values
|
|
1752
|
+
if (ELEMENT_METHODS.has(k) || LIST_METHODS.has(k) || k === 'match' || k === 'matchAll' || k === 'exec') { stack.push({ node: c.object, scope: s }); if (k === 'concat') push(x.arguments, s); break; }
|
|
1753
|
+
if (k != null && STRING_METHODS.has(k)) {
|
|
1754
|
+
stack.push({ node: c.object, scope: s });
|
|
1755
|
+
if (k === 'concat') push(x.arguments, s);
|
|
1756
|
+
// fetch(u).then((r) => r.text()): the chain's value is what the callback returns.
|
|
1757
|
+
if (k === 'then' || k === 'finally') { const cb = x.arguments[0] && this.callbackOf(x.arguments[0], s); if (cb) for (const r of this.returnsOf(cb.node, cb.scope.analysis ?? this)) stack.push({ node: r, scope: (cb.scope.analysis ?? this).scopes.get(cb.node) ?? s }); }
|
|
1758
|
+
}
|
|
1759
|
+
break;
|
|
1760
|
+
}
|
|
1761
|
+
default: break;
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
return null;
|
|
1765
|
+
}
|
|
1766
|
+
// Is this a URLSearchParams: constructed, a URL's searchParams, or a name bound to one?
|
|
1767
|
+
isSearchParams(n, scope) {
|
|
1768
|
+
for (const l of this.resolve(n, scope)) {
|
|
1769
|
+
const x = l.node;
|
|
1770
|
+
if (x.type === 'NewExpression' && this.globalName(x.callee, l.scope) === 'URLSearchParams') return true;
|
|
1771
|
+
if (x.type === 'MemberExpression' && this.key(x, l.scope) === 'searchParams') return true;
|
|
1772
|
+
}
|
|
1773
|
+
return false;
|
|
1774
|
+
}
|
|
1775
|
+
// A callback given inline, or by a name bound to a function: { node, scope }.
|
|
1776
|
+
callbackOf(arg, scope) {
|
|
1777
|
+
const a = unchain(arg);
|
|
1778
|
+
if (!a) return null;
|
|
1779
|
+
if (FUNCTIONS.has(a.type)) return { node: a, scope };
|
|
1780
|
+
if (a.type === 'Identifier') { const b = scope.resolve(a.name); if (b?.alias === 'fn') return { node: b.node, scope: b.scope ?? scope }; }
|
|
1781
|
+
return null;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// The expression a function returns when that is one expression: an
|
|
1785
|
+
// arrow's body, or the argument of the one return statement of a block.
|
|
1786
|
+
returned(fn) {
|
|
1787
|
+
if (fn.type === 'ArrowFunctionExpression' && fn.expression) return fn.body;
|
|
1788
|
+
const returns = fn.body.type === 'BlockStatement' ? fn.body.body.filter((s) => s.type === 'ReturnStatement') : [];
|
|
1789
|
+
return returns.length === 1 && returns[0].argument ? returns[0].argument : null;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// The ways a markup or URL expression can read, for the tag walk: its
|
|
1793
|
+
// exact values when every piece is written down, else what its pieces
|
|
1794
|
+
// spell with each computed part standing in as a MARKER, one string per
|
|
1795
|
+
// conditional branch. [] when nothing is written down.
|
|
1796
|
+
shapes(n, scope) {
|
|
1797
|
+
return this.alternatives(n, scope).texts;
|
|
1798
|
+
}
|
|
1799
|
+
// Every alternative a markup or URL value can be: `texts` are the written
|
|
1800
|
+
// ones (exact, or spelled with MARKERs for computed parts), `placed` counts
|
|
1801
|
+
// alternatives of which something is written (a written empty string, a
|
|
1802
|
+
// copy of the page's own markup among them), `unwritten` those of which
|
|
1803
|
+
// nothing is: a value the analyser could not place at all.
|
|
1804
|
+
alternatives(n, scope) {
|
|
1805
|
+
const values = this.exact(n, scope);
|
|
1806
|
+
if (values) return { texts: values, placed: values.length, unwritten: 0 };
|
|
1807
|
+
const s = this.spelled(n, scope);
|
|
1808
|
+
if (s == null) return { texts: [], placed: 0, unwritten: 1 };
|
|
1809
|
+
const out = { texts: [], placed: 0, unwritten: 0 };
|
|
1810
|
+
for (const part of s.split('\u0000')) {
|
|
1811
|
+
const plain = part.replace(MARKERS, '');
|
|
1812
|
+
// An alternative with nothing written: a computed part. One that is a
|
|
1813
|
+
// written empty string is nothing either way (`x || ''` writes no
|
|
1814
|
+
// markup down); a copy of the page's own markup counts as written.
|
|
1815
|
+
if (plain === '' && part.includes(MARKER)) out.unwritten++;
|
|
1816
|
+
else if (plain === '' && !part.includes(COPY)) continue;
|
|
1817
|
+
else { out.placed++; const text = part.replace(COPIES, ''); if (text !== '') out.texts.push(text); }
|
|
1818
|
+
}
|
|
1819
|
+
return out;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// A member's property name when it is written down, here or in the const
|
|
1823
|
+
// the key is bound to: `obj[KEY]` with `const KEY = 'constructor'` is
|
|
1824
|
+
// obj.constructor, readable and judged as such. A name assigned several
|
|
1825
|
+
// plain literals (`many`) is no one key: null, and keyNames() has them all.
|
|
1826
|
+
key(member, scope) {
|
|
1827
|
+
const k = staticKey(member);
|
|
1828
|
+
if (k != null || !member.computed) return k;
|
|
1829
|
+
const p = unchain(member.property);
|
|
1830
|
+
if (p?.type !== 'Identifier') return null;
|
|
1831
|
+
const b = scope.resolve(p.name);
|
|
1832
|
+
return b && b.alias === 'string' && !b.built && !b.many && b.spelled != null ? b.spelled : null;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// Every plain literal a computed key's name was assigned (`let k = 'a'; k = 'b'`).
|
|
1836
|
+
keyNames(member, scope) {
|
|
1837
|
+
if (!member.computed) return [];
|
|
1838
|
+
const p = unchain(member.property);
|
|
1839
|
+
if (p?.type !== 'Identifier') return [];
|
|
1840
|
+
const b = scope.resolve(p.name);
|
|
1841
|
+
return b && b.alias === 'string' && !b.built && b.many && b.spelled != null ? b.spelled.split('\u0000') : [];
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// Does this expression name the global object, `document`, or a global by
|
|
1845
|
+
// name? Returns the global's name ('window' for the object itself, whichever
|
|
1846
|
+
// of its names was used), or null for anything local or unknown.
|
|
1847
|
+
globalName(n, scope, depth = 0, hops = 0) {
|
|
1848
|
+
n = unchain(n);
|
|
1849
|
+
// acorn reads a.b.c… chains in a loop, so one can be as long as a file;
|
|
1850
|
+
// no global is reached through more than a few steps. (Past the bound
|
|
1851
|
+
// the chain's object is read by the resolver below, which cuts and asks
|
|
1852
|
+
// what the expression mentions.)
|
|
1853
|
+
if (!n) return null;
|
|
1854
|
+
if (hops > 32) { this.hopsCut++; return null; }
|
|
1855
|
+
// (Kept per node once the scopes are built, for a node in its own scope, when no bound cut the answer short.)
|
|
1856
|
+
const keep = !this.pass1 && !scope.dynamic && scope.analysis === this && (n.type === 'MemberExpression' || n.type === 'CallExpression' || n.type === 'Identifier');
|
|
1857
|
+
if (keep && this.globalNames.has(n)) return this.globalNames.get(n);
|
|
1858
|
+
const cuts = this.hopsCut, steps = this.steps;
|
|
1859
|
+
const g = this.globalNameIn(n, scope, depth, hops);
|
|
1860
|
+
if (keep && this.hopsCut === cuts && steps > 0 && this.steps > 0) this.globalNames.set(n, g);
|
|
1861
|
+
return g;
|
|
1862
|
+
}
|
|
1863
|
+
globalNameIn(n, scope, depth, hops) {
|
|
1864
|
+
switch (n.type) {
|
|
1865
|
+
case 'Identifier': {
|
|
1866
|
+
const b = scope.resolve(n.name);
|
|
1867
|
+
if (!b) { const pool = this.globalsNamed(n.name, scope); return pool.length ? this.globalOfLeaves(this.resolve(n, scope, depth + 1), n, depth, hops + 1, scope) ?? n.name : n.name; }
|
|
1868
|
+
if (b.alias === 'name') return b.name;
|
|
1869
|
+
if (b.alias === 'value' || b.alias === 'import' || b.alias === 'arguments' || b.param) return this.globalOfLeaves(this.resolve(n, scope, depth + 1), n, depth, hops + 1, scope);
|
|
1870
|
+
return null;
|
|
1871
|
+
}
|
|
1872
|
+
case 'ThisExpression': return scope.thisGlobal ? 'window' : this.globalOfLeaves(this.resolve(n, scope, depth + 1), n, depth, hops + 1, scope);
|
|
1873
|
+
case 'MemberExpression': {
|
|
1874
|
+
const k = this.key(n, scope);
|
|
1875
|
+
// A frame's window is a window of the bundle's own origin (the
|
|
1876
|
+
// policy allows no other frame): the global object of another
|
|
1877
|
+
// realm, whatever holds the frame. A frame's document, and the
|
|
1878
|
+
// document any node belongs to, are documents of that origin.
|
|
1879
|
+
if (k === 'contentWindow') return 'window';
|
|
1880
|
+
if (k === 'contentDocument' || k === 'ownerDocument') return 'document';
|
|
1881
|
+
// Document.prototype[k].call(document, …), Navigator.prototype[k]: the interface's prototype holds the host
|
|
1882
|
+
// object's methods and getters; a member of it is judged as the host object's (write, serviceWorker, a computed name).
|
|
1883
|
+
if (k === 'prototype' && unchain(n.object).type === 'Identifier' && !scope.resolve(unchain(n.object).name) && INTERFACE_HOSTS.has(unchain(n.object).name)) return INTERFACE_HOSTS.get(unchain(n.object).name);
|
|
1884
|
+
const o = this.globalName(n.object, scope, depth, hops + 1);
|
|
1885
|
+
// The object the global object by a cut's over-approximation (a value read short that mentions one):
|
|
1886
|
+
// its member is as unread, and the global object whenever this expression mentions one.
|
|
1887
|
+
if (o != null && GLOBAL_OBJECTS.has(o) && unchain(n.object).type !== 'Identifier' && unchain(n.object).type !== 'ThisExpression' && this.resolve(n.object, scope, depth + 1).some((l) => l.cut)) { const g = this.globalMentioned(n, scope); if (g != null) return g; }
|
|
1888
|
+
if (o != null && k != null) {
|
|
1889
|
+
// frames[0] and window[0] are those frames' windows.
|
|
1890
|
+
if (GLOBAL_OBJECTS.has(o)) return /^\d+$/.test(k) ? 'window' : k;
|
|
1891
|
+
if (o === 'document' && k === 'defaultView') return 'window';
|
|
1892
|
+
if (o === 'document' && k === 'location') return 'location';
|
|
1893
|
+
}
|
|
1894
|
+
if (o != null && HOST_NAMES.has(o)) return null;
|
|
1895
|
+
// [window][0], { w: window }.w, o.w with o written, m.get('w') after m.set('w', window), an export of another file: the element written there (a getter's value is what it returns; a property under a computed key, or read by one, may be any of the values written, which refuses).
|
|
1896
|
+
return this.globalOfLeaves(this.resolve(n, scope, depth + 1), n, depth, hops + 1, scope);
|
|
1897
|
+
}
|
|
1898
|
+
case 'SequenceExpression': return this.globalName(n.expressions[n.expressions.length - 1], scope, depth, hops + 1);
|
|
1899
|
+
case 'ConditionalExpression': return this.eitherGlobal(n.consequent, n.alternate, scope, depth, hops + 1);
|
|
1900
|
+
case 'LogicalExpression': return this.eitherGlobal(n.left, n.right, scope, depth, hops + 1);
|
|
1901
|
+
case 'AwaitExpression': return this.globalName(n.argument, scope, depth, hops + 1);
|
|
1902
|
+
// `a ||= window` is a || window (a plain `=` is looked through by unchain).
|
|
1903
|
+
case 'AssignmentExpression': return ['||=', '??=', '&&='].includes(n.operator) ? this.eitherGlobal(n.left, n.right, scope, depth, hops + 1) : null;
|
|
1904
|
+
case 'ElementOf': return this.globalOfLeaves(this.resolve(n, scope, depth + 1), n, depth, hops + 1, scope);
|
|
1905
|
+
// An object whose prototype is the global object reads every global through the chain: `{ __proto__: window }[k]`.
|
|
1906
|
+
case 'ObjectExpression': {
|
|
1907
|
+
const proto = n.properties.find((p) => p.type === 'Property' && !p.computed && !p.shorthand && p.kind === 'init' && (p.key.type === 'Identifier' ? p.key.name : String(p.key.value)) === '__proto__');
|
|
1908
|
+
if (!proto) return null;
|
|
1909
|
+
const g = this.globalName(proto.value, scope, depth, hops + 1);
|
|
1910
|
+
return g != null && (GLOBAL_OBJECTS.has(g) || g === 'document') ? (g === 'document' ? 'document' : 'window') : null;
|
|
1911
|
+
}
|
|
1912
|
+
case 'CallExpression': {
|
|
1913
|
+
// (function () { return this })() is the global object in sloppy
|
|
1914
|
+
// code; so is (() => this)() where `this` is, and so is a
|
|
1915
|
+
// function returning `this` called with .call(window).
|
|
1916
|
+
const c = unchain(n.callee);
|
|
1917
|
+
if (this.returnsThis(c, scope)) return 'window';
|
|
1918
|
+
// .call(window), and .call(), .call(null), .apply(undefined): a
|
|
1919
|
+
// sloppy function's this is the global object for each of them.
|
|
1920
|
+
const receiver = n.arguments[0] ? unchain(n.arguments[0]) : null;
|
|
1921
|
+
const nullish = !receiver || (receiver.type === 'Literal' && receiver.value === null) || (receiver.type === 'Identifier' && receiver.name === 'undefined' && !scope.resolve('undefined')) || (receiver.type === 'UnaryExpression' && receiver.operator === 'void');
|
|
1922
|
+
if (c.type === 'MemberExpression' && ['call', 'apply'].includes(staticKey(c)) && this.returnsThis(unchain(c.object), scope) && (nullish || this.isGlobalObject(n.arguments[0], scope))) return 'window';
|
|
1923
|
+
// Reflect.apply(f, window, []) is f.call(window).
|
|
1924
|
+
if (c.type === 'MemberExpression' && staticKey(c) === 'apply' && this.globalName(c.object, scope, depth, hops + 1) === 'Reflect' && n.arguments[0] && this.returnsThis(unchain(n.arguments[0]), scope) && (!n.arguments[1] || this.isGlobalObject(n.arguments[1], scope) || ['Literal', 'UnaryExpression'].includes(unchain(n.arguments[1]).type))) return 'window';
|
|
1925
|
+
// Reflect.get(window, 'name') is window.name written long.
|
|
1926
|
+
const target = this.reflectRead(n, scope, depth, hops + 1);
|
|
1927
|
+
if (target && target.key != null) return target.key === 'document' ? 'document' : target.key;
|
|
1928
|
+
// Object.create(window), Object.setPrototypeOf(o, window), Reflect.setPrototypeOf(o, window): an
|
|
1929
|
+
// object whose prototype is the global object reads every global through the chain.
|
|
1930
|
+
if (c.type === 'MemberExpression') {
|
|
1931
|
+
const ck = staticKey(c), co = this.globalName(c.object, scope, depth, hops + 1);
|
|
1932
|
+
const protoArg = co === 'Object' && ck === 'create' ? n.arguments[0] : (co === 'Object' || co === 'Reflect') && ck === 'setPrototypeOf' ? n.arguments[1] : null;
|
|
1933
|
+
if (protoArg) { const g = this.globalName(protoArg, scope, depth, hops + 1); if (g != null && (GLOBAL_OBJECTS.has(g) || g === 'document')) return g === 'document' ? 'document' : 'window'; }
|
|
1934
|
+
}
|
|
1935
|
+
// A function written here returning the global object (by any of its
|
|
1936
|
+
// returns, its parameters bound to the call's arguments), an element
|
|
1937
|
+
// method handing it on ([window].pop(), Array.of(window)[0],
|
|
1938
|
+
// Object.values({ w: window })[0], await Promise.resolve(window)),
|
|
1939
|
+
// x.valueOf(): what the call resolves to.
|
|
1940
|
+
if (c.type === 'MemberExpression' && ['valueOf', 'toString'].includes(staticKey(c)) && n.arguments.length === 0) { const g = this.globalName(c.object, scope, depth, hops + 1); if (g != null && GLOBAL_OBJECTS.has(g)) return 'window'; }
|
|
1941
|
+
return this.globalOfLeaves(this.resolve(n, scope, depth + 1), n, depth, hops + 1, scope);
|
|
1942
|
+
}
|
|
1943
|
+
default: return null;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
// The global object among what a value resolves to, if any of its leaves
|
|
1947
|
+
// is one (the over-approximation that refuses); else a host global any
|
|
1948
|
+
// leaf is (the same over-approximation: a name that may hold navigator is
|
|
1949
|
+
// judged as navigator); else null. A value read short (a cut leaf among
|
|
1950
|
+
// them) is the global object whenever the expression, or anything its
|
|
1951
|
+
// names were given, mentions one: what was not read may be it.
|
|
1952
|
+
globalOfLeaves(leaves, n, depth = 0, hops = 0, scope = null, rounds = 0) {
|
|
1953
|
+
// A cut leaf is the expression left unread, in its scope, with the reads the resolver was making of it
|
|
1954
|
+
// (its position). It is read again from there, with the depth to spare (the cut was the depth running
|
|
1955
|
+
// out along one path; from where it stopped the resolver reads on, precisely and once per node), the
|
|
1956
|
+
// position's member reads applied to what it gives; when that too is cut, or the
|
|
1957
|
+
// position holds a call, what the expression mentions decides (globalMentioned). In pass 1 nothing is
|
|
1958
|
+
// claimed of a cut: the name is bound lazily and pass 2 asks again.
|
|
1959
|
+
for (const l of leaves) {
|
|
1960
|
+
if (!l.cut) continue;
|
|
1961
|
+
if (this.pass1) return null;
|
|
1962
|
+
if (rounds < 1 && l.node.type !== 'Cut' && l.scope && !hasCall(l.pos)) {
|
|
1963
|
+
// (The node left unread was never kept — a cut returns before anything is kept — so it is read afresh here.)
|
|
1964
|
+
const again = this.applyPos(this.resolve(l.node, l.scope), l.pos ?? null, l.node, l.scope);
|
|
1965
|
+
const g = this.globalOfLeaves(again, l.node, depth, hops, l.scope, rounds + 1);
|
|
1966
|
+
if (g != null) return g;
|
|
1967
|
+
if (!again.some((a) => a.cut)) continue; // (read through: not the global object)
|
|
1968
|
+
}
|
|
1969
|
+
const g = this.globalMentioned(l.node, l.scope ?? scope, l.pos ?? null);
|
|
1970
|
+
if (g != null) return g;
|
|
1971
|
+
}
|
|
1972
|
+
if (this.unread(leaves, n) || hops > 32) return null;
|
|
1973
|
+
let host = null;
|
|
1974
|
+
for (const l of leaves) {
|
|
1975
|
+
const x = l.node;
|
|
1976
|
+
let g = null;
|
|
1977
|
+
if (x.type === 'Identifier') { if (x.global) g = x.name; else { const b = l.scope.resolve(x.name); g = !b ? x.name : b.alias === 'name' ? b.name : null; } }
|
|
1978
|
+
else if (x.type === 'ThisExpression') g = l.scope.thisGlobal ? 'window' : null;
|
|
1979
|
+
else if (x.type === 'MemberExpression' || x.type === 'CallExpression' || x.type === 'ObjectExpression') { if (x !== n) g = this.globalName(x, l.scope, depth, hops + 1); }
|
|
1980
|
+
if (g != null && GLOBAL_OBJECTS.has(g)) return 'window';
|
|
1981
|
+
if (g != null && HOST_NAMES.has(g) && (host == null || g === 'document')) host = g;
|
|
1982
|
+
}
|
|
1983
|
+
return host;
|
|
1984
|
+
}
|
|
1985
|
+
// The member reads of a position applied to leaves: `.state` of each (a cut leaf keeps the reads still to
|
|
1986
|
+
// make); for globalOfLeaves, which reads a cut value again from where the resolver stopped.
|
|
1987
|
+
applyPos(leaves, pos, n, scope) {
|
|
1988
|
+
const steps = [];
|
|
1989
|
+
for (let p = pos; p; p = p.next) steps.push(p);
|
|
1990
|
+
for (let i = steps.length - 1; i >= 0; i--) {
|
|
1991
|
+
const k = steps[i].m;
|
|
1992
|
+
const out = [];
|
|
1993
|
+
for (const l of leaves) {
|
|
1994
|
+
if (l.cut) { out.push({ ...l, pos: posMember(k, l.pos ?? null) }); continue; }
|
|
1995
|
+
const m = this.membersOf(l, k, n, scope, 0);
|
|
1996
|
+
if (m === null) out.push(leaf(n, scope)); else out.push(...m);
|
|
1997
|
+
}
|
|
1998
|
+
leaves = this.union(out);
|
|
1999
|
+
}
|
|
2000
|
+
return leaves;
|
|
2001
|
+
}
|
|
2002
|
+
// The global the syntax of an expression mentions, or the syntax of what
|
|
2003
|
+
// its names were given (their values, a function's body, an import, a
|
|
2004
|
+
// call site's argument, a write read back): 'window' for the global
|
|
2005
|
+
// object under any of its names (also `this` where it may be the global,
|
|
2006
|
+
// `window[0]`, a frame's window), a host object's name, or null. What a
|
|
2007
|
+
// cut value may be: read when the resolver could not finish, so that a
|
|
2008
|
+
// value it did not read to the end never passes as not the global object.
|
|
2009
|
+
// `window.X` with X written is the global X, not the window: judged as X.
|
|
2010
|
+
//
|
|
2011
|
+
// `pos` is where the value is read: [] for the value itself, else the path
|
|
2012
|
+
// of reads the resolver was making of it when it cut — a member by key
|
|
2013
|
+
// ({ m: key }, key null when computed), a call ({ c: true }) — innermost
|
|
2014
|
+
// first. A literal object read for a key gives that property; read as a
|
|
2015
|
+
// value it is itself, whatever it holds (`outbox = { player: window.PLAYER
|
|
2016
|
+
// }` is not the window, and `outbox.request` reads only `request`); a
|
|
2017
|
+
// function called gives what it returns, in the call's position.
|
|
2018
|
+
//
|
|
2019
|
+
// Evaluated once per node and position over the whole analysis, in post
|
|
2020
|
+
// order with an explicit stack (an answer is kept per node and position),
|
|
2021
|
+
// so the many cuts a large file's resolution makes cost its size once, not
|
|
2022
|
+
// a walk each. A node in a call's scope (a parameter bound at one call
|
|
2023
|
+
// site) is read each time, in that scope. A cycle adds nothing; an answer
|
|
2024
|
+
// that leaned on a node still being read is not kept.
|
|
2025
|
+
globalMentioned(n, scope, pos = null) {
|
|
2026
|
+
n = unchain(n);
|
|
2027
|
+
if (!n || !scope) return 'window';
|
|
2028
|
+
if (this.pass1) return null;
|
|
2029
|
+
const key = posKey(pos);
|
|
2030
|
+
if (!scope.dynamic) { const m = this.mentioned.get(n); if (m && m.has(key)) return m.get(key); }
|
|
2031
|
+
// (The evaluation asks the resolver nothing, so it never re-enters; were it to, what it has not read may be anything.)
|
|
2032
|
+
if (this.mentioning) return 'window';
|
|
2033
|
+
this.mentioning = true;
|
|
2034
|
+
try { return this.globalMentionedIn(n, scope, pos); } finally { this.mentioning = false; }
|
|
2035
|
+
}
|
|
2036
|
+
globalMentionedIn(root, rootScope, rootPos) {
|
|
2037
|
+
const memoGet = (x, k) => this.mentioned.get(x)?.get(k);
|
|
2038
|
+
// (Kept for the shorter paths only: a long path is one of a chain the resolver cuts anyway, asked once.)
|
|
2039
|
+
const memoSet = (x, k, v, len) => { if (len > 8) return; let m = this.mentioned.get(x); if (!m) this.mentioned.set(x, (m = new Map())); if (m.size < 64) m.set(k, v); };
|
|
2040
|
+
const stack = [{ node: root, scope: rootScope, pos: rootPos, key: posKey(rootPos), i: -1, succ: null, result: null, tainted: false }];
|
|
2041
|
+
const active = new Map(); // node → Set of position keys being read
|
|
2042
|
+
const leaned = []; // answers of nothing that leaned on a node still being read: kept once the whole reading finds nothing
|
|
2043
|
+
let steps = 0, final = null;
|
|
2044
|
+
const finish = (f) => {
|
|
2045
|
+
stack.pop();
|
|
2046
|
+
if (f.i >= 0) {
|
|
2047
|
+
active.get(f.node)?.delete(f.key);
|
|
2048
|
+
if (!f.scope.dynamic && (f.result === 'window' || !f.tainted)) memoSet(f.node, f.key, f.result, f.pos?.len ?? 0);
|
|
2049
|
+
else if (!f.scope.dynamic && f.result == null) leaned.push(f);
|
|
2050
|
+
}
|
|
2051
|
+
const p = stack[stack.length - 1];
|
|
2052
|
+
if (!p) { final = f.result; return; }
|
|
2053
|
+
if (f.result === 'window') p.result = 'window';
|
|
2054
|
+
else if (f.result != null && p.result == null) p.result = f.result;
|
|
2055
|
+
if (f.tainted) p.tainted = true;
|
|
2056
|
+
};
|
|
2057
|
+
while (stack.length) {
|
|
2058
|
+
// (Not read to the end: what is left may mention anything. Nothing is kept of it.)
|
|
2059
|
+
if (steps++ > MENTION_STEPS) return 'window';
|
|
2060
|
+
const f = stack[stack.length - 1];
|
|
2061
|
+
if (f.i < 0) {
|
|
2062
|
+
const x = f.node;
|
|
2063
|
+
const known = f.scope.dynamic ? undefined : memoGet(x, f.key);
|
|
2064
|
+
if (known !== undefined) { f.result = known; finish(f); continue; }
|
|
2065
|
+
// (A cycle: the node again, at the same position or at one that reads it further and then as before —
|
|
2066
|
+
// `X = X.map(f)` read for `.map` of `.map` of … — adds nothing new.)
|
|
2067
|
+
if (active.has(x) && [...active.get(x)].some((k) => f.key === k || (f.key.endsWith(k) && (k === '' || f.key.charCodeAt(f.key.length - k.length - 1) === 124)))) { f.tainted = true; finish(f); continue; }
|
|
2068
|
+
if (!active.has(x)) active.set(x, new Set());
|
|
2069
|
+
active.get(x).add(f.key);
|
|
2070
|
+
// (A position path past the resolver's own depth is one it would have cut: what is read there may be anything.)
|
|
2071
|
+
const own = (f.pos?.len ?? 0) > RESOLVE_DEPTH ? { hit: 'window', succ: [] } : this.mentionsOwn(x, f.scope, f.pos);
|
|
2072
|
+
f.result = own.hit;
|
|
2073
|
+
f.succ = own.hit === 'window' ? [] : own.succ;
|
|
2074
|
+
f.i = 0;
|
|
2075
|
+
continue;
|
|
2076
|
+
}
|
|
2077
|
+
if (f.result === 'window' || f.i >= f.succ.length) { finish(f); continue; }
|
|
2078
|
+
const s = f.succ[f.i++];
|
|
2079
|
+
stack.push({ node: s.node, scope: s.scope, pos: s.pos, key: posKey(s.pos), i: -1, succ: null, result: null, tainted: false });
|
|
2080
|
+
}
|
|
2081
|
+
// (The whole reading found nothing: every node it read, cycles and all, reaches nothing. Had it found the
|
|
2082
|
+
// global object, a node that leaned on one still being read might reach it: those are not kept.)
|
|
2083
|
+
if (final == null) for (const f of leaned) memoSet(f.node, f.key, null, f.pos?.len ?? 0);
|
|
2084
|
+
return final;
|
|
2085
|
+
}
|
|
2086
|
+
// What one node says of itself in a position (`hit`: 'window', a host
|
|
2087
|
+
// object's name, or null) and what it is read through next (`succ`: nodes
|
|
2088
|
+
// with their scope and position), by the rules in globalMentioned.
|
|
2089
|
+
mentionsOwn(x, s, pos) {
|
|
2090
|
+
const succ = [];
|
|
2091
|
+
let hit = null;
|
|
2092
|
+
const head = pos, rest = pos?.next ?? null;
|
|
2093
|
+
const hostOf = (name) => { if (GLOBAL_OBJECTS.has(name)) return 'window'; if (HOST_NAMES.has(name)) return name; return null; };
|
|
2094
|
+
const take = (g) => { if (g === 'window') hit = 'window'; else if (g != null && hit == null) hit = g; };
|
|
2095
|
+
// (A node is read in its own scope when it opens one — a block's, a function's — else in its parent's.)
|
|
2096
|
+
const push = (node, sc, p) => { if (node && typeof node.type === 'string' && sc) succ.push({ node, scope: (sc.analysis ?? this).scopes.get(node) ?? sc, pos: p }); };
|
|
2097
|
+
const pushBinding = (b, bscope, p) => {
|
|
2098
|
+
switch (b.alias) {
|
|
2099
|
+
case 'value': for (const v of b.nodes) push(v.node, v.scope, p); for (const v of b.dropped ?? []) push(v.node, v.scope, p); break;
|
|
2100
|
+
case 'fn': push(b.node, b.scope ?? bscope, p); break;
|
|
2101
|
+
case 'import': { const t = this.followExport(b.from, b.name, bscope, 0); if (t?.binding) pushBinding(t.binding, t.scope, p); break; }
|
|
2102
|
+
case 'arguments': { const a = bscope.analysis ?? this; for (const site of a.sites.get(b.fn) ?? []) push({ type: 'ArrayExpression', elements: site.args }, site.scope, p); break; }
|
|
2103
|
+
default: break;
|
|
2104
|
+
}
|
|
2105
|
+
if (b.param) { const a = bscope.analysis ?? this; for (const site of a.sites.get(b.param.fn) ?? []) { const arg = site.args[b.param.index]; if (arg) push(arg, site.scope, p); } }
|
|
2106
|
+
};
|
|
2107
|
+
// A global by name, read in a position: the object itself, or `window.k` the global k (its pool bindings), `window[0]` a frame's window.
|
|
2108
|
+
const global = (name, p) => {
|
|
2109
|
+
if (!p) { take(hostOf(name)); return; }
|
|
2110
|
+
const h = p;
|
|
2111
|
+
if (h.c) return; // (the global object is not called; a host function's value the resolver reads as unread)
|
|
2112
|
+
if (!GLOBAL_OBJECTS.has(name)) { if (name === 'document' && (h.m === 'defaultView' || h.m == null)) take('window'); else if (name === 'document' && h.m === 'location') take('location'); return; }
|
|
2113
|
+
const k = h.m, r = p.next;
|
|
2114
|
+
if (k == null || /^\d+$/.test(k)) { take('window'); return; }
|
|
2115
|
+
if (k === 'document' || hostOf(k) != null) { global(k === 'document' ? 'document' : hostOf(k) === 'window' ? 'window' : k, r); return; }
|
|
2116
|
+
for (const g of this.globalsNamed(k, s)) pushBinding(g.binding, g.scope, r);
|
|
2117
|
+
};
|
|
2118
|
+
switch (x.type) {
|
|
2119
|
+
case 'Cut': take('window'); break;
|
|
2120
|
+
case 'ThisExpression': { if (s.thisGlobal) take('window'); else { const m = this.literalOfThis(s); if (m) push(m.node, m.scope, pos); } break; }
|
|
2121
|
+
case 'Identifier': {
|
|
2122
|
+
if (x.global) { global(x.name, pos); break; }
|
|
2123
|
+
const b = s.resolve(x.name);
|
|
2124
|
+
if (!b) { global(x.name, pos); for (const g of this.globalsNamed(x.name, s)) pushBinding(g.binding, g.scope, pos); break; }
|
|
2125
|
+
if (b.alias === 'name') { global(b.name, pos); for (const g of this.globalsNamed(b.name, s)) pushBinding(g.binding, g.scope, pos); break; }
|
|
2126
|
+
pushBinding(b, s, pos);
|
|
2127
|
+
break;
|
|
2128
|
+
}
|
|
2129
|
+
case 'MemberExpression': {
|
|
2130
|
+
const k = this.key(x, s);
|
|
2131
|
+
const obj = unchain(x.object);
|
|
2132
|
+
if (k === 'contentWindow') { global('window', pos); break; }
|
|
2133
|
+
if (k === 'contentDocument' || k === 'ownerDocument') { global('document', pos); break; }
|
|
2134
|
+
// A property read: what was written under that key, anywhere, by the writes' syntax alone — values in
|
|
2135
|
+
// this member's position — and the object, read for this key, then as this member is.
|
|
2136
|
+
const { writes, exhausted } = this.writesNamed(s.analysis ?? this, k, obj, s);
|
|
2137
|
+
if (exhausted) { take('window'); break; }
|
|
2138
|
+
for (const w of writes) push(w.value, w.scope, pos);
|
|
2139
|
+
push(x.object, s, posMember(k, pos));
|
|
2140
|
+
break;
|
|
2141
|
+
}
|
|
2142
|
+
case 'FunctionDeclaration': case 'FunctionExpression': case 'ArrowFunctionExpression': {
|
|
2143
|
+
// (A function called gives what it returns, read in the call's position; as a value or a member's object it is itself.)
|
|
2144
|
+
if (!head?.c) break;
|
|
2145
|
+
const owner = s.analysis ?? this;
|
|
2146
|
+
const inner = owner.scopes.get(x) ?? s;
|
|
2147
|
+
push(x.body, inner, rest);
|
|
2148
|
+
break;
|
|
2149
|
+
}
|
|
2150
|
+
case 'ObjectExpression': {
|
|
2151
|
+
if (!head || head.c) break;
|
|
2152
|
+
for (const p of x.properties) {
|
|
2153
|
+
if (p.type === 'SpreadElement') { push(p.argument, s, pos); continue; }
|
|
2154
|
+
if (p.type !== 'Property' || p.kind === 'set') continue;
|
|
2155
|
+
const name = p.computed ? null : p.key.type === 'Identifier' ? p.key.name : String(p.key.value);
|
|
2156
|
+
if (name === '__proto__' && !p.computed && !p.shorthand) { push(p.value, s, pos); continue; }
|
|
2157
|
+
if (head.m != null && name != null && name !== head.m) continue;
|
|
2158
|
+
if (p.kind === 'get') push(p.value, s, posCall(rest)); else push(p.value, s, rest);
|
|
2159
|
+
}
|
|
2160
|
+
break;
|
|
2161
|
+
}
|
|
2162
|
+
case 'ArrayExpression': {
|
|
2163
|
+
// (An element by index, or any; a method of the runtime's that hands elements on (pop, find, filter, map …),
|
|
2164
|
+
// called: its elements, as the call's value; any other member of a list is the runtime's: nothing.)
|
|
2165
|
+
if (!head || head.c) break;
|
|
2166
|
+
let at = rest;
|
|
2167
|
+
if (head.m != null && !/^\d+$/.test(head.m)) { if (!rest?.c || !(ELEMENT_METHODS.has(head.m) || LIST_METHODS.has(head.m) || head.m === 'reduce' || head.m === 'values' || head.m === 'entries')) break; at = rest.next; }
|
|
2168
|
+
for (const e of x.elements) { if (!e) continue; if (e.type === 'SpreadElement') push(e.argument, s, pos); else push(e, s, at); }
|
|
2169
|
+
break;
|
|
2170
|
+
}
|
|
2171
|
+
// Statements, walked for what a function returns: a return's value, in the function's position; the
|
|
2172
|
+
// statements inside a block, a branch, a loop, a try; nothing of an expression statement, a declaration,
|
|
2173
|
+
// a test or a throw (not the value returned — their names are reached through the returns that read them).
|
|
2174
|
+
case 'ReturnStatement': push(x.argument, s, pos); break;
|
|
2175
|
+
case 'BlockStatement': case 'StaticBlock': for (const st of x.body) push(st, s, pos); break;
|
|
2176
|
+
case 'IfStatement': push(x.consequent, s, pos); push(x.alternate, s, pos); break;
|
|
2177
|
+
case 'ForStatement': case 'ForInStatement': case 'ForOfStatement': case 'WhileStatement': case 'DoWhileStatement': case 'LabeledStatement': case 'WithStatement': push(x.body, s, pos); break;
|
|
2178
|
+
case 'SwitchStatement': for (const c of x.cases) for (const st of c.consequent) push(st, s, pos); break;
|
|
2179
|
+
case 'TryStatement': push(x.block, s, pos); push(x.handler?.body, s, pos); push(x.finalizer, s, pos); break;
|
|
2180
|
+
case 'ExpressionStatement': case 'VariableDeclaration': case 'ThrowStatement': case 'BreakStatement': case 'ContinueStatement': case 'EmptyStatement': case 'DebuggerStatement': case 'ImportDeclaration': case 'ExportNamedDeclaration': case 'ExportDefaultDeclaration': case 'ExportAllDeclaration': case 'ClassDeclaration':
|
|
2181
|
+
break;
|
|
2182
|
+
case 'NewExpression': case 'ClassExpression':
|
|
2183
|
+
// (An instance read into: what went into it may come out; as a value it is itself.)
|
|
2184
|
+
if (!head) break;
|
|
2185
|
+
for (const [key, child] of children(x)) { if (childCtx(x, key, 'expr') === 'skip') continue; push(child, s, posMember(null)); }
|
|
2186
|
+
break;
|
|
2187
|
+
case 'Literal': case 'TemplateLiteral': case 'TaggedTemplateExpression': case 'BinaryExpression': case 'UnaryExpression': case 'UpdateExpression':
|
|
2188
|
+
break; // (a primitive, whatever it is made of)
|
|
2189
|
+
case 'CallExpression': {
|
|
2190
|
+
// (The callee is read for what it returns, in this call's position. The arguments flow into its parameters,
|
|
2191
|
+
// and are reached through them where a return reads one: pushed here they would make every call's value
|
|
2192
|
+
// mention whatever any argument does. A host function has no body to read: its arguments may be its
|
|
2193
|
+
// value, as they are or read into — Promise.resolve(window), Object.values({ w: window })[0], Array.of(w).)
|
|
2194
|
+
push(x.callee, s, posCall(pos));
|
|
2195
|
+
const c = unchain(x.callee);
|
|
2196
|
+
const hostName = (id) => id.type === 'Identifier' && (!s.resolve(id.name) || s.resolve(id.name).alias === 'name') ? (s.resolve(id.name)?.name ?? id.name) : null;
|
|
2197
|
+
const passes = c.type === 'Identifier' ? hostName(c) === 'structuredClone' : c.type === 'MemberExpression' && hostName(unchain(c.object)) != null && (PASS_THROUGH.get(hostName(unchain(c.object))) ?? []).includes(staticKey(c)) && hostName(unchain(c.object)) !== 'JSON';
|
|
2198
|
+
if (passes) for (const a of x.arguments) { push(a, s, pos); push(a, s, posMember(null, pos)); }
|
|
2199
|
+
break;
|
|
2200
|
+
}
|
|
2201
|
+
case 'ElementOf': push(x.list, x.scope, posMember(x.index == null ? null : String(x.index), pos)); break;
|
|
2202
|
+
case 'Piece': push(x.of, x.scope, pos); break;
|
|
2203
|
+
case 'JsonValue': case 'PageMarkup': case 'PageElement': case 'Dataset': case 'Namespace': break;
|
|
2204
|
+
default: {
|
|
2205
|
+
for (const [key, child] of children(x)) { if (childCtx(x, key, 'expr') === 'skip') continue; if (child.type === 'Identifier' && x.type === 'VariableDeclarator' && key === 'id') continue; push(child, s, pos); }
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
return { hit, succ };
|
|
2209
|
+
}
|
|
2210
|
+
// The writes that may land on a member read, by their syntax alone (no
|
|
2211
|
+
// value followed): by a written key, every write of that key on anything,
|
|
2212
|
+
// and every write by a computed key on the same base name (or on `this`,
|
|
2213
|
+
// a call, anything unnamed); by a computed key, every write on the same
|
|
2214
|
+
// base name; this file's and (the pool) the others'. For globalMentioned,
|
|
2215
|
+
// which must not ask the resolver. Bounded: `exhausted` when there are too
|
|
2216
|
+
// many to walk.
|
|
2217
|
+
writesNamed(a, k, obj, scope) {
|
|
2218
|
+
const index = this.syntaxIndex(a);
|
|
2219
|
+
const ds = obj.type === 'MemberExpression' && this.key(obj, scope) === 'dataset';
|
|
2220
|
+
const key = k == null ? null : ds ? 'data-' + kebab(k) : ATTR_PROPS.get(k) ?? k;
|
|
2221
|
+
const base = this.baseOf(obj);
|
|
2222
|
+
const pool = a.pool?.writes ? this.poolIndex(a.pool) : null;
|
|
2223
|
+
const writes = [];
|
|
2224
|
+
let exhausted = false;
|
|
2225
|
+
const take = (list) => { if (!list) return; if (writes.length + list.length > WRITES * 4) { exhausted = true; return; } for (const w of list) writes.push(w); };
|
|
2226
|
+
if (key == null) {
|
|
2227
|
+
take(index.byBase.get(base));
|
|
2228
|
+
if (pool) take(pool.byBase.get(base));
|
|
2229
|
+
} else {
|
|
2230
|
+
take(index.byKey.get(key));
|
|
2231
|
+
take(index.computedByBase.get(base));
|
|
2232
|
+
if (base !== '*') take(index.computedByBase.get('*'));
|
|
2233
|
+
if (pool) {
|
|
2234
|
+
take(a.pool.writes.get(key));
|
|
2235
|
+
take(pool.computedByBase.get(base));
|
|
2236
|
+
if (base !== '*') take(pool.computedByBase.get('*'));
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
return { writes, exhausted };
|
|
2240
|
+
}
|
|
2241
|
+
// This file's writes by their syntactic key (every write of a written key, under that key), by the base name
|
|
2242
|
+
// they are on ('*' for `this` or an object not named), and those by a computed key by base, built once.
|
|
2243
|
+
syntaxIndex(a) {
|
|
2244
|
+
if (a.writesBySyntax) return a.writesBySyntax;
|
|
2245
|
+
const byKey = new Map(), byBase = new Map(), computedByBase = new Map();
|
|
2246
|
+
const put = (map, key, w) => { if (!map.has(key)) map.set(key, []); map.get(key).push(w); };
|
|
2247
|
+
for (const w of a.writes) {
|
|
2248
|
+
let key = null, target, element = false;
|
|
2249
|
+
if (w.node.type === 'AssignmentExpression') {
|
|
2250
|
+
const left = unchain(w.node.left);
|
|
2251
|
+
const lk = this.key(left, w.scope);
|
|
2252
|
+
const dsk = unchain(left.object).type === 'MemberExpression' && this.key(unchain(left.object), w.scope) === 'dataset';
|
|
2253
|
+
if (lk != null) key = dsk ? 'data-' + kebab(lk) : ATTR_PROPS.get(lk) ?? lk;
|
|
2254
|
+
target = left.object;
|
|
2255
|
+
} else {
|
|
2256
|
+
const c = unchain(w.node.callee), m = staticKey(c), arg = literalString(w.node.arguments[0]);
|
|
2257
|
+
if (m === 'replaceState' || m === 'pushState') key = 'state';
|
|
2258
|
+
else if ((m === 'set' || m === 'setAttribute' || m === 'setItem') && arg != null) key = m === 'setAttribute' ? arg.toLowerCase() : arg;
|
|
2259
|
+
else if (m === 'push' || m === 'unshift' || m === 'add') element = true;
|
|
2260
|
+
target = c.object;
|
|
2261
|
+
}
|
|
2262
|
+
const base = baseName(target);
|
|
2263
|
+
if (key != null) put(byKey, key, w); else if (!element) put(computedByBase, base, w);
|
|
2264
|
+
put(byBase, base, w);
|
|
2265
|
+
}
|
|
2266
|
+
a.writesBySyntax = { byKey, byBase, computedByBase };
|
|
2267
|
+
return a.writesBySyntax;
|
|
2268
|
+
}
|
|
2269
|
+
// The pool's writes by the base name they are on, and those by a computed key (not an element pushed) by base,
|
|
2270
|
+
// built once from the targets the wiring read.
|
|
2271
|
+
poolIndex(pool) {
|
|
2272
|
+
if (pool.index) return pool.index;
|
|
2273
|
+
const byBase = new Map(), computedByBase = new Map();
|
|
2274
|
+
const put = (map, key, w) => { if (!map.has(key)) map.set(key, []); map.get(key).push(w); };
|
|
2275
|
+
for (const list of pool.writes.values()) for (const w of list) {
|
|
2276
|
+
const t = this.targetOf(w);
|
|
2277
|
+
if (!t) continue;
|
|
2278
|
+
const base = t.root?.type === 'Identifier' ? t.root.name : '*';
|
|
2279
|
+
put(byBase, base, w);
|
|
2280
|
+
if (w.key == null && (w.node.type === 'AssignmentExpression' || !['push', 'unshift', 'add'].includes(staticKey(unchain(w.node.callee))))) put(computedByBase, base, w);
|
|
2281
|
+
}
|
|
2282
|
+
pool.index = { byBase, computedByBase };
|
|
2283
|
+
return pool.index;
|
|
2284
|
+
}
|
|
2285
|
+
// The leftmost name a member chain stands on, kept per link (see baseName).
|
|
2286
|
+
baseOf(n) {
|
|
2287
|
+
n = unchain(n);
|
|
2288
|
+
const path = [];
|
|
2289
|
+
let x = n;
|
|
2290
|
+
while (x && x.type === 'MemberExpression' && !this.bases.has(x)) { path.push(x); x = unchain(x.object); }
|
|
2291
|
+
const base = x && x.type === 'MemberExpression' ? this.bases.get(x) : x && x.type === 'Identifier' ? x.name : '*';
|
|
2292
|
+
for (const m of path) this.bases.set(m, base);
|
|
2293
|
+
return base;
|
|
2294
|
+
}
|
|
2295
|
+
// What a binding was given, for globalMentioned to walk: its expressions, its function, its import, its call sites' arguments.
|
|
2296
|
+
pushBinding(b, bscope, from, push, d) {
|
|
2297
|
+
switch (b.alias) {
|
|
2298
|
+
case 'value': for (const v of b.nodes) push(v.node, v.scope, from, d); break;
|
|
2299
|
+
case 'fn': push(b.node, b.scope ?? bscope, from, d); break;
|
|
2300
|
+
case 'import': { const t = this.followExport(b.from, b.name, bscope, 0); if (t?.binding) this.pushBinding(t.binding, t.scope, from, push, d); break; }
|
|
2301
|
+
case 'arguments': { const a = bscope.analysis ?? this; for (const site of a.sites.get(b.fn) ?? []) for (const arg of site.args) push(arg, site.scope, from, d); break; }
|
|
2302
|
+
default: break;
|
|
2303
|
+
}
|
|
2304
|
+
if (b.param) {
|
|
2305
|
+
const a = bscope.analysis ?? this;
|
|
2306
|
+
for (const site of a.sites.get(b.param.fn) ?? []) { const arg = site.args[b.param.index]; if (arg) push(arg, site.scope, from, d); }
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
// A function whose body is, or returns, `this`: the global object when
|
|
2310
|
+
// called bare in sloppy code (a FunctionExpression), or when `this` at the
|
|
2311
|
+
// arrow's definition is the global object.
|
|
2312
|
+
returnsThis(fn, scope, depth = 0) {
|
|
2313
|
+
if (!fn || depth > 8) return false;
|
|
2314
|
+
if (fn.type === 'FunctionExpression' || fn.type === 'FunctionDeclaration') return fn.body.body.some((s) => s.type === 'ReturnStatement' && unchain(s.argument)?.type === 'ThisExpression');
|
|
2315
|
+
if (fn.type === 'ArrowFunctionExpression') {
|
|
2316
|
+
const inner = this.scopes.get(fn);
|
|
2317
|
+
if (!(inner ? inner.thisGlobal : scope.thisGlobal)) return false;
|
|
2318
|
+
return fn.expression ? unchain(fn.body).type === 'ThisExpression' : fn.body.body.some((s) => s.type === 'ReturnStatement' && unchain(s.argument)?.type === 'ThisExpression');
|
|
2319
|
+
}
|
|
2320
|
+
// A function kept in a name (`function g() { return this }`, `const g = function …`), or the last of a sequence (`(0, function …)()`).
|
|
2321
|
+
if (fn.type === 'Identifier') { const b = scope.resolve(fn.name); return !!(b && b.alias === 'fn' && this.returnsThis(b.node, scope, depth + 1)); }
|
|
2322
|
+
if (fn.type === 'SequenceExpression') return this.returnsThis(unchain(fn.expressions[fn.expressions.length - 1]), scope, depth + 1);
|
|
2323
|
+
return false;
|
|
2324
|
+
}
|
|
2325
|
+
eitherGlobal(a, b, scope, depth, hops = 0) {
|
|
2326
|
+
const x = this.globalName(a, scope, depth, hops), y = this.globalName(b, scope, depth, hops);
|
|
2327
|
+
if (GLOBAL_OBJECTS.has(x)) return x;
|
|
2328
|
+
if (GLOBAL_OBJECTS.has(y)) return y;
|
|
2329
|
+
return null;
|
|
2330
|
+
}
|
|
2331
|
+
isGlobalObject(n, scope) { const g = this.globalName(n, scope); return g != null && (GLOBAL_OBJECTS.has(g) || g === 'document'); }
|
|
2332
|
+
|
|
2333
|
+
// Reflect.get(G, k) / Object.getOwnPropertyDescriptor(G, k) on the global
|
|
2334
|
+
// object: { key } when k is written down, { key: null } when computed; null
|
|
2335
|
+
// when the call is something else.
|
|
2336
|
+
reflectRead(call, scope, depth = 0, hops = 0) {
|
|
2337
|
+
const c = unchain(call.callee);
|
|
2338
|
+
if (c.type !== 'MemberExpression') return null;
|
|
2339
|
+
// (The method's name first: only Reflect's and Object's readers are read, so a chain of other calls is not descended.)
|
|
2340
|
+
const k = this.key(c, scope);
|
|
2341
|
+
if (!DESCRIPTOR_READERS.has(k) && k !== 'getOwnPropertyDescriptor' && k !== 'defineProperty') return null;
|
|
2342
|
+
// (The depth is the caller's: a chain of calls is one chain, however it is read.)
|
|
2343
|
+
const o = this.globalName(c.object, scope, depth + 1, hops + 1);
|
|
2344
|
+
const reads = (o === 'Reflect' && DESCRIPTOR_READERS.has(k)) || (o === 'Object' && (k === 'getOwnPropertyDescriptor' || k === 'defineProperty'));
|
|
2345
|
+
if (!reads || !call.arguments[0] || !this.isGlobalObject(call.arguments[0], scope)) return null;
|
|
2346
|
+
const prop = call.arguments[1];
|
|
2347
|
+
return { key: prop ? literalString(prop) : null };
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// Is this expression a string at run time, as far as syntax tells? For the
|
|
2351
|
+
// timer rule: a literal, a template, a concatenation with a literal in it,
|
|
2352
|
+
// a decoder's or a string method's result, a name bound to one of those.
|
|
2353
|
+
isStringy(n, scope, depth = 0) {
|
|
2354
|
+
n = unchain(n);
|
|
2355
|
+
if (!n || depth > 32) return false;
|
|
2356
|
+
switch (n.type) {
|
|
2357
|
+
case 'Literal': return typeof n.value === 'string';
|
|
2358
|
+
case 'TemplateLiteral': case 'TaggedTemplateExpression': return true;
|
|
2359
|
+
case 'BinaryExpression': return n.operator === '+' && (this.isStringy(n.left, scope, depth + 1) || this.isStringy(n.right, scope, depth + 1));
|
|
2360
|
+
case 'ConditionalExpression': return this.isStringy(n.consequent, scope, depth + 1) || this.isStringy(n.alternate, scope, depth + 1);
|
|
2361
|
+
case 'LogicalExpression': return this.isStringy(n.left, scope, depth + 1) || this.isStringy(n.right, scope, depth + 1);
|
|
2362
|
+
case 'SequenceExpression': return this.isStringy(n.expressions[n.expressions.length - 1], scope, depth + 1);
|
|
2363
|
+
case 'AssignmentExpression': return n.operator === '+=' && (this.isStringy(n.left, scope, depth + 1) || this.isStringy(n.right, scope, depth + 1)); // `s += 'x'` is a concatenation; `=` is looked through by unchain
|
|
2364
|
+
case 'Identifier': { const b = scope.resolve(n.name); return !!(b && b.alias === 'string'); }
|
|
2365
|
+
case 'CallExpression': {
|
|
2366
|
+
if (this.isDecoder(n, scope)) return true;
|
|
2367
|
+
const c = unchain(n.callee);
|
|
2368
|
+
if (c.type === 'Identifier' && this.globalName(c, scope) === 'String') return true;
|
|
2369
|
+
if (c.type === 'MemberExpression') { const k = staticKey(c); return ['join', 'concat', 'replace', 'replaceAll', 'toString', 'trim', 'slice', 'substring', 'substr', 'toLowerCase', 'toUpperCase', 'stringify', 'repeat', 'padStart', 'padEnd', 'normalize'].includes(k) || (k === 'raw' && this.globalName(c.object, scope) === 'String') || (k === 'createObjectURL' && this.globalName(c.object, scope) === 'URL'); }
|
|
2370
|
+
return false;
|
|
2371
|
+
}
|
|
2372
|
+
default: return false;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// atob(...), unescape(...), decodeURIComponent(...), String.fromCharCode(...), x.decode(...)
|
|
2377
|
+
isDecoder(n, scope) {
|
|
2378
|
+
n = unchain(n);
|
|
2379
|
+
if (!n || n.type !== 'CallExpression') return false;
|
|
2380
|
+
const c = unchain(n.callee);
|
|
2381
|
+
if (c.type === 'Identifier') return DECODERS.has(this.globalName(c, scope));
|
|
2382
|
+
if (c.type === 'MemberExpression') {
|
|
2383
|
+
const k = this.key(c, scope), o = this.globalName(c.object, scope);
|
|
2384
|
+
if (o === 'String' && (k === 'fromCharCode' || k === 'fromCodePoint')) return true;
|
|
2385
|
+
if (DECODERS.has(k) && GLOBAL_OBJECTS.has(o)) return true;
|
|
2386
|
+
return k === 'decode';
|
|
2387
|
+
}
|
|
2388
|
+
return false;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
// What the literal fragments of a string expression spell, with whatever is
|
|
2392
|
+
// computed left out: 'ev' + x + 'al' spells "eval"; ['c','onstructor'].join('')
|
|
2393
|
+
// spells "constructor"; atob('ZXZhbA==') spells "eval". null when nothing
|
|
2394
|
+
// is written down.
|
|
2395
|
+
// A computed part stands as a MARKER in its place (`'ev' + x + 'al'` spells
|
|
2396
|
+
// "eval"), so a tag walk sees where a word is missing; readers that
|
|
2397
|
+
// judge a name strip the markers (hiddenName: it spells "eval").
|
|
2398
|
+
spelled(n, scope, depth = 0) {
|
|
2399
|
+
n = unchain(n);
|
|
2400
|
+
if (!n) return null;
|
|
2401
|
+
if (n.type === 'Literal') return typeof n.value === 'string' ? n.value : typeof n.value === 'number' ? String(n.value) : n.value === undefined && n.raw === null ? 'undefined' : null;
|
|
2402
|
+
if (depth > 64 || this.spend() || !this.enter()) return null;
|
|
2403
|
+
try { return this.spelledIn(n, scope, depth); } finally { this.leave(); }
|
|
2404
|
+
}
|
|
2405
|
+
spelledIn(n, scope, depth) {
|
|
2406
|
+
switch (n.type) {
|
|
2407
|
+
case 'TemplateLiteral': {
|
|
2408
|
+
let out = '', any = false;
|
|
2409
|
+
for (let i = 0; i < n.quasis.length; i++) {
|
|
2410
|
+
out += n.quasis[i].value.cooked ?? '';
|
|
2411
|
+
if (i < n.expressions.length) { const e = this.spelled(n.expressions[i], scope, depth + 1); if (e != null) any = true; out += e ?? MARKER; }
|
|
2412
|
+
}
|
|
2413
|
+
return out.replace(MARKERS, '') === '' && !any && n.expressions.length ? null : out;
|
|
2414
|
+
}
|
|
2415
|
+
case 'TaggedTemplateExpression': return n.quasi.quasis.map((q) => q.value.raw).join(MARKER);
|
|
2416
|
+
case 'BinaryExpression': {
|
|
2417
|
+
if (n.operator !== '+') return null;
|
|
2418
|
+
const pieces = plusChain(n);
|
|
2419
|
+
if (pieces.length > EXACT_PIECES) return null;
|
|
2420
|
+
let out = '', any = false;
|
|
2421
|
+
for (const p of pieces) { const s = this.spelled(p, scope, depth + 1); if (s != null) any = true; out += s ?? MARKER; }
|
|
2422
|
+
return any ? out : null;
|
|
2423
|
+
}
|
|
2424
|
+
// Either branch: an alternative of which nothing is written stands as a computed part.
|
|
2425
|
+
case 'ConditionalExpression': { const a = this.spelled(n.consequent, scope, depth + 1), b = this.spelled(n.alternate, scope, depth + 1); return a == null && b == null ? null : `${a ?? MARKER}\u0000${b ?? MARKER}`; }
|
|
2426
|
+
case 'LogicalExpression': { const a = this.spelled(n.left, scope, depth + 1), b = this.spelled(n.right, scope, depth + 1); return a == null && b == null ? null : `${a ?? MARKER}\u0000${b ?? MARKER}`; }
|
|
2427
|
+
case 'SequenceExpression': return this.spelled(n.expressions[n.expressions.length - 1], scope, depth + 1);
|
|
2428
|
+
case 'AwaitExpression': return this.spelled(n.argument, scope, depth + 1);
|
|
2429
|
+
case 'AssignmentExpression': {
|
|
2430
|
+
// `obj[s += 'al']`: pass 1 joined the right side onto what the
|
|
2431
|
+
// name spelled, so the name is the whole; a member target spells
|
|
2432
|
+
// its right side only. A plain `=` is looked through by unchain.
|
|
2433
|
+
if (n.operator !== '+=') return null;
|
|
2434
|
+
return this.spelled(n.left, scope, depth + 1) ?? this.spelled(n.right, scope, depth + 1);
|
|
2435
|
+
}
|
|
2436
|
+
case 'ArrayExpression': { const parts = n.elements.map((e) => this.spelled(e?.type === 'SpreadElement' ? e.argument : e, scope, depth + 1)); return parts.every((p) => p == null) ? null : parts.map((p) => p ?? MARKER).join('\u0000'); }
|
|
2437
|
+
case 'Identifier': case 'MemberExpression': case 'CallExpression': {
|
|
2438
|
+
const d = this.spelledDirect(n, scope, depth);
|
|
2439
|
+
return d === undefined ? this.spelledLeaves(n, scope, depth) : d;
|
|
2440
|
+
}
|
|
2441
|
+
case 'NewExpression': return this.globalName(n.callee, scope) === 'URL' ? this.spelledUrl(n, 'href', scope, depth) : null;
|
|
2442
|
+
case 'ElementOf': return this.spelledLeaves(n, scope, depth);
|
|
2443
|
+
case 'JsonValue': return typeof n.value === 'string' ? n.value : typeof n.value === 'number' || typeof n.value === 'boolean' || n.value === null ? String(n.value) : null;
|
|
2444
|
+
case 'PageMarkup': return COPY;
|
|
2445
|
+
case 'Piece': { const inner = this.spelled(n.of, n.scope, depth + 1); return inner == null ? null : MARKER + inner; }
|
|
2446
|
+
default: return null;
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
// What a name, a member or a call spells on its own, before it is followed
|
|
2450
|
+
// (resolve): a string bound to the name; the page's address (its scheme
|
|
2451
|
+
// and root are the page's, the rest computed: `location.pathname` spells
|
|
2452
|
+
// "/" and a computed part); a URL object's parts; a decoder or a string
|
|
2453
|
+
// method of written pieces; `URL.createObjectURL` (a blob: URL). undefined
|
|
2454
|
+
// when it has no reading of its own and is followed instead; null when it
|
|
2455
|
+
// spells nothing (a host object's member, a method that may spell a word
|
|
2456
|
+
// its receiver did not — replace by a pattern, sort, normalize, repeat,
|
|
2457
|
+
// pad, a case change — when not exact: a computed part).
|
|
2458
|
+
spelledDirect(n, scope, depth) {
|
|
2459
|
+
switch (n.type) {
|
|
2460
|
+
case 'Identifier': {
|
|
2461
|
+
const b = scope.resolve(n.name);
|
|
2462
|
+
if (b && b.alias === 'string') return b.spelled ?? null;
|
|
2463
|
+
if (b && b.alias === 'number') return String(b.value);
|
|
2464
|
+
if (b && (b.alias === 'strings' || b.alias === 'fn' || b.alias === 'module')) return null;
|
|
2465
|
+
if (!b && n.name === 'undefined') return 'undefined';
|
|
2466
|
+
if (!b && !this.globalsNamed(n.name, scope).length) return null;
|
|
2467
|
+
return undefined;
|
|
2468
|
+
}
|
|
2469
|
+
case 'MemberExpression': {
|
|
2470
|
+
const k = this.key(n, scope), o = this.globalName(n.object, scope);
|
|
2471
|
+
if (o === 'location' && k != null) return ADDRESS_HEADS.get(k) ?? null;
|
|
2472
|
+
if (o === 'document' && k != null && DOCUMENT_ADDRESS.has(k)) return ADDRESS_HEADS.get(DOCUMENT_ADDRESS.get(k));
|
|
2473
|
+
const obj = unchain(n.object);
|
|
2474
|
+
if (k === 'url' && obj.type === 'MetaProperty') return ADDRESS_HEADS.get('href');
|
|
2475
|
+
if (obj.type === 'NewExpression' && this.globalName(obj.callee, scope) === 'URL' && k != null) return this.spelledUrl(obj, k, scope, depth);
|
|
2476
|
+
if (o != null && (HOST_NAMES.has(o) || GLOBAL_OBJECTS.has(o)) && !(GLOBAL_OBJECTS.has(o) && k != null && this.globalsNamed(k, scope).length)) return null;
|
|
2477
|
+
// A copy of markup the page already holds (el.innerHTML = other.innerHTML): judged where it was written; nothing of it to walk here.
|
|
2478
|
+
if (k != null && MARKUP_PROPS.has(k) && this.unread(this.resolve(n, scope), n)) return COPY;
|
|
2479
|
+
return undefined;
|
|
2480
|
+
}
|
|
2481
|
+
case 'CallExpression': {
|
|
2482
|
+
const c = unchain(n.callee);
|
|
2483
|
+
if (c.type === 'Identifier') {
|
|
2484
|
+
const g = this.globalName(c, scope), a = n.arguments[0] && literalString(n.arguments[0]);
|
|
2485
|
+
if (g === 'atob' && a != null) return decodeBase64(a);
|
|
2486
|
+
if ((g === 'decodeURIComponent' || g === 'decodeURI') && a != null) { try { return decodeURIComponent(a); } catch { return a; } }
|
|
2487
|
+
if (g === 'unescape' && a != null) return a.replace(/%([0-9a-f]{2})/gi, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
2488
|
+
if (g === 'String') return this.spelled(n.arguments[0], scope, depth + 1);
|
|
2489
|
+
if (g != null && (DECODERS.has(g) || HOST_NAMES.has(g) || GLOBAL_OBJECTS.has(g))) return null;
|
|
2490
|
+
return undefined;
|
|
2491
|
+
}
|
|
2492
|
+
if (c.type !== 'MemberExpression') return null;
|
|
2493
|
+
const k = this.key(c, scope), o = this.globalName(c.object, scope);
|
|
2494
|
+
if (o === 'String' && (k === 'fromCharCode' || k === 'fromCodePoint')) return fromCharCodes(n.arguments);
|
|
2495
|
+
if (o === 'URL' && k === 'createObjectURL') return 'blob:' + MARKER;
|
|
2496
|
+
if (o === 'JSON' && k === 'stringify') return null;
|
|
2497
|
+
// A list's elements, each a piece (Object.values(o), items.map(f), list.filter(f), Array.from(…), s.split(…)).
|
|
2498
|
+
if ((o === 'Object' && (k === 'values' || k === 'keys' || k === 'entries')) || (o === 'Array' && (k === 'from' || k === 'of')) || (o === 'Promise' && k === 'all')) return this.spelledList(n, scope, depth);
|
|
2499
|
+
if (o != null && (HOST_NAMES.has(o) || GLOBAL_OBJECTS.has(o))) return null;
|
|
2500
|
+
if (k === 'join') {
|
|
2501
|
+
const inner = this.spelled(c.object, scope, depth + 1);
|
|
2502
|
+
if (inner == null) return null;
|
|
2503
|
+
const sep = n.arguments[0] ? literalString(n.arguments[0]) ?? '' : ',';
|
|
2504
|
+
return inner.split('\u0000').join(sep);
|
|
2505
|
+
}
|
|
2506
|
+
if (k === 'map' || k === 'flatMap' || k === 'filter' || k === 'flat' || k === 'split' || k === 'match' || k === 'matchAll') return this.spelledList(n, scope, depth);
|
|
2507
|
+
if (k === 'reverse' || k === 'sort' || k === 'toSorted' || k === 'toReversed') { const l = this.exactList(n, scope, 0); return l ? l.join('\u0000') : this.spelled(c.object, scope, depth + 1); }
|
|
2508
|
+
if (k === 'slice' && n.arguments.length && this.exact(c.object, scope) == null) {
|
|
2509
|
+
// A string's slice keeps its pieces contiguous; a list's slice keeps whole elements.
|
|
2510
|
+
return this.spelled(c.object, scope, depth + 1);
|
|
2511
|
+
}
|
|
2512
|
+
if (k === 'replace' || k === 'replaceAll') {
|
|
2513
|
+
// A written string replaced by a written string: the pieces with the replacement made (the first occurrence may be in a computed part: both ways); a pattern not written is a computed part.
|
|
2514
|
+
const inner = this.spelled(c.object, scope, depth + 1);
|
|
2515
|
+
if (inner == null) return null;
|
|
2516
|
+
const from = n.arguments[0] && literalString(n.arguments[0]), to = n.arguments[1] && literalString(n.arguments[1]);
|
|
2517
|
+
if (from == null || to == null || to.includes('$') || unchain(n.arguments[0]).regex) return null;
|
|
2518
|
+
const replaced = inner.split('\u0000').map((part) => (k === 'replaceAll' ? part.split(from).join(to) : part.replace(from, to))).join('\u0000');
|
|
2519
|
+
return inner.includes(MARKER) && k === 'replace' ? `${inner}\u0000${replaced}` : replaced;
|
|
2520
|
+
}
|
|
2521
|
+
if (k === 'concat') {
|
|
2522
|
+
const inner = this.spelled(c.object, scope, depth + 1);
|
|
2523
|
+
const extra = n.arguments.map((a) => this.spelled(a, scope, depth + 1) ?? MARKER).join('');
|
|
2524
|
+
return inner == null && !extra ? null : (inner ?? '') + extra;
|
|
2525
|
+
}
|
|
2526
|
+
if (k != null && SUBSTRING_METHODS.has(k)) return this.spelled(c.object, scope, depth + 1);
|
|
2527
|
+
if (['repeat', 'padStart', 'padEnd', 'normalize', 'toLowerCase', 'toUpperCase', 'toLocaleLowerCase', 'toLocaleUpperCase', 'at', 'charAt', 'splice', 'with', 'toSpliced', 'stringify'].includes(k)) { const v = this.exact(n, scope); return v ? v.join('\u0000') : null; }
|
|
2528
|
+
return undefined;
|
|
2529
|
+
}
|
|
2530
|
+
default: return undefined;
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
// What a value spells through what it resolves to: each alternative a
|
|
2534
|
+
// branch, a computed part for one of which nothing is written.
|
|
2535
|
+
spelledLeaves(n, scope, depth) {
|
|
2536
|
+
const leaves = this.resolve(n, scope);
|
|
2537
|
+
if (this.unread(leaves, n)) return null;
|
|
2538
|
+
if (!leaves.length) return 'undefined';
|
|
2539
|
+
return leaves.map((l) => this.spelledLeaf(l.node, l.scope, depth + 1) ?? MARKER).join('\u0000');
|
|
2540
|
+
}
|
|
2541
|
+
spelledLeaf(x, s, depth) {
|
|
2542
|
+
if (x.type === 'Identifier' || x.type === 'MemberExpression' || x.type === 'CallExpression') { const d = this.spelledDirect(x, s, depth); return d === undefined ? null : d; }
|
|
2543
|
+
return this.spelled(x, s, depth);
|
|
2544
|
+
}
|
|
2545
|
+
// A list's elements as pieces (branches), each what it spells.
|
|
2546
|
+
spelledList(n, scope, depth) {
|
|
2547
|
+
const elements = this.elementsOf(n, scope, 0, null);
|
|
2548
|
+
if (elements.some((l) => l.node.type === 'ElementOf' && l.node.list === n)) return null;
|
|
2549
|
+
if (!elements.length) return '';
|
|
2550
|
+
return elements.map((l) => this.spelledLeaf(l.node, l.scope, depth + 1) ?? MARKER).join('\u0000');
|
|
2551
|
+
}
|
|
2552
|
+
// A URL object's part, as a written head: `new URL(rel, base).href` is on
|
|
2553
|
+
// the base's origin (a relative address against any base is hierarchical:
|
|
2554
|
+
// never javascript: or data:, which have no base), its pathname starts at
|
|
2555
|
+
// the root; an absolute written address is itself; a part of an address
|
|
2556
|
+
// not written down is computed.
|
|
2557
|
+
spelledUrl(url, k, scope, depth) {
|
|
2558
|
+
const [u] = url.arguments;
|
|
2559
|
+
const us = u ? this.spelled(u, scope, depth + 1) : null;
|
|
2560
|
+
if (us == null) return null;
|
|
2561
|
+
const heads = us.split('\u0000').map((part) => part.split(MARKER)[0].replace(/^[\u0000- ]+/, ''));
|
|
2562
|
+
const relative = heads.every((h) => h !== '' && !SCHEME.test(h) && !h.startsWith('//'));
|
|
2563
|
+
const absolute = heads.every((h) => /^https?:/i.test(h));
|
|
2564
|
+
if (k === 'href' || k === 'toString' || k === 'toJSON') return relative ? ADDRESS_HEADS.get('href') : absolute ? us : null;
|
|
2565
|
+
if (k === 'pathname') return relative || absolute ? ADDRESS_HEADS.get('pathname') : null;
|
|
2566
|
+
if (k === 'origin' || k === 'protocol' || k === 'host' || k === 'hostname' || k === 'port') return relative || absolute ? ADDRESS_HEADS.get(k) : null;
|
|
2567
|
+
return null;
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
// A property name computed at run time that spells one of the names a game
|
|
2571
|
+
// has no reason to hide: refused. `obj[key]` with a plain variable is not.
|
|
2572
|
+
hiddenName(prop, scope) {
|
|
2573
|
+
prop = unchain(prop);
|
|
2574
|
+
if (!prop) return null;
|
|
2575
|
+
if (escapedIdentifier(prop)) return `'${prop.raw.slice(1, -1)}' spells ${prop.value} in escapes`;
|
|
2576
|
+
if (this.isDecoder(prop, scope)) return 'a name decoded at run time';
|
|
2577
|
+
if (prop.type === 'Identifier') {
|
|
2578
|
+
// A name bound to a plain literal (`const tag = '[object Function]'`)
|
|
2579
|
+
// is readable; one bound to a built string is not.
|
|
2580
|
+
const b = scope.resolve(prop.name);
|
|
2581
|
+
if (!b || b.alias !== 'string' || !b.built) return null;
|
|
2582
|
+
if (b.decoded) return `${prop.name}, a name decoded at run time`;
|
|
2583
|
+
}
|
|
2584
|
+
const s = this.spelled(prop, scope);
|
|
2585
|
+
if (s == null || literalString(prop) != null) return null;
|
|
2586
|
+
// \u0000 separates the branches of a conditional or the values of a
|
|
2587
|
+
// reassigned name, MARKER stands where a part is computed: judged
|
|
2588
|
+
// joined and one by one.
|
|
2589
|
+
const whole = s.replace(MARKERS, ''), reversed = [...whole].reverse().join('');
|
|
2590
|
+
if (SENSITIVE_EXACT.has(whole)) return `spells ${whole}`;
|
|
2591
|
+
if (SENSITIVE_EXACT.has(reversed)) return `spells ${reversed} backwards`;
|
|
2592
|
+
for (const part of s.split('\u0000')) if (SENSITIVE_EXACT.has(part)) return `spells ${part}`;
|
|
2593
|
+
for (const token of (whole + '\u0000' + reversed).split(/[^\w$]+/)) if (SENSITIVE_TOKENS.has(token)) return `spells ${token}`;
|
|
2594
|
+
return null;
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
// A number an expression is at run time when it is written in literals:
|
|
2598
|
+
// 1000 * 60 * 60, 36e5, a const bound to one of those.
|
|
2599
|
+
fold(n, scope, depth = 0) {
|
|
2600
|
+
n = unchain(n);
|
|
2601
|
+
if (!n || depth > 12) return null;
|
|
2602
|
+
switch (n.type) {
|
|
2603
|
+
case 'Literal': return typeof n.value === 'number' ? n.value : null;
|
|
2604
|
+
case 'UnaryExpression': { const v = this.fold(n.argument, scope, depth + 1); return v == null ? null : n.operator === '-' ? -v : n.operator === '+' ? v : null; }
|
|
2605
|
+
case 'BinaryExpression': {
|
|
2606
|
+
const l = this.fold(n.left, scope, depth + 1), r = this.fold(n.right, scope, depth + 1);
|
|
2607
|
+
if (l == null || r == null) return null;
|
|
2608
|
+
switch (n.operator) { case '+': return l + r; case '-': return l - r; case '*': return l * r; case '/': return l / r; case '%': return l % r; case '**': return l ** r; default: return null; }
|
|
2609
|
+
}
|
|
2610
|
+
case 'Identifier': { const b = scope.resolve(n.name); return b && b.alias === 'number' ? b.value : null; }
|
|
2611
|
+
case 'SequenceExpression': return this.fold(n.expressions[n.expressions.length - 1], scope, depth + 1);
|
|
2612
|
+
default: return null;
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
// What a declaration's initialiser makes the name: a global by its name
|
|
2617
|
+
// (the global object under any of its names is 'window'), a spelled
|
|
2618
|
+
// string, a number.
|
|
2619
|
+
aliasInfo(init, scope) {
|
|
2620
|
+
if (!init) return {};
|
|
2621
|
+
const g = this.globalName(init, scope);
|
|
2622
|
+
if (g != null && (GLOBAL_OBJECTS.has(g) || HOST_NAMES.has(g) || unchain(init).type === 'Identifier' || unchain(init).type === 'MemberExpression')) return { alias: 'name', name: GLOBAL_OBJECTS.has(g) ? 'window' : g };
|
|
2623
|
+
const fn = unchain(init);
|
|
2624
|
+
if (fn && FUNCTIONS.has(fn.type)) return { alias: 'fn', node: fn, scope };
|
|
2625
|
+
// Why the value is text the analyser never read, carried with the name
|
|
2626
|
+
// to every markup or URL sink it reaches.
|
|
2627
|
+
const hidden = this.hiddenSource(init, scope);
|
|
2628
|
+
// (The address is outside text at a markup sink and a head of the page's scheme at a URL sink: a name holding it says which.)
|
|
2629
|
+
const addressOnly = hidden != null && this.hiddenSource(init, scope, { url: true }) == null;
|
|
2630
|
+
const with_ = (info) => (hidden ? { ...info, hidden, ...(addressOnly ? { addressOnly: true } : {}) } : info);
|
|
2631
|
+
if (this.isStringy(init, scope)) return with_({ alias: 'string', spelled: this.spelled(init, scope), decoded: this.isDecoder(init, scope), built: literalString(init) == null });
|
|
2632
|
+
const v = this.fold(init, scope);
|
|
2633
|
+
if (v != null) return { alias: 'number', value: v };
|
|
2634
|
+
// A list of written strings (`const attributes = ['title', 'alt']`): a
|
|
2635
|
+
// for…of or a forEach over it reads each name.
|
|
2636
|
+
const list = this.exactList(init, scope, 0);
|
|
2637
|
+
if (list && list.length && list.length <= EXACT_VALUES && unchain(init).type === 'ArrayExpression') return { alias: 'strings', values: list };
|
|
2638
|
+
// Anything else written: read lazily, where the name is used (resolve).
|
|
2639
|
+
if (fn && !['ThisExpression', 'UpdateExpression', 'UnaryExpression', 'ClassExpression'].includes(fn.type)) return with_({ alias: 'value', nodes: [{ node: fn, scope }] });
|
|
2640
|
+
return with_({});
|
|
2641
|
+
}
|
|
2642
|
+
// What a name takes from each element of a list of written strings: one
|
|
2643
|
+
// of them, read wherever the name is used (`many`, as a name assigned one
|
|
2644
|
+
// literal after another is).
|
|
2645
|
+
eachOf(values) {
|
|
2646
|
+
return values.length === 1 ? { alias: 'string', spelled: values[0], built: false } : { alias: 'string', spelled: values.join('\u0000'), built: false, many: true };
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// The names a binding pattern introduces with what each is bound to, read
|
|
2650
|
+
// off the initialiser: `const { eval: e } = window` binds e to window.eval,
|
|
2651
|
+
// `const [d] = [Date]` binds d to Date. A nested pattern reads through the
|
|
2652
|
+
// member it destructures; a rest element, a default, a pattern on anything
|
|
2653
|
+
// but an array literal bind names the analyser knows nothing about.
|
|
2654
|
+
patternAliases(pattern, init, scope) {
|
|
2655
|
+
const out = [];
|
|
2656
|
+
const stack = [[pattern, init]];
|
|
2657
|
+
while (stack.length) {
|
|
2658
|
+
const [p, from] = stack.pop();
|
|
2659
|
+
if (!p) continue;
|
|
2660
|
+
switch (p.type) {
|
|
2661
|
+
case 'Identifier': out.push([p, from ? this.aliasInfo(from, scope) : {}]); break;
|
|
2662
|
+
// `{ a = window } = {}`: the default is the value when the source yields undefined; a source
|
|
2663
|
+
// the analyser reads as a global, a string or a function is taken as is, any other source
|
|
2664
|
+
// and the default are both what the name may hold.
|
|
2665
|
+
case 'AssignmentPattern': {
|
|
2666
|
+
const read = from ? this.aliasInfo(from, scope) : {};
|
|
2667
|
+
if (!from || (!read.alias && !read.hidden)) { stack.push([p.left, p.right]); break; }
|
|
2668
|
+
if (read.alias && read.alias !== 'value') { stack.push([p.left, from]); break; }
|
|
2669
|
+
if (p.left.type !== 'Identifier') { stack.push([p.left, from]); break; }
|
|
2670
|
+
const dflt = this.aliasInfo(p.right, scope);
|
|
2671
|
+
const nodes = [...(read.alias === 'value' ? read.nodes : [{ node: from, scope }]), ...(dflt.alias === 'value' ? dflt.nodes : [{ node: p.right, scope }])];
|
|
2672
|
+
const hidden = read.hidden || dflt.hidden;
|
|
2673
|
+
out.push([p.left, { alias: 'value', nodes, ...(hidden ? { hidden } : {}) }]);
|
|
2674
|
+
break;
|
|
2675
|
+
}
|
|
2676
|
+
case 'RestElement': stack.push([p.argument, null]); break;
|
|
2677
|
+
case 'ObjectPattern': for (const prop of p.properties) stack.push(prop.type === 'RestElement' ? [prop.argument, null] : [prop.value, from && memberOf(from, prop)]); break;
|
|
2678
|
+
case 'ArrayPattern': {
|
|
2679
|
+
// `const [a] = [window]`, and `const [m, i] = await Promise.all([…])`: the elements written there;
|
|
2680
|
+
// `const [a] = t.split('|')`, `[x] = list`: the element the list resolves to, read lazily.
|
|
2681
|
+
const list = this.listOf(from);
|
|
2682
|
+
p.elements.forEach((e, i) => { if (!e) return; if (e.type === 'RestElement') stack.push([e.argument, null]); else stack.push([e, list && list[i]?.type !== 'SpreadElement' ? list[i] : from ? elementOf(from, scope, i, e) : null]); });
|
|
2683
|
+
break;
|
|
2684
|
+
}
|
|
2685
|
+
default: break; // a MemberExpression target in a destructuring assignment binds nothing
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
return out;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
// An assignment seen in pass 1: the target takes what it is assigned, so
|
|
2692
|
+
// `let w; w = window; w[k]` is the global object indexed, as `const w =
|
|
2693
|
+
// window` is. A global is taken whatever the target held before (the
|
|
2694
|
+
// over-approximation that refuses); a string joins what the name already
|
|
2695
|
+
// spelled (one plain literal after another is `many`, one of them read
|
|
2696
|
+
// as the key wherever the name is used; anything built marks the whole
|
|
2697
|
+
// as built). A target with no declaration is a property of the global
|
|
2698
|
+
// object: tracked in the function and at the root when it is given one
|
|
2699
|
+
// of the globals the rules judge, left alone otherwise.
|
|
2700
|
+
assigned(left, right, operator, scope) {
|
|
2701
|
+
const info = this.aliasInfo(right, scope);
|
|
2702
|
+
if (operator === '+=' || operator === '||=' || operator === '??=' || operator === '&&=') {
|
|
2703
|
+
if (operator !== '+=' && !info.alias && !info.hidden) return;
|
|
2704
|
+
} else if (operator !== '=') return;
|
|
2705
|
+
if (operator === '+=') {
|
|
2706
|
+
const b = scope.resolve(left.name);
|
|
2707
|
+
if (b && (b.alias === 'string' || this.isStringy(right, scope))) {
|
|
2708
|
+
const was = b.alias === 'string' ? b.spelled : null, decoded = !!(b.decoded || info.decoded), hidden = b.hidden || info.hidden;
|
|
2709
|
+
for (const k of Object.keys(b)) delete b[k];
|
|
2710
|
+
Object.assign(b, { alias: 'string', spelled: (was ?? '') + (this.spelled(right, scope) ?? MARKER), built: true, decoded, ...(hidden ? { hidden } : {}) });
|
|
2711
|
+
}
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
this.bind(left.name, info, scope, right);
|
|
2715
|
+
}
|
|
2716
|
+
// A name takes what it is given (`info`, as aliasInfo reads it; `node` the
|
|
2717
|
+
// expression, when there is one) wherever it was declared; one with no
|
|
2718
|
+
// declaration is a property of the global object, tracked in the function
|
|
2719
|
+
// and at the root when given one of the globals the rules judge.
|
|
2720
|
+
bind(name, info, scope, node = null) {
|
|
2721
|
+
const b = scope.resolve(name);
|
|
2722
|
+
if (!b) {
|
|
2723
|
+
// No declaration: a property of the global object, which other scripts
|
|
2724
|
+
// read (the pool). A host object's name (`location = url` moves the page,
|
|
2725
|
+
// `name = …` sets the window's) stays what it is unless given a global.
|
|
2726
|
+
if (!info.alias && !info.hidden) return;
|
|
2727
|
+
// `x = y` with y a name nobody binds says nothing of x.
|
|
2728
|
+
if (info.alias === 'name' && !HOST_NAMES.has(info.name) && !GLOBAL_OBJECTS.has(info.name) && !this.globalsNamed(info.name, scope).length) return;
|
|
2729
|
+
if ((HOST_NAMES.has(name) || GLOBAL_OBJECTS.has(name) || name === 'name' || name === 'history') && info.alias !== 'name') return;
|
|
2730
|
+
for (const s of new Set([scope.fnScope(), scope.root()])) s.declare(name, { ...info, global: true });
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
if (info.alias === 'name' && !HOST_NAMES.has(info.name) && !this.globalsNamed(info.name, scope).length && b.alias) return;
|
|
2734
|
+
if (!info.alias && !info.hidden) return;
|
|
2735
|
+
const param = b.param;
|
|
2736
|
+
// A name that holds a global (the global object, document, navigator, a global another script set) keeps
|
|
2737
|
+
// it whatever it is later given: `let w = window; if (c) w = {}; w[k]` indexes the window on one path, and
|
|
2738
|
+
// the analyser cannot tell which is taken. The global stays one of the name's values, read with the rest.
|
|
2739
|
+
if (b.alias === 'name' && info.alias !== 'name' && (GLOBAL_OBJECTS.has(b.name) || HOST_NAMES.has(b.name) || this.globalsNamed(b.name, scope).length)) {
|
|
2740
|
+
const kept = { node: { type: 'Identifier', name: b.name, global: GLOBAL_OBJECTS.has(b.name) || HOST_NAMES.has(b.name), ...at(node) }, scope: scope.root() };
|
|
2741
|
+
const nodes = [kept, ...(info.alias === 'value' ? info.nodes : info.alias === 'fn' ? [{ node: info.node, scope: info.scope ?? scope }] : node ? [{ node, scope }] : [])];
|
|
2742
|
+
const hidden = b.hidden || info.hidden;
|
|
2743
|
+
for (const k of Object.keys(b)) delete b[k];
|
|
2744
|
+
Object.assign(b, { alias: 'value', nodes });
|
|
2745
|
+
if (hidden) b.hidden = hidden;
|
|
2746
|
+
if (param) b.param = param;
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
if (!b.alias || info.alias === 'name' || info.alias === 'fn' || (b.alias === 'name' && !GLOBAL_OBJECTS.has(b.name))) { const hidden = b.hidden, addressOnly = b.addressOnly, cut = b.cut, dropped = b.dropped; for (const k of Object.keys(b)) delete b[k]; Object.assign(b, info); if (hidden && !b.hidden) { b.hidden = hidden; if (addressOnly) b.addressOnly = true; } else if (b.hidden && !info.addressOnly) delete b.addressOnly; if (param) b.param = param; if (cut) { b.cut = true; if (dropped) b.dropped = dropped; } return; }
|
|
2750
|
+
if (b.alias === 'string' && info.alias === 'string') {
|
|
2751
|
+
const plain = !b.built && !info.built;
|
|
2752
|
+
const spelled = b.spelled == null ? info.spelled : info.spelled == null ? b.spelled : `${b.spelled}\u0000${info.spelled}`;
|
|
2753
|
+
Object.assign(b, { spelled, built: !plain, many: plain, decoded: !!(b.decoded || info.decoded) });
|
|
2754
|
+
} else if (b.alias === 'value' && info.alias === 'value') {
|
|
2755
|
+
// One more expression the name may hold; past the bound the rest is not read: the name is marked
|
|
2756
|
+
// cut (unread at a sink), and holds the global object when any expression dropped mentions one.
|
|
2757
|
+
if (b.nodes.length >= VALUE_NODES) { b.cut = true; b.dropped = b.dropped ?? []; if (b.dropped.length < VALUE_NODES * 2) b.dropped.push(...info.nodes); }
|
|
2758
|
+
else b.nodes.push(...info.nodes);
|
|
2759
|
+
} else if (b.alias === 'value' || info.alias === 'value' || b.alias !== info.alias) {
|
|
2760
|
+
// A string then an object, a number then a list: the name holds either, read lazily.
|
|
2761
|
+
const nodes = [...(b.alias === 'value' ? b.nodes : b.nodes_ ?? []), ...(info.alias === 'value' ? info.nodes : [])];
|
|
2762
|
+
const hidden = b.hidden || info.hidden;
|
|
2763
|
+
if (b.alias !== 'value' && b.alias !== 'name') nodes.push(...(b.node ? [{ node: b.node, scope }] : []));
|
|
2764
|
+
for (const k of Object.keys(b)) delete b[k];
|
|
2765
|
+
Object.assign(b, nodes.length ? { alias: 'value', nodes } : {});
|
|
2766
|
+
if (hidden) b.hidden = hidden;
|
|
2767
|
+
if (param) b.param = param;
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
2770
|
+
if (info.hidden && !b.hidden) { b.hidden = info.hidden; if (info.addressOnly) b.addressOnly = true; }
|
|
2771
|
+
else if (info.hidden && b.addressOnly && !info.addressOnly) { b.hidden = info.hidden; delete b.addressOnly; }
|
|
2772
|
+
}
|
|
2773
|
+
// The elements an expression lists, for a pattern to read: an array
|
|
2774
|
+
// written down, or `await Promise.all([…])` (each element's settled value).
|
|
2775
|
+
listOf(from) {
|
|
2776
|
+
let n = unchain(from);
|
|
2777
|
+
if (n?.type === 'AwaitExpression') n = unchain(n.argument);
|
|
2778
|
+
if (n?.type === 'CallExpression') {
|
|
2779
|
+
const c = unchain(n.callee);
|
|
2780
|
+
if (c.type === 'MemberExpression' && ['all', 'allSettled'].includes(staticKey(c)) && this.globalName(c.object, this.root) === 'Promise') n = unchain(n.arguments[0]);
|
|
2781
|
+
else return null;
|
|
2782
|
+
}
|
|
2783
|
+
return n?.type === 'ArrayExpression' ? n.elements : null;
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
// ---- pass 1: scopes and declarations, so pass 2 can resolve every name ----
|
|
2787
|
+
|
|
2788
|
+
buildScopes(ast) {
|
|
2789
|
+
this.pass1 = true;
|
|
2790
|
+
const root = new Scope(null, true, true, this);
|
|
2791
|
+
this.scopes.set(ast, root);
|
|
2792
|
+
this.root = root;
|
|
2793
|
+
const stack = [{ node: ast, parent: null, scope: root, ctx: 'expr' }];
|
|
2794
|
+
while (stack.length) {
|
|
2795
|
+
const frame = stack.pop();
|
|
2796
|
+
const { node, parent, scope, ctx } = frame;
|
|
2797
|
+
// (Each node read with the resolver's budget of its own.)
|
|
2798
|
+
this.begin();
|
|
2799
|
+
// A declaration or an assignment is recorded after its children:
|
|
2800
|
+
// `a = (b = window, b)` binds b first, then reads it for a.
|
|
2801
|
+
if (frame.after) { this.record(node, scope); continue; }
|
|
2802
|
+
let inner = scope;
|
|
2803
|
+
switch (node.type) {
|
|
2804
|
+
case 'Program': inner = root; this.hoist(node.body, root); break;
|
|
2805
|
+
case 'Identifier':
|
|
2806
|
+
// A name read: a function read otherwise than as a callee is called from where the analyser does not see.
|
|
2807
|
+
if (ctx === 'expr' && !(parent?.type === 'AssignmentExpression' && parent.left === node && parent.operator === '=')) this.refs.push({ node, parent, grand: frame.grand, scope });
|
|
2808
|
+
break;
|
|
2809
|
+
case 'ObjectExpression':
|
|
2810
|
+
// An object handed to a function (an argument) may have its methods called from where the analyser does not see.
|
|
2811
|
+
if (parent && ((parent.type === 'CallExpression' || parent.type === 'NewExpression') && parent.arguments.includes(node) || parent.type === 'SpreadElement')) this.handed.push({ node, scope });
|
|
2812
|
+
break;
|
|
2813
|
+
// Markup the code writes as a string: its elements are elements of the page once written (a lookup finds them, the pool holds them).
|
|
2814
|
+
case 'Literal': if (typeof node.value === 'string' && MARKUP_IN_TEXT.test(node.value)) this.collectWritten(node.value); break;
|
|
2815
|
+
case 'TemplateLiteral': { if (node.expressions.length) { const t = node.quasis.map((q) => q.value.cooked ?? '').join(MARKER); if (MARKUP_IN_TEXT.test(t)) this.collectWritten(t); } break; }
|
|
2816
|
+
case 'FunctionDeclaration': case 'FunctionExpression': case 'ArrowFunctionExpression': {
|
|
2817
|
+
const arrow = node.type === 'ArrowFunctionExpression';
|
|
2818
|
+
// An arrow's this is its surroundings'. A method's this is its
|
|
2819
|
+
// object (a class member, an object literal's property, a
|
|
2820
|
+
// function assigned to a property such as X.prototype.m, one
|
|
2821
|
+
// given a receiver by .call/.apply/.bind). Any other function
|
|
2822
|
+
// (a declaration, an IIFE, a callback, a constructor) may be
|
|
2823
|
+
// called bare, and in a classic script its this is then the
|
|
2824
|
+
// global object: judged as such (the over-approximation that
|
|
2825
|
+
// refuses; in a module it would be undefined).
|
|
2826
|
+
inner = new Scope(scope, true, arrow ? scope.thisGlobal : !this.isMethod(node, parent), this);
|
|
2827
|
+
inner.node = node;
|
|
2828
|
+
this.scopes.set(node, inner);
|
|
2829
|
+
// A method of an object literal: its `this` is the literal (resolved as such).
|
|
2830
|
+
if (!arrow && parent?.type === 'Property' && parent.value === node && frame.grand?.type === 'ObjectExpression') this.methodOf.set(node, { node: frame.grand, scope });
|
|
2831
|
+
// `arguments` of a function that is not an arrow: the arguments written at its call sites.
|
|
2832
|
+
if (!arrow) inner.declare('arguments', { alias: 'arguments', fn: node });
|
|
2833
|
+
if (node.id) (node.type === 'FunctionDeclaration' ? scope : inner).declare(node.id.name, node.type === 'FunctionDeclaration' ? { alias: 'fn', node, scope } : {});
|
|
2834
|
+
// (function (root) { … })(window): the parameter is the window inside.
|
|
2835
|
+
const iife = parent && parent.type === 'CallExpression' && parent.callee === node;
|
|
2836
|
+
// ['title', 'alt'].forEach((name) => …): the first parameter is one of the names; list.forEach((item) => …): an element of the list.
|
|
2837
|
+
const each = this.paramStrings.get(node), list = this.paramLists.get(node);
|
|
2838
|
+
// fetch(u).then((r) => r.text()).then((t) => …), addEventListener('message', (e) => …): the first parameter is text from outside.
|
|
2839
|
+
const hidden = this.paramHidden.get(node);
|
|
2840
|
+
// p.then((v) => …): the first parameter is the chain's value (read lazily: what the receiver resolves to).
|
|
2841
|
+
const given = this.paramValues.get(node);
|
|
2842
|
+
node.params.forEach((p, i) => {
|
|
2843
|
+
const param = { fn: node, index: i };
|
|
2844
|
+
if (i === 0 && each && p.type === 'Identifier') { inner.declare(p.name, { ...this.eachOf(each), param }); this.countName(p.name); return; }
|
|
2845
|
+
if (i === 0 && hidden && p.type === 'Identifier') { inner.declare(p.name, { hidden, param }); this.countName(p.name); return; }
|
|
2846
|
+
if (list && i === (list.index ?? 0) && p.type === 'Identifier') { inner.declare(p.name, { ...this.aliasInfo(elementOf(list.list, list.scope, null, p), list.scope), param }); this.countName(p.name); return; }
|
|
2847
|
+
if (i === 0 && given && p.type === 'Identifier') { inner.declare(p.name, { alias: 'value', nodes: [{ node: given.node, scope: given.scope }], param }); this.countName(p.name); return; }
|
|
2848
|
+
const from = iife ? parent.arguments[i] : list && i === (list.index ?? 0) ? elementOf(list.list, list.scope, null, p) : given && i === 0 ? given.node : null;
|
|
2849
|
+
for (const [id, info] of this.patternAliases(p, from, iife ? scope : list ? list.scope : given && i === 0 ? given.scope : scope)) {
|
|
2850
|
+
const plain = p.type === 'Identifier' || (p.type === 'AssignmentPattern' && p.left === id);
|
|
2851
|
+
const declared = info.alias === 'name' ? { alias: 'name', name: info.name } : info.hidden ? { hidden: info.hidden } : {};
|
|
2852
|
+
if (info.alias && info.alias !== 'name' && from) Object.assign(declared, info);
|
|
2853
|
+
// (A destructured parameter is read from each call site's argument through its pattern.)
|
|
2854
|
+
declared.param = plain ? param : { ...param, pattern: p, id };
|
|
2855
|
+
if (i === 0 && hidden) declared.hidden = hidden;
|
|
2856
|
+
inner.declare(id.name, declared);
|
|
2857
|
+
this.countName(id.name);
|
|
2858
|
+
}
|
|
2859
|
+
});
|
|
2860
|
+
break;
|
|
2861
|
+
}
|
|
2862
|
+
case 'ForOfStatement': {
|
|
2863
|
+
// for (const name of ['title', 'alt']), or of a const bound to such a list: the name is one of them.
|
|
2864
|
+
// for (const w of [window]): the name is a global written in the list (the first; refuses).
|
|
2865
|
+
// for (const p of parts): an element of the list, read lazily.
|
|
2866
|
+
inner = new Scope(scope, false, scope.thisGlobal, this); this.scopes.set(node, inner);
|
|
2867
|
+
const list = unchain(node.right)?.type === 'ArrayExpression' || unchain(node.right)?.type === 'Identifier' ? this.exactList(node.right, scope, 0) : null;
|
|
2868
|
+
const elements = unchain(node.right)?.type === 'ArrayExpression' ? unchain(node.right).elements.filter((e) => e && e.type !== 'SpreadElement') : [];
|
|
2869
|
+
const global = elements.map((e) => this.globalName(e, scope)).find((g) => g != null && (GLOBAL_OBJECTS.has(g) || HOST_NAMES.has(g)));
|
|
2870
|
+
const info = list && list.length && list.length <= EXACT_VALUES ? this.eachOf(list) : global ? { alias: 'name', name: GLOBAL_OBJECTS.has(global) ? 'window' : global } : this.aliasInfo(elementOf(node.right, scope, null, node.left), scope);
|
|
2871
|
+
if (node.left.type === 'VariableDeclaration' && node.left.declarations.length === 1) {
|
|
2872
|
+
const id = node.left.declarations[0].id;
|
|
2873
|
+
const target = node.left.kind === 'var' ? scope.fnScope() : inner;
|
|
2874
|
+
if (id.type === 'Identifier') { target.declare(id.name, info); this.countName(id.name); }
|
|
2875
|
+
else for (const [pid, pinfo] of this.patternAliases(id, elementOf(node.right, scope, null, id), scope)) { target.declare(pid.name, pinfo); this.countName(pid.name); }
|
|
2876
|
+
} else if (node.left.type === 'Identifier') this.bind(node.left.name, info, scope);
|
|
2877
|
+
break;
|
|
2878
|
+
}
|
|
2879
|
+
case 'CallExpression': case 'NewExpression': {
|
|
2880
|
+
const c = unchain(node.callee);
|
|
2881
|
+
this.calls.push({ node, scope });
|
|
2882
|
+
if (node.type === 'NewExpression') break;
|
|
2883
|
+
if (c.type === 'MemberExpression' && (EACH_METHODS.has(staticKey(c)) || staticKey(c) === 'reduce') && node.arguments[0] && FUNCTIONS.has(node.arguments[0].type)) {
|
|
2884
|
+
const list = this.exactList(c.object, scope, 0);
|
|
2885
|
+
if (list && list.length && list.length <= EXACT_VALUES && staticKey(c) !== 'reduce') this.paramStrings.set(node.arguments[0], list);
|
|
2886
|
+
else this.paramLists.set(node.arguments[0], { list: c.object, scope, index: staticKey(c) === 'reduce' ? 1 : 0 });
|
|
2887
|
+
}
|
|
2888
|
+
// p.then((value) => …): the value is the chain's, text from outside
|
|
2889
|
+
// when the chain fetched it. A chain is read once, from its base
|
|
2890
|
+
// up (its outermost link is visited first), so a chain of any
|
|
2891
|
+
// length costs its length; once text from outside flows in, every
|
|
2892
|
+
// later callback's parameter is that text (a callback that turns
|
|
2893
|
+
// it into something else is not read: the over-approximation refuses).
|
|
2894
|
+
if (c.type === 'MemberExpression' && ['then', 'catch', 'finally'].includes(staticKey(c)) && !this.thenRead.has(node)) {
|
|
2895
|
+
const links = [];
|
|
2896
|
+
let base = node;
|
|
2897
|
+
while (base && base.type === 'CallExpression') {
|
|
2898
|
+
const cc = unchain(base.callee);
|
|
2899
|
+
if (cc.type !== 'MemberExpression' || !['then', 'catch', 'finally'].includes(staticKey(cc))) break;
|
|
2900
|
+
links.push(base); base = unchain(cc.object);
|
|
2901
|
+
}
|
|
2902
|
+
let why = this.outside(base, scope);
|
|
2903
|
+
for (let i = links.length - 1; i >= 0; i--) {
|
|
2904
|
+
const link = links[i];
|
|
2905
|
+
this.thenRead.add(link);
|
|
2906
|
+
const arg = link.arguments.find((a) => FUNCTIONS.has(unchain(a)?.type) || unchain(a)?.type === 'Identifier');
|
|
2907
|
+
if (!arg) continue;
|
|
2908
|
+
if (why) this.handlers.push({ arg, scope, why });
|
|
2909
|
+
if (staticKey(unchain(link.callee)) !== 'then') continue;
|
|
2910
|
+
// The callback's first parameter is the chain's value so far: Promise.resolve(window).then((w) => w[k]) indexes the window.
|
|
2911
|
+
if (FUNCTIONS.has(unchain(arg).type) && unchain(link.callee).object) this.paramValues.set(unchain(arg), { node: unchain(link.callee).object, scope });
|
|
2912
|
+
const callback = this.callbackOf(arg, scope);
|
|
2913
|
+
why = why || (callback ? this.returnsOf(callback.node).map((r) => this.outside(r, this.scopes.get(callback.node) ?? scope)).find(Boolean) : null) || null;
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
// addEventListener('message', handler), in every spelling: bare or on any target, the
|
|
2917
|
+
// handler inline or by name, the event's name written, spelled or not written (then it
|
|
2918
|
+
// may be 'message'): the handler's first parameter is another window's text.
|
|
2919
|
+
const listens = (c.type === 'MemberExpression' && staticKey(c) === 'addEventListener') || (c.type === 'Identifier' && this.globalName(c, scope) === 'addEventListener');
|
|
2920
|
+
if (listens && node.arguments[1]) {
|
|
2921
|
+
const names = node.arguments[0] ? this.exact(node.arguments[0], scope) : null;
|
|
2922
|
+
if (!names || names.some((e) => /^message(?:error)?$/i.test(e))) this.handlers.push({ arg: node.arguments[1], scope, why: 'a message from another window' });
|
|
2923
|
+
}
|
|
2924
|
+
// A write read back elsewhere: m.set(k, v), arr.push(v), el.setAttribute(name, v), storage.setItem(k, v), history.replaceState(state).
|
|
2925
|
+
if (c.type === 'MemberExpression' && ['set', 'push', 'unshift', 'add', 'setAttribute', 'setItem', 'replaceState', 'pushState'].includes(staticKey(c))) {
|
|
2926
|
+
const value = staticKey(c) === 'set' || staticKey(c) === 'setAttribute' || staticKey(c) === 'setItem' ? node.arguments[1] : node.arguments[0];
|
|
2927
|
+
if (value && this.writes.length < WRITES) this.writes.push({ node, value, scope, target: undefined });
|
|
2928
|
+
}
|
|
2929
|
+
break;
|
|
2930
|
+
}
|
|
2931
|
+
case 'ClassDeclaration': case 'ClassExpression':
|
|
2932
|
+
inner = new Scope(scope, false, false, this);
|
|
2933
|
+
this.scopes.set(node, inner);
|
|
2934
|
+
if (node.id) { inner.declare(node.id.name); if (node.type === 'ClassDeclaration') scope.declare(node.id.name); this.countName(node.id.name); }
|
|
2935
|
+
break;
|
|
2936
|
+
case 'StaticBlock': inner = new Scope(scope, false, false, this); this.scopes.set(node, inner); break;
|
|
2937
|
+
case 'BlockStatement': case 'ForStatement': case 'ForInStatement': case 'SwitchStatement':
|
|
2938
|
+
inner = new Scope(scope, false, scope.thisGlobal, this); this.scopes.set(node, inner);
|
|
2939
|
+
if (node.type === 'BlockStatement') this.hoist(node.body, inner);
|
|
2940
|
+
break;
|
|
2941
|
+
case 'CatchClause':
|
|
2942
|
+
inner = new Scope(scope, false, scope.thisGlobal, this); this.scopes.set(node, inner);
|
|
2943
|
+
if (node.param) for (const id of patternNames(node.param)) { inner.declare(id.name); this.countName(id.name); }
|
|
2944
|
+
break;
|
|
2945
|
+
case 'VariableDeclaration': case 'AssignmentExpression':
|
|
2946
|
+
// Recorded once the children are walked (the frame below the children pops after them).
|
|
2947
|
+
stack.push({ node, parent, scope, after: true });
|
|
2948
|
+
if (node.type === 'AssignmentExpression') {
|
|
2949
|
+
// x.onmessage = handler: the handler's first parameter is another window's text.
|
|
2950
|
+
if ((node.left.type === 'MemberExpression' && staticKey(node.left) === 'onmessage') || (node.left.type === 'Identifier' && node.left.name === 'onmessage')) this.handlers.push({ arg: node.right, scope, why: 'a message from another window' });
|
|
2951
|
+
// o.k = v, el.dataset.x = v, this.x = v: a write read back where the same thing is read.
|
|
2952
|
+
if (unchain(node.left).type === 'MemberExpression' && this.writes.length < WRITES) this.writes.push({ node, value: node.right, scope, target: undefined });
|
|
2953
|
+
}
|
|
2954
|
+
break;
|
|
2955
|
+
case 'ImportDeclaration': {
|
|
2956
|
+
const from = node.source.value;
|
|
2957
|
+
this.imports.push(from);
|
|
2958
|
+
for (const s of node.specifiers) {
|
|
2959
|
+
if (s.type === 'ImportNamespaceSpecifier') root.declare(s.local.name, { alias: 'module', from });
|
|
2960
|
+
else root.declare(s.local.name, { alias: 'import', from, name: s.type === 'ImportDefaultSpecifier' ? 'default' : s.imported.type === 'Identifier' ? s.imported.name : String(s.imported.value) });
|
|
2961
|
+
this.countName(s.local.name);
|
|
2962
|
+
}
|
|
2963
|
+
break;
|
|
2964
|
+
}
|
|
2965
|
+
case 'ExportNamedDeclaration': {
|
|
2966
|
+
const d = node.declaration;
|
|
2967
|
+
if (d?.type === 'VariableDeclaration') for (const decl of d.declarations) for (const id of patternNames(decl.id)) this.exports.set(id.name, { local: id.name });
|
|
2968
|
+
else if (d?.id) this.exports.set(d.id.name, { local: d.id.name });
|
|
2969
|
+
for (const s of node.specifiers) {
|
|
2970
|
+
const exported = s.exported.type === 'Identifier' ? s.exported.name : String(s.exported.value);
|
|
2971
|
+
const local = s.local.type === 'Identifier' ? s.local.name : String(s.local.value);
|
|
2972
|
+
this.exports.set(exported, node.source ? { from: node.source.value, name: local } : { local });
|
|
2973
|
+
}
|
|
2974
|
+
break;
|
|
2975
|
+
}
|
|
2976
|
+
case 'ExportDefaultDeclaration': {
|
|
2977
|
+
const d = node.declaration;
|
|
2978
|
+
if (d.type === 'FunctionDeclaration' || d.type === 'ClassDeclaration') { if (d.id) this.exports.set('default', { local: d.id.name }); else { this.exports.set('default', { local: '*default*' }); if (d.type === 'FunctionDeclaration') root.declare('*default*', { alias: 'fn', node: d, scope: root }); } }
|
|
2979
|
+
else { this.exports.set('default', { local: '*default*' }); stack.push({ node, parent, scope, after: true }); }
|
|
2980
|
+
break;
|
|
2981
|
+
}
|
|
2982
|
+
case 'ExportAllDeclaration':
|
|
2983
|
+
if (node.exported) this.exports.set(node.exported.type === 'Identifier' ? node.exported.name : String(node.exported.value), { from: node.source.value, name: '*' });
|
|
2984
|
+
else this.stars.push(node.source.value);
|
|
2985
|
+
break;
|
|
2986
|
+
default: break;
|
|
2987
|
+
}
|
|
2988
|
+
// Children in source order (the stack pops the last pushed first):
|
|
2989
|
+
// a declaration's initialiser sees the names declared before it, so
|
|
2990
|
+
// `const a = window; const b = a` makes b the window too.
|
|
2991
|
+
const kids = [...children(node)];
|
|
2992
|
+
for (let i = kids.length - 1; i >= 0; i--) {
|
|
2993
|
+
const childContext = childCtx(node, kids[i][0], ctx);
|
|
2994
|
+
stack.push({ node: kids[i][1], parent: node, grand: parent, scope: inner, ctx: childContext });
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
this.finishScopes();
|
|
2998
|
+
this.pass1 = false;
|
|
2999
|
+
}
|
|
3000
|
+
// What a declaration or an assignment binds, read once its right side has
|
|
3001
|
+
// been walked (so an assignment inside it is recorded first).
|
|
3002
|
+
record(node, scope) {
|
|
3003
|
+
this.begin();
|
|
3004
|
+
if (node.type === 'VariableDeclaration') {
|
|
3005
|
+
const target = node.kind === 'var' ? scope.fnScope() : scope;
|
|
3006
|
+
for (const d of node.declarations) {
|
|
3007
|
+
for (const [id, info] of this.patternAliases(d.id, d.init, scope)) { target.declare(id.name, info); this.countName(id.name); }
|
|
3008
|
+
}
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
3011
|
+
if (node.type === 'ExportDefaultDeclaration') { this.root.declare('*default*', this.aliasInfo(node.declaration, scope)); return; }
|
|
3012
|
+
const left = unchain(node.left);
|
|
3013
|
+
if (left.type === 'Identifier') this.assigned(left, node.right, node.operator, scope);
|
|
3014
|
+
// [a] = [window], ({ a } = { a: window }), ({ a = window } = {}): each name takes what the pattern reads for it.
|
|
3015
|
+
else if ((left.type === 'ArrayPattern' || left.type === 'ObjectPattern') && node.operator === '=') {
|
|
3016
|
+
for (const [id, info] of this.patternAliases(left, node.right, scope)) if (info.alias || info.hidden) this.bind(id.name, info, scope);
|
|
3017
|
+
}
|
|
3018
|
+
// window.w = navigator: the global `w`, assigned (its value read where it is written, bound at the root).
|
|
3019
|
+
else if (left.type === 'MemberExpression' && GLOBAL_OBJECTS.has(this.globalName(left.object, scope))) {
|
|
3020
|
+
const k = this.key(left, scope);
|
|
3021
|
+
if (k != null && IDENTIFIER.test(k) && node.operator === '=') { const info = this.aliasInfo(node.right, scope); if (info.alias || info.hidden) this.bindGlobal(k, info); }
|
|
3022
|
+
}
|
|
3023
|
+
// o.__proto__ = window: o reads every global through its prototype chain; the name is the global object from here on.
|
|
3024
|
+
else if (left.type === 'MemberExpression' && this.key(left, scope) === '__proto__' && node.operator === '=' && unchain(left.object).type === 'Identifier') {
|
|
3025
|
+
const g = this.globalName(node.right, scope);
|
|
3026
|
+
if (g != null && (GLOBAL_OBJECTS.has(g) || g === 'document')) this.bind(unchain(left.object).name, { alias: 'name', name: g === 'document' ? 'document' : 'window' }, scope, node.right);
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
// A property of the global object given a value: bound at the root, for
|
|
3030
|
+
// this file and (through the pool) the others.
|
|
3031
|
+
bindGlobal(name, info) {
|
|
3032
|
+
const b = this.root.names.get(name);
|
|
3033
|
+
if (!b) { this.root.declare(name, { ...info, global: true }); return; }
|
|
3034
|
+
const was = { ...b };
|
|
3035
|
+
this.bind(name, info, this.root);
|
|
3036
|
+
if (!b.alias && was.alias) Object.assign(b, was);
|
|
3037
|
+
b.global = true;
|
|
3038
|
+
}
|
|
3039
|
+
// After pass 1: the handlers' parameters are marked (a handler given by
|
|
3040
|
+
// name has its scope only now), each function's call sites are gathered,
|
|
3041
|
+
// and the functions called from where the analyser does not see (read as
|
|
3042
|
+
// a value, exported, declared at a classic script's root, set on the
|
|
3043
|
+
// global object) are marked escaped: their parameters stay unknown.
|
|
3044
|
+
finishScopes() {
|
|
3045
|
+
for (const h of this.handlers) {
|
|
3046
|
+
// (Resolved now: a handler given by a name declared further down has its binding only at the end.)
|
|
3047
|
+
const cb = h.fn ? { node: h.fn } : this.callbackOf(h.arg, h.scope);
|
|
3048
|
+
if (!cb) continue;
|
|
3049
|
+
const inner = this.scopes.get(cb.node);
|
|
3050
|
+
const p = cb.node.params[0];
|
|
3051
|
+
if (!inner || !p) continue;
|
|
3052
|
+
for (const id of patternNames(p)) { const b = inner.names.get(id.name); if (b && !b.hidden) b.hidden = h.why; }
|
|
3053
|
+
}
|
|
3054
|
+
for (const { node, scope } of this.calls) {
|
|
3055
|
+
const c = unchain(node.callee);
|
|
3056
|
+
let fn = null;
|
|
3057
|
+
if (FUNCTIONS.has(c.type)) fn = c;
|
|
3058
|
+
else if (c.type === 'Identifier') { const b = scope.resolve(c.name); if (b?.alias === 'fn') fn = b.node; }
|
|
3059
|
+
else if (c.type === 'SequenceExpression') { const last = unchain(c.expressions[c.expressions.length - 1]); if (FUNCTIONS.has(last.type)) fn = last; }
|
|
3060
|
+
if (!fn) continue;
|
|
3061
|
+
let s = this.sites.get(fn);
|
|
3062
|
+
if (!s) this.sites.set(fn, (s = []));
|
|
3063
|
+
if (s.length < EXACT_VALUES * 2) s.push({ args: node.arguments, scope }); else this.escaped.add(fn);
|
|
3064
|
+
}
|
|
3065
|
+
for (const r of this.refs) {
|
|
3066
|
+
const b = r.scope.resolve(r.node.name);
|
|
3067
|
+
if (b?.alias === 'value') { this.escapesMethod(r); continue; }
|
|
3068
|
+
if (b?.alias !== 'fn') continue;
|
|
3069
|
+
if (this.escapes(r)) this.escaped.add(b.node);
|
|
3070
|
+
}
|
|
3071
|
+
// The methods of an object handed to a function are called from where the analyser does not see.
|
|
3072
|
+
for (const h of this.handed) for (const p of h.node.properties) { const v = p.type === 'Property' ? unchain(p.value) : null; if (v && FUNCTIONS.has(v.type)) this.escaped.add(v); }
|
|
3073
|
+
// An exported function, a classic script's root function, one set on the global object: called from other
|
|
3074
|
+
// files too. The scan gathers those calls across the bundle (bundle.mjs); a one-file analysis cannot.
|
|
3075
|
+
for (const [, e] of this.exports) if (e.local != null) { const b = this.root.names.get(e.local); if (b?.alias === 'fn') this.open.add(b.node); }
|
|
3076
|
+
for (const b of this.root.names.values()) if (b.alias === 'fn' && (this.mode === 'script' || b.global)) this.open.add(b.node);
|
|
3077
|
+
}
|
|
3078
|
+
// Does this reference to a name hand what it names on to code the analyser
|
|
3079
|
+
// does not see? A call of it does not; a function set as a property of an
|
|
3080
|
+
// object literal kept in a name (a module object, `window.X = { f }`) is
|
|
3081
|
+
// called through that object, which the resolver follows; an export
|
|
3082
|
+
// (`export { f }`) is reached by its importers, whose calls the scan
|
|
3083
|
+
// gathers. Anything else (an argument, a value kept in a name, a default
|
|
3084
|
+
// export) is a call site the analyser does not read.
|
|
3085
|
+
escapes(r) {
|
|
3086
|
+
const called = r.parent && (r.parent.type === 'CallExpression' || r.parent.type === 'NewExpression') && unchain(r.parent.callee) === r.node;
|
|
3087
|
+
const stored = r.parent?.type === 'Property' && r.parent.value === r.node && r.grand?.type === 'ObjectExpression' && !this.handed.some((h) => h.node === r.grand);
|
|
3088
|
+
const exported = r.parent?.type === 'ExportSpecifier';
|
|
3089
|
+
return !called && !stored && !exported;
|
|
3090
|
+
}
|
|
3091
|
+
// A function of another file (an import, a member of a namespace, a global
|
|
3092
|
+
// another script set) or a method of a written object, referenced here
|
|
3093
|
+
// otherwise than as a callee — handed to forEach, a timer, a listener, kept
|
|
3094
|
+
// in a name — is called from where the analyser does not see: escaped, in
|
|
3095
|
+
// the file that wrote it, so its parameters stay unknown there. Run once
|
|
3096
|
+
// the bundle is wired (bundle.mjs), before the call sites are gathered.
|
|
3097
|
+
collectEscapes() {
|
|
3098
|
+
for (const r of this.refs) {
|
|
3099
|
+
const b = r.scope.resolve(r.node.name);
|
|
3100
|
+
// (Only a name that reaches another file is followed: an import, a namespace, a global another script set.
|
|
3101
|
+
// This file's own functions are marked by finishScopes, its written objects' methods by escapesMethod.)
|
|
3102
|
+
if (b && b.alias !== 'import' && b.alias !== 'module' && b.alias !== 'name') continue;
|
|
3103
|
+
if (!b && !this.globalsNamed(r.node.name, r.scope).length) continue;
|
|
3104
|
+
if (b?.alias === 'name' && (GLOBAL_OBJECTS.has(b.name) || HOST_NAMES.has(b.name))) continue;
|
|
3105
|
+
// The expression used: the name, or the member chain read off it (ns.f, API.show).
|
|
3106
|
+
let used = r.node, parent = r.parent, grand = r.grand;
|
|
3107
|
+
if (parent?.type === 'MemberExpression' && parent.object === r.node) {
|
|
3108
|
+
used = parent; parent = grand; grand = null;
|
|
3109
|
+
// (Only one member deep is known here: a longer chain's parent is not recorded, so it is read as handed on.)
|
|
3110
|
+
}
|
|
3111
|
+
if (!this.escapes({ node: used, parent, grand })) continue;
|
|
3112
|
+
this.begin();
|
|
3113
|
+
let leaves;
|
|
3114
|
+
try { leaves = this.resolve(used, r.scope); } catch { continue; }
|
|
3115
|
+
for (const l of leaves) {
|
|
3116
|
+
if (!FUNCTIONS.has(l.node.type)) continue;
|
|
3117
|
+
const owner = l.scope.analysis ?? this;
|
|
3118
|
+
if (owner.scopes.has(l.node)) owner.escaped.add(l.node);
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
this.cache.clear();
|
|
3122
|
+
}
|
|
3123
|
+
// A method of a written object kept in a name (`const API = { show(h) { … } }`), read off it otherwise than
|
|
3124
|
+
// as a callee (`setTimeout(API.show)`, `list.forEach(API.show)`): called from where the analyser does not see.
|
|
3125
|
+
// By the syntax alone: the name's object literals, the property of that name, a function written there.
|
|
3126
|
+
escapesMethod(r) {
|
|
3127
|
+
const m = r.parent;
|
|
3128
|
+
if (m?.type !== 'MemberExpression' || m.object !== r.node) return;
|
|
3129
|
+
const k = this.key(m, r.scope);
|
|
3130
|
+
if (k == null) return;
|
|
3131
|
+
if (!this.escapes({ node: m, parent: r.grand, grand: null })) return;
|
|
3132
|
+
const b = r.scope.resolve(r.node.name);
|
|
3133
|
+
if (b?.alias !== 'value') return;
|
|
3134
|
+
for (const v of b.nodes) {
|
|
3135
|
+
const x = unchain(v.node);
|
|
3136
|
+
if (x?.type !== 'ObjectExpression') continue;
|
|
3137
|
+
for (const p of x.properties) {
|
|
3138
|
+
if (p.type !== 'Property' || p.computed) continue;
|
|
3139
|
+
const name = p.key.type === 'Identifier' ? p.key.name : String(p.key.value);
|
|
3140
|
+
const fn = unchain(p.value);
|
|
3141
|
+
if (name === k && fn && FUNCTIONS.has(fn.type)) this.escaped.add(fn);
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
collectWritten(text) {
|
|
3146
|
+
if (this.writtenOverflow) return;
|
|
3147
|
+
const { elements } = analyseHtml(text);
|
|
3148
|
+
if (this.written.length + elements.length > ELEMENTS) { this.writtenOverflow = true; return; }
|
|
3149
|
+
for (const e of elements) this.written.push({ ...e, written: true });
|
|
3150
|
+
}
|
|
3151
|
+
// Every call of this file whose callee the resolver reaches as a function
|
|
3152
|
+
// (a method of a written object, an export, a function set on the global
|
|
3153
|
+
// object by another file): a call site of that function, in whatever file
|
|
3154
|
+
// it is, so its parameters are the arguments written there. Run once the
|
|
3155
|
+
// bundle is wired (bundle.mjs), or on the file alone (judgeSource).
|
|
3156
|
+
collectSites() {
|
|
3157
|
+
if (this.sitesCollected) return;
|
|
3158
|
+
this.sitesCollected = true;
|
|
3159
|
+
// (The writes were indexed in pass 1, before every write was recorded: indexed again when next read.)
|
|
3160
|
+
this.writesByKey = null; this.writesBySyntax = null;
|
|
3161
|
+
// (Innermost first: a chain of calls is then read once, each link from the one below it already read.)
|
|
3162
|
+
for (let i = this.calls.length - 1; i >= 0; i--) {
|
|
3163
|
+
const { node, scope } = this.calls[i];
|
|
3164
|
+
this.begin();
|
|
3165
|
+
const c = unchain(node.callee);
|
|
3166
|
+
if (c.type !== 'MemberExpression' && c.type !== 'Identifier') continue;
|
|
3167
|
+
let leaves;
|
|
3168
|
+
try { leaves = this.resolve(c, scope); } catch { continue; }
|
|
3169
|
+
for (const l of leaves) {
|
|
3170
|
+
if (!FUNCTIONS.has(l.node.type)) continue;
|
|
3171
|
+
const owner = l.scope.analysis ?? this;
|
|
3172
|
+
if (!owner.scopes.has(l.node)) continue;
|
|
3173
|
+
let s = owner.sites.get(l.node);
|
|
3174
|
+
if (!s) owner.sites.set(l.node, (s = []));
|
|
3175
|
+
if (s.some((x) => x.args === node.arguments)) continue;
|
|
3176
|
+
if (s.length < 128) s.push({ args: node.arguments, scope }); else owner.escaped.add(l.node);
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
this.cache.clear();
|
|
3180
|
+
}
|
|
3181
|
+
// Where a recorded write lands (target), read once and kept.
|
|
3182
|
+
targetOf(w) {
|
|
3183
|
+
if (w.target === undefined) {
|
|
3184
|
+
const t = w.node.type === 'AssignmentExpression' ? this.target(w.node.left, w.scope) : this.target(w.node, w.scope);
|
|
3185
|
+
w.target = t;
|
|
3186
|
+
w.key = t ? t.key : null;
|
|
3187
|
+
}
|
|
3188
|
+
return w.target;
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
// A function declaration is hoisted: known to the whole block before the
|
|
3192
|
+
// line it stands on (`const w = g(); … function g() { return this }`).
|
|
3193
|
+
hoist(statements, scope) {
|
|
3194
|
+
for (const s of statements) if (s.type === 'FunctionDeclaration' && s.id) scope.declare(s.id.name, { alias: 'fn', node: s });
|
|
3195
|
+
}
|
|
3196
|
+
// Is this function's this its object's? A class member, an object
|
|
3197
|
+
// literal's property, a function assigned to a property (X.prototype.m =
|
|
3198
|
+
// function, el.onload = function), one handed a receiver (.call, .apply,
|
|
3199
|
+
// .bind), a getter or setter. Anything else may be called bare.
|
|
3200
|
+
isMethod(fn, parent) {
|
|
3201
|
+
if (!parent) return false;
|
|
3202
|
+
switch (parent.type) {
|
|
3203
|
+
case 'MethodDefinition': case 'PropertyDefinition': case 'Property': return parent.value === fn;
|
|
3204
|
+
case 'AssignmentExpression': return parent.right === fn && unchain(parent.left).type === 'MemberExpression';
|
|
3205
|
+
case 'MemberExpression': return parent.object === fn && ['call', 'apply', 'bind'].includes(staticKey(parent));
|
|
3206
|
+
default: return false;
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
countName(name) {
|
|
3210
|
+
this.names++;
|
|
3211
|
+
if (name.length <= 2) this.short++;
|
|
3212
|
+
if (HEX_NAME.test(name)) this.hexNames++;
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
// ---- pass 2: the rules ----------------------------------------------------
|
|
3216
|
+
|
|
3217
|
+
run(ast) {
|
|
3218
|
+
const stack = [{ node: ast, parent: null, grand: null, key: null, ctx: 'expr', scope: this.scopes.get(ast) }];
|
|
3219
|
+
while (stack.length) {
|
|
3220
|
+
const frame = stack.pop();
|
|
3221
|
+
const { node, ctx } = frame;
|
|
3222
|
+
const inner = this.scopes.get(node) || frame.scope;
|
|
3223
|
+
if (ctx === 'expr') this.check(frame);
|
|
3224
|
+
else if (ctx === 'bind' && node.type !== 'Identifier') this.check(frame); // a default value or computed key inside a pattern
|
|
3225
|
+
for (const [key, child] of children(node)) {
|
|
3226
|
+
const childContext = childCtx(node, key, ctx);
|
|
3227
|
+
if (childContext === 'skip') continue;
|
|
3228
|
+
stack.push({ node: child, parent: node, grand: frame.parent, key, parentKey: frame.key, ctx: childContext, scope: inner });
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
check(frame) {
|
|
3234
|
+
const { node, parent, key, scope } = frame;
|
|
3235
|
+
this.begin();
|
|
3236
|
+
switch (node.type) {
|
|
3237
|
+
// A constant spelled across pieces is judged where it stands, as a
|
|
3238
|
+
// literal is: 'java' + 'script:x', ['<img o', 'nerror=x>'].join(''),
|
|
3239
|
+
// atob('PGltZy…') anywhere in the code, not only at a sink (a helper
|
|
3240
|
+
// may hand it to one).
|
|
3241
|
+
case 'BinaryExpression': if (node.operator === '+' && !(parent?.type === 'BinaryExpression' && parent.operator === '+')) this.constant(node, scope); return;
|
|
3242
|
+
case 'Identifier': {
|
|
3243
|
+
if (frame.ctx === 'bind') return;
|
|
3244
|
+
// The target of a plain assignment is written, not read (`W = WebAssembly` binds W; the right side is the reference).
|
|
3245
|
+
if (parent?.type === 'AssignmentExpression' && key === 'left' && parent.operator === '=') return;
|
|
3246
|
+
// An unshadowed name is the global of that name; a name bound to
|
|
3247
|
+
// a global (`const e = eval`, `let d; d = Date`) is judged as it.
|
|
3248
|
+
const g = this.globalName(node, scope);
|
|
3249
|
+
if (g != null) this.globalReference(g, frame);
|
|
3250
|
+
return;
|
|
3251
|
+
}
|
|
3252
|
+
case 'MemberExpression': return this.member(frame);
|
|
3253
|
+
case 'VariableDeclarator': if (node.id.type !== 'Identifier' && node.init) this.destructure(node.id, node.init, scope); return;
|
|
3254
|
+
case 'CallExpression': { this.call(frame); const c = unchain(node.callee); if (this.isDecoder(node, scope) || (c.type === 'MemberExpression' && staticKey(c) === 'join')) this.constant(node, scope); return; }
|
|
3255
|
+
case 'NewExpression': return this.construct(frame);
|
|
3256
|
+
case 'WithStatement': return this.add('with', node, 'with (…)');
|
|
3257
|
+
case 'ForInStatement': if (this.isGlobalObject(node.right, scope)) this.add('computed-global', node, 'for (… in the global object)'); return;
|
|
3258
|
+
case 'SpreadElement': if (parent.type === 'ObjectExpression' && this.isGlobalObject(node.argument, scope)) this.add('computed-global', node, '{ ...the global object }'); return;
|
|
3259
|
+
case 'ImportExpression': {
|
|
3260
|
+
const s = literalString(node.source);
|
|
3261
|
+
if (s == null) this.add('import-dynamic', node, 'import(…) of a computed address');
|
|
3262
|
+
else if (SCHEME.test(s)) this.add('import-url', node, `import(${JSON.stringify(s.slice(0, 60))})`);
|
|
3263
|
+
return;
|
|
3264
|
+
}
|
|
3265
|
+
case 'ImportDeclaration': case 'ExportAllDeclaration': case 'ExportNamedDeclaration': {
|
|
3266
|
+
const s = node.source && literalString(node.source);
|
|
3267
|
+
if (s != null && SCHEME.test(s)) this.add('import-url', node, `import from ${JSON.stringify(s.slice(0, 60))}`);
|
|
3268
|
+
return;
|
|
3269
|
+
}
|
|
3270
|
+
case 'AssignmentExpression': {
|
|
3271
|
+
const left = unchain(node.left);
|
|
3272
|
+
if (left.type === 'ObjectPattern' || left.type === 'ArrayPattern') return this.destructure(left, node.right, scope);
|
|
3273
|
+
if (left.type === 'MemberExpression') {
|
|
3274
|
+
const k = this.key(left, scope);
|
|
3275
|
+
const o = this.globalName(left.object, scope);
|
|
3276
|
+
// el.dataset.src is the data-src attribute, not the element's address.
|
|
3277
|
+
const inner = unchain(left.object);
|
|
3278
|
+
if (inner.type === 'MemberExpression' && this.key(inner, scope) === 'dataset') return;
|
|
3279
|
+
if (k === 'srcdoc') this.add('navigation', node, 'srcdoc =');
|
|
3280
|
+
// Any part of the address written is read back by the page: text decoded or fetched written there is refused as at a URL sink.
|
|
3281
|
+
if (o === 'location') { this.add('navigate', node, `location.${k ?? '[…]'} =`); this.urlSink(node, node.right, scope, `location.${k ?? '[…]'} =`); }
|
|
3282
|
+
// window.location = u and document.location = u move the page as location = u does.
|
|
3283
|
+
if (k === 'location' && (GLOBAL_OBJECTS.has(o) || o === 'document')) { this.add('navigate', node, `${o === 'document' ? 'document' : 'window'}.location =`); this.urlSink(node, node.right, scope, 'location ='); }
|
|
3284
|
+
if (k != null && MARKUP_PROPS.has(k)) this.markupSink(node, node.right, scope, `${k} ${node.operator}`);
|
|
3285
|
+
else if (k != null && URL_PROPS.has(k)) this.urlSink(node, node.right, scope, `${k} =`, left.object);
|
|
3286
|
+
else if (k === 'protocol') this.protocolSink(node, node.right, scope, 'protocol =');
|
|
3287
|
+
// o.__proto__ = window: o reads every global through its prototype chain, by whatever name.
|
|
3288
|
+
else if (k === '__proto__' && this.isGlobalObject(node.right, scope)) this.add('computed-global', node, '__proto__ = the global object: every global reached through the prototype chain');
|
|
3289
|
+
} else if (left.type === 'Identifier' && this.globalName(left, scope) === 'location') { this.add('navigate', node, 'location ='); this.urlSink(node, node.right, scope, 'location ='); }
|
|
3290
|
+
return;
|
|
3291
|
+
}
|
|
3292
|
+
case 'Literal': if (typeof node.value === 'string') this.text(node, node.value, node.raw); return;
|
|
3293
|
+
// A template's markup is read whole, each expression standing in as a
|
|
3294
|
+
// word: `<div class="${c}" onclick="…">` is one tag, not two halves.
|
|
3295
|
+
case 'TemplateLiteral': if (node.expressions.length) { const values = this.exact(node, scope); if (values) for (const v of values) this.text(node, v, null); else for (const s of this.shapes(node, scope)) this.markup(node, s); } return;
|
|
3296
|
+
case 'TemplateElement': this.text(node, node.value.cooked ?? node.value.raw, node.value.raw, parent.expressions.length === 0); return;
|
|
3297
|
+
default: return;
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
constant(node, scope) {
|
|
3301
|
+
if (literalString(node) != null) return;
|
|
3302
|
+
const values = this.exact(node, scope);
|
|
3303
|
+
if (!values) return;
|
|
3304
|
+
for (const v of values) this.text(node, v, null);
|
|
3305
|
+
}
|
|
3306
|
+
// `a.protocol = 'javascript'`: a link whose address has a scheme that is
|
|
3307
|
+
// not special (`x:evil()`) takes another such scheme from its protocol
|
|
3308
|
+
// setter (the URL Standard's scheme state with a state override), and
|
|
3309
|
+
// becomes javascript:evil(). The scheme is judged as what it spells; one
|
|
3310
|
+
// not written down is refused.
|
|
3311
|
+
protocolSink(node, value, scope, what) {
|
|
3312
|
+
if (!value) return;
|
|
3313
|
+
const hidden = this.hiddenSource(value, scope, { url: true });
|
|
3314
|
+
if (hidden) return this.add('hidden-markup', node, `${what} ${hidden}`);
|
|
3315
|
+
const values = this.exact(value, scope);
|
|
3316
|
+
if (!values) return this.add('markup-source', node, `${what} a scheme not written down`);
|
|
3317
|
+
for (const v of values) if (isJavascriptUrl(v.trim().replace(/:?$/, ':'))) this.add('inline-handler', node, `${what} '${v.slice(0, 12)}': the link becomes a javascript: URL`);
|
|
3318
|
+
}
|
|
3319
|
+
// Is this element one the code made as a media element (new Image(), new
|
|
3320
|
+
// Audio(), createElement('img'))? Its src is a resource the browser
|
|
3321
|
+
// fetches and never runs. An element found in the page is not known to be
|
|
3322
|
+
// one: the code may have put another in its place.
|
|
3323
|
+
isMedia(receiver, scope) {
|
|
3324
|
+
if (!receiver) return false;
|
|
3325
|
+
const leaves = this.resolve(receiver, scope);
|
|
3326
|
+
if (this.unread(leaves, unchain(receiver)) || !leaves.length) return false;
|
|
3327
|
+
return leaves.every((l) => {
|
|
3328
|
+
const x = l.node;
|
|
3329
|
+
if (x.type === 'NewExpression') { const g = this.globalName(x.callee, l.scope); return g === 'Image' || g === 'Audio'; }
|
|
3330
|
+
if (x.type !== 'CallExpression') return false;
|
|
3331
|
+
const c = unchain(x.callee);
|
|
3332
|
+
if (c.type !== 'MemberExpression') return false;
|
|
3333
|
+
const k = this.key(c, l.scope);
|
|
3334
|
+
if (k !== 'createElement' && k !== 'createElementNS') return false;
|
|
3335
|
+
const tags = this.exact(k === 'createElement' ? x.arguments[0] : x.arguments[1], l.scope);
|
|
3336
|
+
return !!tags && tags.length > 0 && tags.every((t) => MEDIA_TAGS.has(t.toLowerCase().replace(/^[\w.-]+:/, '')));
|
|
3337
|
+
});
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
// A value the page parses as markup (innerHTML, outerHTML,
|
|
3341
|
+
// insertAdjacentHTML, setHTMLUnsafe, parseHTMLUnsafe,
|
|
3342
|
+
// createContextualFragment, DOMParser.parseFromString, Reflect.set of
|
|
3343
|
+
// innerHTML): read as its written pieces make it, a handler, a script or
|
|
3344
|
+
// a javascript: URL spelled across pieces refused as one; a piece decoded
|
|
3345
|
+
// or rearranged at run time refused as unread. A plain literal was walked
|
|
3346
|
+
// already as a string.
|
|
3347
|
+
markupSink(node, value, scope, what) {
|
|
3348
|
+
if (!value) return;
|
|
3349
|
+
const hidden = this.hiddenSource(value, scope);
|
|
3350
|
+
if (hidden) this.add('hidden-markup', node, `${what} ${hidden}`);
|
|
3351
|
+
if (literalString(value) != null) return;
|
|
3352
|
+
const { texts, placed, unwritten } = this.alternatives(value, scope);
|
|
3353
|
+
// Nothing of any alternative written down (not here, not in a written
|
|
3354
|
+
// object or list, a function of the game's, another file, an attribute
|
|
3355
|
+
// of the page): refused. An alternative written down is read as
|
|
3356
|
+
// written; one not written beside it stands as a computed part, named
|
|
3357
|
+
// for review (the stated limit a computed part in text position is).
|
|
3358
|
+
if (!placed) { if (!hidden) this.add('markup-source', node, `${what} a value not written down`); }
|
|
3359
|
+
else if (unwritten) this.add('markup-parts', node, `${what} a value one of whose alternatives is not written down`);
|
|
3360
|
+
for (const s of texts) {
|
|
3361
|
+
if (SCRIPT_TAG.test(s)) this.add('script-element', node, `a <script> spelled from pieces (${what})`);
|
|
3362
|
+
this.markup(node, s, 'spelled from pieces');
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
// A value the page follows as a URL (href, src, location, open,
|
|
3366
|
+
// setAttribute('href')): a javascript: URL or a data: document spelled
|
|
3367
|
+
// across pieces is refused as one. The written start of each alternative
|
|
3368
|
+
// fixes its scheme: a start that could still spell javascript: ('java' +
|
|
3369
|
+
// x) or a data: document of a type not written is refused; one with
|
|
3370
|
+
// nothing written at the start is a URL not written down, refused unless
|
|
3371
|
+
// the element is a media element the code made (its src is a resource,
|
|
3372
|
+
// never code); an alternative written beside one not written is named
|
|
3373
|
+
// for review.
|
|
3374
|
+
urlSink(node, value, scope, what, receiver = null) {
|
|
3375
|
+
if (!value) return;
|
|
3376
|
+
const hidden = this.hiddenSource(value, scope, { url: true });
|
|
3377
|
+
if (hidden) this.add('hidden-markup', node, `${what} ${hidden}`);
|
|
3378
|
+
if (literalString(value) != null) return;
|
|
3379
|
+
const { texts, placed, unwritten } = this.alternatives(value, scope);
|
|
3380
|
+
let heads = 0, headless = 0;
|
|
3381
|
+
for (const s of texts) {
|
|
3382
|
+
const plain = s.replace(MARKERS, '');
|
|
3383
|
+
if (!s.includes(MARKER)) { if (isJavascriptUrl(plain)) this.add('inline-handler', node, `${what} a javascript: URL spelled from pieces`); this.dataUrl(node, plain, what); heads++; continue; }
|
|
3384
|
+
const head = s.split(MARKER)[0].replace(/[\t\n\r]/g, '').replace(/^[\u0000- ]+/, '');
|
|
3385
|
+
if (head === '') { headless++; continue; }
|
|
3386
|
+
heads++;
|
|
3387
|
+
if ('javascript:'.startsWith(head.toLowerCase())) this.add('inline-handler', node, `${what} a URL whose written start '${head.slice(0, 12)}' could spell javascript:`);
|
|
3388
|
+
else if (/^data:/i.test(head)) {
|
|
3389
|
+
const m = /^data:([^,;]*)([;,])/i.exec(head);
|
|
3390
|
+
const mime = m ? m[1].trim().toLowerCase() : '';
|
|
3391
|
+
if (!m || mime === '' || JS_MIME.test(mime) || PAGE_MIME.test(mime) || XML_MIME.test(mime) || !/^[\w.+-]+\/[\w.+-]+$/.test(mime)) this.add('data-url-code', node, `${what} a data: URL whose document is not written down`);
|
|
3392
|
+
} else if (isJavascriptUrl(plain)) this.add('inline-handler', node, `${what} a javascript: URL spelled from pieces`);
|
|
3393
|
+
}
|
|
3394
|
+
if (receiver && this.isMedia(receiver, scope)) return;
|
|
3395
|
+
if (!heads) { if (!hidden) this.add('markup-source', node, `${what} a URL not written down`); }
|
|
3396
|
+
else if (headless || unwritten) this.add('markup-parts', node, `${what} a URL one of whose alternatives is not written down`);
|
|
3397
|
+
void placed;
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
// A destructuring, judged as the members it reads: `const { eval: e } =
|
|
3401
|
+
// window` reads window.eval and is refused as eval is; `{ constructor: F }
|
|
3402
|
+
// = fn` reads fn.constructor; `{ ...rest } = window` and `{ [k]: v } =
|
|
3403
|
+
// window` read the global object by names not written down. Patterns
|
|
3404
|
+
// nest (`{ self: { eval: e } } = window`); the walk is a loop. An array
|
|
3405
|
+
// pattern's initialiser elements are expressions pass 2 reads on its own.
|
|
3406
|
+
destructure(pattern, init, scope) {
|
|
3407
|
+
const stack = [[pattern, init]];
|
|
3408
|
+
while (stack.length) {
|
|
3409
|
+
const [p, from] = stack.pop();
|
|
3410
|
+
if (!p || !from) continue;
|
|
3411
|
+
switch (p.type) {
|
|
3412
|
+
case 'AssignmentPattern': stack.push([p.left, from]); break;
|
|
3413
|
+
case 'ObjectPattern': {
|
|
3414
|
+
const global = this.isGlobalObject(from, scope);
|
|
3415
|
+
for (const prop of p.properties) {
|
|
3416
|
+
if (prop.type === 'RestElement') { if (global) this.add('computed-global', prop, '{ …rest } = the global object'); continue; }
|
|
3417
|
+
const m = memberOf(from, prop);
|
|
3418
|
+
this.member({ node: m, parent: null, grand: null, key: null, scope });
|
|
3419
|
+
stack.push([prop.value, m]);
|
|
3420
|
+
}
|
|
3421
|
+
break;
|
|
3422
|
+
}
|
|
3423
|
+
case 'ArrayPattern': p.elements.forEach((e, i) => { const el = from.type === 'ArrayExpression' ? from.elements[i] : null; if (e && el && el.type !== 'SpreadElement') stack.push([e, el]); }); break;
|
|
3424
|
+
default: break;
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3429
|
+
// A reference to a global by name: an unshadowed identifier, `window.name`,
|
|
3430
|
+
// `Reflect.get(window, 'name')`. The position (parent, key) decides what is
|
|
3431
|
+
// allowed of Function and of the global object itself.
|
|
3432
|
+
globalReference(name, frame) {
|
|
3433
|
+
const { node, parent, grand, key } = frame;
|
|
3434
|
+
switch (name) {
|
|
3435
|
+
case 'eval': return this.add('eval', node, 'eval');
|
|
3436
|
+
case 'Function': {
|
|
3437
|
+
// Function.prototype.call and friends are a reference to a method,
|
|
3438
|
+
// not to the constructor; so are instanceof and typeof.
|
|
3439
|
+
const prototypeMethod = parent?.type === 'MemberExpression' && key === 'object' && staticKey(parent) === 'prototype' && grand?.type === 'MemberExpression' && grand.object === parent && staticKey(grand) != null;
|
|
3440
|
+
const compared = parent?.type === 'BinaryExpression' && ['instanceof', '===', '!==', '==', '!='].includes(parent.operator);
|
|
3441
|
+
const typeOf = parent?.type === 'UnaryExpression' && parent.operator === 'typeof';
|
|
3442
|
+
if (!prototypeMethod && !compared && !typeOf) this.add('function-constructor', node, 'Function');
|
|
3443
|
+
return;
|
|
3444
|
+
}
|
|
3445
|
+
case 'Date': case 'Temporal': return this.add('clock', node, name);
|
|
3446
|
+
case 'Intl': if (!(parent?.type === 'MemberExpression' && key === 'object')) this.add('locale', node, 'Intl'); return;
|
|
3447
|
+
case 'localStorage': case 'sessionStorage': case 'indexedDB': case 'caches': return this.add('local-storage', node, name);
|
|
3448
|
+
case 'XMLHttpRequest': case 'EventSource': return this.add('network', node, name);
|
|
3449
|
+
case 'open': if (parent?.type === 'CallExpression' && key === 'callee') this.add('navigate', node, 'open(…)'); return;
|
|
3450
|
+
// WebAssembly kept in a name, passed on or reached is a use (the
|
|
3451
|
+
// scan refuses it outside a known library); a feature check is not.
|
|
3452
|
+
case 'WebAssembly': {
|
|
3453
|
+
const member = parent?.type === 'MemberExpression' && key === 'object';
|
|
3454
|
+
const typeOf = parent?.type === 'UnaryExpression' && parent.operator === 'typeof';
|
|
3455
|
+
const compared = parent?.type === 'BinaryExpression';
|
|
3456
|
+
// A name the analyser follows (`const W = WebAssembly`, `w = WebAssembly`) is judged where it is used.
|
|
3457
|
+
// (A pattern's members are judged by destructure(); parent null is such a member read.)
|
|
3458
|
+
const aliased = parent == null || (parent.type === 'VariableDeclarator' && key === 'init') || (parent.type === 'AssignmentExpression' && key === 'right' && parent.operator === '=' && unchain(parent.left).type !== 'MemberExpression');
|
|
3459
|
+
if (!member && !typeOf && !compared && !aliased) this.wasm.push({ line: (node.loc?.start.line ?? 1) + this.lineOffset, what: 'WebAssembly kept in a name or passed on' });
|
|
3460
|
+
return;
|
|
3461
|
+
}
|
|
3462
|
+
default: {
|
|
3463
|
+
const net = NETWORK_CLASSES.get(name);
|
|
3464
|
+
if (net) return this.add(net, node, name);
|
|
3465
|
+
if (FRAME_NAMES.has(name)) this.add('frame-reach', node, name);
|
|
3466
|
+
return;
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
member(frame) {
|
|
3472
|
+
const { node, parent, key, scope } = frame;
|
|
3473
|
+
const o = this.globalName(node.object, scope);
|
|
3474
|
+
const k = this.key(node, scope);
|
|
3475
|
+
// A setter, getter or method reached through its property descriptor
|
|
3476
|
+
// (Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML').set):
|
|
3477
|
+
// called at once through .call/.apply/Reflect.apply it is judged as the
|
|
3478
|
+
// property is (call()); kept, bound or passed on it is refused, and so
|
|
3479
|
+
// is one of a name not written down.
|
|
3480
|
+
if (k === 'set' || k === 'get' || k === 'value') {
|
|
3481
|
+
const desc = this.descriptorOf(node.object, scope);
|
|
3482
|
+
if (desc && desc.judged) {
|
|
3483
|
+
const through = parent?.type === 'MemberExpression' && key === 'object' && ['call', 'apply'].includes(staticKey(parent));
|
|
3484
|
+
const called = parent?.type === 'CallExpression' && key === 'callee';
|
|
3485
|
+
const applied = parent?.type === 'CallExpression' && key === 'arguments' && unchain(parent.callee).type === 'MemberExpression' && staticKey(unchain(parent.callee)) === 'apply' && this.globalName(unchain(parent.callee).object, scope) === 'Reflect' && parent.arguments[0] === node;
|
|
3486
|
+
if (!through && !called && !applied) this.add('method-reference', node, `.${k} of a property's descriptor${desc.name != null ? ` ('${desc.name}')` : ' (a name not written down)'} kept, bound or passed on`);
|
|
3487
|
+
return;
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
if (o != null && (GLOBAL_OBJECTS.has(o) || o === 'document')) {
|
|
3491
|
+
// `this[k] = v` in a function that is not a method (a constructor's
|
|
3492
|
+
// `for (k in opts) this[k] = opts[k]`) writes a property; nothing
|
|
3493
|
+
// runs from it. A read or a call of this[k] is the global object indexed.
|
|
3494
|
+
const viaThis = unchain(node.object)?.type === 'ThisExpression';
|
|
3495
|
+
const written = (parent?.type === 'AssignmentExpression' && key === 'left') || parent?.type === 'UpdateExpression' || (parent?.type === 'UnaryExpression' && parent.operator === 'delete');
|
|
3496
|
+
if (k == null && viaThis && written) return;
|
|
3497
|
+
if (k == null) return this.add('computed-global', node, viaThis ? 'this[…] where this may be the global object' : `${o === 'document' ? 'document' : 'the global object'}[…]`);
|
|
3498
|
+
if (node.computed && escapedIdentifier(node.property)) this.add('hidden-name', node, `'${node.property.raw.slice(1, -1)}' spells ${k} in escapes`);
|
|
3499
|
+
if (o === 'document') {
|
|
3500
|
+
if (k === 'write' || k === 'writeln') this.add('document-write', node, `document.${k}`);
|
|
3501
|
+
else if (k === 'cookie') this.add('local-storage', node, 'document.cookie');
|
|
3502
|
+
else if (JUDGED_METHODS.has(k)) this.methodReference(frame, k);
|
|
3503
|
+
return;
|
|
3504
|
+
}
|
|
3505
|
+
// window.name is the global `name`.
|
|
3506
|
+
if (k === 'document') return;
|
|
3507
|
+
this.globalReference(k, { node, parent, grand: frame.grand, key });
|
|
3508
|
+
return;
|
|
3509
|
+
}
|
|
3510
|
+
switch (o) {
|
|
3511
|
+
case 'navigator':
|
|
3512
|
+
if (k == null) return this.hostComputed(frame, 'navigator');
|
|
3513
|
+
if (k === 'serviceWorker') return this.add('service-worker', node, 'navigator.serviceWorker');
|
|
3514
|
+
if (k === 'language' || k === 'languages') return this.add('locale', node, `navigator.${k}`);
|
|
3515
|
+
if (k === 'webdriver') return this.add('fingerprint', node, 'navigator.webdriver (an automation check)');
|
|
3516
|
+
if (FINGERPRINT_NAV.has(k)) return this.add('fingerprint', node, `navigator.${k}`);
|
|
3517
|
+
if (k === 'sendBeacon') return this.add('network', node, 'navigator.sendBeacon');
|
|
3518
|
+
if (k === 'storage') return this.add('local-storage', node, 'navigator.storage');
|
|
3519
|
+
return;
|
|
3520
|
+
case 'screen': if (k == null) return this.hostComputed(frame, 'screen'); if (FINGERPRINT_SCREEN.has(k)) this.add('fingerprint', node, `screen.${k}`); return;
|
|
3521
|
+
case 'Intl': if (k == null) return this.hostComputed(frame, 'Intl'); return this.add(k === 'DateTimeFormat' ? 'timezone' : 'locale', node, `Intl.${k}`);
|
|
3522
|
+
case 'location': if (k == null) return this.hostComputed(frame, 'location'); return;
|
|
3523
|
+
case 'WebAssembly': {
|
|
3524
|
+
// Any member of WebAssembly is a use but a feature check (typeof WebAssembly.instantiate, 'x' in WebAssembly, === undefined).
|
|
3525
|
+
const typeOf = parent?.type === 'UnaryExpression' && parent.operator === 'typeof';
|
|
3526
|
+
const compared = parent?.type === 'BinaryExpression';
|
|
3527
|
+
if (!typeOf && !compared) this.wasm.push({ line: (node.loc?.start.line ?? 1) + this.lineOffset, what: `WebAssembly.${k ?? '[…]'}` });
|
|
3528
|
+
return;
|
|
3529
|
+
}
|
|
3530
|
+
case 'Math': if (k === 'random') this.add('browser-rng', node, 'Math.random'); return;
|
|
3531
|
+
case 'crypto': if (k == null) return this.hostComputed(frame, 'crypto'); if (k === 'getRandomValues' || k === 'randomUUID') this.add('browser-rng', node, `crypto.${k}`); return;
|
|
3532
|
+
case 'performance': if (k == null) return this.hostComputed(frame, 'performance'); if (k === 'timeOrigin') this.add('clock', node, 'performance.timeOrigin'); return;
|
|
3533
|
+
case 'URL': if (k === 'createObjectURL') this.add('blob-code', node, 'URL.createObjectURL'); return;
|
|
3534
|
+
// The Navigation API moves the page as location does.
|
|
3535
|
+
case 'navigation': if (k == null) return this.hostComputed(frame, 'navigation'); if (['navigate', 'reload', 'traverseTo', 'back', 'forward'].includes(k)) this.add('navigate', node, `navigation.${k}`); return;
|
|
3536
|
+
// Reflect[k](window, name), Object[k](window): a method of Reflect or Object by a name not written
|
|
3537
|
+
// down, handed the global object (or a host object): what it reads of it cannot be read; refused.
|
|
3538
|
+
case 'Reflect': case 'Object': {
|
|
3539
|
+
if (k != null) break;
|
|
3540
|
+
const call = parent?.type === 'CallExpression' && key === 'callee' ? parent : null;
|
|
3541
|
+
const hostArg = (a) => { const g = this.globalName(a, scope); return g != null && (GLOBAL_OBJECTS.has(g) || g === 'document' || ['navigator', 'location', 'screen', 'crypto', 'performance', 'navigation', 'Intl', 'WebAssembly'].includes(g)); };
|
|
3542
|
+
if (!call) return this.add('method-reference', node, `${o}[…]: a method of ${o} by a name not written down, kept or passed on`);
|
|
3543
|
+
if (call.arguments.some(hostArg)) return this.add('computed-global', node, `${o}[…](the global object, …): a method of ${o} by a name computed at run time`);
|
|
3544
|
+
return;
|
|
3545
|
+
}
|
|
3546
|
+
default: break;
|
|
3547
|
+
}
|
|
3548
|
+
if (k === 'constructor') {
|
|
3549
|
+
const compared = parent?.type === 'BinaryExpression' && ['instanceof', '===', '!==', '==', '!='].includes(parent.operator);
|
|
3550
|
+
const property = parent?.type === 'MemberExpression' && key === 'object' ? staticKey(parent) : null;
|
|
3551
|
+
const read = property != null && !CONSTRUCTOR_DENIED.has(property);
|
|
3552
|
+
const bare = parent?.type === 'NewExpression' && key === 'callee' && parent.arguments.length === 0;
|
|
3553
|
+
const typeOf = parent?.type === 'UnaryExpression' && parent.operator === 'typeof';
|
|
3554
|
+
if (!compared && !read && !bare && !typeOf) this.add('function-constructor', node, '.constructor');
|
|
3555
|
+
return;
|
|
3556
|
+
}
|
|
3557
|
+
if (k != null && TIMEZONE_METHODS.has(k) && parent?.type === 'CallExpression' && key === 'callee') return this.add('timezone', node, `.${k}()`);
|
|
3558
|
+
// A method the rules judge by its arguments, read as a value: bound,
|
|
3559
|
+
// kept in a name, passed on, or looked at (`if (el.setAttribute)`,
|
|
3560
|
+
// which `typeof` says plainly). Called, directly or through .call/.apply,
|
|
3561
|
+
// call() reads its arguments.
|
|
3562
|
+
if (k != null && JUDGED_METHODS.has(k)) this.methodReference(frame, k);
|
|
3563
|
+
if (k == null) {
|
|
3564
|
+
// `let key = 'x'; key = 'constructor'; obj[key](…)`: one of the names
|
|
3565
|
+
// the key is assigned is judged as the key would be.
|
|
3566
|
+
if (this.keyNames(node, scope).includes('constructor')) this.add('function-constructor', node, '.constructor (one of the names the key is assigned)');
|
|
3567
|
+
const hidden = this.hiddenName(node.property, scope);
|
|
3568
|
+
if (hidden) this.add('hidden-name', node, hidden);
|
|
3569
|
+
} else if (node.computed && escapedIdentifier(node.property)) this.add('hidden-name', node, `'${node.property.raw.slice(1, -1)}' spells ${k} in escapes`);
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
// A host object (navigator, location, screen, crypto, performance, Intl,
|
|
3573
|
+
// the Navigation API) by a name computed at run time: what it reaches
|
|
3574
|
+
// (serviceWorker, the address, the browser's identity, randomness, the
|
|
3575
|
+
// time zone) cannot be read; refused, unless every name the key may be is
|
|
3576
|
+
// written (a name assigned one literal after another), each then judged.
|
|
3577
|
+
hostComputed(frame, o) {
|
|
3578
|
+
const { node, scope } = frame;
|
|
3579
|
+
const names = [...new Set([...this.keyNames(node, scope), ...(this.exact(node.property, scope) ?? [])])];
|
|
3580
|
+
if (!names.length) return this.add('computed-global', node, `${o}[…]: a host object by a name computed at run time`);
|
|
3581
|
+
for (const name of names) this.member({ ...frame, node: { ...node, computed: true, property: literalNode(name, node.property) } });
|
|
3582
|
+
}
|
|
3583
|
+
// The property a descriptor names, and its target: Object.getOwnPropertyDescriptor(T, 'name'),
|
|
3584
|
+
// Reflect.getOwnPropertyDescriptor(T, 'name'), Object.getOwnPropertyDescriptors(T).name /
|
|
3585
|
+
// ['name']. `judged` when the name is one the rules judge (a method, a markup or URL
|
|
3586
|
+
// property, a sensitive token) or is not written down. null for anything else.
|
|
3587
|
+
descriptorOf(n, scope) {
|
|
3588
|
+
n = unchain(n);
|
|
3589
|
+
if (!n) return null;
|
|
3590
|
+
let target = null, nameArg = null, name = null;
|
|
3591
|
+
if (n.type === 'CallExpression') {
|
|
3592
|
+
const c = unchain(n.callee);
|
|
3593
|
+
if (c.type !== 'MemberExpression') return null;
|
|
3594
|
+
const o = this.globalName(c.object, scope), k = this.key(c, scope);
|
|
3595
|
+
if (!((o === 'Object' || o === 'Reflect') && k === 'getOwnPropertyDescriptor')) return null;
|
|
3596
|
+
target = n.arguments[0] ?? null; nameArg = n.arguments[1] ?? null;
|
|
3597
|
+
const v = nameArg ? this.exact(nameArg, scope) : null;
|
|
3598
|
+
name = v && v.length === 1 ? v[0] : null;
|
|
3599
|
+
if (v && v.length > 1) name = null;
|
|
3600
|
+
} else if (n.type === 'MemberExpression') {
|
|
3601
|
+
const inner = unchain(n.object);
|
|
3602
|
+
if (inner.type !== 'CallExpression') return null;
|
|
3603
|
+
const c = unchain(inner.callee);
|
|
3604
|
+
if (c.type !== 'MemberExpression' || this.globalName(c.object, scope) !== 'Object' || this.key(c, scope) !== 'getOwnPropertyDescriptors') return null;
|
|
3605
|
+
target = inner.arguments[0] ?? null;
|
|
3606
|
+
name = this.key(n, scope);
|
|
3607
|
+
} else return null;
|
|
3608
|
+
const judged = name == null || JUDGED_METHODS.has(name) || MARKUP_PROPS.has(name) || URL_PROPS.has(name) || SENSITIVE_TOKENS.has(name) || ['srcdoc', 'location', 'protocol', 'cookie', 'write', 'writeln', 'eval', 'Function'].includes(name);
|
|
3609
|
+
return { target, name, judged };
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3612
|
+
methodReference(frame, k) {
|
|
3613
|
+
const { node, parent, grand, key, parentKey } = frame;
|
|
3614
|
+
const called = parent?.type === 'CallExpression' && key === 'callee';
|
|
3615
|
+
const through = parent?.type === 'MemberExpression' && key === 'object' && ['call', 'apply'].includes(staticKey(parent));
|
|
3616
|
+
const typeOf = parent?.type === 'UnaryExpression' && parent.operator === 'typeof';
|
|
3617
|
+
// A feature check reads the method as a truth value, never as a function:
|
|
3618
|
+
// `if (el.setAttribute)`, `document.createElementNS ? … : …`, `!el.setHTMLUnsafe`,
|
|
3619
|
+
// and `a && b` standing in such a test.
|
|
3620
|
+
const tested = (p, k) => (p && TESTED.has(p.type) && k === 'test') || (p?.type === 'UnaryExpression' && p.operator === '!');
|
|
3621
|
+
const check = tested(parent, key) || (parent?.type === 'LogicalExpression' && tested(grand, parentKey));
|
|
3622
|
+
if (!called && !through && !typeOf && !check) this.add('method-reference', node, `.${k} kept in a name, bound or passed on`);
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3625
|
+
// The method a call reaches and the arguments it gets, through the forms
|
|
3626
|
+
// that hand a method on: `o.m.call(t, a, b)` and `o.m.apply(t, [a, b])` are
|
|
3627
|
+
// `o.m(a, b)`; `Reflect.apply(o.m, t, [a, b])` too; and
|
|
3628
|
+
// `Function.prototype.call.call(o.m, t, a)` is `o.m.call(t, a)`. An argument
|
|
3629
|
+
// list not written down (`apply(t, args)`) is unknown: null, which the
|
|
3630
|
+
// rules read as "not written down" and refuse.
|
|
3631
|
+
resolveCall(node, scope) {
|
|
3632
|
+
let callee = unchain(node.callee), args = node.arguments, receiver = null;
|
|
3633
|
+
for (let i = 0; i < 8 && callee; i++) {
|
|
3634
|
+
if (callee.type === 'MemberExpression') {
|
|
3635
|
+
const m = staticKey(callee);
|
|
3636
|
+
// Reflect.apply(f, t, [a, b]) is f(a, b) with this = t.
|
|
3637
|
+
if (m === 'apply' && this.globalName(callee.object, scope) === 'Reflect') {
|
|
3638
|
+
const f = args && args[0] ? unchain(args[0]) : null;
|
|
3639
|
+
if (!f) return { callee: null, args: null, receiver };
|
|
3640
|
+
receiver = args[1] ?? receiver;
|
|
3641
|
+
callee = f; args = args.length < 3 ? [] : elementsOf(args[2]);
|
|
3642
|
+
continue;
|
|
3643
|
+
}
|
|
3644
|
+
if (m !== 'call' && m !== 'apply') break;
|
|
3645
|
+
const inner = unchain(callee.object);
|
|
3646
|
+
receiver = args && args[0] ? args[0] : receiver;
|
|
3647
|
+
const shifted = args == null ? null : m === 'call' ? args.slice(1) : args.length < 2 ? [] : elementsOf(args[1]);
|
|
3648
|
+
// Function.prototype.call.call(f, t, …): the method called is f, with .call's shifted arguments.
|
|
3649
|
+
const proto = inner.type === 'MemberExpression' && ['call', 'apply'].includes(staticKey(inner)) && unchain(inner.object).type === 'MemberExpression' && staticKey(unchain(inner.object)) === 'prototype' && this.globalName(unchain(inner.object).object, scope) === 'Function';
|
|
3650
|
+
if (proto) {
|
|
3651
|
+
const f = args && args[0] ? unchain(args[0]) : null;
|
|
3652
|
+
if (!f) return { callee: null, args: null, receiver };
|
|
3653
|
+
callee = { type: 'MemberExpression', object: f, property: { type: 'Identifier', name: staticKey(inner) }, computed: false, optional: false, loc: node.loc };
|
|
3654
|
+
args = shifted;
|
|
3655
|
+
continue;
|
|
3656
|
+
}
|
|
3657
|
+
callee = inner; args = shifted;
|
|
3658
|
+
continue;
|
|
3659
|
+
}
|
|
3660
|
+
break;
|
|
3661
|
+
}
|
|
3662
|
+
return { callee, args, receiver };
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
call(frame) {
|
|
3666
|
+
const { node, parent, key, scope } = frame;
|
|
3667
|
+
const resolved = this.resolveCall(node, scope);
|
|
3668
|
+
let callee = resolved.callee;
|
|
3669
|
+
// (f.call(t, …) with f not written down: nothing to judge.)
|
|
3670
|
+
if (!callee) return;
|
|
3671
|
+
// The arguments as written, or none when the list is not written down
|
|
3672
|
+
// (`apply(t, list)`): the rules that need one read it as missing.
|
|
3673
|
+
const args = resolved.args ?? [];
|
|
3674
|
+
const unknownArgs = resolved.args == null;
|
|
3675
|
+
// A setter or a method reached through its property descriptor:
|
|
3676
|
+
// desc.set.call(el, v) is el.<name> = v, desc.value.call(el, …) is
|
|
3677
|
+
// el.<name>(…); a getter read is a read. A descriptor of a name not
|
|
3678
|
+
// written down is refused (member() names it where it is read).
|
|
3679
|
+
let viaDescriptor = false;
|
|
3680
|
+
if (callee.type === 'MemberExpression' && ['set', 'value', 'get'].includes(staticKey(callee))) {
|
|
3681
|
+
const desc = this.descriptorOf(callee.object, scope);
|
|
3682
|
+
if (desc) {
|
|
3683
|
+
const which = staticKey(callee);
|
|
3684
|
+
if (desc.name == null) return this.add('method-reference', node, `.${which} of a property's descriptor by a name not written down, called`);
|
|
3685
|
+
if (which === 'get') return;
|
|
3686
|
+
// (desc.set.apply(el, list), desc.value.apply(el, list) with the list not written down: what the setter or method gets is not read.)
|
|
3687
|
+
if (unknownArgs) return this.add('hidden-markup', node, `.${which} of '${desc.name}' through its descriptor, with arguments not written down`);
|
|
3688
|
+
const target = resolved.receiver ?? desc.target ?? { type: 'Identifier', name: '*descriptor*', ...at(node) };
|
|
3689
|
+
const property = { type: 'MemberExpression', object: target, property: { type: 'Identifier', name: desc.name, ...at(node) }, computed: false, optional: false, ...at(node) };
|
|
3690
|
+
if (which === 'set') return this.check({ node: { type: 'AssignmentExpression', operator: '=', left: property, right: args[0] ?? literalNode(undefined, node), ...at(node) }, parent, key, grand: frame.grand, scope });
|
|
3691
|
+
callee = property;
|
|
3692
|
+
viaDescriptor = true;
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
// (function ({ eval: e }) { … })(window): the pattern reads the argument.
|
|
3696
|
+
if (FUNCTIONS.has(callee.type)) callee.params.forEach((p, i) => { if (p.type !== 'Identifier' && args[i]) this.destructure(p, args[i], scope); });
|
|
3697
|
+
let name = this.globalName(callee, scope);
|
|
3698
|
+
// A judged method called bare (insertAdjacentHTML(…) with no receiver, as under a `with` or a
|
|
3699
|
+
// page's own function of that name): judged as the method, whatever it is called on.
|
|
3700
|
+
if (callee.type === 'Identifier' && name != null && JUDGED_METHODS.has(name)) { callee = { type: 'MemberExpression', object: { type: 'Identifier', name: '*bare*', ...at(callee) }, property: { type: 'Identifier', name, ...at(callee) }, computed: false, optional: false, ...at(callee) }; name = null; }
|
|
3701
|
+
if (name === 'setTimeout' || name === 'setInterval') {
|
|
3702
|
+
if (args[0] && this.isStringy(args[0], scope)) this.add('string-timer', node, `${name}(a string, …)`);
|
|
3703
|
+
if (args[1]) {
|
|
3704
|
+
const ms = this.fold(args[1], scope);
|
|
3705
|
+
const simple = ['Literal', 'Identifier', 'MemberExpression', 'ChainExpression'].includes(args[1].type);
|
|
3706
|
+
if (ms != null && ms > LIMITS.longTimerMs) this.add('timer', node, `${name} after ${Math.round(ms / 1000)} s`);
|
|
3707
|
+
else if (ms == null && !simple) this.add('timer', node, `${name} with a computed delay`);
|
|
3708
|
+
}
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
if (name === 'fetch') {
|
|
3712
|
+
const s = args[0] ? literalString(args[0]) : null;
|
|
3713
|
+
if (s == null) this.add('network', node, 'fetch(a computed address)');
|
|
3714
|
+
else if (s.startsWith('/api/')) this.add('own-server', node, `fetch(${JSON.stringify(s.slice(0, 40))})`);
|
|
3715
|
+
return;
|
|
3716
|
+
}
|
|
3717
|
+
if (name === 'importScripts') {
|
|
3718
|
+
if (unknownArgs) this.add('import-dynamic', node, 'importScripts(a list not written down)');
|
|
3719
|
+
for (const a of args) { const s = literalString(a); if (s == null) this.add('import-dynamic', node, 'importScripts(a computed address)'); else if (SCHEME.test(s)) this.add('import-url', node, `importScripts(${JSON.stringify(s.slice(0, 60))})`); }
|
|
3720
|
+
return;
|
|
3721
|
+
}
|
|
3722
|
+
if (name === 'open') { if (args[0]) this.urlSink(node, args[0], scope, 'open('); return; }
|
|
3723
|
+
if (callee.type !== 'MemberExpression') return;
|
|
3724
|
+
const o = this.globalName(callee.object, scope), k = this.key(callee, scope);
|
|
3725
|
+
// document.write reached otherwise than as the member written (through its descriptor: member() never saw the node).
|
|
3726
|
+
if (o === 'document' && (k === 'write' || k === 'writeln') && viaDescriptor) return this.add('document-write', node, `document.${k} through its descriptor`);
|
|
3727
|
+
// Object.setPrototypeOf(o, window), Reflect.setPrototypeOf(o, window): o reads every global through its prototype chain from here on.
|
|
3728
|
+
if ((o === 'Object' || o === 'Reflect') && k === 'setPrototypeOf' && args[1] && this.isGlobalObject(args[1], scope)) return this.add('computed-global', node, `${o}.setPrototypeOf(…, the global object): every global reached through the prototype chain`);
|
|
3729
|
+
const read = this.reflectRead({ callee, arguments: args }, scope);
|
|
3730
|
+
if (read) {
|
|
3731
|
+
if (read.key == null) this.add('computed-global', node, `${o}.${k}(the global object, a computed name)`);
|
|
3732
|
+
// Reflect.set(window, 'location', u) moves the page as window.location = u does.
|
|
3733
|
+
else if (read.key === 'location' && k === 'set') { this.add('navigate', node, "Reflect.set(window, 'location')"); this.urlSink(node, args[2], scope, 'location ='); }
|
|
3734
|
+
else if (read.key !== 'document') this.globalReference(read.key, { node, parent, grand: frame.grand, key });
|
|
3735
|
+
return;
|
|
3736
|
+
}
|
|
3737
|
+
// Reflect.get(navigator, k): a host object by a name computed at run time is refused; by a written one, judged as the member.
|
|
3738
|
+
if (o === 'Reflect' && (k === 'get' || k === 'has' || k === 'getOwnPropertyDescriptor') && args[0]) {
|
|
3739
|
+
const host = this.globalName(args[0], scope);
|
|
3740
|
+
if (host != null && ['navigator', 'location', 'screen', 'crypto', 'performance', 'navigation', 'Intl', 'WebAssembly'].includes(host)) {
|
|
3741
|
+
const prop = args[1] ? this.exact(args[1], scope) : null;
|
|
3742
|
+
if (!prop || !prop.length) return this.add('computed-global', node, `Reflect.${k}(${host}, a name computed at run time): a host object by a name computed at run time`);
|
|
3743
|
+
for (const p of prop) this.member({ node: { type: 'MemberExpression', object: args[0], property: literalNode(p, node), computed: true, optional: false, ...at(node) }, parent: node, grand: parent, key: 'callee', scope });
|
|
3744
|
+
return;
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
if (o === 'Reflect' && DESCRIPTOR_READERS.has(k) && args[1]) {
|
|
3748
|
+
const hidden = this.hiddenName(args[1], scope);
|
|
3749
|
+
if (hidden) this.add('hidden-name', node, `Reflect.${k}(…, ${hidden})`);
|
|
3750
|
+
else if (literalString(args[1]) === 'constructor' && !(parent?.type === 'BinaryExpression')) this.add('function-constructor', node, `Reflect.${k}(…, 'constructor')`);
|
|
3751
|
+
// Reflect.set(el, 'innerHTML', v) is el.innerHTML = v; Reflect.set(location, 'href', u) moves the page.
|
|
3752
|
+
if (k === 'set') {
|
|
3753
|
+
const prop = literalString(args[1]);
|
|
3754
|
+
if (args[0] && this.globalName(args[0], scope) === 'location') { this.add('navigate', node, `Reflect.set(location, '${prop ?? '…'}')`); this.urlSink(node, args[2], scope, 'location ='); }
|
|
3755
|
+
else if (prop != null && MARKUP_PROPS.has(prop)) this.markupSink(node, args[2], scope, `Reflect.set(…, '${prop}')`);
|
|
3756
|
+
else if (prop != null && URL_PROPS.has(prop)) this.urlSink(node, args[2], scope, `Reflect.set(…, '${prop}')`, args[0]);
|
|
3757
|
+
else if (prop === 'protocol') this.protocolSink(node, args[2], scope, "Reflect.set(…, 'protocol')");
|
|
3758
|
+
else if (prop === 'srcdoc') this.add('navigation', node, "Reflect.set(…, 'srcdoc')");
|
|
3759
|
+
}
|
|
3760
|
+
if (k === 'getOwnPropertyDescriptor') this.descriptorTaken(frame, args, 'Reflect');
|
|
3761
|
+
return;
|
|
3762
|
+
}
|
|
3763
|
+
if (o === 'Object' && (k === 'getOwnPropertyDescriptor' || k === 'defineProperty') && args[1]) {
|
|
3764
|
+
const hidden = this.hiddenName(args[1], scope);
|
|
3765
|
+
if (hidden) this.add('hidden-name', node, `Object.${k}(…, ${hidden})`);
|
|
3766
|
+
else if (k === 'getOwnPropertyDescriptor' && literalString(args[1]) === 'constructor') this.add('function-constructor', node, "Object.getOwnPropertyDescriptor(…, 'constructor')");
|
|
3767
|
+
if (k === 'getOwnPropertyDescriptor') this.descriptorTaken(frame, args, 'Object');
|
|
3768
|
+
return;
|
|
3769
|
+
}
|
|
3770
|
+
// Object.getOwnPropertyDescriptors(o) of a game object, indexed by a name not written down: a descriptor of anything.
|
|
3771
|
+
if (o === 'Object' && k === 'getOwnPropertyDescriptors' && args[0] && !this.isGlobalObject(args[0], scope) && parent?.type === 'MemberExpression' && key === 'object' && this.key(parent, scope) == null) { this.add('method-reference', node, 'Object.getOwnPropertyDescriptors(…)[a name not written down]'); return; }
|
|
3772
|
+
if ((o === 'Object' && ENUMERATORS.has(k)) || (o === 'Reflect' && k === 'ownKeys')) {
|
|
3773
|
+
if (args.some((a) => this.isGlobalObject(a, scope))) this.add('computed-global', node, `${o}.${k}(the global object)`);
|
|
3774
|
+
return;
|
|
3775
|
+
}
|
|
3776
|
+
if (o === 'Object' && k === 'assign') {
|
|
3777
|
+
if (args.slice(1).some((a) => this.isGlobalObject(a, scope))) this.add('computed-global', node, 'Object.assign(…, the global object)');
|
|
3778
|
+
// Object.assign(location, { href: u }) moves the page; the href written there is read as location.href = u.
|
|
3779
|
+
if (args[0] && this.globalName(args[0], scope) === 'location') {
|
|
3780
|
+
this.add('navigate', node, 'Object.assign(location, …)');
|
|
3781
|
+
for (const a of args.slice(1)) { const source = unchain(a); if (source?.type === 'ObjectExpression') for (const p of source.properties) { if (p.type === 'Property' && !p.computed && ['href', 'pathname', 'search', 'hash'].includes(p.key.name ?? p.key.value)) this.urlSink(node, p.value, scope, 'location.href ='); } else this.urlSink(node, a, scope, 'location ='); }
|
|
3782
|
+
}
|
|
3783
|
+
return;
|
|
3784
|
+
}
|
|
3785
|
+
if (o === 'navigation' && k === 'navigate') { this.urlSink(node, args[0], scope, 'navigation.navigate('); return; }
|
|
3786
|
+
if (k === 'createElement' || k === 'createElementNS') {
|
|
3787
|
+
// The tag as written or as its pieces spell it; a name not written
|
|
3788
|
+
// down is named for review (a helper's `el(tag, …)` is the common
|
|
3789
|
+
// case, and the browser's policy stands behind what it creates).
|
|
3790
|
+
const tags = this.exact(k === 'createElement' ? args[0] : args[1], scope);
|
|
3791
|
+
if (!tags) return this.add('element-name', node, `${k}(a computed name)`);
|
|
3792
|
+
for (const tag of tags) {
|
|
3793
|
+
const t = tag.toLowerCase().replace(/^[\w.-]+:/, '');
|
|
3794
|
+
if (t === 'script') this.add('script-element', node, "createElement('script')");
|
|
3795
|
+
else if (INPUT_TAGS.has(t)) this.add('input-element', node, `createElement('${t}')`);
|
|
3796
|
+
else if (t === 'object' || t === 'embed') this.add('navigation', node, `createElement('${t}')`);
|
|
3797
|
+
}
|
|
3798
|
+
return;
|
|
3799
|
+
}
|
|
3800
|
+
// Markup the page parses: the second argument of insertAdjacentHTML, the first of the rest.
|
|
3801
|
+
if (k === 'insertAdjacentHTML') {
|
|
3802
|
+
if (unknownArgs) return this.add('hidden-markup', node, 'insertAdjacentHTML with arguments not written down');
|
|
3803
|
+
return this.markupSink(node, args[1], scope, 'insertAdjacentHTML(…,');
|
|
3804
|
+
}
|
|
3805
|
+
if (k === 'setHTMLUnsafe' || k === 'parseHTMLUnsafe' || k === 'createContextualFragment' || k === 'parseFromString') {
|
|
3806
|
+
if (unknownArgs) return this.add('hidden-markup', node, `${k} with arguments not written down`);
|
|
3807
|
+
return this.markupSink(node, args[0], scope, `${k}(`);
|
|
3808
|
+
}
|
|
3809
|
+
if (k === 'setAttribute' || k === 'setAttributeNS' || k === 'toggleAttribute' || k === 'createAttribute' || k === 'createAttributeNS') {
|
|
3810
|
+
// The attribute's name as written, or as its pieces make it
|
|
3811
|
+
// (`'on' + 'click'`, atob(…), a const, each of a list a loop runs
|
|
3812
|
+
// over, a reversed string): a handler is refused however it is
|
|
3813
|
+
// spelled. A name built from a written prefix and something
|
|
3814
|
+
// computed is read by the prefix: `'on' + type` is a handler,
|
|
3815
|
+
// `'data-' + key` is not. A name the analyser cannot read is refused.
|
|
3816
|
+
const ns = k === 'setAttributeNS' || k === 'createAttributeNS';
|
|
3817
|
+
const nameArg = unknownArgs ? null : args[ns ? 1 : 0];
|
|
3818
|
+
const valueArg = k === 'setAttribute' ? args[1] : k === 'setAttributeNS' ? args[2] : null;
|
|
3819
|
+
if (!nameArg) return this.add('attribute-name', node, `${k}(a name not written down)`);
|
|
3820
|
+
const names = this.exact(nameArg, scope);
|
|
3821
|
+
if (names) {
|
|
3822
|
+
for (const n of names) {
|
|
3823
|
+
const local = n.toLowerCase().replace(/^[\w.-]+:/, '');
|
|
3824
|
+
if (/^on/.test(local)) this.add('inline-handler', node, `${k}('${n.slice(0, 20)}', …)`);
|
|
3825
|
+
else if (local === 'srcdoc' || local === 'http-equiv') this.add('navigation', node, `${k}('${local}', …)`);
|
|
3826
|
+
else if (URL_ATTRIBUTES.has(local) && valueArg) this.urlSink(node, valueArg, scope, `${k}('${local}',`, resolved.receiver ?? callee.object);
|
|
3827
|
+
}
|
|
3828
|
+
return;
|
|
3829
|
+
}
|
|
3830
|
+
const spelled = this.spelled(nameArg, scope);
|
|
3831
|
+
// A reversed or rearranged name reads as its pieces: judged backwards too.
|
|
3832
|
+
const whole = spelled == null ? '' : spelled.replace(MARKERS, '').toLowerCase();
|
|
3833
|
+
const reversed = [...whole].reverse().join('');
|
|
3834
|
+
if (/^on/.test(whole) || /^on/.test(reversed)) return this.add('inline-handler', node, `${k}('${(spelled ?? '').replace(MARKERS, '…').slice(0, 20)}', …) spelled from pieces`);
|
|
3835
|
+
const prefix = spelled == null || /\u0000/.test(spelled) || this.hiddenSource(nameArg, scope) ? '' : spelled.split(MARKER)[0].toLowerCase();
|
|
3836
|
+
if (prefix === '') return this.add('attribute-name', node, `${k}(a name not written down)`);
|
|
3837
|
+
const could = (attr) => attr.startsWith(prefix);
|
|
3838
|
+
if (could('on')) return this.add('inline-handler', node, `${k}('${prefix}' + …, …): a name built from 'on'`);
|
|
3839
|
+
if (could('srcdoc') || could('http-equiv')) return this.add('navigation', node, `${k}('${prefix}' + …, …): could be srcdoc or http-equiv`);
|
|
3840
|
+
if ([...URL_ATTRIBUTES].some(could) && valueArg) this.urlSink(node, valueArg, scope, `${k}('${prefix}' + …,`, resolved.receiver ?? callee.object);
|
|
3841
|
+
return;
|
|
3842
|
+
}
|
|
3843
|
+
if (o === 'location' && (k === 'assign' || k === 'replace' || k === 'reload')) { this.add('navigate', node, `location.${k}(…)`); if (k !== 'reload') this.urlSink(node, args[0], scope, `location.${k}(`); return; }
|
|
3844
|
+
if (k === 'postMessage' && GLOBAL_OBJECTS.has(o)) return this.add('frame-reach', node, `${o}.postMessage`);
|
|
3845
|
+
}
|
|
3846
|
+
// A descriptor taken of a game object's property: read at once through
|
|
3847
|
+
// .set/.get/.value and called, it is judged by call(); kept otherwise, a
|
|
3848
|
+
// judged name (or one not written down) is a method kept in a name.
|
|
3849
|
+
descriptorTaken(frame, args, via) {
|
|
3850
|
+
const { node, parent, grand, key, scope } = frame;
|
|
3851
|
+
if (!args[0] || this.isGlobalObject(args[0], scope)) return;
|
|
3852
|
+
const desc = this.descriptorOf(node, scope);
|
|
3853
|
+
if (!desc || !desc.judged) return;
|
|
3854
|
+
const member = parent?.type === 'MemberExpression' && key === 'object' && ['set', 'get', 'value'].includes(staticKey(parent));
|
|
3855
|
+
const called = member && grand && ((grand.type === 'MemberExpression' && ['call', 'apply'].includes(staticKey(grand))) || grand.type === 'CallExpression');
|
|
3856
|
+
if (!called) this.add('method-reference', node, `${via}.getOwnPropertyDescriptor(…, ${desc.name != null ? `'${desc.name}'` : 'a name not written down'}) kept: its setter or method is called where the analyser does not read`);
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
construct(frame) {
|
|
3860
|
+
const { node, scope } = frame;
|
|
3861
|
+
const callee = unchain(node.callee);
|
|
3862
|
+
const name = this.globalName(callee, scope);
|
|
3863
|
+
const args = node.arguments;
|
|
3864
|
+
switch (name) {
|
|
3865
|
+
case 'Worker': case 'SharedWorker': {
|
|
3866
|
+
const s = args[0] ? literalString(args[0]) : null;
|
|
3867
|
+
if (s == null) this.add('blob-code', node, `new ${name}(a computed address)`);
|
|
3868
|
+
else if (SCHEME.test(s)) this.add('blob-code', node, `new ${name}(${JSON.stringify(s.slice(0, 40))})`);
|
|
3869
|
+
return;
|
|
3870
|
+
}
|
|
3871
|
+
case 'Blob': case 'File': {
|
|
3872
|
+
// A Blob of JavaScript is a worker's code, one of HTML or XML (an
|
|
3873
|
+
// SVG, an XHTML page) a document a frame may show: its parts, when
|
|
3874
|
+
// written down, are read as that (a script parsed and walked,
|
|
3875
|
+
// markup walked as the browser renders it); parts not written
|
|
3876
|
+
// down are code or markup the analyser never read.
|
|
3877
|
+
// The options are followed to where they are written (a literal, a name bound to one, Object.assign,
|
|
3878
|
+
// a spread of written objects): every `type` they may hold is read; options not written down, or a
|
|
3879
|
+
// type not written, may name a document or a script — refused, as a Response's init is.
|
|
3880
|
+
const opts = args[name === 'File' ? 2 : 1];
|
|
3881
|
+
if (!opts) return;
|
|
3882
|
+
const types = this.blobTypes(opts, scope);
|
|
3883
|
+
if (types === null) { this.add('blob-source', node, `new ${name}(…, options not written down)`); return; }
|
|
3884
|
+
for (const type of types) this.typedText(node, elementsOf(args[0]), type, scope, `new ${name}(…, { type: ${type.trim().slice(0, 30)} })`);
|
|
3885
|
+
return;
|
|
3886
|
+
}
|
|
3887
|
+
case 'Response': {
|
|
3888
|
+
// A Response typed text/html or XML is a document too (a blob: URL
|
|
3889
|
+
// of it framed): its body judged as a Blob's parts are; headers not
|
|
3890
|
+
// written down may name any type, refused.
|
|
3891
|
+
const init = args[1] ? unchain(args[1]) : null;
|
|
3892
|
+
if (!init) return;
|
|
3893
|
+
if (init.type !== 'ObjectExpression' || init.properties.some((p) => p.type !== 'Property' || p.computed)) return this.add('blob-source', node, 'new Response(…, init not written down)');
|
|
3894
|
+
const headers = init.properties.find((p) => (p.key.name ?? p.key.value) === 'headers');
|
|
3895
|
+
if (!headers) return;
|
|
3896
|
+
const type = this.contentType(headers.value, scope);
|
|
3897
|
+
if (type === undefined) return this.add('blob-source', node, 'new Response(…, { headers not written down })');
|
|
3898
|
+
if (type == null) return;
|
|
3899
|
+
const body = args[0] ? unchain(args[0]) : null;
|
|
3900
|
+
if (body?.type === 'NewExpression') return; // a Blob or a FormData, judged where it is made
|
|
3901
|
+
this.typedText(node, body ? [body] : [], type, scope, `new Response(…, { 'content-type': ${type.trim().slice(0, 30)} })`);
|
|
3902
|
+
return;
|
|
3903
|
+
}
|
|
3904
|
+
case 'Proxy': if (args[0] && this.isGlobalObject(args[0], scope)) this.add('computed-global', node, 'new Proxy(the global object, …)'); return;
|
|
3905
|
+
default: return;
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
// Every MIME type a Blob's or File's options may give (`type`), following
|
|
3909
|
+
// the options to the objects written: [] when none is written there, null
|
|
3910
|
+
// when the options, a property that may be `type`, or a type is not
|
|
3911
|
+
// written down.
|
|
3912
|
+
blobTypes(opts, scope) {
|
|
3913
|
+
const leaves = this.resolve(opts, scope);
|
|
3914
|
+
if (!leaves.length) return null;
|
|
3915
|
+
const out = [];
|
|
3916
|
+
for (const l of leaves) {
|
|
3917
|
+
if (l.cut) return null;
|
|
3918
|
+
const x = l.node;
|
|
3919
|
+
// (undefined or null options give no type.)
|
|
3920
|
+
if (x.type === 'Literal' && (x.value == null || x.value === undefined)) continue;
|
|
3921
|
+
if (x.type === 'Identifier' && x.name === 'undefined') continue;
|
|
3922
|
+
// (A written object, or Object.assign/freeze of ones, has its members read; anything else is not written down.)
|
|
3923
|
+
const types = x.type === 'ObjectExpression' || x.type === 'CallExpression' ? this.membersOf(l, 'type', opts, scope, 0) : null;
|
|
3924
|
+
if (types === null) return null;
|
|
3925
|
+
for (const t of types) {
|
|
3926
|
+
if (t.cut) return null;
|
|
3927
|
+
const v = this.exactLeaf(t.node, t.scope, 0);
|
|
3928
|
+
if (!v) return null;
|
|
3929
|
+
out.push(...v);
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
return [...new Set(out)];
|
|
3933
|
+
}
|
|
3934
|
+
// The content-type a headers initialiser names: of an object literal
|
|
3935
|
+
// (keys compared without case), `new Headers(…)` of one, a list of pairs.
|
|
3936
|
+
// null when none; undefined when the headers are not written down.
|
|
3937
|
+
contentType(headers, scope) {
|
|
3938
|
+
const h = unchain(headers);
|
|
3939
|
+
if (!h) return undefined;
|
|
3940
|
+
if (h.type === 'NewExpression' && this.globalName(h.callee, scope) === 'Headers') return h.arguments[0] ? this.contentType(h.arguments[0], scope) : null;
|
|
3941
|
+
if (h.type === 'ObjectExpression') {
|
|
3942
|
+
if (h.properties.some((p) => p.type !== 'Property' || p.computed)) return undefined;
|
|
3943
|
+
const p = h.properties.find((p) => String(p.key.name ?? p.key.value).toLowerCase() === 'content-type');
|
|
3944
|
+
if (!p) return null;
|
|
3945
|
+
const v = this.exact(p.value, scope);
|
|
3946
|
+
return v && v.length === 1 ? v[0] : undefined;
|
|
3947
|
+
}
|
|
3948
|
+
if (h.type === 'ArrayExpression') {
|
|
3949
|
+
for (const pair of h.elements) {
|
|
3950
|
+
if (!pair || pair.type !== 'ArrayExpression' || pair.elements.length < 2) return undefined;
|
|
3951
|
+
const k = this.exact(pair.elements[0], scope);
|
|
3952
|
+
if (!k || k.length !== 1) return undefined;
|
|
3953
|
+
if (k[0].toLowerCase() === 'content-type') { const v = this.exact(pair.elements[1], scope); return v && v.length === 1 ? v[0] : undefined; }
|
|
3954
|
+
}
|
|
3955
|
+
return null;
|
|
3956
|
+
}
|
|
3957
|
+
return undefined;
|
|
3958
|
+
}
|
|
3959
|
+
// Text typed as code or a document (a Blob's parts, a Response's body): a
|
|
3960
|
+
// script is parsed and walked, a page or an XML document walked as the
|
|
3961
|
+
// browser renders it; a part not written down is refused.
|
|
3962
|
+
typedText(node, parts, type, scope, what) {
|
|
3963
|
+
const js = JS_MIME.test(type), page = PAGE_MIME.test(type), xml = XML_MIME.test(type);
|
|
3964
|
+
if (!js && !page && !xml) return;
|
|
3965
|
+
if (js) this.add('blob-code', node, `${what}: javascript`);
|
|
3966
|
+
const texts = parts && parts.map((p) => { const v = this.exact(p, scope); return v && v.length === 1 ? v[0] : null; });
|
|
3967
|
+
if (!texts || texts.some((t) => t == null)) return this.add('blob-source', node, `${what} with parts not written down`);
|
|
3968
|
+
const text = texts.join('');
|
|
3969
|
+
if (js) {
|
|
3970
|
+
const r = analyseSource(text, { mode: 'script', lineOffset: 0 });
|
|
3971
|
+
for (const f of r.findings) this.add(f.rule, node, `${f.what} (in a blob's code)`);
|
|
3972
|
+
for (const w of r.wasm) this.wasm.push({ line: (node.loc?.start.line ?? 1) + this.lineOffset, what: `${w.what} (in a blob's code)` });
|
|
3973
|
+
if (r.minified) this.add('blob-source', node, 'a blob of minified code');
|
|
3974
|
+
} else this.document(node, text, xml, what);
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3977
|
+
// Every string in the code: markup a game must not build, the report path,
|
|
3978
|
+
// a javascript: URL, fields and credential words for review, escapes. A
|
|
3979
|
+
// piece of a template with expressions is read for its words here and for
|
|
3980
|
+
// its markup with the whole template (`whole` is false).
|
|
3981
|
+
text(node, value, raw, whole = true) {
|
|
3982
|
+
if (value.includes('/csp-report')) this.add('report-path', node, '"/csp-report"');
|
|
3983
|
+
if (SCRIPT_TAG.test(value)) this.add('script-element', node, 'a <script> tag in text');
|
|
3984
|
+
if (META_CSP.test(value)) this.add('meta-csp', node, 'a <meta http-equiv=Content-Security-Policy> in text');
|
|
3985
|
+
if (NAVIGATION.test(value)) this.add('navigation', node, 'base, refresh, object/embed or srcdoc in text');
|
|
3986
|
+
if (isJavascriptUrl(value)) this.add('inline-handler', node, 'a javascript: URL');
|
|
3987
|
+
if (whole) { this.markup(node, value); this.dataUrl(node, value, 'a string'); }
|
|
3988
|
+
const word = CREDENTIAL_WORDS.exec(value);
|
|
3989
|
+
if (word) this.add('credential-words', node, JSON.stringify(word[0]));
|
|
3990
|
+
if (typeof raw === 'string' && raw.includes('\\')) {
|
|
3991
|
+
const escapes = raw.match(ESCAPE);
|
|
3992
|
+
if (escapes && escapes.length >= 4 && escapes.every((e) => { const cp = parseInt(e.replace(/^\\[xu]\{?|\}$/g, ''), 16); return cp >= 0x20 && cp <= 0x7e; })) this.escapedStrings++;
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
|
|
3996
|
+
// A string that holds a tag is markup the page will build (innerHTML,
|
|
3997
|
+
// insertAdjacentHTML, outerHTML, createContextualFragment, setHTMLUnsafe):
|
|
3998
|
+
// walked as a page is, so a handler attribute, a javascript: URL, a field
|
|
3999
|
+
// or a data: script in it is seen, and a computed part where a tag or an
|
|
4000
|
+
// attribute name goes is refused. The walk names the fields; the regex is
|
|
4001
|
+
// for strings without a tag (`contenteditable` on its own).
|
|
4002
|
+
markup(node, value, from = 'in text') {
|
|
4003
|
+
if (MARKUP_IN_TEXT.test(value)) {
|
|
4004
|
+
for (const f of analyseHtml(value).findings) this.add(f.rule, node, `${f.what} ${from}`);
|
|
4005
|
+
return;
|
|
4006
|
+
}
|
|
4007
|
+
const input = INPUT_TAG.exec(value);
|
|
4008
|
+
if (input && !(input[1]?.toLowerCase() === 'input' && PLAIN_INPUT_TYPES.test(input[2]))) this.add('input-element', node, input[1] ? `<${input[1].toLowerCase()}> ${from}` : `contenteditable ${from}`);
|
|
4009
|
+
}
|
|
4010
|
+
|
|
4011
|
+
// A data: URL in a string: a script is code outside the bundle; a page or
|
|
4012
|
+
// an SVG/XML document is read as the frame would render it (its handlers,
|
|
4013
|
+
// scripts and inline code judged); one whose body cannot be decoded is
|
|
4014
|
+
// refused unread. An image or a font is nothing.
|
|
4015
|
+
dataUrl(node, value, what) {
|
|
4016
|
+
const m = DATA_URL.exec(value);
|
|
4017
|
+
if (!m) return;
|
|
4018
|
+
const mime = m[1].trim().toLowerCase();
|
|
4019
|
+
if (JS_MIME.test(mime)) return this.add('data-url-code', node, `${what}: a script in a data: URL`);
|
|
4020
|
+
const page = PAGE_MIME.test(mime), xml = XML_MIME.test(mime);
|
|
4021
|
+
if (!page && !xml) return;
|
|
4022
|
+
// A page from a data: URL is a document that is not a file of the bundle (as a frame on one is); an SVG is art until it holds code.
|
|
4023
|
+
if (page) this.add('data-url-code', node, `${what}: a page in a data: URL`);
|
|
4024
|
+
let body;
|
|
4025
|
+
if (/;\s*base64\s*(?:;|$)/i.test(m[2])) {
|
|
4026
|
+
const b64 = m[3].replace(/\s+/g, '');
|
|
4027
|
+
if (!/^[A-Za-z0-9+/_-]*={0,2}$/.test(b64) || b64.replace(/=+$/, '').length % 4 === 1) return this.add('data-url-code', node, `${what}: a data: document the analyser could not decode`);
|
|
4028
|
+
body = Buffer.from(b64.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
|
|
4029
|
+
} else {
|
|
4030
|
+
try { body = decodeURIComponent(m[3]); } catch { return this.add('data-url-code', node, `${what}: a data: document the analyser could not decode`); }
|
|
4031
|
+
}
|
|
4032
|
+
this.document(node, body, xml, `${what}: a data: document`);
|
|
4033
|
+
}
|
|
4034
|
+
|
|
4035
|
+
// A document a string holds (a data: URL's body, a Blob's parts): walked as
|
|
4036
|
+
// a page or an XML document, its inline scripts parsed and walked.
|
|
4037
|
+
document(node, text, xml, what) {
|
|
4038
|
+
if (xml) { for (const f of analyseXml(text).findings) this.add(f.rule, node, `${f.what} (${what})`); return; }
|
|
4039
|
+
const page = analyseHtml(text);
|
|
4040
|
+
for (const f of page.findings) this.add(f.rule, node, `${f.what} (${what})`);
|
|
4041
|
+
for (const script of page.scripts) {
|
|
4042
|
+
const r = analyseSource(script.code, { mode: script.mode, lineOffset: 0 });
|
|
4043
|
+
for (const f of r.findings) this.add(f.rule, node, `${f.what} (a script in ${what})`);
|
|
4044
|
+
for (const w of r.wasm) this.wasm.push({ line: (node.loc?.start.line ?? 1) + this.lineOffset, what: `${w.what} (${what})` });
|
|
4045
|
+
if (r.minified) this.add('blob-source', node, `minified code in ${what}`);
|
|
4046
|
+
}
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
|
|
4050
|
+
// ---- entry points -----------------------------------------------------------
|
|
4051
|
+
|
|
4052
|
+
function parseScript(source, mode) {
|
|
4053
|
+
const options = { ecmaVersion: 'latest', locations: true, allowHashBang: true };
|
|
4054
|
+
const attempts = mode === 'module' ? ['module'] : mode === 'script' ? ['script'] : ['module', 'script'];
|
|
4055
|
+
let error = null;
|
|
4056
|
+
for (const sourceType of attempts) {
|
|
4057
|
+
try { return { ast: parse(source, { ...options, sourceType }), mode: sourceType }; }
|
|
4058
|
+
catch (e) { error = error || e; if (!(e instanceof SyntaxError)) throw e; }
|
|
4059
|
+
}
|
|
4060
|
+
return { error };
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4063
|
+
/**
|
|
4064
|
+
* Analyse one script. `mode` is 'module', 'script', or 'auto' (a module first,
|
|
4065
|
+
* a classic script when that fails: a .js file loaded by a plain <script> tag).
|
|
4066
|
+
* `lineOffset` places an inline script's lines in its page.
|
|
4067
|
+
*
|
|
4068
|
+
* Returns { parsed, mode, findings: [{ level, rule, line, what }], names, short,
|
|
4069
|
+
* minified, obfuscated, wasm: [{ line, what }], sourceMappingURL }.
|
|
4070
|
+
*/
|
|
4071
|
+
export function analyseSource(source, { mode = 'auto', lineOffset = 0 } = {}) {
|
|
4072
|
+
const unit = prepareSource(source, { mode, lineOffset });
|
|
4073
|
+
return judgeSource(unit);
|
|
4074
|
+
}
|
|
4075
|
+
|
|
4076
|
+
/**
|
|
4077
|
+
* The analysis in two steps, for the scan: every file of a bundle is parsed
|
|
4078
|
+
* and its scopes built first (prepareSource), the files are then wired to one
|
|
4079
|
+
* another (unit.analysis.modules, .pool, .pages: what another file exports,
|
|
4080
|
+
* what a classic script sets on the global object, the elements of the pages
|
|
4081
|
+
* a script runs in), and each is judged (judgeSource). A one-file analysis
|
|
4082
|
+
* (analyseSource) knows nothing beyond its file.
|
|
4083
|
+
*/
|
|
4084
|
+
export function prepareSource(source, { mode = 'auto', lineOffset = 0 } = {}) {
|
|
4085
|
+
const a = new Analysis(lineOffset);
|
|
4086
|
+
const result = { parsed: false, mode: null, findings: a.findings, names: 0, short: 0, minified: false, wasm: a.wasm, sourceMappingURL: sourceMappingURL(source) };
|
|
4087
|
+
const unit = { analysis: a, ast: null, result };
|
|
4088
|
+
let parsed;
|
|
4089
|
+
try { parsed = parseScript(source, mode); }
|
|
4090
|
+
catch (e) {
|
|
4091
|
+
// acorn recurses with the syntax: a file nested deeper than the stack is
|
|
4092
|
+
// not one a browser's parser reads either, and is refused as such.
|
|
4093
|
+
if (e instanceof RangeError) parsed = { error: Object.assign(new SyntaxError('nested too deep to parse'), { loc: { line: 1 } }) };
|
|
4094
|
+
else throw e;
|
|
4095
|
+
}
|
|
4096
|
+
if (parsed.error) {
|
|
4097
|
+
a.add('syntax', { loc: { start: { line: parsed.error.loc?.line ?? 1 } } }, parsed.error.message.replace(/\s*\(\d+:\d+\)$/, ''));
|
|
4098
|
+
return unit;
|
|
4099
|
+
}
|
|
4100
|
+
result.parsed = true;
|
|
4101
|
+
result.mode = parsed.mode;
|
|
4102
|
+
a.mode = parsed.mode;
|
|
4103
|
+
unit.ast = parsed.ast;
|
|
4104
|
+
a.buildScopes(parsed.ast);
|
|
4105
|
+
return unit;
|
|
4106
|
+
}
|
|
4107
|
+
export function judgeSource(unit) {
|
|
4108
|
+
const { analysis: a, ast, result } = unit;
|
|
4109
|
+
if (!ast) return result;
|
|
4110
|
+
// (What pass 1 resolved it resolved before the bundle was wired, and before every name was bound: read again.)
|
|
4111
|
+
a.cache.clear();
|
|
4112
|
+
a.mentioned.clear(); a.globalNames.clear();
|
|
4113
|
+
a.writesByKey = null; a.writesBySyntax = null;
|
|
4114
|
+
a.collectSites();
|
|
4115
|
+
a.run(ast);
|
|
4116
|
+
result.names = a.names;
|
|
4117
|
+
result.short = a.short;
|
|
4118
|
+
result.minified = a.names >= LIMITS.minNames && a.short / a.names >= LIMITS.shortNameRatio;
|
|
4119
|
+
if (a.hexNames >= LIMITS.hexNames) a.add('obfuscated', null, `${a.hexNames} names of the form _0x…`);
|
|
4120
|
+
if (a.escapedStrings >= LIMITS.escapedStrings) a.add('obfuscated', null, `${a.escapedStrings} strings of plain text written in \\x or \\u escapes`);
|
|
4121
|
+
return result;
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
/**
|
|
4125
|
+
* The bundle path a module specifier or a script's src names, from the file
|
|
4126
|
+
* that names it: relative to that file's folder, or from the bundle root
|
|
4127
|
+
* when it starts with `/`. null for a URL, a bare specifier, a path out of
|
|
4128
|
+
* the bundle (`/engine-sdk/sdk.js` is the platform's, not the bundle's).
|
|
4129
|
+
*/
|
|
4130
|
+
export function resolveSpecifier(from, specifier) {
|
|
4131
|
+
if (typeof specifier !== 'string' || !specifier || /^[a-z][a-z0-9+.-]*:/i.test(specifier) || specifier.startsWith('//')) return null;
|
|
4132
|
+
const clean = specifier.split(/[?#]/)[0];
|
|
4133
|
+
if (!clean) return null;
|
|
4134
|
+
const base = clean.startsWith('/') ? [] : from.split('/').slice(0, -1);
|
|
4135
|
+
if (!clean.startsWith('/') && !clean.startsWith('./') && !clean.startsWith('../')) return null;
|
|
4136
|
+
const out = [...base];
|
|
4137
|
+
for (const part of clean.split('/')) {
|
|
4138
|
+
if (part === '' || part === '.') continue;
|
|
4139
|
+
if (part === '..') { if (!out.length) return null; out.pop(); continue; }
|
|
4140
|
+
out.push(part);
|
|
4141
|
+
}
|
|
4142
|
+
return out.join('/');
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
/**
|
|
4146
|
+
* The elements of a page (or of markup written as a string) a selector
|
|
4147
|
+
* finds, by its last compound (the ancestors a complex selector asks for are
|
|
4148
|
+
* not checked: more elements match, never fewer): a tag, #id, .class,
|
|
4149
|
+
* [attr], [attr=value] and its ^= $= *= ~= |= forms, several of them, a
|
|
4150
|
+
* list of selectors. A pseudo-class is skipped (more elements match). null
|
|
4151
|
+
* when the selector cannot be read.
|
|
4152
|
+
*/
|
|
4153
|
+
export function matchSelector(elements, selector) {
|
|
4154
|
+
const out = [];
|
|
4155
|
+
for (const complex of selector.split(',')) {
|
|
4156
|
+
const compounds = complex.trim().split(/\s*[>+~]\s*|\s+/).filter(Boolean);
|
|
4157
|
+
if (!compounds.length) return null;
|
|
4158
|
+
let compound = compounds[compounds.length - 1].replace(/::?[\w-]+(?:\([^()]*\))?/g, '');
|
|
4159
|
+
const tests = [];
|
|
4160
|
+
const tag = /^([a-zA-Z][\w-]*|\*)/.exec(compound);
|
|
4161
|
+
if (tag) { compound = compound.slice(tag[0].length); if (tag[1] !== '*') tests.push((e) => e.tag === tag[1].toLowerCase()); }
|
|
4162
|
+
const token = /^(?:#([\w-]+)|\.([\w-]+)|\[\s*([\w:-]+)\s*(?:([~|^$*]?=)\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\s*)?\s*[is]?\s*\])/;
|
|
4163
|
+
while (compound.length) {
|
|
4164
|
+
const m = token.exec(compound);
|
|
4165
|
+
if (!m) return null;
|
|
4166
|
+
compound = compound.slice(m[0].length);
|
|
4167
|
+
if (m[1] != null) tests.push((e) => e.attrs.get('id') === m[1] || (e.written && (e.attrs.get('id') ?? '').includes(MARKER)));
|
|
4168
|
+
else if (m[2] != null) tests.push((e) => (e.attrs.get('class') ?? '').split(/\s+/).includes(m[2]) || (e.written && (e.attrs.get('class') ?? '').includes(MARKER)));
|
|
4169
|
+
else {
|
|
4170
|
+
const attr = m[3].toLowerCase(), op = m[4], value = m[5] ?? m[6] ?? m[7];
|
|
4171
|
+
tests.push((e) => {
|
|
4172
|
+
if (!e.attrs.has(attr)) return false;
|
|
4173
|
+
const v = e.attrs.get(attr);
|
|
4174
|
+
if (op == null) return true;
|
|
4175
|
+
if (e.written && v.includes(MARKER)) return true;
|
|
4176
|
+
switch (op) {
|
|
4177
|
+
case '=': return v === value;
|
|
4178
|
+
case '~=': return v.split(/\s+/).includes(value);
|
|
4179
|
+
case '|=': return v === value || v.startsWith(value + '-');
|
|
4180
|
+
case '^=': return value !== '' && v.startsWith(value);
|
|
4181
|
+
case '$=': return value !== '' && v.endsWith(value);
|
|
4182
|
+
case '*=': return value !== '' && v.includes(value);
|
|
4183
|
+
default: return false;
|
|
4184
|
+
}
|
|
4185
|
+
});
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
if (!tests.length) return null;
|
|
4189
|
+
for (const e of elements) if (tests.every((t) => t(e))) out.push(e);
|
|
4190
|
+
}
|
|
4191
|
+
return out;
|
|
4192
|
+
}
|
|
4193
|
+
|
|
4194
|
+
// The //# sourceMappingURL=… comment, read from the tail of the file where
|
|
4195
|
+
// bundlers write it.
|
|
4196
|
+
function sourceMappingURL(source) {
|
|
4197
|
+
const tail = source.slice(-4096);
|
|
4198
|
+
const m = /\/\/[#@]\s*sourceMappingURL=(\S+)\s*$/m.exec(tail);
|
|
4199
|
+
return m ? m[1] : null;
|
|
4200
|
+
}
|
|
4201
|
+
|
|
4202
|
+
// ---- HTML ---------------------------------------------------------------------
|
|
4203
|
+
|
|
4204
|
+
// The MIME types a browser runs a <script> as (WHATWG HTML, "JavaScript MIME
|
|
4205
|
+
// type essence match"), compared on the essence alone: `text/javascript;
|
|
4206
|
+
// charset=utf-8` and `text/livescript` run as surely as `text/javascript`.
|
|
4207
|
+
// No type, or `module`, is JavaScript too.
|
|
4208
|
+
const JS_MIME_ESSENCES = new Set(['application/ecmascript', 'application/javascript', 'application/x-ecmascript', 'application/x-javascript', 'text/ecmascript', 'text/javascript', 'text/javascript1.0', 'text/javascript1.1', 'text/javascript1.2', 'text/javascript1.3', 'text/javascript1.4', 'text/javascript1.5', 'text/jscript', 'text/livescript', 'text/x-ecmascript', 'text/x-javascript']);
|
|
4209
|
+
// What a <script type="…"> is: 'script', 'module', 'importmap', or null for
|
|
4210
|
+
// a data block the browser never runs (JSON, a shader, speculation rules).
|
|
4211
|
+
export function scriptKind(type) {
|
|
4212
|
+
const t = type.trim().toLowerCase();
|
|
4213
|
+
if (t === '') return 'script';
|
|
4214
|
+
if (t === 'module') return 'module';
|
|
4215
|
+
if (t === 'importmap') return 'importmap';
|
|
4216
|
+
return JS_MIME_ESSENCES.has(t.split(';')[0].trim()) ? 'script' : null;
|
|
4217
|
+
}
|
|
4218
|
+
// Elements whose content HTML reads as text up to the closing tag. XML has
|
|
4219
|
+
// none: every element's content is markup (or a CDATA section).
|
|
4220
|
+
const RAW_TEXT = new Set(['script', 'style', 'textarea', 'title', 'xmp']);
|
|
4221
|
+
// One attribute at the position asked (sticky): a name, and a value in double
|
|
4222
|
+
// quotes, single quotes or bare. A quoted value holds any character but its
|
|
4223
|
+
// quote, `>` included; one never closed holds the rest of the file, as in the
|
|
4224
|
+
// browser, where the tag it is in is then dropped.
|
|
4225
|
+
const ATTRIBUTE = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)(?:"|$)|'([^']*)(?:'|$)|([^\s>]+)))?/y;
|
|
4226
|
+
// A tag's name where a tag opens: letters, or a MARKER standing for a computed part.
|
|
4227
|
+
const TAG_NAME = /^<([a-zA-Z\uE000][\w:\-\uE000]*)/;
|
|
4228
|
+
const URL_ATTRIBUTES = new Set(['src', 'href', 'action', 'formaction', 'data', 'xlink:href', 'poster', 'ping']);
|
|
4229
|
+
const FRAME_TAGS = new Set(['iframe', 'frame', 'embed']);
|
|
4230
|
+
// SVG animation elements, which rewrite the attribute they name.
|
|
4231
|
+
const SMIL_TAGS = new Set(['animate', 'set', 'animatetransform', 'animatemotion', 'animatecolor']);
|
|
4232
|
+
|
|
4233
|
+
// The character references an attribute value is read through, as the browser
|
|
4234
|
+
// reads them: numeric ones with or without the `;` (`javascript:` is
|
|
4235
|
+
// javascript:), and the named ones that spell a URL's punctuation or the
|
|
4236
|
+
// whitespace the URL parser drops (`java	script:`, `javascript:`).
|
|
4237
|
+
// No named reference spells an ASCII letter (`fj` spells "fj", which no
|
|
4238
|
+
// scheme holds), so this table reads every URL the full one would. A code
|
|
4239
|
+
// point outside Unicode, or a surrogate, is the replacement character.
|
|
4240
|
+
const NAMED_ENTITIES = new Map(Object.entries({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0', tab: '\t', newline: '\n', colon: ':', sol: '/', semi: ';', equals: '=', num: '#', lpar: '(', rpar: ')', excl: '!', quest: '?', period: '.', comma: ',', plus: '+', minus: '-', lowbar: '_', ast: '*', midast: '*', percnt: '%', commat: '@', dollar: '$', lsqb: '[', rsqb: ']', lbrack: '[', rbrack: ']', bsol: '\\', hat: '^', grave: '`', lcub: '{', rcub: '}', lbrace: '{', rbrace: '}', verbar: '|', vert: '|', verticalline: '|', fjlig: 'fj' }));
|
|
4241
|
+
const codePoint = (cp) => (cp > 0 && cp <= 0x10ffff && !(cp >= 0xd800 && cp <= 0xdfff) ? String.fromCodePoint(cp) : '�');
|
|
4242
|
+
const ENTITY = /&(?:#x([0-9a-f]+)|#(\d+)|([a-z]+));?/gi;
|
|
4243
|
+
// Every digit counts, as in the browser (and libxml2): `j` is
|
|
4244
|
+
// U+006A, and a value past Unicode (parseInt/Number of a run of digits that
|
|
4245
|
+
// long is Infinity) is the replacement character.
|
|
4246
|
+
const decodeEntities = (s) => s.replace(ENTITY, (m, hex, dec, name) => {
|
|
4247
|
+
if (hex) return codePoint(parseInt(hex, 16));
|
|
4248
|
+
if (dec) return codePoint(Number(dec));
|
|
4249
|
+
return NAMED_ENTITIES.get(name.toLowerCase()) ?? m;
|
|
4250
|
+
});
|
|
4251
|
+
|
|
4252
|
+
/**
|
|
4253
|
+
* Walk a page's tags once, in linear time, and return its scripts (each with
|
|
4254
|
+
* its line and whether it is a module) and the markup findings: code in
|
|
4255
|
+
* attributes, import maps, scripts and frames on data: URLs, fields, and a
|
|
4256
|
+
* computed part (MARKER) where a tag or an attribute name goes, or as an
|
|
4257
|
+
* unquoted value (markup-parts, for review: the walk cannot know what the
|
|
4258
|
+
* part carries; a part inside a quoted value or in text is a stated limit). The scripts
|
|
4259
|
+
* go to analyseSource; the markup rules a regex serves (CSP, base, refresh,
|
|
4260
|
+
* object/embed, srcdoc, outside URLs) stay in scan.mjs.
|
|
4261
|
+
*
|
|
4262
|
+
* A tag is read whole, as the browser reads it: however long it runs on, and
|
|
4263
|
+
* with `>` allowed inside a quoted value, so a handler after four kilobytes of
|
|
4264
|
+
* padding, or after a value that holds a `>`, is seen. Every attribute written
|
|
4265
|
+
* is judged (the browser keeps the first of a duplicated name; the walk keeps
|
|
4266
|
+
* that one for its lookups and judges them all). Every step of the attribute
|
|
4267
|
+
* walk consumes at least one character, so the time is the file's length; the
|
|
4268
|
+
* attribute regex is anchored at its position and never searches ahead.
|
|
4269
|
+
*
|
|
4270
|
+
* `xml` reads the document as an XML parser does: an element's or an
|
|
4271
|
+
* attribute's namespace prefix is not its name, so `<svg:script>` and
|
|
4272
|
+
* `<h:script>` are scripts and `svg:onload` is judged as onload; no element's
|
|
4273
|
+
* content is raw text.
|
|
4274
|
+
*/
|
|
4275
|
+
export function analyseHtml(html, { xml = false } = {}) {
|
|
4276
|
+
const scripts = [];
|
|
4277
|
+
const findings = [];
|
|
4278
|
+
const elements = [];
|
|
4279
|
+
let overflow = false;
|
|
4280
|
+
const add = (rule, line, what) => findings.push({ level: RULES[rule].level, rule, line, what });
|
|
4281
|
+
let line = 1, lineAt = 0;
|
|
4282
|
+
const lineOf = (pos) => { for (; lineAt < pos; lineAt++) if (html.charCodeAt(lineAt) === 10) line++; return line; };
|
|
4283
|
+
const localName = (name) => (xml ? name.replace(/^[\w.\-\uE000]+:/, '') : name);
|
|
4284
|
+
let i = 0;
|
|
4285
|
+
while (i < html.length) {
|
|
4286
|
+
const lt = html.indexOf('<', i);
|
|
4287
|
+
if (lt < 0) break;
|
|
4288
|
+
if (html.startsWith('<!--', lt)) { const end = html.indexOf('-->', lt + 4); i = end < 0 ? html.length : end + 3; continue; }
|
|
4289
|
+
const nameMatch = TAG_NAME.exec(html.slice(lt, lt + 64));
|
|
4290
|
+
if (!nameMatch) { i = lt + 1; continue; }
|
|
4291
|
+
const name = localName(nameMatch[1].toLowerCase());
|
|
4292
|
+
const tagLine = lineOf(lt);
|
|
4293
|
+
if (name.includes(MARKER)) add('markup-parts', tagLine, 'a tag named by a computed part');
|
|
4294
|
+
const attrs = new Map();
|
|
4295
|
+
const all = [];
|
|
4296
|
+
let pos = lt + nameMatch[0].length, gt = -1;
|
|
4297
|
+
while (pos < html.length) {
|
|
4298
|
+
const ch = html.charCodeAt(pos);
|
|
4299
|
+
if (ch === 0x3e) { gt = pos; break; } // >
|
|
4300
|
+
if (ch === 0x20 || ch === 0x09 || ch === 0x0a || ch === 0x0c || ch === 0x0d || ch === 0x2f) { pos++; continue; } // space, tab, newline, form feed, return, /
|
|
4301
|
+
ATTRIBUTE.lastIndex = pos;
|
|
4302
|
+
const m = ATTRIBUTE.exec(html);
|
|
4303
|
+
if (!m) { pos++; continue; } // a stray quote or =: the browser skips it too
|
|
4304
|
+
const attr = m[1].toLowerCase();
|
|
4305
|
+
const value = decodeEntities(m[2] ?? m[3] ?? m[4] ?? '');
|
|
4306
|
+
// xlink:href is itself a name the rules know; any other prefix is dropped in XML.
|
|
4307
|
+
const local = xml && attr !== 'xlink:href' ? attr.replace(/^[\w.\-\uE000]+:/, '') : attr;
|
|
4308
|
+
// An unquoted value is one word: a computed part there that holds a space is attributes.
|
|
4309
|
+
if (m[4] != null && m[4].includes(MARKER)) add('markup-parts', tagLine, `<${name} ${attr}=…> a computed part as an unquoted attribute value`);
|
|
4310
|
+
all.push([local, value]);
|
|
4311
|
+
if (!attrs.has(local)) attrs.set(local, value);
|
|
4312
|
+
pos = ATTRIBUTE.lastIndex;
|
|
4313
|
+
}
|
|
4314
|
+
// A tag the file ends inside is dropped by the browser; its attributes
|
|
4315
|
+
// were still read, since nothing after it runs anyway.
|
|
4316
|
+
if (gt < 0) gt = html.length - 1;
|
|
4317
|
+
// The element, for the code's lookups (getElementById, a selector) and the attributes it reads back.
|
|
4318
|
+
if (elements.length < ELEMENTS) elements.push({ type: 'PageElement', tag: name, attrs, line: tagLine, written: false }); else overflow = true;
|
|
4319
|
+
for (const [attr, value] of all) {
|
|
4320
|
+
if (attr.includes(MARKER)) add('markup-parts', tagLine, `<${name} …> a computed part where an attribute name goes`);
|
|
4321
|
+
else if (/^on/.test(attr)) add('inline-handler', tagLine, `<${name} ${attr}=…>`);
|
|
4322
|
+
else if (URL_ATTRIBUTES.has(attr) && isJavascriptUrl(value)) add('inline-handler', tagLine, `<${name} ${attr}="javascript:…">`);
|
|
4323
|
+
else if (attr === 'contenteditable') add('input-element', tagLine, `<${name} contenteditable>`);
|
|
4324
|
+
}
|
|
4325
|
+
// SMIL rewrites an attribute over time: <animate attributeName="href"
|
|
4326
|
+
// values="javascript:…"> makes a link run code when clicked (Chromium
|
|
4327
|
+
// does), <set attributeName="onclick"> aims at a handler. The target
|
|
4328
|
+
// and every value it is given are judged as the attribute would be.
|
|
4329
|
+
if (SMIL_TAGS.has(name)) {
|
|
4330
|
+
const target = (attrs.get('attributename') ?? '').toLowerCase().replace(/^[\w.-]+:/, '');
|
|
4331
|
+
if (/^on/.test(target)) add('inline-handler', tagLine, `<${name} attributeName="${target.slice(0, 20)}">: animates a handler attribute`);
|
|
4332
|
+
else if (URL_ATTRIBUTES.has(target)) {
|
|
4333
|
+
for (const k of ['to', 'from', 'by', 'values']) for (const v of (attrs.get(k) ?? '').split(';')) {
|
|
4334
|
+
if (isJavascriptUrl(v)) add('inline-handler', tagLine, `<${name} attributeName="${target}" ${k}="javascript:…">`);
|
|
4335
|
+
else if (/^\s*(?:data|blob):/i.test(v)) add('data-url-code', tagLine, `<${name} attributeName="${target}" ${k}="data:…">`);
|
|
4336
|
+
}
|
|
4337
|
+
}
|
|
4338
|
+
}
|
|
4339
|
+
if (INPUT_TAGS.has(name) && !(name === 'input' && /^(?:range|checkbox|radio|button|hidden|submit|reset|color|file|image)$/i.test(attrs.get('type') ?? ''))) add('input-element', tagLine, `<${name}${attrs.has('type') ? ' type=' + attrs.get('type') : ''}>`);
|
|
4340
|
+
if ((name === 'script' || FRAME_TAGS.has(name)) && /^\s*(?:data|blob):/i.test(attrs.get('src') ?? '')) add('data-url-code', tagLine, `<${name} src="${attrs.get('src').slice(0, 30)}…">`);
|
|
4341
|
+
if (name === 'object' && /^\s*(?:data|blob):/i.test(attrs.get('data') ?? '')) add('data-url-code', tagLine, '<object data="data:…">');
|
|
4342
|
+
let next = gt + 1;
|
|
4343
|
+
if (!xml && RAW_TEXT.has(name)) {
|
|
4344
|
+
const close = html.slice(next).search(new RegExp(`</${name}\\b`, 'i'));
|
|
4345
|
+
const end = close < 0 ? html.length : next + close;
|
|
4346
|
+
const content = html.slice(next, end);
|
|
4347
|
+
if (name === 'script') {
|
|
4348
|
+
const kind = scriptKind(attrs.get('type') ?? '');
|
|
4349
|
+
if (kind === 'importmap') add('import-map', tagLine, '<script type="importmap">');
|
|
4350
|
+
else if (kind && content.trim()) scripts.push({ code: content, line: lineOf(next), mode: kind, src: attrs.get('src') ?? null });
|
|
4351
|
+
// A data block (<script type="application/json">): text the code may read (its textContent) and put into the page as markup: judged as a data file is.
|
|
4352
|
+
else if (!kind && content.trim()) for (const f of analyseData(content).findings) add(f.rule, lineOf(next), `${f.what.replace(/in a data file(?:'s (?:string|key))?/, `in a <script type="${(attrs.get('type') ?? '').slice(0, 30)}"> block`)}`);
|
|
4353
|
+
} else if (content.trim()) {
|
|
4354
|
+
// <xmp>, <textarea>, <title>, <style>: text the page shows as text, which the code may read back as markup; a tag, a handler or a javascript: URL in it is judged as in a data file (entities are read in textarea and title, as the browser reads them).
|
|
4355
|
+
const text = name === 'textarea' || name === 'title' ? decodeEntities(content) : content;
|
|
4356
|
+
for (const f of analyseData(text).findings) add(f.rule, lineOf(next), `${f.what.replace(/in a data file(?:'s (?:string|key))?/, `in <${name}> text`)}`);
|
|
4357
|
+
}
|
|
4358
|
+
next = end;
|
|
4359
|
+
}
|
|
4360
|
+
i = next;
|
|
4361
|
+
}
|
|
4362
|
+
return { scripts, findings, elements, overflow };
|
|
4363
|
+
}
|
|
4364
|
+
|
|
4365
|
+
// ---- XML documents ------------------------------------------------------------
|
|
4366
|
+
|
|
4367
|
+
// An internal DTD subset opens with `[` inside the DOCTYPE; a declaration of an
|
|
4368
|
+
// entity or an attribute default may also sit anywhere a hostile file puts it.
|
|
4369
|
+
const DOCTYPE_WITH_SUBSET = /<!DOCTYPE\s[^\[>]*\[/i;
|
|
4370
|
+
const DTD_DECLARATION = /<!(?:ENTITY|ATTLIST|ELEMENT|NOTATION)\b/i;
|
|
4371
|
+
|
|
4372
|
+
/**
|
|
4373
|
+
* Why an XML document is not read as the browser renders it: an internal DTD
|
|
4374
|
+
* subset (`<!DOCTYPE svg [ … ]>`), whose `<!ENTITY>` declarations become
|
|
4375
|
+
* markup where they are referenced and whose `<!ATTLIST … "default">`
|
|
4376
|
+
* declarations add attributes — an `onload` among them — to every such
|
|
4377
|
+
* element; a declaration of either kind wherever it stands; an
|
|
4378
|
+
* `<?xml-stylesheet?>`, whose transform's output is the page the browser
|
|
4379
|
+
* shows and is not this file. The analyser does not emulate any of it: the
|
|
4380
|
+
* document is refused with the reason, and the studio exports plain SVG. An
|
|
4381
|
+
* external DTD (`<!DOCTYPE svg PUBLIC "…" "…svg11.dtd">`) is not fetched by
|
|
4382
|
+
* browsers (the XHTML DTDs they carry define no attribute default that runs)
|
|
4383
|
+
* and is allowed. Returns [{ at, problem }].
|
|
4384
|
+
*/
|
|
4385
|
+
export function dtdProblems(xml) {
|
|
4386
|
+
const out = [];
|
|
4387
|
+
const subset = DOCTYPE_WITH_SUBSET.exec(xml);
|
|
4388
|
+
if (subset) out.push({ at: subset.index, problem: 'an internal DTD subset (<!DOCTYPE … [ … ]>): its entities and attribute defaults become markup the analyser does not read' });
|
|
4389
|
+
const decl = DTD_DECLARATION.exec(xml);
|
|
4390
|
+
if (decl) out.push({ at: decl.index, problem: `a ${decl[0].slice(2).toUpperCase()} declaration: entities and attribute defaults become markup the analyser does not read` });
|
|
4391
|
+
const stylesheet = xml.search(/<\?xml-stylesheet\b/i);
|
|
4392
|
+
if (stylesheet >= 0) out.push({ at: stylesheet, problem: '<?xml-stylesheet?>: the browser renders the transform\'s output' });
|
|
4393
|
+
return out;
|
|
4394
|
+
}
|
|
4395
|
+
|
|
4396
|
+
/**
|
|
4397
|
+
* An SVG, XML or XHTML document, read as the browser renders it when the
|
|
4398
|
+
* page frames it: its tags walked with namespace prefixes dropped. Any script
|
|
4399
|
+
* element is refused (svg-script: a game's scripts are .js files), as is a
|
|
4400
|
+
* DTD subset, an entity or attribute-list declaration or an
|
|
4401
|
+
* <?xml-stylesheet?> (xml-markup: markup the analyser would have to emulate
|
|
4402
|
+
* the parser to read). Handlers, javascript: URLs and fields are the walk's
|
|
4403
|
+
* findings.
|
|
4404
|
+
*/
|
|
4405
|
+
export function analyseXml(xml) {
|
|
4406
|
+
const findings = [];
|
|
4407
|
+
const lineAt = (at) => (at < 0 ? 1 : xml.slice(0, at).split('\n').length);
|
|
4408
|
+
for (const { at, problem } of dtdProblems(xml)) findings.push({ level: RULES['xml-markup'].level, rule: 'xml-markup', line: lineAt(at), what: problem });
|
|
4409
|
+
findings.push(...analyseHtml(xml, { xml: true }).findings);
|
|
4410
|
+
const script = xml.search(SCRIPT_TAG);
|
|
4411
|
+
if (script >= 0) findings.push({ level: RULES['svg-script'].level, rule: 'svg-script', line: lineAt(script), what: '<script> in an XML document' });
|
|
4412
|
+
return { findings };
|
|
4413
|
+
}
|
|
4414
|
+
|
|
4415
|
+
// ---- data files ---------------------------------------------------------------
|
|
4416
|
+
|
|
4417
|
+
/**
|
|
4418
|
+
* A text data file of the bundle (any file that is UTF-8 text and not code or
|
|
4419
|
+
* markup: .json, .txt, .csv, .atlas, a shader, a template of any extension):
|
|
4420
|
+
* what the game reads and puts into a page as markup comes from here when it
|
|
4421
|
+
* is not written in the code, so its text is walked as markup is — a handler
|
|
4422
|
+
* attribute, a <script>, a javascript: URL or a data: document in it is
|
|
4423
|
+
* refused as it would be in a string of the code. A file that parses as JSON
|
|
4424
|
+
* is read twice: as its raw text (the page may fetch it as text) and as every
|
|
4425
|
+
* string it parses to (< is <), to the last one: the walk is linear in
|
|
4426
|
+
* the file, which the file budget bounds. A byte-order mark is dropped first,
|
|
4427
|
+
* as the browser's response.json() drops it. Returns { findings, parsed }:
|
|
4428
|
+
* `parsed` says whether the text is JSON, so the caller refuses a .json that
|
|
4429
|
+
* is not (the page would read it as JSON; the analyser could not).
|
|
4430
|
+
*/
|
|
4431
|
+
export function analyseData(text) {
|
|
4432
|
+
const findings = [];
|
|
4433
|
+
const seen = new Set();
|
|
4434
|
+
const add = (rule, line, what) => { const id = `${rule}\0${line}\0${what}`; if (seen.has(id)) return; seen.add(id); findings.push({ level: RULES[rule].level, rule, line, what }); };
|
|
4435
|
+
const judge = (value, line, where) => {
|
|
4436
|
+
if (SCRIPT_TAG.test(value)) add('script-element', line, `a <script> tag ${where}`);
|
|
4437
|
+
if (isJavascriptUrl(value)) add('inline-handler', line, `a javascript: URL ${where}`);
|
|
4438
|
+
if (MARKUP_IN_TEXT.test(value)) for (const f of analyseHtml(value).findings) add(f.rule, line, `${f.what} ${where}`);
|
|
4439
|
+
const m = DATA_URL.exec(value);
|
|
4440
|
+
if (m && (JS_MIME.test(m[1]) || PAGE_MIME.test(m[1]) || XML_MIME.test(m[1]))) add('data-url-code', line, `a data: script or document ${where}`);
|
|
4441
|
+
};
|
|
4442
|
+
const src = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
4443
|
+
judge(src, 1, 'in a data file');
|
|
4444
|
+
let parsed;
|
|
4445
|
+
try { parsed = JSON.parse(src); } catch { return { findings, parsed: false }; }
|
|
4446
|
+
const stack = [parsed];
|
|
4447
|
+
// The walk is bounded: a file of more than DATA_VALUES values is not read
|
|
4448
|
+
// to its end and is refused as such (data-file), never passed unread.
|
|
4449
|
+
let steps = 0;
|
|
4450
|
+
while (stack.length && steps++ < DATA_VALUES) {
|
|
4451
|
+
const v = stack.pop();
|
|
4452
|
+
if (typeof v === 'string') judge(v, 1, 'in a data file\'s string');
|
|
4453
|
+
else if (Array.isArray(v)) for (const x of v) stack.push(x);
|
|
4454
|
+
else if (v && typeof v === 'object') for (const k of Object.keys(v)) { judge(k, 1, 'in a data file\'s key'); stack.push(v[k]); }
|
|
4455
|
+
}
|
|
4456
|
+
if (stack.length) add('data-file', 1, `more than ${DATA_VALUES.toLocaleString('en')} values: not read to the end`);
|
|
4457
|
+
return { findings, parsed: true, values: steps };
|
|
4458
|
+
}
|