diff --git a/.github/workflows/security-ci.yml b/.github/workflows/security-ci.yml new file mode 100644 index 000000000..8abb80e22 --- /dev/null +++ b/.github/workflows/security-ci.yml @@ -0,0 +1,240 @@ +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@v4 + - uses: actions/setup-python@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@v4 + with: + name: bandit-report + path: bandit-report.json + + auth-protection-check: + name: Route Authentication Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@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@v4 + - uses: actions/setup-python@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@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@v4 + - uses: actions/setup-python@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@v4 + - uses: actions/setup-python@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@v4 + with: + name: dep-audit-report + path: dep-audit.json