mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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.
489 lines
18 KiB
YAML
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');
|
|
}
|