diff --git a/.github/security/vulnerability-exceptions.json b/.github/security/vulnerability-exceptions.json new file mode 100644 index 00000000..b5aac17f --- /dev/null +++ b/.github/security/vulnerability-exceptions.json @@ -0,0 +1,3 @@ +{ + "exceptions": [] +} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 00000000..7b13c63a --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,42 @@ +name: Dependency Review + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + +jobs: + dependency-review: + name: Review newly introduced dependencies + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup pnpm + # v6.1.0 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 + with: + version: 10.10.0 + + - name: Set up Node.js 20 + # v4.4.0 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 20 + + - name: Dependency Review + # v4.7.3 + uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b + with: + fail-on-severity: high + fail-on-scopes: runtime, development, unknown + comment-summary-in-pr: on-failure + + - name: Fail if HIGH or CRITICAL vulnerabilities exist + shell: bash + run: | + pnpm audit --audit-level high diff --git a/.github/workflows/security-vulnerabilities.yml b/.github/workflows/security-vulnerabilities.yml new file mode 100644 index 00000000..37ee69f7 --- /dev/null +++ b/.github/workflows/security-vulnerabilities.yml @@ -0,0 +1,187 @@ +name: Dependency vulnerabilities + +on: + pull_request: + branches: [main] + push: + branches: [main] + paths: + - package.json + - pnpm-lock.yaml + - .npmrc + - pnpm-workspace.yaml + - .node-version + - .github/workflows/security-vulnerabilities.yml + - .github/security/** + - scripts/security/** + schedule: + - cron: '23 3 * * *' + workflow_dispatch: + +concurrency: + group: dependency-vulnerabilities-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + scan: + name: Multi-source vulnerability scan + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + contents: read + security-events: write # Required only by the SARIF upload step below. + steps: + - name: Checkout repository + # v4.2.2 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup pnpm + # v6.1.0 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 + with: + version: 10.10.0 + + - name: Set up Node.js 20 + # v4.4.0 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 20 + cache: pnpm + + - name: Create report directories and PR baseline + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + mkdir -p reports/baseline/source + if [ "$EVENT_NAME" = 'pull_request' ]; then + git show "$BASE_SHA:package.json" > reports/baseline/source/package.json + git show "$BASE_SHA:pnpm-lock.yaml" > reports/baseline/source/pnpm-lock.yaml + git show "$BASE_SHA:.npmrc" > reports/baseline/source/.npmrc 2>/dev/null || true + fi + + - name: Install OSV-Scanner v2.6.0 + if: always() + continue-on-error: true + shell: bash + run: | + set -euo pipefail + curl --fail --location --retry 3 --retry-all-errors --output osv-scanner https://github.com/google/osv-scanner/releases/download/v2.6.0/osv-scanner_linux_amd64 + echo 'ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108 osv-scanner' | sha256sum --check --status + chmod 0755 osv-scanner + mv osv-scanner /usr/local/bin/osv-scanner + + - name: Install Trivy v0.74.0 + if: always() + continue-on-error: true + shell: bash + run: | + set -euo pipefail + curl --fail --location --retry 3 --retry-all-errors --output trivy.tar.gz https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_Linux-64bit.tar.gz + echo '2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a trivy.tar.gz' | sha256sum --check --status + tar -xzf trivy.tar.gz trivy + install -m 0755 trivy /usr/local/bin/trivy + + - name: Run pnpm audit (npm/GHSA) + id: pnpm_audit + if: always() + shell: bash + run: | + set +e + pnpm audit --json > reports/pnpm-audit.json 2> reports/pnpm-audit.stderr + exit_code=$? + node -e "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'))" reports/pnpm-audit.json + json_code=$? + node -e "const fs=require('fs'); const p='reports/scanner-status.json'; const x=fs.existsSync(p)?JSON.parse(fs.readFileSync(p)):{}; x.scanners??={}; x.scanners.pnpm={status:(process.argv[1] === '0' || process.argv[1] === '1') && process.argv[2] === '0' ? 'success':'error',exitCode:Number(process.argv[1]),completedAt:new Date().toISOString(),message:process.argv[2] === '0'?'report collected':fs.readFileSync('reports/pnpm-audit.stderr','utf8').slice(-1000)}; fs.writeFileSync(p,JSON.stringify(x,null,2))" "$exit_code" "$json_code" + + - name: Run OSV-Scanner (OSV.dev) + id: osv_scan + if: always() + shell: bash + run: | + set +e + osv-scanner scan source --format json --output-file reports/osv.json --lockfile pnpm-lock.yaml --experimental-exclude reports . 2> reports/osv.stderr + exit_code=$? + node -e "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'))" reports/osv.json + json_code=$? + node -e "const fs=require('fs'); const p='reports/scanner-status.json'; const x=fs.existsSync(p)?JSON.parse(fs.readFileSync(p)):{}; x.scanners??={}; x.scanners.osv={status:(process.argv[1] === '0' || process.argv[1] === '1') && process.argv[2] === '0' ? 'success':'error',exitCode:Number(process.argv[1]),completedAt:new Date().toISOString(),message:process.argv[2] === '0'?'report collected':fs.readFileSync('reports/osv.stderr','utf8').slice(-1000)}; fs.writeFileSync(p,JSON.stringify(x,null,2))" "$exit_code" "$json_code" + if [ -f reports/baseline/source/pnpm-lock.yaml ]; then + osv-scanner scan source --format json --output-file reports/baseline/osv.json --lockfile reports/baseline/source/pnpm-lock.yaml reports/baseline/source 2> reports/baseline/osv.stderr + exit_code=$? + node -e "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'))" reports/baseline/osv.json + json_code=$? + fi + : "${exit_code:=0}" "${json_code:=0}" + node -e "const fs=require('fs'); const p='reports/baseline/scanner-status.json'; const x=fs.existsSync(p)?JSON.parse(fs.readFileSync(p)):{}; x.scanners??={}; x.scanners.osv={status:(process.argv[1] === '0' || process.argv[1] === '1') && process.argv[2] === '0' ? 'success':'error',exitCode:Number(process.argv[1]),completedAt:new Date().toISOString(),message:'baseline report'}; fs.writeFileSync(p,JSON.stringify(x,null,2))" "$exit_code" "$json_code" + + - name: Run Trivy filesystem scan + id: trivy_scan + if: always() + shell: bash + run: | + set +e + trivy clean --vuln-db + clean_code=$? + trivy fs --scanners vuln --vuln-type library --format json --output reports/trivy.json --skip-dirs node_modules --skip-dirs build --skip-dirs dist --skip-dirs coverage --skip-dirs reports --skip-dirs test-results --skip-dirs playwright-report . 2> reports/trivy.stderr + exit_code=$? + node -e "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'))" reports/trivy.json + json_code=$? + node -e "const fs=require('fs'); const p='reports/scanner-status.json'; const x=fs.existsSync(p)?JSON.parse(fs.readFileSync(p)):{}; x.scanners??={}; x.scanners.trivy={status:process.argv[1] === '0' && process.argv[2] === '0' && process.argv[3] === '0' ? 'success':'error',exitCode:Number(process.argv[1]),completedAt:new Date().toISOString(),message:process.argv[3] !== '0'?'could not clear the previous vulnerability database':process.argv[2] === '0'?'report collected':fs.readFileSync('reports/trivy.stderr','utf8').slice(-1000)}; fs.writeFileSync(p,JSON.stringify(x,null,2))" "$exit_code" "$json_code" "$clean_code" + if [ -d reports/baseline/source ]; then + trivy fs --scanners vuln --vuln-type library --format json --output reports/baseline/trivy.json --skip-dirs node_modules reports/baseline/source 2> reports/baseline/trivy.stderr + exit_code=$? + node -e "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'))" reports/baseline/trivy.json + json_code=$? + fi + : "${exit_code:=0}" "${json_code:=0}" + node -e "const fs=require('fs'); const p='reports/baseline/scanner-status.json'; const x=fs.existsSync(p)?JSON.parse(fs.readFileSync(p)):{}; x.scanners??={}; x.scanners.trivy={status:process.argv[1] === '0' && process.argv[2] === '0' ? 'success':'error',exitCode:Number(process.argv[1]),completedAt:new Date().toISOString(),message:'baseline report'}; fs.writeFileSync(p,JSON.stringify(x,null,2))" "$exit_code" "$json_code" + + - name: Build PR baseline pnpm audit report + if: always() && github.event_name == 'pull_request' + shell: bash + run: | + set +e + pnpm --dir reports/baseline/source audit --json > reports/baseline/pnpm-audit.json 2> reports/baseline/pnpm-audit.stderr + exit_code=$? + node -e "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'))" reports/baseline/pnpm-audit.json + json_code=$? + node -e "const fs=require('fs'); const p='reports/baseline/scanner-status.json'; const x=fs.existsSync(p)?JSON.parse(fs.readFileSync(p)):{}; x.scanners??={}; x.scanners.pnpm={status:(process.argv[1] === '0' || process.argv[1] === '1') && process.argv[2] === '0' ? 'success':'error',exitCode:Number(process.argv[1]),completedAt:new Date().toISOString(),message:'baseline report'}; fs.writeFileSync(p,JSON.stringify(x,null,2))" "$exit_code" "$json_code" + + - name: Aggregate reports and enforce policy + if: always() + shell: bash + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + run: | + if [ "$GITHUB_EVENT_NAME" = 'pull_request' ]; then + node scripts/security/aggregate-vulnerabilities.mjs --reports-dir reports --baseline-dir reports/baseline --event-name "$GITHUB_EVENT_NAME" + else + node scripts/security/aggregate-vulnerabilities.mjs --reports-dir reports --event-name "$GITHUB_EVENT_NAME" + fi + + - name: Upload combined SARIF + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + continue-on-error: true + # v3.28.17 + uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b + with: + sarif_file: reports/vulnerabilities.sarif + category: dependency-vulnerabilities + + - name: Upload scanner reports + if: always() + # v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: security-vulnerability-reports + path: reports/ + retention-days: 14 + if-no-files-found: warn diff --git a/package.json b/package.json index 642569b4..53a4cd9b 100644 --- a/package.json +++ b/package.json @@ -105,17 +105,22 @@ "pnpm": { "overrides": { "@babel/plugin-transform-modules-systemjs": "7.29.4", - "brace-expansion": "2.0.3", + "brace-expansion": "2.1.7", + "browserslist": "4.29.0", "esbuild": "0.27.2", - "fast-uri": "3.1.2", + "fast-uri": "3.1.8", "flatted": "3.4.2", "glob": "10.5.0", + "js-yaml": "4.3.2", "minimatch": "9.0.9", + "nanoid": "3.3.19", "@eslint/eslintrc>minimatch": "3.1.5", "picomatch": "4.0.4", - "postcss": "8.5.10", + "postcss": "8.5.28", "rollup": "4.60.1", - "svgo": "3.3.3", + "shell-quote": "1.10.0", + "svgo": "3.3.5", + "undici": "7.29.1", "yaml": "1.10.3" } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cf0ead0..c9164b42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,17 +6,22 @@ settings: overrides: '@babel/plugin-transform-modules-systemjs': 7.29.4 - brace-expansion: 2.0.3 + brace-expansion: 2.1.7 + browserslist: 4.29.0 esbuild: 0.27.2 - fast-uri: 3.1.2 + fast-uri: 3.1.8 flatted: 3.4.2 glob: 10.5.0 + js-yaml: 4.3.2 minimatch: 9.0.9 + nanoid: 3.3.19 '@eslint/eslintrc>minimatch': 3.1.5 picomatch: 4.0.4 - postcss: 8.5.10 + postcss: 8.5.28 rollup: 4.60.1 - svgo: 3.3.3 + shell-quote: 1.10.0 + svgo: 3.3.5 + undici: 7.29.1 yaml: 1.10.3 importers: @@ -101,7 +106,7 @@ importers: version: 1.1.4(eslint@9.39.4)(prettier@3.8.4) '@cloud-ru/ft-config-stylelint': specifier: 3.1.2 - version: 3.1.2(postcss@8.5.10)(typescript@6.0.3) + version: 3.1.2(postcss@8.5.28)(typescript@6.0.3) '@cloud-ru/ft-config-vitest': specifier: 1.3.3 version: 1.3.3(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(terser@5.48.0)(yaml@1.10.3)) @@ -164,7 +169,7 @@ importers: version: 7.0.0 postcss-styled-syntax: specifier: 0.7.1 - version: 0.7.1(postcss@8.5.10) + version: 0.7.1(postcss@8.5.28) prettier: specifier: 3.8.4 version: 3.8.4 @@ -2180,8 +2185,9 @@ packages: balanced-match@2.0.0: resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} - baseline-browser-mapping@2.9.19: - resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + baseline-browser-mapping@2.11.25: + resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} + engines: {node: '>=6.0.0'} hasBin: true bidi-js@1.0.3: @@ -2190,15 +2196,15 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@2.0.3: - resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + browserslist@4.29.0: + resolution: {integrity: sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2225,8 +2231,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001767: - resolution: {integrity: sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -2486,8 +2492,8 @@ packages: resolution: {integrity: sha512-QkZboRN28K/iwxigDhlJcI3ux3aNbt8kYGGH/GkqWG0OlGeyuBhb7PdM89Iu+ogV8Lmz16xIlwnXR2UNWI6psg==} engines: {node: '>=11.0.0'} - electron-to-chromium@1.5.283: - resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==} + electron-to-chromium@1.5.433: + resolution: {integrity: sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2766,8 +2772,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} @@ -3173,8 +3179,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsdom@27.4.0: @@ -3455,8 +3461,8 @@ packages: resolution: {integrity: sha512-yTW+2okrElHiH4fsiz/+/zc0EDo9BDDoC3iKk8dpv1GeRc9nUWzUZHx6TofMWErchhUQR8hY9/Eu1Uja9x1nqA==} engines: {node: '>=20.17'} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -3474,8 +3480,9 @@ packages: node-html-parser@7.1.0: resolution: {integrity: sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==} - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-releases@2.0.56: + resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==} + engines: {node: '>=18'} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -3646,13 +3653,13 @@ packages: resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} engines: {node: '>=18.0'} peerDependencies: - postcss: 8.5.10 + postcss: 8.5.28 postcss-scss@4.0.9: resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} engines: {node: '>=12.0'} peerDependencies: - postcss: 8.5.10 + postcss: 8.5.28 postcss-selector-parser@6.1.2: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} @@ -3666,13 +3673,13 @@ packages: resolution: {integrity: sha512-V5Iy8JztqXOKnTojdytF8IJ3zDXyVR927XftBPinJa3TnKdChGvGzUNEYlNuDtR+iqpuFkwJMgZdaJarYfGFCg==} engines: {node: '>=14.17'} peerDependencies: - postcss: 8.5.10 + postcss: 8.5.28 postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.10: - resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -3965,8 +3972,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.4: - resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} engines: {node: '>= 0.4'} side-channel-list@1.0.0: @@ -4123,7 +4130,7 @@ packages: resolution: {integrity: sha512-bhaMhh1u5dQqSsf6ri2GVWWQW5iUjBYgcHkh7SgDDn92ijoItC/cfO/W+fpXshgTQWhwFkP1rVcewcv4jaftRg==} engines: {node: '>=18.12.0'} peerDependencies: - postcss: 8.5.10 + postcss: 8.5.28 stylelint: ^16.6.1 peerDependenciesMeta: postcss: @@ -4171,8 +4178,8 @@ packages: svg-tags@1.0.0: resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} - svgo@3.3.3: - resolution: {integrity: sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==} + svgo@3.3.5: + resolution: {integrity: sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==} engines: {node: '>=14.0.0'} hasBin: true @@ -4320,8 +4327,8 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@7.25.0: - resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} engines: {node: '>=20.18.1'} unicode-canonical-property-names-ecmascript@2.0.1: @@ -4347,11 +4354,11 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} hasBin: true peerDependencies: - browserslist: '>= 4.21.0' + browserslist: 4.29.0 uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -4698,7 +4705,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 + browserslist: 4.29.0 lru-cache: 5.1.1 semver: 6.3.1 @@ -5435,10 +5442,10 @@ snapshots: lint-staged: 16.2.7 prettier: 3.8.4 - '@cloud-ru/ft-config-stylelint@3.1.2(postcss@8.5.10)(typescript@6.0.3)': + '@cloud-ru/ft-config-stylelint@3.1.2(postcss@8.5.28)(typescript@6.0.3)': dependencies: stylelint: 16.6.1(typescript@6.0.3) - stylelint-config-recommended-scss: 14.1.0(postcss@8.5.10)(stylelint@16.6.1(typescript@6.0.3)) + stylelint-config-recommended-scss: 14.1.0(postcss@8.5.28)(stylelint@16.6.1(typescript@6.0.3)) transitivePeerDependencies: - postcss - supports-color @@ -5775,7 +5782,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -6460,7 +6467,7 @@ snapshots: '@svgr/core': 8.1.0(typescript@6.0.3) cosmiconfig: 8.3.6(typescript@6.0.3) deepmerge: 4.3.1 - svgo: 3.3.3 + svgo: 3.3.5 transitivePeerDependencies: - typescript @@ -6854,7 +6861,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -6997,7 +7004,7 @@ snapshots: balanced-match@2.0.0: {} - baseline-browser-mapping@2.9.19: {} + baseline-browser-mapping@2.11.25: {} bidi-js@1.0.3: dependencies: @@ -7005,7 +7012,7 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@2.0.3: + brace-expansion@2.1.7: dependencies: balanced-match: 1.0.2 @@ -7013,13 +7020,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.1: + browserslist@4.29.0: dependencies: - baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001767 - electron-to-chromium: 1.5.283 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + baseline-browser-mapping: 2.11.25 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.433 + node-releases: 2.0.56 + update-browserslist-db: 1.3.3(browserslist@4.29.0) buffer-from@1.1.2: {} @@ -7044,7 +7051,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001767: {} + caniuse-lite@1.0.30001810: {} chai@6.2.2: {} @@ -7096,7 +7103,7 @@ snapshots: dependencies: chalk: 5.6.2 rxjs: 7.8.2 - shell-quote: 1.8.4 + shell-quote: 1.10.0 supports-color: 10.2.2 tree-kill: 1.2.2 yargs: 18.0.0 @@ -7111,7 +7118,7 @@ snapshots: core-js-compat@3.48.0: dependencies: - browserslist: 4.28.1 + browserslist: 4.29.0 core-util-is@1.0.3: {} @@ -7126,7 +7133,7 @@ snapshots: cosmiconfig@8.3.6(typescript@6.0.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.2 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -7136,7 +7143,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.2 parse-json: 5.2.0 optionalDependencies: typescript: 6.0.3 @@ -7302,7 +7309,7 @@ snapshots: effector@23.4.4: {} - electron-to-chromium@1.5.283: {} + electron-to-chromium@1.5.433: {} emoji-regex@10.6.0: {} @@ -7716,7 +7723,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - fast-uri@3.1.2: {} + fast-uri@3.1.8: {} fastest-levenshtein@1.0.16: {} @@ -8121,7 +8128,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.1.1: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -8170,7 +8177,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.25.0 + undici: 7.29.1 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -8385,11 +8392,11 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 2.0.3 + brace-expansion: 2.1.7 minimatch@9.0.9: dependencies: - brace-expansion: 2.0.3 + brace-expansion: 2.1.7 minimist@1.2.8: {} @@ -8399,7 +8406,7 @@ snapshots: nano-spawn@2.1.0: {} - nanoid@3.3.12: {} + nanoid@3.3.19: {} napi-postinstall@0.3.4: {} @@ -8415,7 +8422,7 @@ snapshots: css-select: 5.2.2 he: 1.2.0 - node-releases@2.0.27: {} + node-releases@2.0.56: {} normalize-path@3.0.0: {} @@ -8596,13 +8603,13 @@ snapshots: postcss-resolve-nested-selector@0.1.6: {} - postcss-safe-parser@7.0.1(postcss@8.5.10): + postcss-safe-parser@7.0.1(postcss@8.5.28): dependencies: - postcss: 8.5.10 + postcss: 8.5.28 - postcss-scss@4.0.9(postcss@8.5.10): + postcss-scss@4.0.9(postcss@8.5.28): dependencies: - postcss: 8.5.10 + postcss: 8.5.28 postcss-selector-parser@6.1.2: dependencies: @@ -8614,16 +8621,16 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-styled-syntax@0.7.1(postcss@8.5.10): + postcss-styled-syntax@0.7.1(postcss@8.5.28): dependencies: - postcss: 8.5.10 + postcss: 8.5.28 typescript: 5.7.3 postcss-value-parser@4.2.0: {} - postcss@8.5.10: + postcss@8.5.28: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -8979,7 +8986,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.4: {} + shell-quote@1.10.0: {} side-channel-list@1.0.0: dependencies: @@ -9161,14 +9168,14 @@ snapshots: strip-json-comments@5.0.3: {} - stylelint-config-recommended-scss@14.1.0(postcss@8.5.10)(stylelint@16.6.1(typescript@6.0.3)): + stylelint-config-recommended-scss@14.1.0(postcss@8.5.28)(stylelint@16.6.1(typescript@6.0.3)): dependencies: - postcss-scss: 4.0.9(postcss@8.5.10) + postcss-scss: 4.0.9(postcss@8.5.28) stylelint: 16.6.1(typescript@6.0.3) stylelint-config-recommended: 14.0.1(stylelint@16.6.1(typescript@6.0.3)) stylelint-scss: 6.14.0(stylelint@16.6.1(typescript@6.0.3)) optionalDependencies: - postcss: 8.5.10 + postcss: 8.5.28 stylelint-config-recommended@14.0.1(stylelint@16.6.1(typescript@6.0.3)): dependencies: @@ -9215,9 +9222,9 @@ snapshots: micromatch: 4.0.8 normalize-path: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.10 + postcss: 8.5.28 postcss-resolve-nested-selector: 0.1.6 - postcss-safe-parser: 7.0.1(postcss@8.5.10) + postcss-safe-parser: 7.0.1(postcss@8.5.28) postcss-selector-parser: 6.1.2 postcss-value-parser: 4.2.0 resolve-from: 5.0.0 @@ -9250,7 +9257,7 @@ snapshots: svg-tags@1.0.0: {} - svgo@3.3.3: + svgo@3.3.5: dependencies: commander: 7.2.0 css-select: 5.2.2 @@ -9408,7 +9415,7 @@ snapshots: undici-types@7.24.6: {} - undici@7.25.0: {} + undici@7.29.1: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -9447,9 +9454,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - update-browserslist-db@1.2.3(browserslist@4.28.1): + update-browserslist-db@1.3.3(browserslist@4.29.0): dependencies: - browserslist: 4.28.1 + browserslist: 4.29.0 escalade: 3.2.0 picocolors: 1.1.1 @@ -9507,7 +9514,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.10 + postcss: 8.5.28 rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: diff --git a/scripts/security/aggregate-vulnerabilities.mjs b/scripts/security/aggregate-vulnerabilities.mjs new file mode 100644 index 00000000..af9abcd8 --- /dev/null +++ b/scripts/security/aggregate-vulnerabilities.mjs @@ -0,0 +1,536 @@ +#!/usr/bin/env node + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +const SEVERITY_ORDER = { CRITICAL: 4, HIGH: 3, MODERATE: 2, MEDIUM: 2, LOW: 1, UNKNOWN: 0 }; +const DISPLAY_SEVERITY = { MEDIUM: 'MODERATE' }; +const SOURCE_FILES = { + pnpm: 'pnpm-audit.json', + osv: 'osv.json', + trivy: 'trivy.json', +}; + +const args = Object.fromEntries( + process.argv.slice(2).reduce((pairs, value, index, values) => { + if (value.startsWith('--')) + pairs.push([value.slice(2), values[index + 1]?.startsWith('--') ? 'true' : (values[index + 1] ?? 'true')]); + return pairs; + }, []), +); +const reportsDir = resolve(args['reports-dir'] ?? 'reports'); +const baselineDir = args['baseline-dir'] ? resolve(args['baseline-dir']) : null; +const exceptionsPath = resolve(args.exceptions ?? '.github/security/vulnerability-exceptions.json'); +const eventName = args['event-name'] ?? process.env.GITHUB_EVENT_NAME ?? 'local'; + +function normalizeSeverity(value) { + const normalized = String(value ?? 'UNKNOWN').toUpperCase(); + return DISPLAY_SEVERITY[normalized] ?? (SEVERITY_ORDER[normalized] === undefined ? 'UNKNOWN' : normalized); +} + +function severityFromCvss(value) { + const score = Number(value); + if (!Number.isFinite(score)) return 'UNKNOWN'; + if (score >= 9) return 'CRITICAL'; + if (score >= 7) return 'HIGH'; + if (score >= 4) return 'MODERATE'; + return 'LOW'; +} + +function bestSeverity(...values) { + return values.map(normalizeSeverity).sort((a, b) => SEVERITY_ORDER[b] - SEVERITY_ORDER[a])[0] ?? 'UNKNOWN'; +} + +function idsFrom(...values) { + const found = new Set(); + for (const value of values.flat(Infinity)) { + if (typeof value !== 'string') continue; + const matches = value.toUpperCase().match(/(?:CVE-\d{4}-\d{4,}|GHSA-[\w-]{4,}|[A-Z][A-Z0-9_.-]*-\d{4,}-\d+)/g); + if (matches) matches.forEach(id => found.add(id)); + } + return [...found]; +} + +function firstString(...values) { + return values.flat(Infinity).find(value => typeof value === 'string' && value.trim()) ?? ''; +} + +async function readJson(path) { + try { + return { ok: true, value: JSON.parse(await readFile(path, 'utf8')) }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +async function readPackageTypes(directory) { + const result = await readJson(join(directory, 'package.json')); + const prod = new Set(Object.keys(result.value?.dependencies ?? {})); + const dev = new Set(Object.keys(result.value?.devDependencies ?? {})); + return { prod, dev }; +} + +function packageClass(name, packageTypes) { + if (packageTypes.prod.has(name)) return { dependencyType: 'direct', environment: 'prod' }; + if (packageTypes.dev.has(name)) return { dependencyType: 'direct', environment: 'dev' }; + return { dependencyType: 'transitive', environment: 'unknown' }; +} + +function vulnerableRange(affected, name) { + const match = (affected ?? []).find(item => item.package?.name === name) ?? affected?.[0]; + return (match?.ranges ?? []) + .flatMap(range => range.events ?? []) + .map(event => (event.introduced ? `introduced ${event.introduced}` : event.fixed ? `fixed ${event.fixed}` : '')) + .filter(Boolean) + .join(', '); +} + +function fixedFromAffected(affected, name) { + const match = (affected ?? []).find(item => item.package?.name === name) ?? affected?.[0]; + return ( + (match?.ranges ?? []) + .flatMap(range => range.events ?? []) + .map(event => event.fixed) + .find(Boolean) ?? '' + ); +} + +function pnpmFindings(report, packageTypes) { + const findings = []; + if (report?.advisories && typeof report.advisories === 'object') { + for (const advisory of Object.values(report.advisories)) { + const name = advisory.module_name ?? advisory.name ?? 'unknown'; + findings.push({ + source: 'pnpm audit', + ids: idsFrom(advisory.github_advisory_id, advisory.cves, advisory.url, advisory.title), + severity: normalizeSeverity(advisory.severity), + packageName: name, + installedVersion: firstString(advisory.findings?.map(finding => finding.version)), + vulnerableRange: advisory.vulnerable_versions ?? '', + fixedVersion: advisory.patched_versions ?? '', + fixAvailable: Boolean(advisory.patched_versions && advisory.patched_versions !== '<0.0.0'), + urls: [advisory.url].filter(Boolean), + path: firstString(advisory.findings?.flatMap(finding => finding.paths)), + ...packageClass(name, packageTypes), + }); + } + } + if (report?.vulnerabilities && typeof report.vulnerabilities === 'object') { + for (const [name, vulnerability] of Object.entries(report.vulnerabilities)) { + const via = Array.isArray(vulnerability.via) ? vulnerability.via : [vulnerability.via]; + const advisories = via.filter(item => item && typeof item === 'object'); + for (const advisory of advisories.length ? advisories : [{}]) { + const fix = vulnerability.fixAvailable; + findings.push({ + source: 'pnpm audit', + ids: idsFrom( + advisory.url, + advisory.source, + advisory.name, + advisory.title, + via.filter(item => typeof item === 'string'), + ), + severity: bestSeverity(advisory.severity, vulnerability.severity), + packageName: name, + installedVersion: vulnerability.version ?? '', + vulnerableRange: advisory.range ?? vulnerability.range ?? '', + fixedVersion: typeof fix === 'object' ? (fix.version ?? '') : '', + fixAvailable: Boolean(fix), + urls: [advisory.url].filter(Boolean), + path: '', + ...packageClass(name, packageTypes), + }); + } + } + } + return findings; +} + +function osvFindings(report, packageTypes) { + const findings = []; + for (const result of report?.results ?? []) { + for (const item of result.packages ?? []) { + const pkg = item.package ?? item; + for (const vulnerability of item.vulnerabilities ?? []) { + const severity = bestSeverity( + vulnerability.database_specific?.severity, + vulnerability.ecosystem_specific?.severity, + ...(vulnerability.severity ?? []).map(entry => + severityFromCvss(String(entry.score ?? '').match(/[0-9]+(?:\.[0-9]+)?/)?.[0]), + ), + ); + const name = pkg.name ?? 'unknown'; + findings.push({ + source: 'OSV', + ids: idsFrom(vulnerability.id, vulnerability.aliases, vulnerability.related), + severity, + packageName: name, + installedVersion: pkg.version ?? '', + vulnerableRange: vulnerableRange(vulnerability.affected, name), + fixedVersion: fixedFromAffected(vulnerability.affected, name), + fixAvailable: Boolean(fixedFromAffected(vulnerability.affected, name)), + urls: [ + vulnerability.database_specific?.url, + ...(vulnerability.references ?? []).map(reference => reference.url), + ].filter(Boolean), + path: result.source?.path ?? result.source?.file ?? '', + exploited: Boolean( + vulnerability.database_specific?.known_exploited || vulnerability.database_specific?.actively_exploited, + ), + ...packageClass(name, packageTypes), + }); + } + } + } + return findings; +} + +function trivyFindings(report, packageTypes) { + const findings = []; + for (const result of report?.Results ?? []) { + for (const vulnerability of result.Vulnerabilities ?? []) { + const name = vulnerability.PkgName ?? 'unknown'; + const cvssScores = Object.values(vulnerability.CVSS ?? {}).map(cvss => cvss.V3Score ?? cvss.V2Score); + findings.push({ + source: 'Trivy', + ids: idsFrom(vulnerability.VulnerabilityID, vulnerability.Title, vulnerability.References), + severity: bestSeverity(vulnerability.Severity, ...cvssScores.map(severityFromCvss)), + packageName: name, + installedVersion: vulnerability.InstalledVersion ?? '', + vulnerableRange: vulnerability.VulnerableVersion ?? '', + fixedVersion: vulnerability.FixedVersion ?? '', + fixAvailable: Boolean(vulnerability.FixedVersion), + urls: [vulnerability.PrimaryURL, ...(vulnerability.References ?? [])].filter(Boolean), + path: result.Target ?? '', + exploited: Boolean( + vulnerability.KnownExploited || + vulnerability.ActivelyExploited || + (vulnerability.Status === 'affected' && vulnerability.Custom?.KnownExploited), + ), + ...packageClass(name, packageTypes), + }); + } + } + return findings; +} + +function addFinding(index, finding) { + const ids = finding.ids.length ? finding.ids : [`UNIDENTIFIED:${finding.packageName}:${finding.installedVersion}`]; + const matchingKeys = ids.map(id => index.aliasToKey.get(id)).filter(Boolean); + const key = matchingKeys[0] ?? ids[0]; + let entry = index.entries.get(key); + if (!entry) { + entry = { + ids: new Set(), + sources: new Set(), + severities: new Map(), + packages: new Map(), + urls: new Set(), + paths: new Set(), + exploited: false, + }; + index.entries.set(key, entry); + } + for (const otherKey of matchingKeys.slice(1)) { + if (otherKey === key) continue; + const other = index.entries.get(otherKey); + if (!other) continue; + for (const id of other.ids) (entry.ids.add(id), index.aliasToKey.set(id, key)); + for (const source of other.sources) entry.sources.add(source); + for (const [source, severity] of other.severities) + entry.severities.set(source, bestSeverity(entry.severities.get(source), severity)); + for (const [packageKey, packageInfo] of other.packages) entry.packages.set(packageKey, packageInfo); + for (const url of other.urls) entry.urls.add(url); + for (const path of other.paths) entry.paths.add(path); + entry.exploited ||= other.exploited; + index.entries.delete(otherKey); + } + for (const id of ids) (entry.ids.add(id), index.aliasToKey.set(id, key)); + entry.sources.add(finding.source); + entry.severities.set(finding.source, bestSeverity(entry.severities.get(finding.source), finding.severity)); + let packageKey = `${finding.packageName}@${finding.installedVersion}`; + const packageWithMissingVersion = [...entry.packages.entries()].find( + ([, pkg]) => pkg.packageName === finding.packageName && (!pkg.installedVersion || !finding.installedVersion), + ); + if (packageWithMissingVersion) packageKey = packageWithMissingVersion[0]; + const current = entry.packages.get(packageKey) ?? { ...finding }; + current.installedVersion ||= finding.installedVersion; + current.dependencyType = + current.dependencyType === 'direct' || finding.dependencyType === 'direct' ? 'direct' : 'transitive'; + current.environment = + current.environment === 'prod' || finding.environment === 'prod' + ? 'prod' + : current.environment === 'dev' || finding.environment === 'dev' + ? 'dev' + : 'unknown'; + current.fixedVersion ||= finding.fixedVersion; + current.vulnerableRange ||= finding.vulnerableRange; + current.fixAvailable ||= finding.fixAvailable; + entry.packages.set(packageKey, current); + finding.urls.forEach(url => entry.urls.add(url)); + if (finding.path) entry.paths.add(finding.path); + entry.exploited ||= Boolean(finding.exploited); +} + +async function loadScan(directory) { + const packageTypes = await readPackageTypes(directory === reportsDir ? process.cwd() : join(directory, 'source')); + const index = { entries: new Map(), aliasToKey: new Map() }; + const status = await readJson(join(directory, 'scanner-status.json')); + const errors = []; + const counts = {}; + for (const [source, file] of Object.entries(SOURCE_FILES)) { + const report = await readJson(join(directory, file)); + const scanner = status.value?.scanners?.[source]; + if (!report.ok) { + errors.push(`${source}: report is missing or invalid JSON (${report.error})`); + counts[source] = 0; + continue; + } + if (!scanner) errors.push(`${source}: scanner status is missing`); + else if (scanner.status !== 'success') + errors.push(`${source}: ${scanner.message ?? 'scanner failed'} (exit ${scanner.exitCode ?? 'unknown'})`); + const parser = { pnpm: pnpmFindings, osv: osvFindings, trivy: trivyFindings }[source]; + const findings = parser(report.value, packageTypes); + counts[source] = findings.length; + findings.forEach(finding => addFinding(index, finding)); + } + return { + entries: [...index.entries.values()].map(entry => ({ + ...entry, + ids: [...entry.ids].sort(), + sources: [...entry.sources].sort(), + severity: bestSeverity(...entry.severities.values()), + packages: [...entry.packages.values()], + urls: [...entry.urls].sort(), + paths: [...entry.paths].sort(), + })), + errors, + counts, + scannerStatus: status.value?.scanners ?? {}, + }; +} + +function validateExceptions(raw) { + const errors = []; + const valid = []; + if (!raw || !Array.isArray(raw.exceptions)) + return { valid, errors: ['exceptions must be an object with an exceptions array'] }; + const now = new Date(); + for (const [index, exception] of raw.exceptions.entries()) { + const label = `exceptions[${index}]`; + if (!exception || typeof exception !== 'object') { + errors.push(`${label} must be an object`); + continue; + } + const required = ['id', 'reason', 'owner', 'expires', 'issue']; + for (const field of required) + if (typeof exception[field] !== 'string' || !exception[field].trim()) + errors.push(`${label}.${field} is required`); + if (!/^(?:CVE-\d{4}-\d{4,}|GHSA-[\w-]{4,}|[A-Z][A-Z0-9_.-]*-\d{4,}-\d+)$/i.test(exception.id ?? '')) + errors.push(`${label}.id must be a concrete CVE, GHSA, or OSV id`); + if (/[?*]/.test(exception.id ?? '')) errors.push(`${label}.id must not contain wildcards`); + if (!String(exception.owner ?? '').startsWith('@')) + errors.push(`${label}.owner must be a GitHub handle beginning with @`); + if (!/^https:\/\/github\.com\/.+\/issues\/\d+\/?$/.test(exception.issue ?? '')) + errors.push(`${label}.issue must be a GitHub issue URL`); + const expiry = new Date(`${exception.expires}T23:59:59.999Z`); + if (!/^\d{4}-\d{2}-\d{2}$/.test(exception.expires ?? '') || Number.isNaN(expiry.valueOf())) + errors.push(`${label}.expires must be YYYY-MM-DD`); + else if (expiry < now) errors.push(`${label} is expired (${exception.expires})`); + else if (expiry.valueOf() - now.valueOf() > 30 * 24 * 60 * 60 * 1000) + errors.push(`${label}.expires must be no more than 30 days away`); + if (!errors.some(error => error.startsWith(label))) valid.push({ ...exception, id: exception.id.toUpperCase() }); + } + return { valid, errors }; +} + +function md(value) { + return String(value ?? '') + .replaceAll('|', '\\|') + .replaceAll('\n', '
'); +} +function unique(values) { + return [...new Set(values.filter(Boolean))]; +} + +function sarif(entries) { + const rules = entries.map(entry => ({ + id: entry.ids[0], + name: entry.ids[0], + shortDescription: { text: entry.ids.join(', ') }, + helpUri: entry.urls[0], + })); + return { + version: '2.1.0', + $schema: 'https://json.schemastore.org/sarif-2.1.0.json', + runs: [ + { + tool: { driver: { name: 'Cloudhood dependency vulnerability aggregation', rules } }, + results: entries.map(entry => ({ + ruleId: entry.ids[0], + level: SEVERITY_ORDER[entry.severity] >= 3 ? 'error' : 'warning', + message: { + text: `${entry.severity}: ${entry.packages.map(pkg => `${pkg.packageName}@${pkg.installedVersion}`).join(', ')}`, + }, + locations: entry.paths.slice(0, 1).map(path => ({ physicalLocation: { artifactLocation: { uri: path } } })), + })), + }, + ], + }; +} + +const current = await loadScan(reportsDir); +const baseline = baselineDir ? await loadScan(baselineDir) : null; +const scannerErrors = [...current.errors, ...(baseline ? baseline.errors.map(error => `baseline ${error}`) : [])]; +const rawExceptions = await readJson(exceptionsPath); +const exceptions = rawExceptions.ok + ? validateExceptions(rawExceptions.value) + : { valid: [], errors: [`Unable to read exceptions: ${rawExceptions.error}`] }; +const baselineSeverity = new Map(); +for (const entry of baseline?.entries ?? []) + for (const id of entry.ids) baselineSeverity.set(id, bestSeverity(baselineSeverity.get(id), entry.severity)); +const isPullRequestPolicy = eventName === 'pull_request' && Boolean(baseline); + +let blocking = 0; +let warnings = 0; +let deduplicated = 0; +const accepted = []; +const rows = current.entries.sort( + (a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity] || a.ids[0].localeCompare(b.ids[0]), +); +for (const entry of rows) { + deduplicated += Math.max(0, entry.sources.length - 1); + const exception = exceptions.valid.find(candidate => entry.ids.includes(candidate.id)); + const previous = bestSeverity(...entry.ids.map(id => baselineSeverity.get(id))); + const isHigh = SEVERITY_ORDER[entry.severity] >= SEVERITY_ORDER.HIGH; + const isNewOrWorse = !isPullRequestPolicy || SEVERITY_ORDER[entry.severity] > SEVERITY_ORDER[previous]; + let status = 'warning'; + if (exception) { + status = 'temporarily accepted'; + accepted.push({ entry, exception }); + } else if (isHigh && isNewOrWorse) { + status = 'blocking'; + blocking += 1; + } else if (isHigh) { + status = 'present in baseline'; + } else { + warnings += 1; + } + entry.status = status; + entry.previousSeverity = previous; +} + +const sourceTotals = Object.entries(current.counts) + .map(([source, count]) => `${source}: ${count}`) + .join(', '); +const severityDifferences = rows.filter(entry => new Set(entry.severities.values()).size > 1); +const important = rows.filter(entry => entry.exploited || entry.packages.some(pkg => pkg.environment === 'prod')); +const policySummary = isPullRequestPolicy + ? 'pull request diff against base branch (new or worsened HIGH/CRITICAL block merge)' + : 'full dependency tree (HIGH/CRITICAL block merge)'; +const summary = [ + '## Dependency vulnerability report', + '', + `- Mode: **${policySummary}**`, + `- Unique vulnerabilities: **${rows.length}**; deduplicated source matches: **${deduplicated}**`, + `- Raw findings: ${sourceTotals}`, + `- Scanner data/scans completed: ${ + Object.entries(current.scannerStatus) + .map(([name, value]) => `${name}: ${value.completedAt ?? 'unknown'}`) + .join(', ') || 'not recorded' + }`, + '', + '| Severity | Previous | Dependency | Installed | Fixed in | Direct/Transitive | Prod/Dev | IDs | Sources | Status |', + '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |', + ...rows.map(entry => { + const packages = entry.packages; + return `| ${entry.severity} | ${entry.previousSeverity} | ${md(packages.map(pkg => pkg.packageName).join(', '))} | ${md(packages.map(pkg => pkg.installedVersion).join(', '))} | ${md(unique(packages.map(pkg => pkg.fixedVersion)).join(', ') || 'No fix published')} | ${md(unique(packages.map(pkg => pkg.dependencyType)).join(', '))} | ${md(unique(packages.map(pkg => pkg.environment)).join(', '))} | ${md(entry.ids.join(', '))} | ${md([...entry.sources].join(', '))} | ${entry.status}${entry.exploited ? ' (known/actively exploited)' : ''} |`; + }), + ...scannerErrors.map( + error => `| UNKNOWN | UNKNOWN | scanner | — | — | — | — | — | — | scanner error: ${md(error)} |`, + ), + '', + '### Scanner errors', + ...(scannerErrors.length ? scannerErrors.map(error => `- ${error}`) : ['- None']), + '', + '### Severity differences between sources', + ...(severityDifferences.length + ? severityDifferences.map( + entry => + `- ${entry.ids.join(', ')}: ${[...entry.severities].map(([source, severity]) => `${source}=${severity}`).join(', ')}`, + ) + : ['- None']), + '', + '### Temporarily accepted exceptions', + ...(accepted.length + ? accepted.map( + ({ entry, exception }) => + `- ${entry.ids.join(', ')} until ${exception.expires}; ${exception.issue} — ${exception.reason}`, + ) + : ['- None']), + '', + '### Transitive dependency paths', + ...(rows + .filter(entry => entry.packages.some(pkg => pkg.dependencyType === 'transitive')) + .map( + entry => + `- ${entry.ids.join(', ')}: ${entry.paths.length ? entry.paths.join('; ') : 'not reported by the scanner'}`, + ).length + ? rows + .filter(entry => entry.packages.some(pkg => pkg.dependencyType === 'transitive')) + .map( + entry => + `- ${entry.ids.join(', ')}: ${entry.paths.length ? entry.paths.join('; ') : 'not reported by the scanner'}`, + ) + : ['- No transitive findings.']), + '', + '### Recommended remediation', + ...(rows + .filter(entry => entry.status !== 'temporarily accepted') + .map(entry => { + const fixes = unique(entry.packages.map(pkg => pkg.fixedVersion)); + return `- ${entry.ids.join(', ')}: update ${entry.packages.map(pkg => pkg.packageName).join(', ')}${fixes.length ? ` to ${fixes.join(', ')}` : '; no fixed version is currently reported'}.`; + }).length + ? rows + .filter(entry => entry.status !== 'temporarily accepted') + .map(entry => { + const fixes = unique(entry.packages.map(pkg => pkg.fixedVersion)); + return `- ${entry.ids.join(', ')}: update ${entry.packages.map(pkg => pkg.packageName).join(', ')}${fixes.length ? ` to ${fixes.join(', ')}` : '; no fixed version is currently reported'}.`; + }) + : ['- No remediation required.']), + ...(important.length + ? [ + '', + '### Important exposure', + ...important.map( + entry => + `- ${entry.ids.join(', ')} affects ${entry.packages.map(pkg => pkg.packageName).join(', ')}${entry.exploited ? ' and is marked known/actively exploited' : ' and includes a production dependency'}.`, + ), + ] + : []), +]; + +await mkdir(reportsDir, { recursive: true }); +await writeFile(join(reportsDir, 'vulnerabilities.sarif'), `${JSON.stringify(sarif(rows), null, 2)}\n`); +await writeFile( + join(reportsDir, 'aggregate.json'), + `${JSON.stringify({ generatedAt: new Date().toISOString(), eventName, current: rows, scannerErrors, exceptionErrors: exceptions.errors }, null, 2)}\n`, +); +await writeFile(join(reportsDir, 'summary.md'), `${summary.join('\n')}\n`); +if (process.env.GITHUB_STEP_SUMMARY) + await writeFile(process.env.GITHUB_STEP_SUMMARY, `${summary.join('\n')}\n`, { flag: 'a' }); + +if (exceptions.errors.length) { + console.error(`Invalid vulnerability exception policy:\n${exceptions.errors.map(error => `- ${error}`).join('\n')}`); + process.exitCode = 2; +} else if (scannerErrors.length) { + console.error(`One or more required scanners failed:\n${scannerErrors.map(error => `- ${error}`).join('\n')}`); + process.exitCode = 2; +} else if (blocking) { + console.error( + `${blocking} vulnerability finding(s) violate the ${isPullRequestPolicy ? 'pull request' : 'full-tree'} HIGH/CRITICAL policy.`, + ); + process.exitCode = 1; +} else { + console.log('Dependency vulnerability policy passed.'); +}