security.yml 18 KB

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