feat(security): add CodeQL analysis workflow and ESLint security plugins

This commit is contained in:
alam00000
2026-04-18 14:46:51 +05:30
parent df3567a61f
commit 121de29d80
7 changed files with 210 additions and 32 deletions

59
.github/workflows/codeql.yml vendored Normal file
View File

@@ -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 }}

5
.gitignore vendored
View File

@@ -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

View File

@@ -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',

48
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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)`

View File

@@ -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,13 +408,11 @@ 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;
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'
@@ -407,11 +426,17 @@ function rewriteHtmlPathsPlugin(): Plugin {
'g'
);
asset.source = asset.source
for (const fileName of Object.keys(bundle)) {
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);
}
}
},