Skip to content

fix(notifications): scope rules to owner_id to prevent cross-tenant exfiltration (fixes #2064) #416

fix(notifications): scope rules to owner_id to prevent cross-tenant exfiltration (fixes #2064)

fix(notifications): scope rules to owner_id to prevent cross-tenant exfiltration (fixes #2064) #416

Workflow file for this run

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