Files
maziggy 145c7d5f57 Pin Trivy to a release that still exists (#2844)
The scan pinned Trivy v0.69.1, which aquasecurity have since deleted --
retained releases now run v0.74.0 down to v0.69.2 and then jump back to
v0.26.0. The tag survives, so setup-trivy resolves it, reports "found
version: 0.69.1" and then exits 1 with no asset to fetch.

This repository did not notice because the binary was coming back from
the Actions cache on every run, which skips the download. Forks have no
such cache, which is where it was reported from -- and the same failure
was due here the first time that entry went cold.

Both scans move to trivy-action v0.36.0 and Trivy v0.74.0; every input
they pass is still declared in the new action. The comment records that
this pin has to be bumped rather than left, and that a green run is not
evidence it still resolves.

The config scan is clean on v0.74.0, so the bump adds no new
misconfiguration alerts.
2026-08-16 10:17:02 +02:00

489 lines
18 KiB
YAML

name: Security Audit
on:
schedule:
# Run weekly on Monday at 6:00 UTC
- cron: '0 6 * * 1'
push:
paths:
- 'backend/**'
- 'frontend/**'
- 'spoolbuddy/**'
- 'Dockerfile'
- 'docker-compose*.yml'
- 'requirements.txt'
- 'frontend/package*.json'
- '.github/workflows/security.yml'
pull_request:
paths:
- 'backend/**'
- 'frontend/**'
- 'spoolbuddy/**'
- 'Dockerfile'
- 'docker-compose*.yml'
- 'requirements.txt'
- 'frontend/package*.json'
- '.github/workflows/security.yml'
workflow_dispatch:
# Allow manual trigger
env:
PYTHON_VERSION: '3.11'
NODE_VERSION: '22'
# Default permissions for all jobs
permissions:
contents: read
jobs:
bandit:
name: Python Security Analysis (Bandit)
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install Bandit
run: pip install bandit[sarif]
- name: Run Bandit
run: |
bandit -r backend/ -f sarif -o bandit-results.sarif --severity-level medium || true
- name: Upload Bandit results to GitHub Security
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: bandit-results.sarif
category: bandit
trivy:
name: Container Security Scan (Trivy)
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v6
- name: Build Docker image
run: docker build -t bambuddy:security-scan .
# Bump `version` when it ages rather than leaving it. Old Trivy releases
# are deleted upstream — only the last handful of minors are kept — so a
# pin a few months stale still resolves as a tag but no longer has an
# asset behind it, and the install step exits 1 right after reporting it
# found the version (#2844).
#
# A green run here is not evidence the pin still works. On a repository
# that runs this often the binary comes back from the Actions cache and
# nothing is downloaded, so the breakage shows up first in forks, which
# have no cache, and reaches this repository only once the entry goes
# cold.
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: 'bambuddy:security-scan'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH,MEDIUM'
version: 'v0.74.0'
- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@v4
if: always() && hashFiles('trivy-results.sarif') != ''
with:
sarif_file: trivy-results.sarif
category: trivy
# Keep in step with the scan above — see the note there before changing.
- name: Run Trivy for Dockerfile/IaC
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: 'config'
scan-ref: '.'
format: 'sarif'
output: 'trivy-config-results.sarif'
severity: 'CRITICAL,HIGH,MEDIUM'
version: 'v0.74.0'
- name: Upload Trivy config results
uses: github/codeql-action/upload-sarif@v4
if: always() && hashFiles('trivy-config-results.sarif') != ''
with:
sarif_file: trivy-config-results.sarif
category: trivy-config
backend-audit:
name: Backend Security Audit
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: |
# Upgrade setuptools too: the runner's Python toolcache ships an old
# setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
# A fix exists, so we upgrade rather than --ignore-vuln.
python -m pip install --upgrade pip setuptools
pip install -r requirements.txt
pip install pip-audit
- name: Run pip-audit
id: pip-audit
run: |
# CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): disputed by PyJWT maintainers.
# Advisory says "key length is chosen by the application that uses the library" — no
# PyJWT fix exists or will exist. Bambuddy is safe: backend/app/core/auth.py:184 uses
# secrets.token_urlsafe(64) (~86 chars of entropy) for auto-generated secrets and
# rejects file-loaded secrets shorter than 32 chars at :177. Keep ignored permanently.
pip-audit --desc on --format json --output pip-audit-results.json \
--ignore-vuln CVE-2025-45768 \
|| echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT
pip-audit --desc on \
--ignore-vuln CVE-2025-45768 \
|| true
- name: Upload audit results
if: always()
uses: actions/upload-artifact@v7
with:
name: pip-audit-results
path: pip-audit-results.json
retention-days: 30
- name: Create or close pip security issue
if: always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
// Check for existing open issue
const existingIssues = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'security,automated'
});
const existingIssue = existingIssues.data.find(i => i.title.startsWith('Security Alert:') && i.title.includes('Python'));
// If no vulnerabilities found, auto-close any stale issue
if ('${{ steps.pip-audit.outputs.vulnerabilities_found }}' !== 'true') {
if (existingIssue) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingIssue.number,
body: 'All Python vulnerabilities have been resolved. Closing automatically.'
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingIssue.number,
state: 'closed'
});
console.log(`Auto-closed resolved issue #${existingIssue.number}`);
}
return;
}
let results;
try {
results = JSON.parse(fs.readFileSync('pip-audit-results.json', 'utf8'));
} catch {
console.log('Could not read audit results');
return;
}
// Build vulnerability table
let table = '| Package | Version | Vulnerability | Fix Version |\n';
table += '|---------|---------|---------------|-------------|\n';
for (const vuln of results.dependencies || []) {
for (const v of vuln.vulns || []) {
table += `| ${vuln.name} | ${vuln.version} | ${v.id} | ${v.fix_versions?.join(', ') || 'N/A'} |\n`;
}
}
const vulnCount = results.dependencies?.reduce((acc, d) => acc + (d.vulns?.length || 0), 0) || 0;
if (vulnCount === 0) {
console.log('No vulnerabilities to report');
if (existingIssue) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingIssue.number,
body: 'All Python vulnerabilities have been resolved. Closing automatically.'
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingIssue.number,
state: 'closed'
});
console.log(`Auto-closed resolved issue #${existingIssue.number}`);
}
return;
}
const title = `Security Alert: ${vulnCount} Python vulnerabilities found`;
const body = `## Automated Security Audit Results
The weekly security audit found vulnerabilities in Python dependencies.
${table}
### Recommended Actions
1. Review each vulnerability
2. Update affected packages: \`pip install --upgrade <package>\`
3. Run \`pip-audit\` locally to verify fixes
---
*This issue was automatically created by the security audit workflow.*`;
if (existingIssue) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingIssue.number,
body: body
});
console.log(`Updated existing issue #${existingIssue.number}`);
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: title,
body: body,
labels: ['security', 'automated', 'dependencies']
});
console.log('Created new security issue');
}
frontend-audit:
name: Frontend Security Audit
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Run npm audit
id: npm-audit
working-directory: frontend
run: |
npm audit --omit=dev --json > npm-audit-raw.json 2>/dev/null || true
# Filter audit results to only include actual project dependencies.
# npm 10.x audit/ls reports vulns in its own bundled deps (npm, tar, minimatch)
# so we parse package-lock.json directly to get the real prod dep list.
node -e "
const fs = require('fs');
const raw = fs.readFileSync('npm-audit-raw.json', 'utf8');
let results;
try { results = JSON.parse(raw); } catch { results = { vulnerabilities: {} }; }
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
const prodDeps = new Set();
for (const [path, info] of Object.entries(lock.packages || {})) {
if (path && !info.dev && !info.devOptional) {
prodDeps.add(path.split('node_modules/').pop());
}
}
const vulns = results.vulnerabilities || {};
// Documented advisory exceptions (keyed by GHSA id) - see ci.yml for the
// full rationale and the matching hard gate. GHSA-qwww-vcr4-c8h2: React
// Router RSC-mode CSRF, not reachable from Bambuddy's BrowserRouter SPA
// (@react-router/server not installed); react-router/-dom pinned to 7.18.1
// (the most-patched 7.x), no non-major fix exists. Auto-surfaces again if a
// non-major fix ships.
const ALLOWLIST = new Set(['GHSA-qwww-vcr4-c8h2']);
function advisoryIds(name, seen) {
seen = seen || new Set();
if (seen.has(name)) return new Set();
seen.add(name);
const ids = new Set();
for (const item of (vulns[name] || {}).via || []) {
if (item && typeof item === 'object') {
const url = item.url || '';
if (url.includes('/advisories/')) ids.add(url.split('/').pop());
} else if (typeof item === 'string') {
for (const id of advisoryIds(item, seen)) ids.add(id);
}
}
return ids;
}
function fixIsMajor(info) {
const fa = info.fixAvailable;
return fa && typeof fa === 'object' && fa.isSemVerMajor;
}
function exempt(name, info) {
const ids = advisoryIds(name);
return ids.size > 0 && [...ids].every(id => ALLOWLIST.has(id)) && fixIsMajor(info);
}
const filtered = {};
const flagged = {};
for (const [name, info] of Object.entries(vulns)) {
if (!prodDeps.has(name)) continue;
filtered[name] = info;
if (!exempt(name, info)) flagged[name] = info;
}
results.vulnerabilities = filtered;
fs.writeFileSync('npm-audit-results.json', JSON.stringify(results, null, 2));
const count = Object.keys(flagged).length;
console.log(count > 0
? count + ' production vulnerabilities found'
: 'No production vulnerabilities (filtered ' + Object.keys(vulns).length + ' npm-internal entries)');
if (count > 0) process.exit(1);
" || echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT
npm audit --omit=dev --audit-level=high || true
- name: Upload audit results
if: always()
uses: actions/upload-artifact@v7
with:
name: npm-audit-results
path: frontend/npm-audit-results.json
retention-days: 30
- name: Create or close npm security issue
if: always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
// Check for existing open issue
const existingIssues = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'security,automated'
});
const existingIssue = existingIssues.data.find(i => i.title.startsWith('Security Alert:') && i.title.includes('npm'));
// If filter didn't flag vulnerabilities, auto-close any stale issue
if ('${{ steps.npm-audit.outputs.vulnerabilities_found }}' !== 'true') {
if (existingIssue) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingIssue.number,
body: 'All npm production vulnerabilities have been resolved. Closing automatically.'
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingIssue.number,
state: 'closed'
});
console.log(`Auto-closed resolved issue #${existingIssue.number}`);
}
return;
}
let results;
try {
results = JSON.parse(fs.readFileSync('frontend/npm-audit-results.json', 'utf8'));
} catch {
console.log('Could not read filtered audit results');
return;
}
const vulns = results.vulnerabilities || {};
const vulnCount = Object.keys(vulns).length;
if (vulnCount === 0) {
console.log('No vulnerabilities to report');
if (existingIssue) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingIssue.number,
body: 'All npm production vulnerabilities have been resolved. Closing automatically.'
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingIssue.number,
state: 'closed'
});
console.log(`Auto-closed resolved issue #${existingIssue.number}`);
}
return;
}
// Build vulnerability table
let table = '| Package | Severity | Via | Fix |\n';
table += '|---------|----------|-----|-----|\n';
for (const [name, info] of Object.entries(vulns)) {
const via = Array.isArray(info.via) ? info.via.map(v => typeof v === 'string' ? v : v.name).join(', ') : info.via;
table += `| ${name} | ${info.severity} | ${via} | ${info.fixAvailable ? 'Yes' : 'No'} |\n`;
}
const title = `Security Alert: ${vulnCount} npm vulnerabilities found`;
const body = `## Automated Security Audit Results
The weekly security audit found vulnerabilities in npm dependencies.
${table}
### Recommended Actions
1. Review each vulnerability: \`npm audit\`
2. Auto-fix if possible: \`npm audit fix\`
3. Manual fix for breaking changes: \`npm audit fix --force\` (review changes!)
---
*This issue was automatically created by the security audit workflow.*`;
if (existingIssue) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingIssue.number,
body: body
});
console.log(`Updated existing issue #${existingIssue.number}`);
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: title,
body: body,
labels: ['security', 'automated', 'dependencies']
});
console.log('Created new security issue');
}