security.yml 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. name: Security Audit
  2. on:
  3. schedule:
  4. # Run weekly on Monday at 6:00 UTC
  5. - cron: '0 6 * * 1'
  6. push:
  7. paths:
  8. - 'backend/**'
  9. - 'frontend/**'
  10. - 'spoolbuddy/**'
  11. - 'Dockerfile'
  12. - 'docker-compose*.yml'
  13. - 'requirements.txt'
  14. - 'frontend/package*.json'
  15. - '.github/workflows/security.yml'
  16. pull_request:
  17. paths:
  18. - 'backend/**'
  19. - 'frontend/**'
  20. - 'spoolbuddy/**'
  21. - 'Dockerfile'
  22. - 'docker-compose*.yml'
  23. - 'requirements.txt'
  24. - 'frontend/package*.json'
  25. - '.github/workflows/security.yml'
  26. workflow_dispatch:
  27. # Allow manual trigger
  28. env:
  29. PYTHON_VERSION: '3.11'
  30. NODE_VERSION: '22'
  31. # Default permissions for all jobs
  32. permissions:
  33. contents: read
  34. jobs:
  35. bandit:
  36. name: Python Security Analysis (Bandit)
  37. runs-on: ubuntu-latest
  38. permissions:
  39. contents: read
  40. security-events: write
  41. steps:
  42. - uses: actions/checkout@v6
  43. - name: Set up Python
  44. uses: actions/setup-python@v6
  45. with:
  46. python-version: ${{ env.PYTHON_VERSION }}
  47. - name: Install Bandit
  48. run: pip install bandit[sarif]
  49. - name: Run Bandit
  50. run: |
  51. bandit -r backend/ -f sarif -o bandit-results.sarif --severity-level medium || true
  52. - name: Upload Bandit results to GitHub Security
  53. uses: github/codeql-action/upload-sarif@v4
  54. if: always()
  55. with:
  56. sarif_file: bandit-results.sarif
  57. category: bandit
  58. trivy:
  59. name: Container Security Scan (Trivy)
  60. runs-on: ubuntu-latest
  61. permissions:
  62. contents: read
  63. security-events: write
  64. steps:
  65. - uses: actions/checkout@v6
  66. - name: Build Docker image
  67. run: docker build -t bambuddy:security-scan .
  68. # Bump `version` when it ages rather than leaving it. Old Trivy releases
  69. # are deleted upstream — only the last handful of minors are kept — so a
  70. # pin a few months stale still resolves as a tag but no longer has an
  71. # asset behind it, and the install step exits 1 right after reporting it
  72. # found the version (#2844).
  73. #
  74. # A green run here is not evidence the pin still works. On a repository
  75. # that runs this often the binary comes back from the Actions cache and
  76. # nothing is downloaded, so the breakage shows up first in forks, which
  77. # have no cache, and reaches this repository only once the entry goes
  78. # cold.
  79. - name: Run Trivy vulnerability scanner
  80. uses: aquasecurity/trivy-action@v0.36.0
  81. with:
  82. image-ref: 'bambuddy:security-scan'
  83. format: 'sarif'
  84. output: 'trivy-results.sarif'
  85. severity: 'CRITICAL,HIGH,MEDIUM'
  86. version: 'v0.74.0'
  87. - name: Upload Trivy results to GitHub Security
  88. uses: github/codeql-action/upload-sarif@v4
  89. if: always() && hashFiles('trivy-results.sarif') != ''
  90. with:
  91. sarif_file: trivy-results.sarif
  92. category: trivy
  93. # Keep in step with the scan above — see the note there before changing.
  94. - name: Run Trivy for Dockerfile/IaC
  95. uses: aquasecurity/trivy-action@v0.36.0
  96. with:
  97. scan-type: 'config'
  98. scan-ref: '.'
  99. format: 'sarif'
  100. output: 'trivy-config-results.sarif'
  101. severity: 'CRITICAL,HIGH,MEDIUM'
  102. version: 'v0.74.0'
  103. - name: Upload Trivy config results
  104. uses: github/codeql-action/upload-sarif@v4
  105. if: always() && hashFiles('trivy-config-results.sarif') != ''
  106. with:
  107. sarif_file: trivy-config-results.sarif
  108. category: trivy-config
  109. backend-audit:
  110. name: Backend Security Audit
  111. runs-on: ubuntu-latest
  112. permissions:
  113. contents: read
  114. issues: write
  115. steps:
  116. - uses: actions/checkout@v6
  117. - name: Set up Python
  118. uses: actions/setup-python@v6
  119. with:
  120. python-version: ${{ env.PYTHON_VERSION }}
  121. - name: Install dependencies
  122. run: |
  123. # Upgrade setuptools too: the runner's Python toolcache ships an old
  124. # setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
  125. # A fix exists, so we upgrade rather than --ignore-vuln.
  126. python -m pip install --upgrade pip setuptools
  127. pip install -r requirements.txt
  128. pip install pip-audit
  129. - name: Run pip-audit
  130. id: pip-audit
  131. run: |
  132. # CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): disputed by PyJWT maintainers.
  133. # Advisory says "key length is chosen by the application that uses the library" — no
  134. # PyJWT fix exists or will exist. Bambuddy is safe: backend/app/core/auth.py:184 uses
  135. # secrets.token_urlsafe(64) (~86 chars of entropy) for auto-generated secrets and
  136. # rejects file-loaded secrets shorter than 32 chars at :177. Keep ignored permanently.
  137. pip-audit --desc on --format json --output pip-audit-results.json \
  138. --ignore-vuln CVE-2025-45768 \
  139. || echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT
  140. pip-audit --desc on \
  141. --ignore-vuln CVE-2025-45768 \
  142. || true
  143. - name: Upload audit results
  144. if: always()
  145. uses: actions/upload-artifact@v7
  146. with:
  147. name: pip-audit-results
  148. path: pip-audit-results.json
  149. retention-days: 30
  150. - name: Create or close pip security issue
  151. if: always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
  152. uses: actions/github-script@v9
  153. with:
  154. script: |
  155. const fs = require('fs');
  156. // Check for existing open issue
  157. const existingIssues = await github.rest.issues.listForRepo({
  158. owner: context.repo.owner,
  159. repo: context.repo.repo,
  160. state: 'open',
  161. labels: 'security,automated'
  162. });
  163. const existingIssue = existingIssues.data.find(i => i.title.startsWith('Security Alert:') && i.title.includes('Python'));
  164. // If no vulnerabilities found, auto-close any stale issue
  165. if ('${{ steps.pip-audit.outputs.vulnerabilities_found }}' !== 'true') {
  166. if (existingIssue) {
  167. await github.rest.issues.createComment({
  168. owner: context.repo.owner,
  169. repo: context.repo.repo,
  170. issue_number: existingIssue.number,
  171. body: 'All Python vulnerabilities have been resolved. Closing automatically.'
  172. });
  173. await github.rest.issues.update({
  174. owner: context.repo.owner,
  175. repo: context.repo.repo,
  176. issue_number: existingIssue.number,
  177. state: 'closed'
  178. });
  179. console.log(`Auto-closed resolved issue #${existingIssue.number}`);
  180. }
  181. return;
  182. }
  183. let results;
  184. try {
  185. results = JSON.parse(fs.readFileSync('pip-audit-results.json', 'utf8'));
  186. } catch {
  187. console.log('Could not read audit results');
  188. return;
  189. }
  190. // Build vulnerability table
  191. let table = '| Package | Version | Vulnerability | Fix Version |\n';
  192. table += '|---------|---------|---------------|-------------|\n';
  193. for (const vuln of results.dependencies || []) {
  194. for (const v of vuln.vulns || []) {
  195. table += `| ${vuln.name} | ${vuln.version} | ${v.id} | ${v.fix_versions?.join(', ') || 'N/A'} |\n`;
  196. }
  197. }
  198. const vulnCount = results.dependencies?.reduce((acc, d) => acc + (d.vulns?.length || 0), 0) || 0;
  199. if (vulnCount === 0) {
  200. console.log('No vulnerabilities to report');
  201. if (existingIssue) {
  202. await github.rest.issues.createComment({
  203. owner: context.repo.owner,
  204. repo: context.repo.repo,
  205. issue_number: existingIssue.number,
  206. body: 'All Python vulnerabilities have been resolved. Closing automatically.'
  207. });
  208. await github.rest.issues.update({
  209. owner: context.repo.owner,
  210. repo: context.repo.repo,
  211. issue_number: existingIssue.number,
  212. state: 'closed'
  213. });
  214. console.log(`Auto-closed resolved issue #${existingIssue.number}`);
  215. }
  216. return;
  217. }
  218. const title = `Security Alert: ${vulnCount} Python vulnerabilities found`;
  219. const body = `## Automated Security Audit Results
  220. The weekly security audit found vulnerabilities in Python dependencies.
  221. ${table}
  222. ### Recommended Actions
  223. 1. Review each vulnerability
  224. 2. Update affected packages: \`pip install --upgrade <package>\`
  225. 3. Run \`pip-audit\` locally to verify fixes
  226. ---
  227. *This issue was automatically created by the security audit workflow.*`;
  228. if (existingIssue) {
  229. await github.rest.issues.update({
  230. owner: context.repo.owner,
  231. repo: context.repo.repo,
  232. issue_number: existingIssue.number,
  233. body: body
  234. });
  235. console.log(`Updated existing issue #${existingIssue.number}`);
  236. } else {
  237. await github.rest.issues.create({
  238. owner: context.repo.owner,
  239. repo: context.repo.repo,
  240. title: title,
  241. body: body,
  242. labels: ['security', 'automated', 'dependencies']
  243. });
  244. console.log('Created new security issue');
  245. }
  246. frontend-audit:
  247. name: Frontend Security Audit
  248. runs-on: ubuntu-latest
  249. permissions:
  250. contents: read
  251. issues: write
  252. steps:
  253. - uses: actions/checkout@v6
  254. - name: Set up Node.js
  255. uses: actions/setup-node@v6
  256. with:
  257. node-version: ${{ env.NODE_VERSION }}
  258. cache: 'npm'
  259. cache-dependency-path: frontend/package-lock.json
  260. - name: Install dependencies
  261. working-directory: frontend
  262. run: npm ci
  263. - name: Run npm audit
  264. id: npm-audit
  265. working-directory: frontend
  266. run: |
  267. npm audit --omit=dev --json > npm-audit-raw.json 2>/dev/null || true
  268. # Filter audit results to only include actual project dependencies.
  269. # npm 10.x audit/ls reports vulns in its own bundled deps (npm, tar, minimatch)
  270. # so we parse package-lock.json directly to get the real prod dep list.
  271. node -e "
  272. const fs = require('fs');
  273. const raw = fs.readFileSync('npm-audit-raw.json', 'utf8');
  274. let results;
  275. try { results = JSON.parse(raw); } catch { results = { vulnerabilities: {} }; }
  276. const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
  277. const prodDeps = new Set();
  278. for (const [path, info] of Object.entries(lock.packages || {})) {
  279. if (path && !info.dev && !info.devOptional) {
  280. prodDeps.add(path.split('node_modules/').pop());
  281. }
  282. }
  283. const vulns = results.vulnerabilities || {};
  284. // Documented advisory exceptions (keyed by GHSA id) - see ci.yml for the
  285. // full rationale and the matching hard gate. GHSA-qwww-vcr4-c8h2: React
  286. // Router RSC-mode CSRF, not reachable from Bambuddy's BrowserRouter SPA
  287. // (@react-router/server not installed); react-router/-dom pinned to 7.18.1
  288. // (the most-patched 7.x), no non-major fix exists. Auto-surfaces again if a
  289. // non-major fix ships.
  290. const ALLOWLIST = new Set(['GHSA-qwww-vcr4-c8h2']);
  291. function advisoryIds(name, seen) {
  292. seen = seen || new Set();
  293. if (seen.has(name)) return new Set();
  294. seen.add(name);
  295. const ids = new Set();
  296. for (const item of (vulns[name] || {}).via || []) {
  297. if (item && typeof item === 'object') {
  298. const url = item.url || '';
  299. if (url.includes('/advisories/')) ids.add(url.split('/').pop());
  300. } else if (typeof item === 'string') {
  301. for (const id of advisoryIds(item, seen)) ids.add(id);
  302. }
  303. }
  304. return ids;
  305. }
  306. function fixIsMajor(info) {
  307. const fa = info.fixAvailable;
  308. return fa && typeof fa === 'object' && fa.isSemVerMajor;
  309. }
  310. function exempt(name, info) {
  311. const ids = advisoryIds(name);
  312. return ids.size > 0 && [...ids].every(id => ALLOWLIST.has(id)) && fixIsMajor(info);
  313. }
  314. const filtered = {};
  315. const flagged = {};
  316. for (const [name, info] of Object.entries(vulns)) {
  317. if (!prodDeps.has(name)) continue;
  318. filtered[name] = info;
  319. if (!exempt(name, info)) flagged[name] = info;
  320. }
  321. results.vulnerabilities = filtered;
  322. fs.writeFileSync('npm-audit-results.json', JSON.stringify(results, null, 2));
  323. const count = Object.keys(flagged).length;
  324. console.log(count > 0
  325. ? count + ' production vulnerabilities found'
  326. : 'No production vulnerabilities (filtered ' + Object.keys(vulns).length + ' npm-internal entries)');
  327. if (count > 0) process.exit(1);
  328. " || echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT
  329. npm audit --omit=dev --audit-level=high || true
  330. - name: Upload audit results
  331. if: always()
  332. uses: actions/upload-artifact@v7
  333. with:
  334. name: npm-audit-results
  335. path: frontend/npm-audit-results.json
  336. retention-days: 30
  337. - name: Create or close npm security issue
  338. if: always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
  339. uses: actions/github-script@v9
  340. with:
  341. script: |
  342. const fs = require('fs');
  343. // Check for existing open issue
  344. const existingIssues = await github.rest.issues.listForRepo({
  345. owner: context.repo.owner,
  346. repo: context.repo.repo,
  347. state: 'open',
  348. labels: 'security,automated'
  349. });
  350. const existingIssue = existingIssues.data.find(i => i.title.startsWith('Security Alert:') && i.title.includes('npm'));
  351. // If filter didn't flag vulnerabilities, auto-close any stale issue
  352. if ('${{ steps.npm-audit.outputs.vulnerabilities_found }}' !== 'true') {
  353. if (existingIssue) {
  354. await github.rest.issues.createComment({
  355. owner: context.repo.owner,
  356. repo: context.repo.repo,
  357. issue_number: existingIssue.number,
  358. body: 'All npm production vulnerabilities have been resolved. Closing automatically.'
  359. });
  360. await github.rest.issues.update({
  361. owner: context.repo.owner,
  362. repo: context.repo.repo,
  363. issue_number: existingIssue.number,
  364. state: 'closed'
  365. });
  366. console.log(`Auto-closed resolved issue #${existingIssue.number}`);
  367. }
  368. return;
  369. }
  370. let results;
  371. try {
  372. results = JSON.parse(fs.readFileSync('frontend/npm-audit-results.json', 'utf8'));
  373. } catch {
  374. console.log('Could not read filtered audit results');
  375. return;
  376. }
  377. const vulns = results.vulnerabilities || {};
  378. const vulnCount = Object.keys(vulns).length;
  379. if (vulnCount === 0) {
  380. console.log('No vulnerabilities to report');
  381. if (existingIssue) {
  382. await github.rest.issues.createComment({
  383. owner: context.repo.owner,
  384. repo: context.repo.repo,
  385. issue_number: existingIssue.number,
  386. body: 'All npm production vulnerabilities have been resolved. Closing automatically.'
  387. });
  388. await github.rest.issues.update({
  389. owner: context.repo.owner,
  390. repo: context.repo.repo,
  391. issue_number: existingIssue.number,
  392. state: 'closed'
  393. });
  394. console.log(`Auto-closed resolved issue #${existingIssue.number}`);
  395. }
  396. return;
  397. }
  398. // Build vulnerability table
  399. let table = '| Package | Severity | Via | Fix |\n';
  400. table += '|---------|----------|-----|-----|\n';
  401. for (const [name, info] of Object.entries(vulns)) {
  402. const via = Array.isArray(info.via) ? info.via.map(v => typeof v === 'string' ? v : v.name).join(', ') : info.via;
  403. table += `| ${name} | ${info.severity} | ${via} | ${info.fixAvailable ? 'Yes' : 'No'} |\n`;
  404. }
  405. const title = `Security Alert: ${vulnCount} npm vulnerabilities found`;
  406. const body = `## Automated Security Audit Results
  407. The weekly security audit found vulnerabilities in npm dependencies.
  408. ${table}
  409. ### Recommended Actions
  410. 1. Review each vulnerability: \`npm audit\`
  411. 2. Auto-fix if possible: \`npm audit fix\`
  412. 3. Manual fix for breaking changes: \`npm audit fix --force\` (review changes!)
  413. ---
  414. *This issue was automatically created by the security audit workflow.*`;
  415. if (existingIssue) {
  416. await github.rest.issues.update({
  417. owner: context.repo.owner,
  418. repo: context.repo.repo,
  419. issue_number: existingIssue.number,
  420. body: body
  421. });
  422. console.log(`Updated existing issue #${existingIssue.number}`);
  423. } else {
  424. await github.rest.issues.create({
  425. owner: context.repo.owner,
  426. repo: context.repo.repo,
  427. title: title,
  428. body: body,
  429. labels: ['security', 'automated', 'dependencies']
  430. });
  431. console.log('Created new security issue');
  432. }