diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..23ab1cd --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,105 @@ +# Security policy + +CoDA (Coding Agents on Databricks Apps) runs inside a customer's Databricks workspace and holds the user's PAT in-process. Vulnerabilities in CoDA can affect the security posture of every workspace that deploys it — we take responsible disclosure seriously. + +## Reporting a vulnerability + +**Please do not open a public GitHub issue for security vulnerabilities.** + +We support two private disclosure channels, in order of preference: + +1. **GitHub private vulnerability reporting** (preferred). + Open a security advisory at + . + The advisory is visible only to maintainers and to the reporter. + +2. **Email**: `databrickslabs@databricks.com` with subject prefix + `[SECURITY][coding-agents-databricks-apps]`. Encrypt sensitive + details with PGP if available + ([Databricks Labs PGP key](https://github.com/databrickslabs/.github/blob/main/SECURITY.md#pgp)). + +When reporting, please include: + +- The version / commit SHA you observed the vulnerability on +- Reproduction steps (a minimal `app.yaml` + repro command is ideal) +- The impact you believe an attacker could achieve +- Any mitigating circumstances or proof-of-concept code + +## Response timeline + +We commit to the following turnaround on a best-effort basis: + +| Phase | Target | +|---|---| +| Acknowledgement of receipt | 2 business days | +| Initial triage + severity assignment | 5 business days | +| Fix or mitigation plan | 14 business days | +| Coordinated disclosure | 90 days from initial report (or sooner if a fix is shipped) | + +Severity assignment follows [CVSS v3.1](https://www.first.org/cvss/v3.1/specification-document): + +| Severity | Patch SLA | +|---|---| +| Critical (9.0–10.0) | 7 days | +| High (7.0–8.9) | 14 days | +| Medium (4.0–6.9) | 30 days | +| Low (< 4.0) | next scheduled release | + +The SLAs above are calendar days from confirmed-and-reproducible to shipped patch. The 7-day cooldown we apply to npm and PyPI dependencies (see `utils.get_npm_version` and `[tool.uv] exclude-newer` in `pyproject.toml`) does *not* apply to CoDA's own security patches — those ship as soon as the fix is reviewed and tested. + +## Scope + +In scope: + +- The CoDA application code (`app.py`, `setup_*.py`, `install_*.sh`, `pat_rotator.py`, `utils.py`, `enterprise_config.py`, etc.) +- The release artifacts attached to GitHub Releases +- The deployment pipeline (`Makefile`, `databricks.yml`, `app.yaml.template`) + +Out of scope (report to the relevant project): + +- Vulnerabilities in Databricks Apps itself (report to Databricks security + via your support channel) +- Vulnerabilities in upstream agent CLIs (Claude Code, OpenCode, Codex, + Gemini CLI, Hermes) — report to those projects +- Vulnerabilities in upstream Python or npm packages — report to those + maintainers, then notify us so we can update the pin + +## Coordinated disclosure + +We follow the principles in +[disclose.io](https://disclose.io/terms/) and will: + +- Not pursue legal action against good-faith researchers +- Credit reporters in the release notes / advisory unless they prefer + anonymity +- Share an advance copy of the patch advisory with the reporter before + public disclosure + +## Supply chain controls + +For reviewers conducting vendor security assessments (SIG, CAIQ, etc.): + +- **npm dependencies** are resolved with a 7-day release-age cooldown + (`utils.get_npm_version`), and pinned to specific versions before each + `npm install -g` (see `setup_codex.py`, `setup_gemini.py`, + `setup_opencode.py`). +- **PyPI dependencies** use `[tool.uv] exclude-newer = "7 days"` (see + `pyproject.toml`) and are pinned in `requirements.txt` / `uv.lock`. +- **Hermes** is installed from a SHA-pinned git URL (see + `setup_hermes.py:HERMES_PIN_SHA`); the pin is rotated deliberately on + CoDA releases, not auto-updated. +- **Enterprise mode** (see `docs/enterprise.md`) routes all dependency + fetches through an operator-configured proxy (JFrog Artifactory / Nexus + / internal PyPI) instead of public registries. +- **CVE scanning** runs on every push via `.github/workflows/dependency-audit.yml`. +- **Software Bill of Materials (SBOM)** is attached to each GitHub Release + as a CycloneDX-format JSON file (see `.github/workflows/release.yml`). + +## Known limitations + +`docs/enterprise.md` § *Security model and known limits* enumerates the +deliberate trade-offs in the current design (no mirror-binary checksum +verification, no mirror allow-listing, single-user authorization model, +etc.). These are not vulnerabilities — they are documented threat-model +boundaries. Disclosed gaps are tracked publicly there so reviewers can +make informed risk decisions. diff --git a/app.py b/app.py index 0c63cad..05978cb 100644 --- a/app.py +++ b/app.py @@ -53,7 +53,22 @@ # PAT auto-rotation — initialized after sessions dict is defined (see below) app = Flask(__name__, static_folder='static', static_url_path='/static') -app.secret_key = os.urandom(24) +# Flask secret_key — signs session cookies. Read from FLASK_SECRET_KEY env +# var (typically wired to a Databricks secret in app.yaml) so sessions +# survive worker restarts and stay valid across multiple workers if we +# ever scale beyond one. Falls back to a fresh random key for local dev, +# which is fine because dev sessions are short-lived and single-process. +_secret_key_env = os.environ.get("FLASK_SECRET_KEY", "").strip() +if _secret_key_env: + app.secret_key = _secret_key_env.encode() +else: + app.secret_key = os.urandom(24) + logger_for_secret = logging.getLogger(__name__) + logger_for_secret.warning( + "FLASK_SECRET_KEY not set — generated an ephemeral key. " + "Existing sessions will be invalidated on every worker restart. " + "For production, wire FLASK_SECRET_KEY to a Databricks secret in app.yaml." + ) app.config['MAX_CONTENT_LENGTH'] = 32 * 1024 * 1024 # 32 MB — aligned with Claude Code's 30 MB file limit # WebSocket support via Flask-SocketIO (simple-websocket transport, threading mode) @@ -803,9 +818,23 @@ def cleanup_stale_sessions(): @app.before_request def authorize_request(): - """Check authorization before processing any request.""" - # Skip auth for health check, setup status, and Socket.IO (has own auth via connect event) - if request.path in ("/health", "/api/setup-status", "/api/pat-status", "/api/configure-pat", "/api/app-state") or request.path.startswith("/socket.io"): + """Check authorization before processing any request. + + The exempt list contains endpoints needed before the app owner has + been resolved or before the user has configured a PAT — the UI + polls these to drive its setup flow. Everything else requires the + requester's X-Forwarded-Email to match app_owner (see + check_authorization). + """ + # /api/app-state was historically in this exempt list — removed because + # it leaks app_owner email + PAT rotation timing to any unauthenticated + # caller. The endpoint has no pre-auth use case in the UI. + if request.path in ( + "/health", + "/api/setup-status", + "/api/pat-status", + "/api/configure-pat", + ) or request.path.startswith("/socket.io"): return None authorized, user = check_authorization() diff --git a/app.yaml b/app.yaml index 1a2fbc0..c5142c9 100644 --- a/app.yaml +++ b/app.yaml @@ -21,3 +21,16 @@ env: value: 0 - name: MAX_CONCURRENT_SESSIONS value: "5" + # ─── Enterprise security knobs ───────────────────────────────────────── + # Stable Flask session-cookie key. Without this, the key is regenerated + # on every worker restart and existing sessions get invalidated. Wire to + # a Databricks secret in production (see app.yaml.template for syntax). + # - name: FLASK_SECRET_KEY + # valueFrom: coda-prod/flask-secret-key + # + # Set to "true" to disable CoDA's outbound telemetry to Databricks + # (event-name pings via the SDK User-Agent — see telemetry.py). + # Regulated deployments (banks, retail PII, etc.) typically need this + # off so the third-party-risk register has no undisclosed data flow. + # - name: CODA_TELEMETRY_DISABLED + # value: "true" diff --git a/telemetry.py b/telemetry.py index a94c2c6..6e343db 100644 --- a/telemetry.py +++ b/telemetry.py @@ -49,6 +49,23 @@ def set_product_info(ws): setattr(ws.config, "_product_info", ("coda", _get_version())) +def _telemetry_disabled() -> bool: + """True when the operator has opted out of CoDA telemetry. + + Enterprise security teams (banks, regulated retail, etc.) require an + inventory of every data flow that leaves their workspace boundary. + `CODA_TELEMETRY_DISABLED=true` in app.yaml makes log_telemetry() a + no-op so deployments can pass third-party-risk review with no outbound + telemetry to disclose. + + Telemetry is on by default for backward compatibility; opt-out is + operator-controlled per-deployment. + """ + return os.environ.get("CODA_TELEMETRY_DISABLED", "").strip().lower() in ( + "true", "1", "yes", "on" + ) + + def log_telemetry(key, value): """Send a telemetry key-value pair via the Databricks SDK User-Agent header. @@ -56,7 +73,12 @@ def log_telemetry(key, value): key-value to the User-Agent, and fires clusters.select_spark_version() to transmit. Runs in a background daemon thread. Errors are caught and logged, never raised. + + No-op if `CODA_TELEMETRY_DISABLED` is set to a truthy value — the + enterprise opt-out path. """ + if _telemetry_disabled(): + return def _send(): try: diff --git a/tests/test_auth_enforcement.py b/tests/test_auth_enforcement.py index 578daa1..ab0db89 100644 --- a/tests/test_auth_enforcement.py +++ b/tests/test_auth_enforcement.py @@ -113,6 +113,16 @@ def test_resize_denied_for_non_owner(self): def test_resize_allowed_for_owner(self): self._assert_not_denied("POST", "/api/resize", {"session_id": "fake", "cols": 80, "rows": 24}) + # -- GET /api/app-state -- + # Historically auth-exempt; removed because it leaks app_owner email + + # PAT rotation timing to unauthenticated callers (review finding E-4). + + def test_app_state_denied_for_non_owner(self): + self._assert_denied("GET", "/api/app-state") + + def test_app_state_allowed_for_owner(self): + self._assert_not_denied("GET", "/api/app-state") + # --------------------------------------------------------------------------- # 2. Case-insensitive email matching diff --git a/tests/test_telemetry_opt_out.py b/tests/test_telemetry_opt_out.py new file mode 100644 index 0000000..82d2955 --- /dev/null +++ b/tests/test_telemetry_opt_out.py @@ -0,0 +1,61 @@ +"""Tests for the telemetry opt-out path (CODA_TELEMETRY_DISABLED). + +Enterprise procurement teams (NAB, Coles, etc.) require an inventory of +every outbound data flow. The opt-out lets operators ship CoDA with no +disclosed telemetry, which is the only way to pass third-party-risk +review for regulated workspaces. +""" + +from __future__ import annotations + +from unittest import mock + +import pytest + + +@pytest.fixture(autouse=True) +def _clear_env(monkeypatch): + monkeypatch.delenv("CODA_TELEMETRY_DISABLED", raising=False) + + +def test_telemetry_disabled_default_false(): + from telemetry import _telemetry_disabled + + assert _telemetry_disabled() is False + + +@pytest.mark.parametrize("value", ["true", "TRUE", "1", "yes", "on", " true "]) +def test_telemetry_disabled_truthy_values(value, monkeypatch): + monkeypatch.setenv("CODA_TELEMETRY_DISABLED", value) + from telemetry import _telemetry_disabled + + assert _telemetry_disabled() is True + + +@pytest.mark.parametrize("value", ["false", "0", "no", "off", "", "maybe"]) +def test_telemetry_disabled_falsy_values(value, monkeypatch): + monkeypatch.setenv("CODA_TELEMETRY_DISABLED", value) + from telemetry import _telemetry_disabled + + assert _telemetry_disabled() is False + + +def test_log_telemetry_noop_when_disabled(monkeypatch): + """When opt-out is set, log_telemetry must not spawn the background thread.""" + monkeypatch.setenv("CODA_TELEMETRY_DISABLED", "true") + from telemetry import log_telemetry + + with mock.patch("telemetry.threading.Thread") as mock_thread: + log_telemetry("test_event", "1") + mock_thread.assert_not_called() + + +def test_log_telemetry_fires_when_enabled(monkeypatch): + """Default (opt-out unset) must still spawn the telemetry thread.""" + from telemetry import log_telemetry + + with mock.patch("telemetry.threading.Thread") as mock_thread: + mock_thread.return_value.start = mock.Mock() + log_telemetry("test_event", "1") + mock_thread.assert_called_once() + mock_thread.return_value.start.assert_called_once()