diff --git a/.github/workflows/dependency-vulnerability-scan.yml b/.github/workflows/dependency-vulnerability-scan.yml new file mode 100644 index 0000000..c5e261b --- /dev/null +++ b/.github/workflows/dependency-vulnerability-scan.yml @@ -0,0 +1,110 @@ +name: Dependency Vulnerability Scan + +on: + pull_request: + branches: [main, develop] + paths: + - "package.json" + - "package-lock.json" + - ".github/workflows/dependency-vulnerability-scan.yml" + - "docs/dependency-vulnerability-scanning.md" + - "docs/runbooks/dependency-vulnerability-scanning.md" + push: + branches: [main] + schedule: + # Run before the US business day starts so alerts are available for triage. + - cron: "17 10 * * 1-5" + workflow_dispatch: + +permissions: + contents: read + actions: read + security-events: write + pull-requests: write + +concurrency: + group: dependency-vulnerability-scan-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: 20 + AUDIT_LEVEL: high + +jobs: + dependency-audit: + name: npm audit gate + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run npm audit + id: npm-audit + run: | + set +e + npm audit --audit-level=${AUDIT_LEVEL} --json > npm-audit.json + AUDIT_EXIT=$? + node scripts/summarize-npm-audit.mjs npm-audit.json > npm-audit-summary.md + cat npm-audit-summary.md >> "$GITHUB_STEP_SUMMARY" + exit "$AUDIT_EXIT" + + - name: Upload npm audit report + if: always() + uses: actions/upload-artifact@v4 + with: + name: npm-audit-report + path: | + npm-audit.json + npm-audit-summary.md + retention-days: 30 + + - name: Comment audit summary on PR + if: github.event_name == 'pull_request' && always() + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: gh pr comment ${{ github.event.pull_request.number }} --body-file npm-audit-summary.md + + osv-scanner: + name: OSV lockfile scan + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Run OSV Scanner + id: osv-scan + uses: google/osv-scanner-action/osv-scanner-action@v2.2.3 + with: + scan-args: |- + --lockfile=package-lock.json + --format=sarif + --output=osv-results.sarif + continue-on-error: true + + - name: Upload OSV SARIF to code scanning + if: always() && hashFiles('osv-results.sarif') != '' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: osv-results.sarif + category: osv-dependencies + + - name: Upload OSV artifact + if: always() && hashFiles('osv-results.sarif') != '' + uses: actions/upload-artifact@v4 + with: + name: osv-sarif-report + path: osv-results.sarif + retention-days: 30 + + - name: Fail when OSV finds vulnerabilities + if: steps.osv-scan.outcome == 'failure' + run: exit 1 diff --git a/docs/dependency-vulnerability-scanning.md b/docs/dependency-vulnerability-scanning.md new file mode 100644 index 0000000..ab2d556 --- /dev/null +++ b/docs/dependency-vulnerability-scanning.md @@ -0,0 +1,20 @@ +# Dependency Vulnerability Scanning Architecture + +## Scope + +The dependency vulnerability scanning pipeline protects this frontend service by checking the npm dependency graph on every dependency-changing pull request, every push to `main`, a weekday scheduled scan, and manual dispatches. + +## Architecture + +1. **Dependency installation** uses `npm ci` with the repository lockfile so the scan evaluates the same dependency graph used by build and deployment jobs. +2. **npm audit gate** fails the workflow for vulnerabilities at `high` severity or above and publishes a Markdown summary as both a workflow summary and PR comment. +3. **OSV lockfile scan** checks `package-lock.json` against the Open Source Vulnerabilities database and uploads SARIF results to GitHub code scanning for centralized security review. +4. **Artifacts** retain the raw npm audit JSON, Markdown summary, and OSV SARIF for 30 days to support security review and incident follow-up. + +## Monitoring and alerting + +GitHub Actions workflow failures are the primary alert. The security review queue should monitor the `Dependency Vulnerability Scan` workflow and GitHub code scanning alerts. Pull requests cannot be considered security-ready until this workflow is green or the security team documents a temporary exception. + +## Deployment and availability impact + +The pipeline is CI-only and does not add application runtime code, network calls, or critical-path latency. It has no direct P99 latency impact and does not change production availability. Dependency updates that pass this gate should continue through the existing release process, including blue-green deployment and canary analysis in the deployment platform. diff --git a/docs/runbooks/dependency-vulnerability-scanning.md b/docs/runbooks/dependency-vulnerability-scanning.md new file mode 100644 index 0000000..ce81d74 --- /dev/null +++ b/docs/runbooks/dependency-vulnerability-scanning.md @@ -0,0 +1,27 @@ +# Dependency Vulnerability Scanning Runbook + +## When the scan fails + +1. Open the failed `Dependency Vulnerability Scan` workflow run. +2. Review the `npm-audit-report` artifact and the OSV code scanning alert. +3. Prefer safe dependency upgrades with `npm update ` or a targeted package version bump. +4. Run the local validation commands below before pushing the fix. +5. If no safe fix is available, request security review and document the accepted risk, affected package, severity, compensating controls, and expiration date in the pull request. + +## Local validation + +```bash +npm ci +npm audit --audit-level=high +npm run lint +npm test +npm run build +``` + +## Escalation + +Escalate immediately to the security owner when a critical vulnerability affects a direct runtime dependency, has known exploitation, or impacts authentication, signing, transaction handling, or browser-executed code. + +## Rollout guidance + +Dependency remediation should be deployed through the standard blue-green process. During canary analysis, monitor frontend error rate, Web Vitals, dependency-related console errors, and any security dashboard alerts for the changed package set. diff --git a/package.json b/package.json index bb5c1b4..c5014b4 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "test:watch": "vitest", "test:ui": "vitest --ui", "test:visual": "npx playwright test tests/visual", - "visual:update-baselines": "npx tsx scripts/update-baselines.ts" + "visual:update-baselines": "npx tsx scripts/update-baselines.ts", + "audit:security": "npm audit --audit-level=high" }, "dependencies": { "@stellar/stellar-sdk": "^15.1.0", diff --git a/scripts/summarize-npm-audit.mjs b/scripts/summarize-npm-audit.mjs new file mode 100644 index 0000000..f7af998 --- /dev/null +++ b/scripts/summarize-npm-audit.mjs @@ -0,0 +1,51 @@ +import { readFileSync } from 'node:fs'; + +const [reportPath = 'npm-audit.json'] = process.argv.slice(2); +const report = JSON.parse(readFileSync(reportPath, 'utf8')); +const vulnerabilities = Object.values(report.vulnerabilities ?? {}); +const metadata = report.metadata?.vulnerabilities ?? {}; + +const severityOrder = ['critical', 'high', 'moderate', 'low', 'info']; +const totals = severityOrder + .map((severity) => `${severity}: ${metadata[severity] ?? 0}`) + .join(', '); + +const lines = []; +lines.push('## Dependency Vulnerability Scan'); +lines.push(''); +lines.push(`Audit severity threshold: **${process.env.AUDIT_LEVEL ?? 'high'}**`); +lines.push(`Vulnerability totals: ${totals}`); +lines.push(''); + +if (vulnerabilities.length === 0) { + lines.push('No npm advisory vulnerabilities were reported.'); + process.stdout.write(`${lines.join('\n')}\n`); + process.exit(0); +} + +lines.push('| Package | Severity | Direct | Fix available | Via |'); +lines.push('|---|---:|:---:|---|---|'); + +for (const vulnerability of vulnerabilities + .sort((a, b) => severityOrder.indexOf(a.severity) - severityOrder.indexOf(b.severity)) + .slice(0, 25)) { + const via = (vulnerability.via ?? []) + .map((entry) => (typeof entry === 'string' ? entry : entry.title ?? entry.source ?? 'advisory')) + .slice(0, 3) + .join('
'); + const fix = vulnerability.fixAvailable + ? typeof vulnerability.fixAvailable === 'object' + ? `${vulnerability.fixAvailable.name}@${vulnerability.fixAvailable.version}` + : 'yes' + : 'no'; + lines.push( + `| ${vulnerability.name} | ${vulnerability.severity} | ${vulnerability.isDirect ? 'yes' : 'no'} | ${fix} | ${via} |`, + ); +} + +if (vulnerabilities.length > 25) { + lines.push(''); + lines.push(`Showing 25 of ${vulnerabilities.length} vulnerable packages. Download the npm audit artifact for full details.`); +} + +process.stdout.write(`${lines.join('\n')}\n`);