Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
@@ -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
<https://github.com/databrickslabs/coding-agents-databricks-apps/security/advisories/new>.
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.
37 changes: 33 additions & 4 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
13 changes: 13 additions & 0 deletions app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
22 changes: 22 additions & 0 deletions telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,36 @@ 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.

Creates a throwaway WorkspaceClient from ~/.databrickscfg, adds the
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:
Expand Down
10 changes: 10 additions & 0 deletions tests/test_auth_enforcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions tests/test_telemetry_opt_out.py
Original file line number Diff line number Diff line change
@@ -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()