fix(notifications): scope rules to owner_id to prevent cross-tenant exfiltration (fixes #2064) #416
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Security CI/CD | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| permissions: | |
| contents: read | |
| security-events: write | |
| jobs: | |
| security-lint: | |
| name: Python Security Linting (Bandit) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Install bandit | |
| run: pip install bandit[toml] | |
| - name: Run bandit security linter | |
| run: bandit -r backend/secuscan -f json -o bandit-report.json --severity-level medium || true | |
| - name: Check for high-severity findings | |
| run: | | |
| python -c " | |
| import json, sys | |
| with open('bandit-report.json') as f: | |
| data = json.load(f) | |
| high = [r for r in data.get('results', []) if r.get('issue_severity') == 'HIGH'] | |
| if high: | |
| print('::error::Found {} HIGH severity security issues'.format(len(high))) | |
| for r in high: | |
| print(' - {}:{}: {}'.format(r['filename'], r['line_number'], r['issue_text'])) | |
| sys.exit(1) | |
| print('No HIGH severity issues found') | |
| " | |
| - name: Upload bandit report | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: bandit-report | |
| path: bandit-report.json | |
| auth-protection-check: | |
| name: Route Authentication Check | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Check all API routers have authentication | |
| run: | | |
| python -c " | |
| import re, sys | |
| from pathlib import Path | |
| vulns = [] | |
| for py_file in Path('backend/secuscan').rglob('*.py'): | |
| content = py_file.read_text() | |
| router_pattern = re.compile(r'APIRouter\((.*?)\)', re.DOTALL) | |
| for match in router_pattern.finditer(content): | |
| args = match.group(1) | |
| if 'tags' in args and 'auth' not in args.lower() and 'health' not in args.lower(): | |
| if 'dependencies' not in args or 'require_api_key' not in args: | |
| line_num = content[:match.start()].count('\n') + 1 | |
| vulns.append(' {}:{} - Router missing require_api_key dependency'.format(py_file, line_num)) | |
| if vulns: | |
| print('::warning::API routers without authentication dependency:') | |
| for v in vulns: | |
| print(v) | |
| print() | |
| print('If these are intentional (e.g., health check), ignore this warning.') | |
| print('Otherwise, add: dependencies=[Depends(require_api_key)]') | |
| else: | |
| print('All API routers have authentication dependencies') | |
| " | |
| debug-mode-check: | |
| name: Debug Mode Default Check | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Verify debug mode is off by default | |
| run: | | |
| python -c " | |
| import re, sys | |
| from pathlib import Path | |
| issues = [] | |
| for py_file in Path('backend/secuscan').rglob('*.py'): | |
| content = py_file.read_text() | |
| lines = content.splitlines() | |
| for i, line in enumerate(lines, 1): | |
| stripped = line.strip() | |
| # Check for debug=True defaults (not in if statements or comments) | |
| if re.search(r'debug\s*=\s*True', stripped): | |
| if not stripped.startswith('if') and not stripped.startswith('#'): | |
| issues.append('{}:{}: debug defaults to True'.format(py_file, i)) | |
| # Check for traceback.format_exc in responses - verify guarded by debug check | |
| if 'traceback.format_exc' in line: | |
| # Look backwards up to 10 lines for an if settings.debug guard | |
| guarded = False | |
| for j in range(max(0, i - 11), i - 1): | |
| prev = lines[j] if j < len(lines) else '' | |
| if 'settings.debug' in prev and ('if' in prev or 'elif' in prev): | |
| guarded = True | |
| break | |
| if not guarded: | |
| issues.append('{}:{}: traceback exposed without debug guard'.format(py_file, i)) | |
| if issues: | |
| print('::error::Debug mode security issues found:') | |
| for i in issues: | |
| print(' ' + i) | |
| sys.exit(1) | |
| print('Debug mode defaults verified safe') | |
| " | |
| secret-scan: | |
| name: Hardcoded Secret Detection | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Scan for hardcoded secrets | |
| run: | | |
| python -c " | |
| import re, sys | |
| from pathlib import Path | |
| patterns = [ | |
| (r'password\s*=\s*[\"\\x27][^\"\\x27]+[\"\\x27]', 'Hardcoded password'), | |
| (r'api_key\s*=\s*[\"\\x27][A-Za-z0-9]{20,}[\"\\x27]', 'Hardcoded API key'), | |
| (r'secret\s*=\s*[\"\\x27][^\"\\x27]+[\"\\x27]', 'Hardcoded secret'), | |
| (r'token\s*=\s*[\"\\x27][A-Za-z0-9]{20,}[\"\\x27]', 'Hardcoded token'), | |
| (r'POSTGRES_PASSWORD\s*:\s*secuscan', 'Default DB password'), | |
| ] | |
| skip_files = {'.env.example', 'conftest.py'} | |
| skip_lines = ['# ', 'Example:', 'example:', 'default', 'replace-with'] | |
| issues = [] | |
| for py_file in Path('backend').rglob('*.py'): | |
| if any(s in py_file.name for s in skip_files) or py_file.name.startswith('test_'): | |
| continue | |
| content = py_file.read_text() | |
| for i, line in enumerate(content.splitlines(), 1): | |
| if any(s in line for s in skip_lines): | |
| continue | |
| for pattern, desc in patterns: | |
| if re.search(pattern, line, re.IGNORECASE): | |
| issues.append('{}:{}: {}'.format(py_file, i, desc)) | |
| for yml in Path('.').glob('docker-compose*.yml'): | |
| content = yml.read_text() | |
| for i, line in enumerate(content.splitlines(), 1): | |
| if 'POSTGRES_PASSWORD' in line and 'secuscan' in line: | |
| if '\${' not in line: | |
| issues.append('{}:{}: Default database password in compose file'.format(yml, i)) | |
| if issues: | |
| print('::warning::Potential hardcoded secrets found:') | |
| for i in issues: | |
| print(' ' + i) | |
| print() | |
| print('Review these findings. False positives are possible.') | |
| else: | |
| print('No hardcoded secrets detected') | |
| " | |
| env-config-check: | |
| name: Environment Config Validation | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Validate .env.example safe defaults | |
| run: | | |
| python -c " | |
| import sys | |
| from pathlib import Path | |
| env_file = Path('.env.example') | |
| if not env_file.exists(): | |
| print('.env.example not found, skipping') | |
| sys.exit(0) | |
| content = env_file.read_text() | |
| issues = [] | |
| checks = [ | |
| ('SECUSCAN_DEBUG=true', 'SECUSCAN_DEBUG should default to false for safety'), | |
| ] | |
| for bad, msg in checks: | |
| if bad in content: | |
| issues.append(msg) | |
| if issues: | |
| print('::warning::.env.example configuration issues:') | |
| for i in issues: | |
| print(' - ' + i) | |
| else: | |
| print('.env.example configuration validated') | |
| " | |
| dependency-audit: | |
| name: Dependency Security Audit | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Install pip-audit | |
| run: pip install pip-audit | |
| - name: Audit Python dependencies | |
| run: | | |
| pip-audit -r backend/requirements.txt --desc --format json > dep-audit.json || true | |
| python -c " | |
| import json, sys | |
| with open('dep-audit.json') as f: | |
| data = json.load(f) | |
| vulns = data.get('dependencies', []) | |
| critical = [] | |
| for dep in vulns: | |
| for v in dep.get('vulns', []): | |
| sev = v.get('fix_versions', []) | |
| if sev: | |
| critical.append('{}=={}: {}'.format(dep['name'], dep['version'], v.get('id', 'unknown'))) | |
| if critical: | |
| print('::warning::Found {} vulnerable dependencies:'.format(len(critical))) | |
| for c in critical[:10]: | |
| print(' ' + c) | |
| else: | |
| print('No known vulnerable dependencies found') | |
| " | |
| - name: Upload audit report | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: dep-audit-report | |
| path: dep-audit.json |