diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..fa3f119 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,59 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 6 * * 1' + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + strategy: + fail-fast: false + matrix: + language: [javascript-typescript] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: security-extended,security-and-quality + config: | + paths-ignore: + - dist + - dist-test + - coverage + - node_modules + - vendor + - bentopdf-pymupdf-wasm + - libreoffice-wasm-package + - bentopdf-airgap-bundle + - public/pdfjs-viewer + - public/pdfjs-annotation-viewer + - public/libreoffice-wasm + - public/coherentpdf.browser.min.js + - public/workers + - public/embedpdf + - docs/.vitepress + - '**/*.min.js' + - '**/*.d.ts' + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: /language:${{ matrix.language }} diff --git a/.gitignore b/.gitignore index 1f6997f..f6ca795 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,11 @@ public/sitemap.xml # Generated by scripts/generate-security-headers.mjs at build time security-headers.conf +# CodeQL local scan artifacts (npm run security:codeql) +codeql-db/ +codeql-results.sarif +codeql-results.csv + #backup .seo-backup libreoffice-wasm-package diff --git a/eslint.config.mjs b/eslint.config.mjs index add1932..0c303c2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,6 +2,8 @@ import globals from 'globals'; import pluginJs from '@eslint/js'; import tseslint from 'typescript-eslint'; import eslintConfigPrettier from 'eslint-config-prettier'; +import security from 'eslint-plugin-security'; +import noUnsanitized from 'eslint-plugin-no-unsanitized'; export default [ { files: ['**/*.{js,mjs,cjs,ts}'] }, @@ -12,14 +14,41 @@ export default [ 'node_modules/**', '**/.vitepress/cache/**', 'public/**/*.min.js', + 'vendor/**', + 'bentopdf-pymupdf-wasm/**', + 'libreoffice-wasm-package/**', + 'bentopdf-airgap-bundle/**', + 'public/pdfjs-viewer/**', + 'public/pdfjs-annotation-viewer/**', + 'public/coherentpdf.browser.min.js', + 'public/libreoffice-wasm/**', + 'public/workers/**', ], }, { languageOptions: { globals: { ...globals.browser, ...globals.node } } }, pluginJs.configs.recommended, ...tseslint.configs.recommended, eslintConfigPrettier, + security.configs.recommended, + noUnsanitized.configs.recommended, { rules: { + 'no-unsanitized/method': 'warn', + 'no-unsanitized/property': 'warn', + 'security/detect-eval-with-expression': 'error', + 'security/detect-new-buffer': 'error', + 'security/detect-unsafe-regex': 'warn', + 'security/detect-pseudoRandomBytes': 'error', + 'security/detect-bidi-characters': 'error', + 'security/detect-disable-mustache-escape': 'error', + 'security/detect-possible-timing-attacks': 'off', + 'security/detect-object-injection': 'off', + 'security/detect-non-literal-fs-filename': 'off', + 'security/detect-non-literal-regexp': 'off', + 'security/detect-non-literal-require': 'off', + 'security/detect-child-process': 'off', + 'security/detect-no-csrf-before-method-override': 'off', + 'security/detect-buffer-noassert': 'off', '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-unused-vars': [ 'warn', diff --git a/package-lock.json b/package-lock.json index e32c90f..ed5640f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,6 +94,8 @@ "@vitest/ui": "^4.0.18", "eslint": "^10.0.2", "eslint-config-prettier": "^10.1.8", + "eslint-plugin-no-unsanitized": "^4.1.5", + "eslint-plugin-security": "^4.0.0", "globals": "^17.4.0", "husky": "^9.1.7", "jsdom": "^28.1.0", @@ -6743,6 +6745,32 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-plugin-no-unsanitized": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-no-unsanitized/-/eslint-plugin-no-unsanitized-4.1.5.tgz", + "integrity": "sha512-MSB4hXPVFQrI8weqzs6gzl7reP2k/qSjtCoL2vUMSDejIIq9YL1ZKvq5/ORBXab/PvfBBrWO2jWviYpL+4Ghfg==", + "dev": true, + "license": "MPL-2.0", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-plugin-security": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-4.0.0.tgz", + "integrity": "sha512-tfuQT8K/Li1ZxhFzyD8wPIKtlzZxqBcPr9q0jFMQ77wWAbKBVEhaMPVQRTMTvCMUDhwBe5vPVqQPwAGk/ASfxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^2.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", @@ -10401,6 +10429,16 @@ "dev": true, "license": "MIT" }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -10731,6 +10769,16 @@ ], "license": "MIT" }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, "node_modules/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", diff --git a/package.json b/package.json index 910876c..e7fc220 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,10 @@ "docs:preview": "vitepress preview docs", "lint": "eslint .", "lint:fix": "eslint . --fix", + "lint:security": "eslint . --no-inline-config --rule 'no-unsanitized/method:error' --rule 'no-unsanitized/property:error' --rule 'security/detect-eval-with-expression:error'", + "security:codeql": "codeql database create ./codeql-db --language=javascript-typescript --source-root=. --overwrite --threads=0 && codeql database analyze ./codeql-db --format=sarif-latest --output=codeql-results.sarif --threads=0 codeql/javascript-queries:codeql-suites/javascript-security-extended.qls", + "security:codeql:quick": "codeql database analyze ./codeql-db --format=csv --output=codeql-results.csv --threads=0 codeql/javascript-queries:codeql-suites/javascript-security-extended.qls", + "security:audit": "npm audit --audit-level=high && npm run lint:security", "prepare": "husky" }, "devDependencies": { @@ -48,6 +52,8 @@ "@vitest/ui": "^4.0.18", "eslint": "^10.0.2", "eslint-config-prettier": "^10.1.8", + "eslint-plugin-no-unsanitized": "^4.1.5", + "eslint-plugin-security": "^4.0.0", "globals": "^17.4.0", "husky": "^9.1.7", "jsdom": "^28.1.0", diff --git a/src/tests/xss-replay.test.ts b/src/tests/xss-replay.test.ts index 941f449..a4b231e 100644 --- a/src/tests/xss-replay.test.ts +++ b/src/tests/xss-replay.test.ts @@ -203,7 +203,13 @@ describe('XSS replay — CDN URL version pinning', () => { const urls = WasmProvider.getAllProviders(); for (const [pkg, url] of Object.entries(urls)) { if (!url) continue; - if (!url.includes('cdn.jsdelivr.net')) continue; + let hostname: string; + try { + hostname = new URL(url).hostname; + } catch { + continue; + } + if (hostname !== 'cdn.jsdelivr.net') continue; expect( /@\d+\.\d+\.\d+/.test(url), `${pkg} URL "${url}" must be pinned to an exact version (e.g. pkg@1.2.3)` diff --git a/vite.config.ts b/vite.config.ts index d6a5fcd..4ffb79a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,7 +12,6 @@ import handlebars from 'vite-plugin-handlebars'; import { resolve } from 'path'; import fs from 'fs'; import { constants as zlibConstants } from 'zlib'; -import type { OutputBundle } from 'rollup'; const SUPPORTED_LANGUAGES = [ 'en', @@ -360,19 +359,41 @@ function flattenPagesPlugin(): Plugin { return { name: 'flatten-pages', enforce: 'post', - generateBundle(_: unknown, bundle: OutputBundle): void { + writeBundle(options, bundle) { + const outDir = options.dir; + if (!outDir) return; + + const moves: Array<{ from: string; to: string }> = []; + for (const fileName of Object.keys(bundle)) { if (fileName.startsWith('src/pages/') && fileName.endsWith('.html')) { - const newFileName = fileName.replace('src/pages/', ''); - bundle[newFileName] = bundle[fileName]; - bundle[newFileName].fileName = newFileName; - delete bundle[fileName]; + moves.push({ + from: fileName, + to: fileName.replace('src/pages/', ''), + }); } } + if (process.env.SIMPLE_MODE === 'true' && bundle['simple-index.html']) { - bundle['index.html'] = bundle['simple-index.html']; - bundle['index.html'].fileName = 'index.html'; - delete bundle['simple-index.html']; + moves.push({ from: 'simple-index.html', to: 'index.html' }); + } + + for (const { from, to } of moves) { + const oldPath = resolve(outDir, from); + const newPath = resolve(outDir, to); + if (!fs.existsSync(oldPath)) continue; + fs.mkdirSync(resolve(newPath, '..'), { recursive: true }); + if (fs.existsSync(newPath)) fs.rmSync(newPath, { force: true }); + fs.renameSync(oldPath, newPath); + } + + const pagesDir = resolve(outDir, 'src/pages'); + if (fs.existsSync(pagesDir) && fs.readdirSync(pagesDir).length === 0) { + fs.rmdirSync(pagesDir); + } + const srcDir = resolve(outDir, 'src'); + if (fs.existsSync(srcDir) && fs.readdirSync(srcDir).length === 0) { + fs.rmdirSync(srcDir); } }, }; @@ -387,31 +408,35 @@ function rewriteHtmlPathsPlugin(): Plugin { return { name: 'rewrite-html-paths', enforce: 'post', - generateBundle(_: unknown, bundle: OutputBundle): void { + writeBundle(options, bundle) { if (normalizedBase === '/') return; + const outDir = options.dir; + if (!outDir) return; + + const hrefRegex = new RegExp( + `href="\\/(?!${escapedBase.slice(1)}|test\\/|http|\\/\\/)`, + 'g' + ); + const srcRegex = new RegExp( + `src="\\/(?!${escapedBase.slice(1)}|test\\/|http|\\/\\/)`, + 'g' + ); + const contentRegex = new RegExp( + `content="\\/(?!${escapedBase.slice(1)}|test\\/|http|\\/\\/)`, + 'g' + ); for (const fileName of Object.keys(bundle)) { - if (fileName.endsWith('.html')) { - const asset = bundle[fileName]; - if (asset.type === 'asset' && typeof asset.source === 'string') { - const hrefRegex = new RegExp( - `href="\\/(?!${escapedBase.slice(1)}|test\\/|http|\\/\\/)`, - 'g' - ); - const srcRegex = new RegExp( - `src="\\/(?!${escapedBase.slice(1)}|test\\/|http|\\/\\/)`, - 'g' - ); - const contentRegex = new RegExp( - `content="\\/(?!${escapedBase.slice(1)}|test\\/|http|\\/\\/)`, - 'g' - ); - - asset.source = asset.source - .replace(hrefRegex, `href="${normalizedBase}`) - .replace(srcRegex, `src="${normalizedBase}`) - .replace(contentRegex, `content="${normalizedBase}`); - } + if (!fileName.endsWith('.html')) continue; + const diskPath = resolve(outDir, fileName); + if (!fs.existsSync(diskPath)) continue; + const source = fs.readFileSync(diskPath, 'utf8'); + const updated = source + .replace(hrefRegex, `href="${normalizedBase}`) + .replace(srcRegex, `src="${normalizedBase}`) + .replace(contentRegex, `content="${normalizedBase}`); + if (updated !== source) { + fs.writeFileSync(diskPath, updated); } } },